Skip to content

feat(mcp): support remote MCP servers via Streamable HTTP#360

Open
lizhengfeng101 wants to merge 3 commits into
mainfrom
feat/remote-mcp-support
Open

feat(mcp): support remote MCP servers via Streamable HTTP#360
lizhengfeng101 wants to merge 3 commits into
mainfrom
feat/remote-mcp-support

Conversation

@lizhengfeng101

Copy link
Copy Markdown
Collaborator

Summary

Add support for remote (HTTP-based) MCP servers, complementing the existing stdio transport.

  • Extend MCPServerConfig with type, url, and headers fields
  • Add NewRemoteClient using go-sdk's StreamableClientTransport
  • Support environment variable expansion in header values ($TOKEN → runtime value)
  • Validate URL scheme (http/https only) and emit warnings for empty expanded headers
  • Authentication stays outside OCR — users provide tokens via headers, matching CLI tool conventions

Configuration example

{
  "mcp_servers": {
    "my-remote": {
      "type": "remote",
      "url": "https://mcp.example.com/sse",
      "headers": {
        "Authorization": "Bearer $MCP_TOKEN"
      }
    }
  }
}

Design decisions

  • Auth is the user's responsibility (provide via headers/env vars), not OCR's — consistent with how stdio MCP servers handle tool installation/setup externally
  • Uses StreamableClientTransport from go-sdk v1.6.1 (the current MCP spec's recommended HTTP transport)
  • Header values support $ENV_VAR / ${ENV_VAR} expansion via os.Expand at connect time
  • Backward-compatible: existing stdio configs work unchanged (type defaults to "stdio")

Closes #335

🤖 Generated with Claude Code

Support connecting to remote MCP servers using the Streamable HTTP
transport from go-sdk. Users configure remote servers with type=remote,
a URL, and optional headers (with environment variable expansion for
secrets). Authentication stays outside OCR — users provide tokens via
headers, matching CLI tool conventions.

Closes #335
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 3 issue(s) in this PR.

  • ✅ Successfully posted inline: 3 comment(s)

Comment on lines +285 to +291
initCtx, initCancel := context.WithTimeout(ctx, 30*time.Second)
mc, err := mcp.NewRemoteClient(initCtx, name, serverCfg.URL, serverCfg.Headers, version)
initCancel()
if err != nil {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to connect to remote MCP server %q: %v\n", name, err)
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a header value contains an env var reference like $AUTH_TOKEN and that variable is unset, os.Expand will produce an empty string. The NewRemoteClient function only prints a warning but still proceeds to connect with the empty header value. This could lead to silent authentication failures or, worse, unauthenticated requests being sent to the remote server.

Consider either:

  1. Treating empty-after-expansion headers as a fatal error for this server (skip it), or
  2. At minimum, making the warning more prominent (e.g., ERROR level) so users don't miss it in logs.

This is especially important for Authorization headers where an empty value means the request goes out unauthenticated.

Comment thread internal/mcp/client.go
Comment on lines +70 to +75
for k, v := range headers {
expanded[k] = os.Expand(v, os.Getenv)
if expanded[k] == "" {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: MCP server %q header %q expanded to empty value — check your environment variables\n", name, k)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a header value expands to an empty string (e.g., because the referenced environment variable is unset), the code prints a warning but still proceeds to set the empty header on outgoing requests. This can lead to silent authentication failures or protocol errors when connecting to the remote MCP server.

Consider either returning an error or skipping the empty header, rather than sending a request known to have invalid headers.

Suggestion:

Suggested change
for k, v := range headers {
expanded[k] = os.Expand(v, os.Getenv)
if expanded[k] == "" {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: MCP server %q header %q expanded to empty value — check your environment variables\n", name, k)
}
}
for k, v := range headers {
expanded[k] = os.Expand(v, os.Getenv)
if expanded[k] == "" {
return nil, fmt.Errorf("MCP server %q header %q expanded to empty value — check your environment variables", name, k)
}
}

Comment on lines +579 to +585
parsed, err := url.Parse(value)
if err != nil {
return fmt.Errorf("invalid MCP server URL %q: %w", value, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("MCP server URL must use http or https scheme, got %q", parsed.Scheme)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The url.Parse function in Go is very permissive and rarely returns an error. For example, url.Parse("http://") succeeds with Host == "", which would pass this validation but is not a usable endpoint. Consider adding a check for parsed.Host == "" to catch URLs that have a valid scheme but no host.

Suggestion:

Suggested change
parsed, err := url.Parse(value)
if err != nil {
return fmt.Errorf("invalid MCP server URL %q: %w", value, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("MCP server URL must use http or https scheme, got %q", parsed.Scheme)
}
parsed, err := url.Parse(value)
if err != nil {
return fmt.Errorf("invalid MCP server URL %q: %w", value, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("MCP server URL must use http or https scheme, got %q", parsed.Scheme)
}
if parsed.Host == "" {
return fmt.Errorf("MCP server URL %q must include a host", value)
}

@cometkim

Copy link
Copy Markdown
Contributor

Support environment variable expansion in header values ($TOKEN → runtime value)

When calling config via the CLI, $VALUE expansion can be resolved accidentally by the user's shell.

@lizhengfeng101

Copy link
Copy Markdown
Collaborator Author

Good catch! This is indeed a common CLI pitfall — the user's shell may expand $TOKEN before OCR sees it.

In practice, this is the same issue every CLI tool faces (docker run -e, git config, etc.). The mitigations are:

  1. Single quotes prevent shell expansion: ocr config set mcp_servers.foo.headers '{"Authorization": "Bearer $MCP_TOKEN"}'
  2. Most users will edit the JSON config file directly, where $MCP_TOKEN is stored literally and only expanded at runtime by os.Expand.

We'll add a note in the documentation to remind users to use single quotes (or edit the config file directly) when header values contain environment variable references. No syntax change needed — $VAR is the widely understood convention.

…ning

Add remote transport (Streamable HTTP) documentation to MCP Server
sections in all READMEs and CLI help text. Include type/url/headers
field descriptions, remote server examples (CLI and JSON config),
and a tip about using single quotes to prevent shell expansion.
@cometkim

cometkim commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Then, do you think #332 should follow that convention too?

$VAR is the widely understood convention.

Others (e.g. OpenCode) are often using {env:VAR} btw. (additionally {cmd:...})

@lizhengfeng101

Copy link
Copy Markdown
Collaborator Author

Thanks for raising the syntax consistency question — it's a good design consideration.

I looked into how OpenCode actually handles this. From their source code and documentation:

  • MCP headers are plain string values with no template expansion (config.goHeaders map[string]string, passed directly via client.WithHeaders)
  • Custom commands use $NAME placeholders (shell-style): $ISSUE_NUMBER, $AUTHOR_NAME, etc.
  • Environment variables are resolved via standard os.Getenv calls, not a custom {env:} parser

So in practice, OpenCode also follows the conventional $VAR pattern rather than a {env:VAR} syntax.

Regarding consistency with #332's {ocr_session_key}: these serve different semantic purposes — $VAR means "substitute an OS environment variable at runtime" (same as Docker Compose, GitHub Actions env, Kubernetes manifests), while {ocr_session_key} is an OCR-internal computed value. Many tools maintain this distinction (e.g., GitHub Actions uses ${{ }} for expressions vs $VAR in shell steps). We think keeping them separate actually makes the intent clearer rather than forcing both into one syntax.

That said, the shell-expansion footgun you raised is a real concern and we've addressed it with documentation tips (single quotes for CLI, or edit config.json directly). Happy to hear if you have further thoughts on this.

@lizhengfeng101

Copy link
Copy Markdown
Collaborator Author

Correction to my previous comment — I referenced the wrong OpenCode repository (the archived opencode-ai/opencode). The actively maintained one is anomalyco/opencode.

@cometkim is correct: anomalyco/opencode does use {env:VAR} syntax. Their packages/opencode/src/config/variable.ts implements:

/** Apply {env:VAR} and {file:path} substitutions to config text. */
export async function substitute(input: SubstituteInput) {
  let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
    return (input.env?.[varName] ?? process.env[varName]) || ""
  })
  // ... also supports {file:path} for reading secrets from files
}

So OpenCode supports {env:VAR} and {file:path} as a unified template system, applied to both URLs and headers in MCP config.

That said, our position remains to use $VAR for this PR, for these reasons:

  1. Broader industry convention — Docker Compose, Kubernetes, GitHub Actions, and most CLI tools use $VAR/${VAR} for environment variable substitution. OpenCode is one data point, but the wider ecosystem leans toward shell-style syntax.
  2. Semantic separation$VAR (OS env var) and {ocr_session_key} (feat(llm): task-scoped session affinity for prompt caching #332, OCR-internal computed value) are different concepts. Keeping distinct syntax makes the intent immediately clear.
  3. Simplicity — Go's os.Expand handles $VAR/${VAR} natively. A custom {env:} parser adds code and edge cases (escaping } in values, etc.) for limited practical benefit.

The shell-expansion footgun is real, but well-mitigated by documentation (single quotes) and by the fact that most users edit config.json directly rather than using the CLI for header values.

If the project later decides to support richer substitution (like {file:path} for reading tokens from secret files), we can introduce that as a separate enhancement without breaking $VAR compatibility. Apologies for the confusion in my earlier comment.

- Reject URLs with missing host (e.g. "http://") in config validation
- Make empty-after-expansion header values a fatal error instead of a warning
- Detect HTTP 401/403 responses and surface clear authentication error messages
- Drain response body before closing to preserve connection pool reuse
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support remote MCP servers

2 participants