Skip to content

feat(ai): MCP remote HTTP/streamable transport + Docker service (AI-049)#347

Merged
mrviduus merged 1 commit into
mainfrom
ai-049-mcp-http
Jun 16, 2026
Merged

feat(ai): MCP remote HTTP/streamable transport + Docker service (AI-049)#347
mrviduus merged 1 commit into
mainfrom
ai-049-mcp-http

Conversation

@mrviduus

Copy link
Copy Markdown
Owner

AI-049 — Remote HTTP/streamable transport for the MCP server (Phase 8)

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 one 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 MCP client config), never the per-process device-flow cache.

  • HttpContextTokenProvider (scoped) reads the bearer from the current request via IHttpContextAccessorAuthorized/Failed; never device flow, never the token cache.
  • http mode registers identity services scoped; the SDK's stateless streamable transport resolves tools/call from the per-HTTP-request scope, so two users = two scopes = two bearers (verified by QA through the real BuildHttp container, traced into the decompiled SDK — not just fakes). ValidateScopes + ValidateOnBuild are ON so any future singleton-capture mistake fails at startup instead of silently leaking one user's token in prod.
  • McpToolCatalog + TextStackApiClient are unchanged — only DI lifetime + the 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 wiring

  • backend/Docker/Mcp.Dockerfile (thin, non-root) + a localhost-only mcp-server compose service behind profiles:[mcp] so CI's bare docker compose up never builds/runs it. TEXTSTACK_API_URL=http://api:8080 keeps the bridge's upstream calls inside the docker network (no Cloudflare round-trip; Host: textstack.app still drives site resolution).
  • nginx: upstream textstack_mcp + location /mcp with SSE/streaming settings (proxy_buffering off, HTTP/1.1, Authorization forwarded, 3600s timeouts, dedicated rate zone). Public path /mcp on textstack.app via the existing tunnel. Applied at deploy.

Security (adversarial review — 0 P1)

No identity leak (per-request scope, proven); bearer never logged; /health is 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 under ValidateOnBuild), bearer parsing edge cases. stdio + StudyBuddy set-equality green; no ITool leaked.

Verify

  • dotnet test tests/TextStack.UnitTests → 620 pass · dotnet build / dotnet format --verify-no-changes → clean
  • docker build -f backend/Docker/Mcp.Dockerfile → builds; MCP_TRANSPORT=http container /health → 200
  • docker compose config default excludes mcp-server; --profile mcp includes it (valid)

Remaining Phase 8: AI-051 (npm wrapper), AI-052 (/mcp landing + manifest), AI-053 (integration tests).

🤖 Generated with Claude Code

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>
@mrviduus mrviduus merged commit a71f9d1 into main Jun 16, 2026
5 checks passed
@mrviduus mrviduus deleted the ai-049-mcp-http branch June 16, 2026 21:53
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