-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat(mcp): support remote MCP servers via Streamable HTTP #360
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d0534d0
a06e9ab
18fea3e
e18f12a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -274,6 +274,26 @@ func initMCPClients(ctx context.Context, cfg *Config, tools *tool.Registry, repo | |
| var clients []*mcp.Client | ||
| for _, name := range mcpNames { | ||
| serverCfg := cfg.MCPServers[name] | ||
|
|
||
| isRemote := serverCfg.Type == "remote" | ||
|
|
||
| if isRemote { | ||
| if serverCfg.URL == "" { | ||
| fmt.Fprintf(os.Stderr, "[ocr] WARNING: remote MCP server %q has no URL configured, skipping\n", name) | ||
| continue | ||
| } | ||
| 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 | ||
| } | ||
|
Comment on lines
+285
to
+291
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When a header value contains an env var reference like Consider either:
This is especially important for |
||
| clients = append(clients, mc) | ||
| mcp.RegisterAll(tools, mc, serverCfg.Tools) | ||
| continue | ||
| } | ||
|
|
||
| if serverCfg.Command == "" { | ||
| fmt.Fprintf(os.Stderr, "[ocr] WARNING: MCP server %q has no command configured, skipping\n", name) | ||
| continue | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,22 +3,24 @@ package mcp | |||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||
| "context" | ||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||
| "io" | ||||||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||||||
| "os" | ||||||||||||||||||||||||||
| "os/exec" | ||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| "github.com/modelcontextprotocol/go-sdk/mcp" | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Client wraps a single MCP server connection via stdio transport. | ||||||||||||||||||||||||||
| // Client wraps a single MCP server connection. | ||||||||||||||||||||||||||
| type Client struct { | ||||||||||||||||||||||||||
| name string | ||||||||||||||||||||||||||
| session *mcp.ClientSession | ||||||||||||||||||||||||||
| tools []*mcp.Tool | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // NewClient starts an MCP server subprocess, initializes the connection, | ||||||||||||||||||||||||||
| // and caches the list of available tools. The context governs the | ||||||||||||||||||||||||||
| // NewClient starts an MCP server subprocess (stdio transport), initializes the | ||||||||||||||||||||||||||
| // connection, and caches the list of available tools. The context governs the | ||||||||||||||||||||||||||
| // initialization timeout (Connect + ListTools), NOT the subprocess | ||||||||||||||||||||||||||
| // lifetime — the subprocess stays alive until Close is called. | ||||||||||||||||||||||||||
| // When dir is non-empty, the subprocess runs with that working directory. | ||||||||||||||||||||||||||
|
|
@@ -60,6 +62,92 @@ func NewClient(ctx context.Context, name, command string, args, env []string, di | |||||||||||||||||||||||||
| }, nil | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // NewRemoteClient connects to a remote MCP server via Streamable HTTP transport. | ||||||||||||||||||||||||||
| // Header values may contain $ENV_VAR references which are expanded at runtime. | ||||||||||||||||||||||||||
| // Returns an error if any header value expands to an empty string. | ||||||||||||||||||||||||||
| func NewRemoteClient(ctx context.Context, name, url string, headers map[string]string, version string) (*Client, error) { | ||||||||||||||||||||||||||
| var expanded map[string]string | ||||||||||||||||||||||||||
| if len(headers) > 0 { | ||||||||||||||||||||||||||
| expanded = make(map[string]string, len(headers)) | ||||||||||||||||||||||||||
| 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
+72
to
+77
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| httpClient := &http.Client{ | ||||||||||||||||||||||||||
| Transport: &headerTransport{ | ||||||||||||||||||||||||||
| base: http.DefaultTransport, | ||||||||||||||||||||||||||
| headers: expanded, | ||||||||||||||||||||||||||
| serverName: name, | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| client := mcp.NewClient( | ||||||||||||||||||||||||||
| &mcp.Implementation{Name: "open-code-review", Version: version}, | ||||||||||||||||||||||||||
| nil, | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| transport := &mcp.StreamableClientTransport{ | ||||||||||||||||||||||||||
| Endpoint: url, | ||||||||||||||||||||||||||
| HTTPClient: httpClient, | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| session, err := client.Connect(ctx, transport, nil) | ||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||
| return nil, fmt.Errorf("connect to remote MCP server %q at %s: %w", name, url, err) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var success bool | ||||||||||||||||||||||||||
| defer func() { | ||||||||||||||||||||||||||
| if !success { | ||||||||||||||||||||||||||
| session.Close() | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| }() | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| toolsResult, err := session.ListTools(ctx, nil) | ||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||
| return nil, fmt.Errorf("list tools from remote MCP server %q: %w", name, err) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| success = true | ||||||||||||||||||||||||||
| return &Client{ | ||||||||||||||||||||||||||
| name: name, | ||||||||||||||||||||||||||
| session: session, | ||||||||||||||||||||||||||
| tools: toolsResult.Tools, | ||||||||||||||||||||||||||
| }, nil | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // headerTransport injects custom headers into every HTTP request and surfaces | ||||||||||||||||||||||||||
| // clear authentication errors for 401/403 responses. | ||||||||||||||||||||||||||
| type headerTransport struct { | ||||||||||||||||||||||||||
| base http.RoundTripper | ||||||||||||||||||||||||||
| headers map[string]string | ||||||||||||||||||||||||||
| serverName string | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { | ||||||||||||||||||||||||||
| cloned := req.Clone(req.Context()) | ||||||||||||||||||||||||||
| for k, v := range t.headers { | ||||||||||||||||||||||||||
| cloned.Header.Set(k, v) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| resp, err := t.base.RoundTrip(cloned) | ||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| switch resp.StatusCode { | ||||||||||||||||||||||||||
| case http.StatusUnauthorized: | ||||||||||||||||||||||||||
| io.Copy(io.Discard, resp.Body) | ||||||||||||||||||||||||||
| resp.Body.Close() | ||||||||||||||||||||||||||
| return nil, fmt.Errorf("remote MCP server %q returned HTTP 401 Unauthorized — check your token/header configuration", t.serverName) | ||||||||||||||||||||||||||
| case http.StatusForbidden: | ||||||||||||||||||||||||||
| io.Copy(io.Discard, resp.Body) | ||||||||||||||||||||||||||
| resp.Body.Close() | ||||||||||||||||||||||||||
| return nil, fmt.Errorf("remote MCP server %q returned HTTP 403 Forbidden — your credentials may lack required permissions", t.serverName) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return resp, nil | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| func (c *Client) Name() string { return c.name } | ||||||||||||||||||||||||||
| func (c *Client) Tools() []*mcp.Tool { return c.tools } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
url.Parsefunction in Go is very permissive and rarely returns an error. For example,url.Parse("http://")succeeds withHost == "", which would pass this validation but is not a usable endpoint. Consider adding a check forparsed.Host == ""to catch URLs that have a valid scheme but no host.Suggestion: