Skip to content

feat(mcp): support host-local MCP servers#183

Merged
dpup merged 2 commits into
mainfrom
feat/69-host-local-mcp
Mar 5, 2026
Merged

feat(mcp): support host-local MCP servers#183
dpup merged 2 commits into
mainfrom
feat/69-host-local-mcp

Conversation

@dpup
Copy link
Copy Markdown
Collaborator

@dpup dpup commented Feb 17, 2026

Summary

Closes #69 — enables agents in containers to communicate with MCP servers running on the host machine.

  • URL-based detection: MCP servers with URLs pointing to localhost, 127.0.0.1, or [::1] are automatically detected as host-local
  • Proxy relay bridge: The existing proxy relay pattern forwards requests from the container to host-local MCP servers, bridging the container network boundary
  • Optional auth: Host-local servers support optional credential injection via grants (same mechanism as remote MCP)
  • Relaxed HTTPS: HTTP is allowed for host-local URLs since traffic stays on the loopback interface
  • Config validation: Host-local URLs validated via url.Parse + Hostname() with support for IPv4/IPv6 loopback addresses

Usage in moat.yaml

mcp:
  - name: my-local-server
    url: http://localhost:3000/mcp

  - name: my-auth-server
    url: http://127.0.0.1:8080/api
    auth:
      grant: mcp-my-auth-server
      header: Authorization

Test plan

  • TestLoad_MCP_HostLocal — host-local config parsing
  • TestLoad_MCP_HostLocal_Validation — validation rules
  • TestIsHostLocalURL — 12 subtests (localhost, 127.0.0.1, [::1], remote hosts)
  • TestLoad_MCP_MixedHostAndRemote — mixed host + remote configs
  • TestLoad_MCP_HostLocalIPv6 — IPv6 loopback support
  • TestIsHostLocalURL_EdgeCases — 11 edge cases
  • TestMCPRelay_HostLocal — proxy relay for host-local
  • TestMCPRelay_HostLocalWithPath / WithAuth — path and auth forwarding
  • go test -race — no races, make lint — 0 issues

Review notes

  • Rebased against main (resolved conflicts from agent.yamlmoat.yaml rename in feat(config): rename agent.yaml to moat.yaml #204)
  • isHostLocalURL uses url.Parse + Hostname() instead of manual string parsing
  • No changes to proxy relay code — host-local servers use the existing handleMCPRelay path
  • Unauthenticated MCP servers now get relay URLs (removed Auth == nil skip in manager.go)

@claude
Copy link
Copy Markdown

claude Bot commented Feb 17, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 2m 5s —— View job


Code Review

  • Gather context
  • Read changed files
  • Analyze for bugs, security issues, edge cases
  • Post review feedback

Overall this is a clean, well-tested implementation. The proxy relay pattern for bridging host-local MCP servers is sound, the validation is thorough, and the test coverage is extensive. A few things worth addressing:

Issues

1. mcpRelayClient has no timeout (internal/proxy/mcp.go:17-21)

var mcpRelayClient = &http.Client{
    Transport: &http.Transport{
        Proxy: nil,
    },
}

There's no Timeout on the relay client. If a host-local MCP server hangs, the relay goroutine blocks indefinitely. The r.Context() is correctly passed via NewRequestWithContext so client disconnect cancels in-flight requests, but a completely unresponsive server that doesn't close the connection won't be cancelled. Consider adding a Timeout, or at minimum documenting that the client timeout is intentionally unbounded. This matches existing behavior for remote MCP servers too, so it's not a regression — just worth noting.

2. Test duplication between mcp_test.go and mcp_regression_test.go

mcp_test.go added in this PR duplicates several tests already in mcp_regression_test.go:

mcp_test.go mcp_regression_test.go
TestMCPRelay_MissingCredentialStore TestMCPRelay_NilCredentialStore
TestMCPRelay_MissingCredentialForGrant TestMCPRelay_MissingCredential
TestMCPRelay_HeadersCopied TestMCPRelay_ProxyHeadersFiltered (partial overlap)
TestMCPRelay_ProxyHeadersStripped TestMCPRelay_ProxyHeadersFiltered

They test the same code paths with slightly different names. Not a correctness issue, but the duplication will grow harder to maintain as the relay evolves.

3. Partial overlap in TestMCPCredentialInjection_NoAuthConfig vs relay tests (mcp_test.go:90)

TestMCPCredentialInjection_NoAuthConfig tests ServeHTTP (the pass-through proxy path) with no auth, then TestMCPRelay_HostLocal tests handleMCPRelay directly with no auth. These are actually testing different code paths and are both correct — but the test comments don't make this distinction clear. The name _NoAuthConfig implies it's the relay behavior but it's actually the injectMCPCredentials pass-through path.

4. isHostLocalURL is package-private but validateTopLevelMCPServerSpec duplicates the logic description (config.go:568-572)

if !strings.HasPrefix(spec.URL, "https://") {
    if !isHostLocalURL(spec.URL) {
        return fmt.Errorf("... http:// is only allowed for localhost and 127.0.0.1")
    }
}

The error message says "localhost and 127.0.0.1" but the function also accepts [::1]. Minor inconsistency — the IPv6 loopback is silently supported but not mentioned. Fix this →

Observations (no action required)

Manager change is correct. Removing the if mcp.Auth == nil { continue } guard (previously skipping unauthenticated servers) now correctly relays all MCP servers — both authenticated and unauthenticated — through the proxy. The old behavior silently dropped public MCP servers.

isHostLocalURL manual parsing is fine. Using net/url.Parse would be cleaner but the manual approach is correct for the cases tested. The IPv6 bracket handling works because strings.LastIndexByte on [::1]:3000 finds the last : (between ] and 3000), not the colons inside the brackets.

