support remote mcp_tools() - #108
Conversation
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.
|
A more minimal version of this is on Some notes on a middle ground here, which I don't find very compelling.
Minimal OAuth middle layerBuild on
This is not the full #108 transport. It intentionally skips session-expiration ConfigStatic 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
To-dos
EstimateCurrent branch sizes: Estimated middle layer relative to Estimated middle layer relative to #108: If we drop DCR and require every OAuth server to provide a pre-registered client, Decision noteIf user simplicity is the deciding criterion, prefer #108's fuller OAuth path
With that priority, the better plan is not "minimal OAuth." It is to take #108's |
Closes #88.