Serve the Stash tools over HTTPS as a remote MCP endpoint - #1035
Serve the Stash tools over HTTPS as a remote MCP endpoint#1035henry-dowling wants to merge 2 commits into
Conversation
mcp 2.0.0 (released 2026-07-28) moved FastMCP to mcp.server.mcpserver and dropped streamablehttp_client. Both pins were an unbounded `>=1.23.0`, so every fresh install resolved to 2.0 and `stash-mcp` — the MCP server the CLI ships — died on its import line. Anyone who installed or upgraded in the last two weeks has a dead entrypoint. Prod is unaffected: its running image is on 1.x, confirmed from a traceback that still shows streamablehttp_client present. But backend/requirements.txt carried the same unbounded floor, so the next fresh resolve would have taken the Granola and PostHog clients down at import time. That's the tripwire this defuses. Pinning rather than porting: the CLI half is a two-line change (MCPServer is a drop-in for FastMCP), but the backend half needs a real port to the new client API, and splitting mcp majors across one repo is worse than holding both. The lockfile picks up unrelated drift because it was already stale against the last version bump. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
cli/mcp_server.py runs on the user's laptop and reads their API key out of ~/.stash/config.json. That works for Claude Code and cannot work anywhere else: Cowork's cloud sessions run in a Linux container with no view of the user's Mac, and Claude on the web and on a phone never touch it at all. Reaching those surfaces means HTTP and OAuth, which is what this adds — the same tools, on the backend we already run. Tools call our own REST API in process, through an ASGI transport carrying the caller's bearer token. Authorization, scoping, plan gates and rate limits are then enforced by exactly the code that enforces them for the web app. Re-implementing 72 tools against the service layer would mean a second authorization path to keep correct, and the first missed scope check is a data leak between users. The OAuth audience is the endpoint's own URL, not the web app's API identifier. Claude sends the MCP server URL as the `resource` parameter and Auth0 stamps it into `aud` (needs the tenant's resource-parameter compatibility profile, now on). So MCP tokens and web-app tokens are separate credentials, and neither is accepted where the other is expected. Gated on MCP_PUBLIC_URL: unset, nothing mounts, so this is inert in production until that variable is set — and self-hosted deployments, which have no Auth0 tenant to verify tokens against, never serve it. Three tools to prove the path end to end; the remaining 69 are mechanical and follow once it has been verified against a real Claude connection. Tests cover the discovery handshake (401 -> WWW-Authenticate -> RFC 9728 document -> Auth0) and the audience separation. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
2 clusters identified |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 72ac610. Configure here.
| # Surface the API's own error text: an MCP tool that swallows a 403 and | ||
| # returns an empty result teaches the model the data does not exist. | ||
| resp.raise_for_status() | ||
| return resp.json() |
There was a problem hiding this comment.
MCP tokens rejected by API routes
High Severity
Tools forward the caller's MCP bearer token into in-process REST routes, but that token is minted for MCP_PUBLIC_URL while get_current_user validates JWTs only against AUTH0_AUDIENCE. After a successful OAuth handshake, every tool call hits the API with the wrong audience and fails auth, so the remote endpoint cannot actually serve data.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 72ac610. Configure here.
| ): | ||
| app.router.routes.append(route) | ||
|
|
||
| app.mount(path, _build_server().streamable_http_app()) |
There was a problem hiding this comment.
MCP session manager never started
High Severity
attach mounts streamable_http_app() but discards the FastMCP instance and never enters session_manager.run() in the parent FastAPI lifespan. Mounted Streamable HTTP apps do not inherit sub-app lifespan, so authenticated MCP traffic fails with an uninitialized task group even though the unauthenticated discovery 401 still works.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 72ac610. Configure here.