Security model is sound. Host-local URLs are validated at config load time, not at request time. The relay uses a nil-proxy transport to prevent circular loops. Proxy-Authorization and Proxy-Connection headers are stripped before forwarding. No SSRF surface is opened beyond what's declared in config.

Docs are accurate and match the implementation. The guide correctly describes the three server types and the proxy relay mechanism.

Comment thread internal/config/config.go Outdated
@claude
Copy link
Copy Markdown

claude Bot commented Feb 23, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 8s —— View job


Code Review

  • Gather context and read changed files
  • Review implementation for bugs, security, and edge cases
  • Check test coverage and docs
  • Post review feedback

The follow-up commit (8dcca5f) correctly fixes the [::1] omission from the error message flagged in the previous review. The rest of the implementation looks solid. A few things worth noting:

Issues

1. isHostLocalURL silently rejects [::1] without a port (internal/config/config.go:531-547)

// Strip port
host := hostPart
if idx := strings.LastIndexByte(host, ':'); idx >= 0 {
    host = host[:idx]
}
return host == "localhost" || host == "127.0.0.1" || host == "[::1]"

For http://[::1]/mcp (no explicit port), after stripping the path you have [::1]. Then strings.LastIndexByte("[::1]", ':') returns 2 (the last : inside the brackets), so host becomes [: — not [::1]. The function returns false and validation rejects a valid IPv6 loopback URL.

This case isn't in the test suite: TestIsHostLocalURL and TestIsHostLocalURL_EdgeCases only test http://[::1]:3000/mcp (with port). In practice no one runs a local MCP server on port 80, but the behaviour is inconsistent — http://localhost/mcp and http://127.0.0.1/mcp both work without a port, but http://[::1]/mcp doesn't. Fix this →

2. Code comments still say "localhost or 127.0.0.1" after the [::1] fix (config.go:105-108, config.go:529-532)

The error message was updated but the doc comments weren't:

// MCPServerConfig:
// Host-local servers (http://localhost or http://127.0.0.1) are reached

// isHostLocalURL:
// (localhost or 127.0.0.1). These are MCP servers running on the host machine

Both should mention [::1]. Minor but creates the same confusion the error-message fix was meant to address. Fix this →%20MCPServerConfig%20doc%20comment%20around%20line%20105%3A%20change%20%22http%3A%2F%2Flocalhost%20or%20http%3A%2F%2F127.0.0.1%22%20to%20%22http%3A%2F%2Flocalhost%2C%20http%3A%2F%2F127.0.0.1%2C%20or%20http%3A%2F%2F%5B%3A%3A1%5D%22.%202)%20isHostLocalURL%20function%20comment%20around%20line%20529%3A%20change%20%22localhost%20or%20127.0.0.1%22%20to%20%22localhost%2C%20127.0.0.1%2C%20or%20%5B%3A%3A1%5D%22.%20Branch%3A%20feat%2F69-host-local-mcp&repo=majorcontext/moat)

Observations (no action required)

