Skip to content

support remote mcp_tools() - #108

Merged
simonpcouch merged 9 commits into
mainfrom
remote
Jun 29, 2026
Merged

support remote mcp_tools()#108
simonpcouch merged 9 commits into
mainfrom
remote

Conversation

@simonpcouch

Copy link
Copy Markdown
Collaborator

Closes #88.

Add direct Streamable HTTP support to mcp_tools() so authenticated remote MCP
servers can be used without the mcp-remote npm shim. A server entry configured
with url (instead of command) connects over HTTP, running the tools lifecycle
(initialize, tools/list, tools/call) with JSON and POST-SSE responses.

Authentication is the focus: static headers cover bearer-token servers, and
full OAuth 2.1 is supported by leaning on httr2. After a 401 challenge mcptools
does the MCP-specific work -- protected-resource and authorization-server
discovery, PKCE, and establishing a client via static config, manual config, or
Dynamic Client Registration -- then hands the client to httr2, which owns the
token lifecycle: on-disk caching across sessions, refresh, and the
authorization-code flow. The registered DCR client id is cached so sessions
reuse it. Tool input schemas are converted with ellmer::type_from_schema().

The implementation deliberately stays scoped to the tools lifecycle and does
not implement protocol SHOULDs unneeded for that path (stream resumability,
progress notifications, granular timeouts, output-schema validation).
Integrate upstream spec tightenings (#107). Resolved additive conflicts in
NEWS.md (kept both feature notes) and test-server.R (kept both the
read_process_json and local_http_post_request helpers). Dropped the now-
redundant null = "null" arguments from the client's to_json() calls, since
#107 makes to_json() always serialize NULL as JSON null.
httr2's oauth_server_metadata() defaults to the OpenID
/.well-known/openid-configuration endpoint, but the MCP authorization spec
requires clients to attempt OAuth 2.0 authorization-server metadata (RFC 8414)
first and only fall back to OpenID. Discovery now tries both in that order, so
servers that expose only /.well-known/oauth-authorization-server are no longer
rejected.
The authorization, token, and registration endpoints from authorization-server
metadata flowed into the OAuth client without a scheme check, so a metadata
document advertising plaintext endpoints could expose authorization codes and
tokens over HTTP. Discovery now validates these endpoints, allowing HTTP only on
loopback or under the explicit allow_http opt-out, consistent with the MCP
endpoint and redirect-URI checks.
A 404 for a request carrying an MCP-Session-Id means the session expired; the
spec requires the client to start a fresh session with a new initialize rather
than give up. The transport now re-runs the initialize handshake and retries the
original request once, so long-lived clients survive routine server-side session
expiry without re-running mcp_tools(). The retry and both reinitialize
sub-requests disable further session retries to bound recovery, and an
unrecoverable session still raises the session-expired error.
read_process_json() was left behind when the HTTP roundtrip test moved to
driving mcp_tools() through a config file rather than reading the server's stdout
directly. Nothing references it.
@simonpcouch

Copy link
Copy Markdown
Collaborator Author

A more minimal version of this is on @http-client. That version is much smaller (currently +245, -59) and only covers static headers, not OAuth. Connect, Github, Stripe, and Linear and covered by that PR. MCP servers like Slack (the official one), Notion, Zoom, Figma, GCal, and Sentry would need this more substantive set of changes.

Some notes on a middle ground here, which I don't find very compelling.

Minimal OAuth middle layer

Build on @http-client rather than replacing it with all of #108. Keep the
small Streamable HTTP transport, static headers, ${ENV_VAR} substitution,
and simple JSON/SSE parsing. Add just enough OAuth to authenticate against the
popular hosted remote MCP servers:

  • static headers continue to cover Connect, GitHub PATs, Stripe, Linear API
    keys, Zapier connection tokens, and similar machine-token cases;
  • pre-registered OAuth clients cover Slack, Asana, Figma, GitHub OAuth, Sentry,
    Google, Zoom, and providers that require app registration;
  • MCP challenge discovery plus optional Dynamic Client Registration covers
    servers that advertise enough metadata to be configured with just url and
    oauth.enabled, notably Notion-style and Atlassian-style remote servers.

This is not the full #108 transport. It intentionally skips session-expiration
recovery, DELETE cleanup, request concurrency guards, full streamed SSE routing,
server-initiated request handling beyond basic response extraction, pagination,
ignore_tools, and most defensive endpoint validation.

Config

Static headers stay as-is:

{
  "mcpServers": {
    "connect": {
      "url": "https://connect.example.com/content/<guid>/mcp",
      "headers": {
        "Authorization": "Key ${CONNECT_API_KEY}"
      }
    }
  }
}

Pre-registered OAuth client:

{
  "mcpServers": {
    "slack": {
      "url": "https://mcp.slack.com/mcp",
      "oauth": {
        "client_id": "${SLACK_MCP_CLIENT_ID}",
        "client_secret": "${SLACK_MCP_CLIENT_SECRET}",
        "issuer": "https://slack.com",
        "scope": "channels:history channels:read",
        "resource": "https://mcp.slack.com/mcp"
      }
    }
  }
}

Explicit endpoints, for providers where issuer discovery is awkward:

{
  "mcpServers": {
    "example": {
      "url": "https://mcp.example.com/mcp",
      "oauth": {
        "client_id": "${EXAMPLE_CLIENT_ID}",
        "auth_url": "https://auth.example.com/oauth/authorize",
        "token_url": "https://auth.example.com/oauth/token",
        "redirect_uri": "http://localhost:1410/oauth/callback",
        "resource": "https://mcp.example.com/mcp"
      }
    }
  }
}

MCP-discovered OAuth/DCR:

{
  "mcpServers": {
    "notion": {
      "url": "https://mcp.notion.com/mcp",
      "oauth": {
        "enabled": true
      }
    }
  }
}

Behavior

  • If headers.Authorization is configured, use it and do not run OAuth.
  • If oauth includes enough information to build a client (client_id plus
    either issuer metadata or explicit auth_url/token_url), build the client
    before the first HTTP request and attach a cached/refreshed access token.
  • If an HTTP request returns 401 or 403 and oauth.enabled is true, parse
    WWW-Authenticate, discover protected-resource metadata, pick an
    authorization server, discover OAuth server metadata, and establish a client.
  • If no explicit client is configured and the authorization server advertises a
    registration_endpoint, dynamically register a public PKCE client and cache
    the resulting client registration.
  • Pass resource through both auth and token requests using httr2's
    auth_params and token_params; default it to the MCP server URL.
  • Delegate the browser flow, PKCE, token refresh, and token disk cache to
    httr2::oauth_token_cached() / httr2::oauth_flow_auth_code().
  • Retry the original request once after acquiring a token. If a cached token is
    rejected, retry once more with reauth = TRUE.
  • Store only minimal OAuth state on the server entry: metadata, client info,
    client object, cache key, and token.

To-dos

  • Add oauth config parsing with recursive ${ENV_VAR} interpolation.
  • Reject simultaneous headers.Authorization and oauth, unless explicitly
    allowed.
  • Support explicit OAuth endpoints and issuer discovery through
    httr2::oauth_server_metadata().
  • Parse WWW-Authenticate for resource_metadata, scope, and OAuth error
    details.
  • Discover protected-resource metadata from the challenge URL, then the MCP
    endpoint well-known URL.
  • Select one authorization server, with a config override for ambiguous
    metadata.
  • Build httr2::oauth_client() from configured client credentials, DCR
    response, or cached DCR response.
  • Implement DCR POST to registration_endpoint for public PKCE clients.
  • Compute stable token and DCR cache keys from server URL, resource, issuer,
    scope, redirect URI, and client id.
  • Attach bearer tokens to HTTP requests and implement one auth retry plus
    one forced-reauth retry.
  • Add focused tests for config parsing, resource params, 401 discovery, DCR
    caching, token attachment, and retry behavior.
  • Update remote-client docs with static-header, pre-registered OAuth, and
    DCR examples.

Estimate

Current branch sizes:

@http-client vs main: +245 / -59
#108 remote vs main:  +3230 / -139
#108 source only:     about +1940 / -103 across DESCRIPTION and R/

Estimated middle layer relative to @http-client:

Source:      +550 to +750 / -20 to -60
Tests/docs:  +350 to +550 / -10 to -40
Total:       +900 to +1300 / -30 to -100

Estimated middle layer relative to #108:

Avoided insertions: about 1900 to 2300 lines
Avoided deletions:  about 40 to 110 lines
Total size:         about 40% of #108 overall
Source size:        about 35% to 45% of #108 source changes

If we drop DCR and require every OAuth server to provide a pre-registered client,
the estimate falls to roughly +350 to +650 total lines over @http-client.
That is the smallest tier, but it gives up the url plus oauth.enabled
experience and may not honestly cover every hosted MCP server without extra
provider-side setup.

Decision note

If user simplicity is the deciding criterion, prefer #108's fuller OAuth path
over the middle layer. The middle layer mostly saves maintenance surface by
moving setup work onto users: they need to know when to register an OAuth app,
where to find issuer/auth/token URLs, what scopes/resource values are required,
and how to cache/manage client registration details. #108 keeps that knowledge
inside mcptools where the MCP authorization spec already defines it:

  • a 401 challenge points to protected-resource metadata;
  • protected-resource metadata points to authorization servers;
  • authorization-server metadata supplies endpoints and DCR availability;
  • DCR creates a usable public PKCE client when the provider supports it;
  • httr2 owns the browser flow, refresh, and token cache.

With that priority, the better plan is not "minimal OAuth." It is to take #108's
auth layer, then look for localized simplifications that do not leak extra
provider-specific setup into user config. Good candidates are non-auth transport
features and test fixture size, not protected-resource discovery or DCR.

@simonpcouch
simonpcouch merged commit aa47d2c into main Jun 29, 2026
6 checks passed
@simonpcouch
simonpcouch deleted the remote branch June 29, 2026 14:34
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.

feat: http transport support

1 participant