Two changes. The first is a bug fix that stands alone; the second is the foundation for reaching Claude surfaces that aren't a developer's laptop.
Hold mcp at 1.x (9694039)
mcp2.0.0 shipped on 2026-07-28, movedFastMCPtomcp.server.mcpserver, and droppedstreamablehttp_client. Both of our pins were an unbounded>=1.23.0, so every fresh install has been resolving to 2.0 andstash-mcpdies on its import line. Anyone who installed or upgraded the CLI in the last two weeks has a dead MCP entrypoint.Prod is not affected — its running image is on 1.x, confirmed from a live traceback that still shows
streamablehttp_clientpresent. Butbackend/requirements.txtcarried the same unbounded floor, so the next fresh Docker resolve would have taken the Granola and PostHog clients down at import time. That tripwire is the main reason this is worth shipping on its own.Pinning rather than porting: the CLI half is a two-line change (
MCPServeris a drop-in), but the backend half needs a real port to the new client API, and splitting mcp majors across one repo is worse than holding both at 1.x.Remote MCP endpoint (72ac610)
cli/mcp_server.pyruns on the user's laptop and reads their API key from~/.stash/config.json. That works for Claude Code and cannot work anywhere else — I verified that a Cowork cloud session runs in a Linux container with no view of the user's Mac at all, and Claude on the web and mobile never touch the machine. Those surfaces reach tools only over HTTP with OAuth.Tools call our own REST API in process, through an ASGI transport carrying the caller's bearer token. Authorization, scoping, plan gates and rate limits are then enforced by exactly the code that enforces them for the web app. Re-implementing 72 tools against the service layer would mean a second authorization path to keep correct, and the first missed scope check is a data leak between users. This is the main design decision in the PR and the one most worth arguing with.
The OAuth audience is the endpoint's own URL, not the web app's API identifier, so MCP tokens and web-app tokens are separate credentials and neither is accepted where the other is expected.
Deployment
Gated on
MCP_PUBLIC_URL. Unset, nothing mounts — merging this changes nothing in production until that variable is set on Render. Self-hosted deployments, which have no Auth0 tenant to verify tokens against, never serve it.Requires the Auth0 tenant's Resource Parameter Compatibility Profile and Include Issuer in Authorization Responses toggles, both now enabled. Without them Auth0 ignores the
resourceparameter Claude sends and every call 401s. Existing web-app sign-ins are unchanged: they always passaudienceexplicitly, which still takes precedence.Scope
Three tools, to prove the path end to end. The remaining 69 are mechanical — a docstring and one line calling an existing route each — and should follow only after this has been verified against a real Claude connection, where the remaining unknowns actually live.
Verification
The full discovery handshake works as Anthropic's spec describes:
test_activity.pyis excluded: it segfaults inside torch/sentence-transformers on my machine, which I confirmed also happens on a clean treeNot done
[email protected]for Anthropic-held client credentials; DCR otherwise registers a new OAuth client on every fresh connection🤖 Generated with Claude Code
Note
High Risk
Introduces a public OAuth-authenticated MCP surface and changes Auth0 audience validation, which are security-critical paths. Gated behind MCP_PUBLIC_URL, but once enabled it exposes tools on the public internet.
Overview
Exposes Stash tools over HTTPS with OAuth so Claude surfaces that aren't a local laptop (Cowork, web, mobile) can reach them. Also pins
mcpto 1.x so fresh installs stop resolving to 2.0 and breakingstash-mcp/ Granola / PostHog imports.The new remote endpoint mounts only when
MCP_PUBLIC_URLis set. Tools are thin wrappers that call existing REST routes in-process with the caller's bearer token, so auth, scoping, plan gates, and rate limits stay on the same path as the web app. Starts with three tools:stash_search,stash_vfs, andstash_list_workspaces.Auth0 JWT validation is split so MCP tokens use the endpoint URL as audience and web-app tokens keep
AUTH0_AUDIENCE— neither credential is accepted on the other surface.Reviewed by Cursor Bugbot for commit 72ac610. Bugbot is set up for automated code reviews on this repo. Configure here.