Test duplication is still present. TestMCPRelay_MissingCredentialStore / TestMCPRelay_MissingCredentialForGrant in mcp_test.go overlap with TestMCPRelay_NilCredentialStore / TestMCPRelay_MissingCredential in mcp_regression_test.go. This was called out in the previous review. Not a blocker but worth cleaning up before the test files diverge further.

manager.go change is correct. Removing if mcp.Auth == nil { continue } properly enables relay for all MCP servers regardless of auth. The old guard was silently dropping public and host-local servers from .claude.json configuration.

Security model holds. The relay client uses Proxy: nil to prevent circular loops. URLs are validated at config load time — the relay doesn't do runtime host validation, but since configs come from the operator this is fine. Proxy-Authorization and Proxy-Connection headers are stripped before forwarding.

Docs are accurate. The guide and reference correctly describe all three server types, the [::1] IPv6 address is mentioned in the field description table, and troubleshooting steps cover host-local-specific failure modes.

Comment thread internal/config/config.go Outdated
@claude
Copy link
Copy Markdown

claude Bot commented Feb 23, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 3s —— View job


Code Review

  • Gather context
  • Read changed files
  • Analyze for bugs, security issues, edge cases
  • Post review feedback

Previous reviews flagged three issues. Here's the status of each, plus anything new.

Previously Flagged — Now Fixed

