feat(ai): MCP remote HTTP/streamable transport + Docker service (AI-049)#347
Merged
Conversation
Adds a remote HTTP transport (ModelContextProtocol.AspNetCore 1.4.0) alongside stdio so MCP clients connect over the network behind the existing nginx + Cloudflare tunnel — no new cloud. Dual-mode Program.cs (env MCP_TRANSPORT: stdio default | http). The critical fork — multi-user identity. Remote HTTP serves many users from one process, so each connection authenticates via its OWN Authorization: Bearer header (the AI-050 device-flow JWT pasted into the client config), NOT the per-process device-flow cache: - HttpContextTokenProvider (scoped) reads the bearer from the current request via IHttpContextAccessor → Authorized/Failed; never device flow. - http mode registers identity services SCOPED; the SDK's stateless streamable transport resolves tools/call from the per-request scope, so two users = two scopes = two bearers (verified through the real BuildHttp container, not just fakes). ValidateScopes + ValidateOnBuild ON so any future singleton-capture mistake fails at startup, not as a silent prod leak. Catalog + TextStackApiClient unchanged — only lifetime + provider registration differ by transport. - stdio stays byte-identical: singleton DI, logs→stderr, device-flow/ static provider, stdio transport (incl. the env-overridable timeout). Deploy: Mcp.Dockerfile (thin, non-root) + a localhost-only mcp-server compose service behind profiles:[mcp] so CI's bare 'compose up' never builds/runs it; TEXTSTACK_API_URL=http://api:8080 keeps bridge calls inside the docker network (no Cloudflare round-trip). nginx upstream + /mcp location with SSE/streaming settings (proxy_buffering off, HTTP/1.1, Authorization forwarded, 3600s timeouts, rate zone). Public path /mcp on textstack.app via the existing tunnel. Security (adversarial review, 0 P1): no identity leak (per-request scope), bearer never logged, /health internal-only + unauth, no CORS, public tools read-only + user-scoped require the caller's own JWT. 26 MCP transport tests (per-request isolation, no-bearer→clean IsError+zero sends, dual-mode startup, bearer parsing). 620 unit green; stdio + StudyBuddy set-equality green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
AI-049 — Remote HTTP/streamable transport for the MCP server (Phase 8)
Adds a remote HTTP transport (
ModelContextProtocol.AspNetCore1.4.0) alongside stdio so MCP clients connect over the network behind the existing nginx + Cloudflare tunnel — no new cloud. Dual-mode oneProgram.cs(envMCP_TRANSPORT:stdiodefault |http).The critical fork — multi-user identity
Remote HTTP serves many users from one process, so each connection authenticates via its own
Authorization: Bearerheader (the AI-050 device-flow JWT pasted into the MCP client config), never the per-process device-flow cache.HttpContextTokenProvider(scoped) reads the bearer from the current request viaIHttpContextAccessor→Authorized/Failed; never device flow, never the token cache.tools/callfrom the per-HTTP-request scope, so two users = two scopes = two bearers (verified by QA through the realBuildHttpcontainer, traced into the decompiled SDK — not just fakes).ValidateScopes+ValidateOnBuildare ON so any future singleton-capture mistake fails at startup instead of silently leaking one user's token in prod.McpToolCatalog+TextStackApiClientare unchanged — only DI lifetime + the provider registration differ by transport.Deploy wiring
backend/Docker/Mcp.Dockerfile(thin, non-root) + a localhost-onlymcp-servercompose service behindprofiles:[mcp]so CI's baredocker compose upnever builds/runs it.TEXTSTACK_API_URL=http://api:8080keeps the bridge's upstream calls inside the docker network (no Cloudflare round-trip;Host: textstack.appstill drives site resolution).upstream textstack_mcp+location /mcpwith SSE/streaming settings (proxy_buffering off, HTTP/1.1,Authorizationforwarded, 3600s timeouts, dedicated rate zone). Public path/mcpon textstack.app via the existing tunnel. Applied at deploy.Security (adversarial review — 0 P1)
No identity leak (per-request scope, proven); bearer never logged;
/healthis internal-only + unauthenticated (nginx proxies only/mcp); no CORS (MCP clients aren't browsers); public tools are read-only, user-scoped tools require the caller's own JWT — no escalation beyond what the API already grants. TLS terminates at Cloudflare; the internal hop is the trusted docker network.Tests — 26 MCP transport (full unit suite 620)
Per-request isolation through the real container (distinct scoped provider/catalog/client per scope; two bearers no cross-talk), no-bearer → clean auth-required
IsError+ zero HTTP sends, dual-mode startup (/health→200 underValidateOnBuild), bearer parsing edge cases. stdio + StudyBuddy set-equality green; noIToolleaked.Verify
dotnet test tests/TextStack.UnitTests→ 620 pass ·dotnet build/dotnet format --verify-no-changes→ cleandocker build -f backend/Docker/Mcp.Dockerfile→ builds;MCP_TRANSPORT=httpcontainer/health→ 200docker compose configdefault excludesmcp-server;--profile mcpincludes it (valid)Remaining Phase 8: AI-051 (npm wrapper), AI-052 (
/mcplanding + manifest), AI-053 (integration tests).🤖 Generated with Claude Code