MCP for outboundReference
Remote MCP Server vs Local MCP Server: The Difference Is an Auth Surface
Remote vs local MCP servers, probed: six unauthenticated requests found two well-known paths answering 200 with an HTML shell.
What we ranSix unauthenticated HTTP requests run against our own remote MCP server on 2026-07-28 to map its OAuth surface and check for gaps.
A local stdio MCP server is a process your host spawns; whoever can run the
binary is authorized, full stop. A remote MCP server puts an OAuth deployment
in front of the same tools/list call — a 401, a discovery chain, a
registration endpoint, a token you have to verify. We probed our own remote
server, https://app.heykairon.com/mcp, with six unauthenticated requests and
no credentials. The surface it exposes is the whole difference — including two
conventional key-discovery paths that answer HTTP 200 with an HTML page
instead of a 404.
The handshake, in six requests
A tools/list with nothing in hand
Call the endpoint cold, no bearer token, no prior setup:
curl -s -i -X POST https://app.heykairon.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
HTTP/2 401
content-type: application/json; charset=utf-8
content-length: 24
www-authenticate: Bearer resource_metadata="https://app.heykairon.com/.well-known/oauth-protected-resource"
access-control-expose-headers: Mcp-Session-Id, WWW-Authenticate
server: cloudflare
via: 1.1 Caddy
x-powered-by: Express
The 401 by itself buys nothing. The WWW-Authenticate header does the work:
it carries a resource_metadata pointer, and that pointer is the entire
bootstrap. A client that knows only the URL now knows where to ask who can
issue it a token.
The protected-resource document
curl -s https://app.heykairon.com/.well-known/oauth-protected-resource
{
"resource": "https://app.heykairon.com/mcp",
"authorization_servers": ["https://app.heykairon.com"],
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"]
}
This names the authorization server. Nothing here is secret — it's meant to be fetched by a client that has never talked to us before.
The authorization-server document
curl -s https://app.heykairon.com/.well-known/oauth-authorization-server
Fields present, sorted (checked 2026-07-28):
acr_values_supported registration_endpoint
authorization_endpoint response_modes_supported
claims_supported response_types_supported
code_challenge_methods_supported scopes_supported
grant_types_supported subject_types_supported
id_token_signing_alg_values_supported token_endpoint
issuer token_endpoint_auth_methods_supported
userinfo_endpoint
The values that matter for a client deciding how to connect:
authorization_endpoint: https://app.heykairon.com/api/auth/mcp/authorize
token_endpoint: https://app.heykairon.com/api/auth/mcp/token
registration_endpoint: https://app.heykairon.com/api/auth/mcp/register
code_challenge_methods_supported: ["S256"]
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"]
grant_types_supported: ["authorization_code", "refresh_token"]
"none" sitting inside token_endpoint_auth_methods_supported, paired with
S256, is the public-client-plus-PKCE shape: a desktop MCP client holds no
secret and instead proves possession of the authorization code per exchange.
Dynamic client registration, anonymous
curl -s -X POST https://app.heykairon.com/api/auth/mcp/register \
-H 'Content-Type: application/json' \
-d '{"client_name":"kairon-seo-loop-probe",
"redirect_uris":["http://localhost:33418/callback"],
"grant_types":["authorization_code","refresh_token"],
"response_types":["code"],
"token_endpoint_auth_method":"none"}'
client_id: oOCWywYngamo… (truncated here; issued in full)
client_name: kairon-seo-loop-probe
token_endpoint_auth_method: none
grant_types: ["authorization_code", "refresh_token"]
client_id_issued_at: 1785268394
client_secret present: False
It works with no prior credential. That's the entire point of dynamic client registration: a user pastes a URL into their MCP client and gets connected, no dashboard visit, no key to copy first. This probe created one real client registration in our production database — the same record a genuine desktop connect would leave behind.
See our MCP setup docs for how a client actually walks this chain end to end.
Where the key discovery goes wrong
Our authorization-server metadata declares
id_token_signing_alg_values_supported and exposes a userinfo_endpoint, but
the field list above has no jwks_uri entry anywhere in the document —
absent, not empty. Without jwks_uri, a client holding an ID token has no
advertised location to fetch the public key and check its signature.
We checked five paths, all on 2026-07-28, before writing "absent":
| Path | HTTP | content-type | Body |
|---|---|---|---|
/.well-known/oauth-authorization-server | 200 | application/json | valid, no jwks_uri key |
/.well-known/openid-configuration | 200 | text/html | the SPA's <!doctype html> shell |
/.well-known/jwks.json | 200 | text/html | the SPA's <!doctype html> shell |
/api/auth/jwks | 404 | — | — |
/api/auth/mcp/jwks | 404 | — | — |
The two conventional locations for a JWKS document return HTTP 200 with an HTML page. A catch-all SPA route answers them. That is worse than a 404: any check that reads only the status code — a monitor, a directory crawler, a health probe — records both paths as present and healthy. The content type is the only signal that gives it away.
Credit where it is due: the earlier check on this
server already reported
/.well-known/jwks.json answering 200 with text/html and already called the
200 worse than the 404s. What this pass adds is that
/.well-known/openid-configuration does the same thing, and that the
behaviour survived a round of remediation — the field that pointed at a dead
URL was removed, the catch-all that swallows these paths was not.
Reproduce it in one line:
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
https://app.heykairon.com/.well-known/jwks.json
# 200 text/html; charset=utf-8
We have probed this same server's tool surface before, in the
tool-annotations audit. That one ran
in-process over InMemoryTransport and never touched HTTP — same server code,
a different layer. Everything here goes over the wire instead.
What we fixed last time, re-checked
A week ago the same two-minute check on this
server found the metadata
advertising a jwks_uri that returned 404 — a dead pointer, worse than no
pointer, because a client would follow it and fail. Checked again on
2026-07-28: that field is no longer present in the document at all (see the
field list above). The remediation was removal, not repair — the dead pointer
is gone, but the keys still aren't served at either conventional location we
checked. That earlier finding was accurate when published and its fix is
visible in the metadata today, which is worth writing down so a later check
doesn't cite the old 404 as if it were still live.
What a local server never has to answer for
A local stdio server — spawned by the host, talking JSON-RPC over a pipe — has
no HTTP surface at all. There is no WWW-Authenticate header to emit, no
discovery document to keep truthful, no registration endpoint, no token
signature to verify, and no public path for a catch-all route to answer
wrongly. Its trust boundary is the OS process table: whoever can run the
binary is authorized.
Choosing remote is not a deployment preference. It's taking on an OAuth deployment, and the failure modes that come with it are HTTP-shaped: a metadata field that's absent, a well-known path a frontend route swallows, a status code that reports healthy when the body says otherwise.
What this probe did not measure, and shouldn't be read into it:
- No latency, throughput, or reliability comparison between the two transports.
- No claim about which is more secure overall. Local trades an auth surface for "any process on the machine can call it" — that's a different risk, not a smaller one.
- No claim about what any other vendor's server does. This probe covers our own infrastructure only.
- Whether an ID token is actually issued in a completed authorization-code exchange was not tested — the probe stopped at registration and never completed a browser consent step. The table above states what the metadata omits, not that a token can't be verified in practice.
The two shapes carry different work. A local server carries zero HTTP surface and all of the OS-level trust exposure. A remote server carries the discovery chain, the registration endpoint, and the obligation to keep every advertised URL true. Ours got that wrong once by pointing at a key document it did not serve, and it is still wrong in the quieter way: a frontend route answering for paths the auth layer never claimed.