Skip to content

Serve the Stash tools over HTTPS as a remote MCP endpoint - #1035

Open
henry-dowling wants to merge 2 commits into
mainfrom
prompt-onboarding
Open

Serve the Stash tools over HTTPS as a remote MCP endpoint#1035
henry-dowling wants to merge 2 commits into
mainfrom
prompt-onboarding

Conversation

@henry-dowling

@henry-dowling henry-dowling commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

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)

mcp 2.0.0 shipped on 2026-07-28, moved FastMCP to mcp.server.mcpserver, and dropped streamablehttp_client. Both of our pins were an unbounded >=1.23.0, so every fresh install has been resolving to 2.0 and stash-mcp dies 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_client present. But backend/requirements.txt carried 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 (MCPServer is 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.py runs 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 resource parameter Claude sends and every call 401s. Existing web-app sign-ins are unchanged: they always pass audience explicitly, 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:

POST /mcp  ->  401
   WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp"
GET that URL  ->  200
   {"resource": ".../mcp", "authorization_servers": ["https://<tenant>/"]}
  • 1360 backend tests pass; ruff clean
  • 7 new tests cover the discovery chain and the audience separation
  • test_activity.py is excluded: it segfaults inside torch/sentence-transformers on my machine, which I confirmed also happens on a clean tree

Not done

  • 69 remaining tools
  • Security review — this puts 72 tools, including destructive ones, on the public internet
  • [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 mcp to 1.x so fresh installs stop resolving to 2.0 and breaking stash-mcp / Granola / PostHog imports.

The new remote endpoint mounts only when MCP_PUBLIC_URL is 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, and stash_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.

henry-dowling and others added 2 commits August 11, 2026 21:39
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]>
@assert-app

assert-app Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review on Assert →

2 clusters identified

Merge candidate is ready!

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stash-web Ready Ready Preview Aug 12, 2026 1:42am

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread backend/mcp_remote.py
# 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 72ac610. Configure here.

Comment thread backend/mcp_remote.py
):
app.router.routes.append(route)

app.mount(path, _build_server().streamable_http_app())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 72ac610. Configure here.

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.

1 participant