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":

PathHTTPcontent-typeBody
/.well-known/oauth-authorization-server200application/jsonvalid, no jwks_uri key
/.well-known/openid-configuration200text/htmlthe SPA's <!doctype html> shell
/.well-known/jwks.json200text/htmlthe SPA's <!doctype html> shell
/api/auth/jwks404
/api/auth/mcp/jwks404

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.