IPv6 loopback without port (http://[::1]/mcp) — Fixed in 083915e. The new isHostLocalURL logic correctly handles the [::1] case by checking for ]: to find the port only after the closing bracket. The fix is correct, and TestIsHostLocalURL now includes {"http://[::1]/mcp", true}.

Error message and comments missing [::1] — Fixed. Both the error message in validateTopLevelMCPServerSpec and the doc comments on MCPServerConfig and isHostLocalURL now include [::1].

moat grants listmoat grant list in docs — Correct fix. The CLI command is moat grant list (confirmed in cmd/moat/cli/grant_list.go).

Still Outstanding

1. Test duplication across mcp_test.go and mcp_regression_test.go

Three reviews have flagged this and it remains unaddressed. The following tests are functionally identical:

mcp_test.go mcp_regression_test.go
TestMCPRelay_MissingCredentialStore TestMCPRelay_NilCredentialStore
TestMCPRelay_MissingCredentialForGrant TestMCPRelay_MissingCredential
TestMCPRelay_UnknownServer_Returns404 TestMCPRelay_ServerNotFound
TestMCPRelay_ProxyHeadersStripped TestMCPRelay_ProxyHeadersFiltered

Not a correctness issue but the duplication will compound as the relay grows. The regression file was presumably created to lock in behavior from a bug fix — the new file duplicates it without adding coverage.

2. No timeout on mcpRelayClient

First flagged in the initial review and not changed. It's the same as the existing behavior for remote MCP servers, so not a regression, but a completely unresponsive host-local server will block a relay goroutine indefinitely. The context propagation via NewRequestWithContext means client disconnects cancel in-flight requests — just not stalled backends that hold a connection open without responding.

Observations (no action required)

isHostLocalURL is case-sensitivehttp://LOCALHOST:3000 returns false. This is stricter than RFC 3986 (which calls hostnames case-insensitive) but safer for a security check. The behavior is documented in TestIsHostLocalURL_EdgeCases.

handleMCPRelay response header forwarding is correct. Go's HTTP client strips Transfer-Encoding when reading responses, so forwarding resp.Header directly won't cause issues with chunked encoding. Hop-by-hop headers like Connection are present in the forwarded headers but harmless in this context.

manager.go change is correct. Removing if mcp.Auth == nil { continue } now relays all configured MCP servers through the proxy regardless of auth, which is necessary for host-local servers and was previously silently dropping unauthenticated remote MCP servers from .claude.json.

Security model is sound. URL validation happens at config load time. The relay uses Proxy: nil to prevent circular loops. Proxy-Authorization and Proxy-Connection are stripped before forwarding. The path extension mechanism (relPath) doesn't enable SSRF — the host is fixed by config, not by the request.

Docs are accurate. The guide correctly describes all three server types, the proxy relay mechanism, and troubleshooting steps for host-local servers specifically.


Overall the implementation is solid. The IPv6 and comment issues from the last review are resolved. The remaining items (test duplication, relay timeout) are pre-existing concerns rather than new problems introduced by this PR.

Enable MCP servers running on the host machine (localhost, 127.0.0.1,
[::1]) to be reached from inside containers via the proxy relay.

- Add isHostLocalURL() using net/url.Parse for loopback detection
- Relax HTTPS validation to allow HTTP for host-local URLs
- Remove Auth==nil skip so unauthenticated servers get relay URLs
- Add proxy relay tests for host-local with/without auth
- Update MCP guide and moat.yaml reference docs

Closes #69
@dpup dpup force-pushed the feat/69-host-local-mcp branch from c80707c to 08f1550 Compare March 5, 2026 01:24
@claude
Copy link
Copy Markdown

claude Bot commented Mar 5, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 43s —— View job


Code Review

  • Gather context
  • Read changed files
  • Analyze for bugs, security issues, edge cases
  • Post review feedback

Three previous reviews flagged issues in earlier iterations. Here's the status of outstanding items and a fresh look at the current state of the code.

Previously Flagged — Now Resolved

IPv6 without port (http://[::1]/mcp) — Fixed. isHostLocalURL now uses url.Parse + u.Hostname() instead of manual string manipulation. net/url handles IPv6 bracket stripping correctly, so [::1] without a port works. This is cleaner than the suggested fix and handles all edge cases.

Error message and doc comments missing [::1] — Fixed. Both the validation error in validateTopLevelMCPServerSpec and the comments on MCPServerConfig and isHostLocalURL now include all three loopback forms.

Test duplication (TestMCPRelay_MissingCredentialStore etc.) — These duplicate tests are no longer present in mcp_test.go. The current mcp_test.go adds TestMCPCredentialInjection_* (testing ServeHTTP credential injection) and TestMCPRelay_HostLocal* (testing relay for host-local). These test different code paths from mcp_regression_test.go's TestMCPRelay_* tests, so there's no longer meaningful duplication.

Still Outstanding

No timeout on mcpRelayClient (internal/proxy/mcp.go:17-21)

var mcpRelayClient = &http.Client{
    Transport: &http.Transport{
        Proxy: nil,
    },
}

A host-local MCP server that accepts a connection but never responds will block the relay goroutine indefinitely. The r.Context() passed via NewRequestWithContext handles client-side cancellation (Claude Code disconnecting), but does nothing if the backend holds the TCP connection open without sending bytes. Not a regression from remote MCP behavior — the same client is used for both. Worth noting since this PR makes host-local servers a first-class feature and the scenario is more plausible (a local server process in a bad state vs. an unreachable remote endpoint which typically fails fast).

New Issues

TestMCPCredentialInjection_* tests use host-local-looking URLs for a remote-server code path (internal/proxy/mcp_test.go:34)

httptest.NewServer always returns http://127.0.0.1:PORT. The tests configure this as the URL for an MCP server and send requests via p.ServeHTTP, which exercises the proxy-interception path (injectMCPCredentials). That path is the HTTPS tunnel interceptor — it's used when a remote HTTPS server's traffic flows through HTTP_PROXY, not for host-local relay. The tests pass and the logic holds (host matching works), but the test setup is misleading: you can't exercise the actual remote-server path without a TLS backend, and using a local HTTP server for these tests makes them look like host-local tests when they're actually testing the credential-injection path. Consider either using https:// test servers (via httptest.NewTLSServer) or a comment clarifying that these test the injectMCPCredentials path (proxy tunnel), not the relay path. Fix this →

Docs observability section implies host-local traffic isn't traced (docs/content/guides/09-mcp.md:297)

All remote MCP traffic flows through the proxy, so it appears in network traces

Host-local traffic also flows through the proxy relay — the relay is the only path from container to host-local server. "All remote MCP traffic" is true but implies host-local is excluded. A reader who just set up a host-local server might wonder why they're seeing proxy traces for it. Fix this →

Observations (no action required)

isHostLocalURL using url.Parse is correct. The function now rejects non-HTTP URLs at the scheme check (u.Scheme != "http"), so https://localhost correctly returns false. The validation flow in validateTopLevelMCPServerSpec is sound: HTTPS URLs pass the prefix check and skip isHostLocalURL; HTTP URLs that aren't loopback addresses are rejected.

handleMCPRelay path joining is correct. The relPath != "/" guard correctly handles the case where the request path ends at the server name (e.g., /mcp/server/relPath = "/" → don't append). Tested by TestMCPRelay_PathHandling in the regression file.

manager.go change is correct. Removing if mcp.Auth == nil { continue } now relays all MCP servers (authenticated and unauthenticated, remote and host-local) through the proxy. The relay correctly no-ops credential injection when mcpServer.Auth == nil.

Security model is correct. URL validation happens at config parse time. The relay uses Proxy: nil transport to prevent circular routing. Proxy-Authorization and Proxy-Connection headers are stripped before forwarding. The relay's target host is fixed at config time — requests cannot redirect the relay to arbitrary hosts at runtime.

mockCredentialStore placement is correct. Defined once in mcp_test.go, shared across the proxy package test files. Previously lived in mcp_regression_test.go; moving it here consolidates the shared test infrastructure.


Overall this is solid. The previous blocking issues are all resolved. The two new items above are minor — the missing timeout is a pre-existing limitation, and the docs/test issues are clarity improvements rather than correctness problems.

- Add comment clarifying TestMCPCredentialInjection tests the
  proxy-interception path, not the relay path
- Update observability docs to note both remote and host-local MCP
  traffic flows through the proxy relay
@claude
Copy link
Copy Markdown

claude Bot commented Mar 5, 2026

Claude finished @dpup's task in 3m 17s —— View job


Code Review

  • Gather context and read changed files
  • Review implementation for bugs, security, and edge cases
  • Check test coverage and docs
  • Post review feedback

All issues from the three prior rounds of review have been addressed. Here's a final accounting.

Previously Flagged — Now Fixed

IPv6 loopback without port — Fixed. isHostLocalURL now uses url.Parse + u.Hostname(), which handles [::1] (no port), [::1]:3000, localhost, and 127.0.0.1 correctly without manual bracket parsing.

Error message and doc comments missing [::1] — Fixed in both the validation error in validateTopLevelMCPServerSpec and the comments on MCPServerConfig and isHostLocalURL.

Test duplication — Resolved. mcp_test.go now covers distinct code paths: TestMCPCredentialInjection_* exercises injectMCPCredentials/ServeHTTP, while TestMCPRelay_HostLocal* exercises handleMCPRelay for host-local servers. No meaningful overlap with mcp_regression_test.go.

TestMCPCredentialInjection_* tests use host-local-looking URLs — Fixed. The function comment on line 13–16 now clearly states these tests exercise the proxy-interception path (injectMCPCredentials), not the relay path.

Docs observability section implied host-local traffic isn't traced — Fixed. Line 297 now reads: "All MCP traffic (both remote and host-local) flows through the proxy relay, so it appears in network traces."

Still Outstanding

No timeout on mcpRelayClient (internal/proxy/mcp.go:17–21)

var mcpRelayClient = &http.Client{
    Transport: &http.Transport{
        Proxy: nil,
    },
}

This has been flagged in every review. A host-local server that accepts a TCP connection but never sends a response will hold a relay goroutine open indefinitely. NewRequestWithContext propagates the incoming request's context, so a client disconnect (Claude Code exiting) will cancel the in-flight request — but only if the backend is cooperative. A completely unresponsive server that holds the connection open won't be cancelled by context cancellation alone.

This is a pre-existing condition for remote MCP servers too. The practical difference here is that a local server in a bad state (crash loop, blocked goroutine, zombie process) is a common scenario, whereas an unreachable remote endpoint typically fails fast with a connection refused or timeout. Worth noting since host-local is now a first-class feature. Fix this →

Observations (no action required)

handleDirectMCPRelay correctly strips the auth token before dispatch. The relay URL in .claude.json is http://proxy:port/mcp/{token}/{name}. The handler at proxy.go:797–806 dispatches to handleDirectMCPRelay, which extracts the token, resolves run context, rewrites the path to /mcp/{name}, then calls handleMCPRelay. The token is never visible to the host-local MCP server.

validateTopLevelMCPServerSpec validation flow is correct. HTTPS URLs pass the prefix check without hitting isHostLocalURL. HTTP URLs that aren't loopback are correctly rejected.

validateMCPGrants in run.go correctly skips unauthenticated host-local servers (those with Auth == nil), so users don't need to grant anything to use a public host-local server.

Security model is sound. URL validation happens at config parse time. The relay uses Proxy: nil transport to prevent circular routing. Proxy-Authorization and Proxy-Connection headers are stripped before forwarding. The relay's target host is fixed by config — requests cannot redirect the relay to arbitrary hosts at runtime.


The implementation is solid. The one remaining item (relay timeout) is a pre-existing limitation. Everything else from prior reviews is resolved.

@dpup dpup merged commit 9404ef9 into main Mar 5, 2026
4 checks passed
@dpup dpup deleted the feat/69-host-local-mcp branch March 5, 2026 17:53
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 host-local MCP servers

1 participant