diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b854c8..eef36d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +### Phase 8 — MCP remote HTTP (streamable) transport (AI-049) (2026-06-16) + +A REMOTE HTTP transport for the MCP server so clients connect over HTTP behind the existing nginx + Cloudflare tunnel (no new cloud), as a new localhost-only Docker service. **stdio behavior is byte-identical** — the http transport is additive. Critical design point: remote HTTP is **multi-user** — each connection authenticates via its OWN `Authorization: Bearer` header (the AI-050 device-flow JWT pasted into the client config), NOT a server-side token cache. + +- **Dual-mode host** (`Program.cs` + new `McpHosts`). Transport selected by env `MCP_TRANSPORT` (`stdio` default | `http`) or the `--http` CLI flag (`McpBridgeOptions.Transport`). **stdio** (default, UNCHANGED): `Host.CreateApplicationBuilder`, logs→stderr (the JSON-RPC-on-stdout invariant preserved), `.WithStdioServerTransport()`, singleton DI, device-flow/static token — one process identity. **http** (AI-049): `WebApplication`, normal logging (no JSON-RPC on stdout here), `AddHttpContextAccessor()`, `.WithHttpTransport(o => o.Stateless = true)`, `app.MapMcp("/mcp")` + `GET /health` (Docker probe), `ASPNETCORE_URLS=http://+:8090`. +- **Per-connection identity** (`Auth/HttpContextTokenProvider.cs`, http only). `IMcpTokenProvider` that reads the bearer off `IHttpContextAccessor.HttpContext.Request.Headers.Authorization` (case-insensitive `Bearer ` strip): non-empty → `Authorized(jwt)`; missing/empty/`"Bearer "`-only/malformed → `Failed("authentication required — set Authorization: Bearer in your MCP client")`. NEVER does device flow, NEVER touches `TokenCache`. +- **DI lifetime — the real impact.** http mode registers `IMcpTokenProvider` → `HttpContextTokenProvider` **scoped** and `McpToolCatalog` **scoped** (the `tools/call` handler resolves from request-scoped `request.Services`; the typed `AddHttpClient` is request-scoped already) so the per-request bearer can't leak across connections. stdio mode keeps the SINGLETON registrations exactly as before. The http host additionally turns DI **scope validation ON** (`UseDefaultServiceProvider` → `ValidateScopes = true`, `ValidateOnBuild = true`) as defense-in-depth for the public multi-user endpoint: a future regression that registers an identity service as a singleton capturing a scoped dep fails at build instead of silently leaking one user's bearer (stdio keeps the defaults — singleton-by-design). The lifetime-agnostic `ListTools`/`CallTool` handler delegates + shared client config are extracted to `McpBridgeCore` so both hosts register identical handlers. `TextStackApiClient`/`McpToolCatalog` are UNCHANGED (already call `GetTokenAsync()`, map `Failed` → clean auth-required `IsError`, public tools use `PublicRequest`). +- **Package.** `ModelContextProtocol.AspNetCore` 1.4.0 (matches the pinned `ModelContextProtocol` 1.4.0) added to `Directory.Packages.props`; the MCP csproj keeps `Microsoft.NET.Sdk` (NOT `.Web`) + an explicit ``. Real API used: `builder.Services.AddMcpServer(...).WithHttpTransport(o => o.Stateless = true)` + `app.MapMcp("/mcp")`. +- **Deploy.** `backend/Docker/Mcp.Dockerfile` (alpine sdk build → aspnet runtime, restores/publishes ONLY the thin MCP csproj, non-root `app`, `ASPNETCORE_URLS=http://+:8090`, EXPOSE 8090). `docker-compose.yml` `mcp-server` behind a **`mcp` profile** (so the CI docker/e2e jobs' bare `docker compose up` does NOT start it): `MCP_TRANSPORT=http`, `TEXTSTACK_API_URL=http://api:8080` (INTERNAL docker network, no Cloudflare round-trip), `TEXTSTACK_SITE_HOST=textstack.app`, `ports: 127.0.0.1:8090:8090`, `/health` healthcheck, `depends_on: api healthy`, `restart: always`, 256M cap. `infra/nginx/textstack.conf`: `upstream textstack_mcp` (keepalive 32), `mcp_limit` zone (10r/s burst 20), `location /mcp` with SSE/streaming settings (`proxy_http_version 1.1`, `Connection ""`, relays `Authorization`, `proxy_buffering off`, `proxy_cache off`, 3600s read/send timeouts) — applied manually on the server at deploy. +- **Tests** (`tests/TextStack.UnitTests/McpHttpTransportTests.cs`, CI-safe, no network/live server): `HttpContextTokenProvider` header→`TokenResult` (bearer→`Authorized`, case-insensitive scheme, absent/empty/`"Bearer "`-only/wrong-scheme/malformed→`Failed`, no-context→`Failed`); composition (catalog over the provider, no bearer → clean auth-required `IsError`, ZERO sends); multi-user guarantee (different bearers → different tokens; same provider re-reads the live request); dual-mode startup (`ResolveTransport` parses env + `--http`; `BuildStdio` constructs; `BuildHttp` starts a `WebApplication` with `/health`→200). No `ITool` added (StudyBuddy set-equality stays green). + ### Phase 8 — `save_highlight` MCP write tool (AI-048b) (2026-06-16) The first WRITE tool on the MCP↔HTTP bridge, completing the 7-tool surface (the 6 reads + `save_highlight`). Now safe to ship because AI-050b gives a per-user consented token, so the write runs on the user's OWN identity, not a shared static secret. All in `backend/src/Ai/TextStack.Ai.Mcp/` + unit tests — **no backend change**. diff --git a/CLAUDE.md b/CLAUDE.md index 88c6650d..dbc05e5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -456,13 +456,13 @@ That single command builds the AAB and pushes it to Internal Testing. Service ac ``` Internet → Cloudflare (DNS+SSL) → Cloudflare Tunnel → nginx (port 80) - ├─ textstack.app → SSG static files + /api/ proxy to :8080 + ├─ textstack.app → SSG static files + /api/ proxy to :8080 + /mcp proxy to :8090 └─ textstack.dev → admin panel (:81) ``` -Docker services: `db` (postgres:16), `migrator`, `api`, `worker`, `admin`, `ssg-worker`, `aspire-dashboard` (profile-gated), `ollama`. All localhost-only, no public ports except 80 via tunnel. +Docker services: `db` (postgres:16), `migrator`, `api`, `worker`, `admin`, `ssg-worker`, `aspire-dashboard` (profile-gated), `ollama`, `mcp-server` (profile-gated, `--profile mcp`). All localhost-only, no public ports except 80 via tunnel. -**Nginx bot detection**: Regex map identifies crawlers (Google, Bing, Yandex, social bots) → routes to prerendered SSG HTML. Rate limiting zones: API (10r/s), uploads (1r/s), translation (5r/m). +**Nginx bot detection**: Regex map identifies crawlers (Google, Bing, Yandex, social bots) → routes to prerendered SSG HTML. Rate limiting zones: API (10r/s), uploads (1r/s), translation (5r/m), MCP (10r/s). **Systemd services**: `seo-publish-poller` (auto-publish with SEO generation). @@ -474,6 +474,16 @@ Supported formats: EPUB, PDF, FB2. Processing order: Spelling → Hyphenation FB2 (`Fb2TextExtractor`): XML-based FictionBook 2.0. Cover from binary elements, metadata extraction, chapter flattening, namespace detection for non-compliant files. +## MCP Server (`backend/src/Ai/TextStack.Ai.Mcp/`) + +Thin, stateless MCP↔HTTP bridge (Phase 8) — every tool call becomes an HTTP request to the public TextStack API (no DB/EF/OpenAI). 7 tools: `search_books`, `get_book`, `get_chapter` (public) + `list_my_highlights`, `list_my_vocabulary`, `ask_book`, `save_highlight` (Bearer). + +**Dual transport** (env `MCP_TRANSPORT`: `stdio` default | `http`; `--http` flag also selects http). Shared wiring (tool catalog handlers, typed `TextStackApiClient`) in `McpBridgeCore`; the two host builders in `McpHosts`. +- **stdio** (local, single identity): `Host.CreateApplicationBuilder`, **logs→stderr** (stdout is JSON-RPC only — never `Console.Write*`), singleton DI, token from `TEXTSTACK_MCP_TOKEN` (static) or the device flow (`DeviceFlowTokenProvider`, AI-050). Byte-identical to the pre-049 server. +- **http** (AI-049, remote, **multi-user**): `WebApplication`, `.WithHttpTransport(o => o.Stateless = true)`, `app.MapMcp("/mcp")` + `GET /health`. Each connection authenticates with its OWN `Authorization: Bearer ` — the AI-050 device-flow JWT pasted into the client config — read per-request by `HttpContextTokenProvider` (SCOPED; `McpToolCatalog` + provider scoped so no identity leaks across connections). NEVER touches the device-flow cache. Package: `ModelContextProtocol.AspNetCore` 1.4.0 (matches the pinned `ModelContextProtocol`). + +**Deploy** (http mode): Docker `mcp-server` (`backend/Docker/Mcp.Dockerfile`, profile `mcp`) binds `http://+:8090`, mapped `127.0.0.1:8090`; talks to the API over the **internal** docker network (`TEXTSTACK_API_URL=http://api:8080`). nginx `location /mcp` (upstream `textstack_mcp`, zone `mcp_limit`) proxies with SSE settings (`proxy_buffering off`, `Connection ""`, relays `Authorization`, 3600s timeouts). Behind Cloudflare tunnel — no new cloud. Bring up: `docker compose --profile mcp up -d mcp-server`. nginx `/mcp` block is applied manually on the server at deploy. + ## Telemetry OpenTelemetry → Aspire Dashboard (`localhost:18888`). OTLP: `:18889`. Services: `textstack-api`, `textstack-worker`. diff --git a/Directory.Packages.props b/Directory.Packages.props index c336930a..e39ad6dc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,6 +32,12 @@ TextStack.Ai.Mcp — the SDK ships the stdio transport + low-level ListTools/CallTool handler API used by the runtime tool catalog. --> + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml index a486f4d0..e71e5647 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -173,6 +173,47 @@ services: reservations: memory: 64M + # =========================================== + # MCP server - remote HTTP (streamable) transport (AI-049) + # =========================================== + # Multi-user MCP bridge: each connection authenticates with its OWN + # Authorization: Bearer (the AI-050 device-flow JWT pasted into the client + # config) — NOT a server-side token cache. Talks to the API over the INTERNAL + # docker network (no Cloudflare round-trip). nginx fronts /mcp → 127.0.0.1:8090. + # + # Behind the `mcp` profile so the default `docker compose up` (used by the CI + # docker/e2e jobs, which only wait on the API /health) does NOT start it. Bring + # it up on the server with: docker compose --profile mcp up -d mcp-server + mcp-server: + build: + context: . + dockerfile: backend/Docker/Mcp.Dockerfile + container_name: textstack_mcp_prod + profiles: ["mcp"] + environment: + MCP_TRANSPORT: http + TEXTSTACK_API_URL: http://api:8080 + TEXTSTACK_SITE_HOST: textstack.app + ASPNETCORE_URLS: http://+:8090 + ports: + - "127.0.0.1:8090:8090" # localhost only - nginx proxies /mcp + restart: always + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8090/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + depends_on: + api: + condition: service_healthy + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 64M + # =========================================== # SSG Worker - prerenders SEO pages # =========================================== diff --git a/infra/nginx/textstack.conf b/infra/nginx/textstack.conf index 99b4749f..2aa5c0d5 100644 --- a/infra/nginx/textstack.conf +++ b/infra/nginx/textstack.conf @@ -7,6 +7,8 @@ limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=upload_limit:10m rate=1r/s; limit_req_zone $binary_remote_addr zone=translate_limit:10m rate=5r/m; +# Remote MCP transport (AI-049) — same shape as the API zone (10r/s, burst 20). +limit_req_zone $binary_remote_addr zone=mcp_limit:10m rate=10r/s; # Bot detection — serve SSG only to crawlers, SPA to real users # NOTE: ~*ahrefs intentionally broad — covers AhrefsBot AND AhrefsSiteAudit/X.X @@ -85,6 +87,13 @@ upstream textstack_admin { server 127.0.0.1:81; } +# Upstream for the remote MCP server (AI-049) — Docker container, localhost only. +# Streamable HTTP / SSE; keepalive so long-lived connections reuse upstream sockets. +upstream textstack_mcp { + server 127.0.0.1:8090; + keepalive 32; +} + # --- textstack.app (Public site) --- # Cloudflare terminates SSL, traffic arrives as HTTP server { @@ -213,6 +222,34 @@ server { proxy_read_timeout 300s; } + # Remote MCP endpoint (AI-049) — streamable HTTP transport behind nginx + + # the Cloudflare tunnel. Multi-user: each client sends its own + # Authorization: Bearer (relayed verbatim below); the MCP + # server validates it per request (no server-side token cache here). + # + # SSE/streaming settings: HTTP/1.1 with no upstream "Connection: close", + # buffering/cache off so server-pushed events flush immediately, and long + # read/send timeouts for held-open streams. + location /mcp { + limit_req zone=mcp_limit burst=20 nodelay; + proxy_pass http://textstack_mcp; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + # Relay the client's bearer to the MCP server (it authenticates per request). + proxy_set_header Authorization $http_authorization; + # Clear Connection so keepalive to the upstream works (no "close"/"upgrade"). + proxy_set_header Connection ""; + # Streaming: do not buffer or cache server-sent events. + proxy_buffering off; + proxy_cache off; + # Hold long-lived streams open. + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + # Storage location /storage/ { alias /home/vasyl/projects/onlinelib/textstack/data/storage/; diff --git a/tests/TextStack.UnitTests/McpHttpTransportTests.cs b/tests/TextStack.UnitTests/McpHttpTransportTests.cs new file mode 100644 index 00000000..7b941485 --- /dev/null +++ b/tests/TextStack.UnitTests/McpHttpTransportTests.cs @@ -0,0 +1,342 @@ +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using TextStack.Ai.Mcp; +using TextStack.Ai.Mcp.Auth; +using TextStack.Ai.Mcp.Http; +using TextStack.Ai.Mcp.Tools; + +namespace TextStack.UnitTests; + +/// +/// AI-049 — the remote HTTP (streamable) transport. Covers the per-connection +/// identity provider (), the multi-user +/// guarantee (different bearers → different tokens), the catalog composition over +/// it (no bearer → clean auth-required IsError, zero HTTP), and the dual-mode host +/// switch (http builds a WebApplication with /health → 200; stdio still constructs; +/// MCP_TRANSPORT parses). +/// +/// CI-safe: no live server, no network. The http host is started on an ephemeral +/// loopback port. Introduces NO ITool (StudyBuddy set-equality stays green). +/// +public class McpHttpTransportTests +{ + // ── fakes ──────────────────────────────────────────────────────────────────── + + private sealed class CapturingHandler(HttpResponseMessage response) : HttpMessageHandler + { + public int Sends { get; private set; } + public HttpRequestMessage? LastRequest { get; private set; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Sends++; + LastRequest = request; + return Task.FromResult(response); + } + } + + private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) => + new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + private static System.Text.Json.JsonElement Args(string json) => + System.Text.Json.JsonDocument.Parse(json).RootElement; + + private static string TextOf(CallToolResult r) => ((TextContentBlock)r.Content[0]).Text; + + // A fake IHttpContextAccessor pinned to ONE request context. Unlike the framework + // HttpContextAccessor (a process-wide AsyncLocal), separate instances are fully + // isolated — which is exactly how two concurrent scoped providers behave, each + // bound to its own request scope. + private sealed class FakeHttpContextAccessor(HttpContext? context) : IHttpContextAccessor + { + public HttpContext? HttpContext { get; set; } = context; + } + + // A fake accessor whose HttpContext carries the given Authorization header + // (null = no context at all; "" / value = header on the request). + private static IHttpContextAccessor AccessorWith(string? authorizationHeader) + { + var ctx = new DefaultHttpContext(); + if (authorizationHeader is not null) + ctx.Request.Headers.Authorization = authorizationHeader; + return new FakeHttpContextAccessor(ctx); + } + + // ── HttpContextTokenProvider: header → TokenResult ─────────────────────────── + + [Fact] + public async Task GetTokenAsync_BearerHeader_ReturnsAuthorizedWithRawJwt() + { + var provider = new HttpContextTokenProvider(AccessorWith("Bearer jwt-abc.def.ghi")); + + var result = await provider.GetTokenAsync(CancellationToken.None); + + Assert.Equal("jwt-abc.def.ghi", Assert.IsType(result).AccessToken); + } + + [Theory] + [InlineData("bearer jwt-lower")] // lower-case scheme + [InlineData("BEARER jwt-upper")] // upper-case scheme + [InlineData("BeArEr jwt-mixed")] // mixed-case scheme + public async Task GetTokenAsync_SchemeIsCaseInsensitive(string header) + { + var provider = new HttpContextTokenProvider(AccessorWith(header)); + + var result = await provider.GetTokenAsync(CancellationToken.None); + + Assert.StartsWith("jwt-", Assert.IsType(result).AccessToken); + } + + [Theory] + [InlineData(null)] // no header + [InlineData("")] // empty header + [InlineData(" ")] // whitespace + [InlineData("Bearer ")] // scheme only + [InlineData("Bearer ")] // scheme + only whitespace + [InlineData("Basic abc123")] // wrong scheme + [InlineData("jwt-no-scheme")] // malformed, no scheme + public async Task GetTokenAsync_MissingOrMalformed_ReturnsFailedWithGuidance(string? header) + { + var provider = new HttpContextTokenProvider(AccessorWith(header)); + + var result = await provider.GetTokenAsync(CancellationToken.None); + + var failed = Assert.IsType(result); + Assert.Contains("Authorization: Bearer", failed.Message); + } + + [Fact] + public async Task GetTokenAsync_NoHttpContext_ReturnsFailed_NoThrow() + { + var provider = new HttpContextTokenProvider(new FakeHttpContextAccessor(null)); + + var result = await provider.GetTokenAsync(CancellationToken.None); + + Assert.IsType(result); + } + + // ── composition: catalog over HttpContextTokenProvider, no bearer → IsError ─── + + [Fact] + public async Task UserScopedTool_NoBearer_ReturnsAuthRequired_AndIssuesZeroSends() + { + var handler = new CapturingHandler(Json("[]")); + var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.example/") }; + var options = new McpBridgeOptions + { + ApiBaseUrl = "https://api.example", + SiteHost = "textstack.test", + Transport = McpTransport.Http, + }; + var provider = new HttpContextTokenProvider(AccessorWith(null)); // no bearer + var catalog = new McpToolCatalog(new TextStackApiClient(http, options, provider)); + + var result = await catalog.CallAsync( + "list_my_highlights", + Args("""{"editionId":"33333333-3333-3333-3333-333333333333"}"""), + CancellationToken.None); + + Assert.True(result.IsError); + Assert.Contains("authentication required", TextOf(result)); + Assert.Equal(0, handler.Sends); // never issued the HTTP call + } + + // ── multi-user guarantee: different bearers → different tokens ─────────────── + + [Fact] + public async Task PerRequestProvider_DifferentBearers_YieldDifferentTokens() + { + // Two independent request contexts (as two scopes would have under HTTP). + var alice = new HttpContextTokenProvider(AccessorWith("Bearer alice-token")); + var bob = new HttpContextTokenProvider(AccessorWith("Bearer bob-token")); + + var a = await alice.GetTokenAsync(CancellationToken.None); + var b = await bob.GetTokenAsync(CancellationToken.None); + + Assert.Equal("alice-token", Assert.IsType(a).AccessToken); + Assert.Equal("bob-token", Assert.IsType(b).AccessToken); + } + + [Fact] + public async Task SameProviderInstance_ReReadsLiveRequest_NoCapturedBearer() + { + // Guards against a singleton capturing the first request's bearer: swap the + // accessor's live context between calls and confirm the provider follows it. + var accessor = new FakeHttpContextAccessor(new DefaultHttpContext()); + var provider = new HttpContextTokenProvider(accessor); + + accessor.HttpContext!.Request.Headers.Authorization = "Bearer first"; + var first = await provider.GetTokenAsync(CancellationToken.None); + + accessor.HttpContext = new DefaultHttpContext(); + accessor.HttpContext.Request.Headers.Authorization = "Bearer second"; + var second = await provider.GetTokenAsync(CancellationToken.None); + + Assert.Equal("first", Assert.IsType(first).AccessToken); + Assert.Equal("second", Assert.IsType(second).AccessToken); + } + + // ── HERMETIC isolation through the REAL http DI container (AI-049 P1) ───────── + // The provider-level tests above use hand-built instances. These resolve through + // the container BuildHttp actually wires, so a singleton-capture regression + // (e.g. someone flips McpToolCatalog/HttpContextTokenProvider to AddSingleton) + // fails HERE, not just in a fake. + + private static McpBridgeOptions HttpOptions() => new() + { + ApiBaseUrl = "http://api:8080", + SiteHost = "textstack.app", + Transport = McpTransport.Http, + }; + + [Fact] + public void BuildHttp_IdentityServices_AreNotSingletons() + { + // The catastrophic-failure guard: if the token provider, catalog, or api + // client were singletons, the FIRST connection's bearer would be reused for + // every later connection. Each must be scope-local (distinct per scope) and + // the token provider must NOT be resolvable from the ROOT (scoped-only). + using var app = McpHosts.BuildHttp(HttpOptions(), args: []); + var root = app.Services; + + using var scopeA = root.CreateScope(); + using var scopeB = root.CreateScope(); + + var providerA = scopeA.ServiceProvider.GetRequiredService(); + var providerB = scopeB.ServiceProvider.GetRequiredService(); + var catalogA = scopeA.ServiceProvider.GetRequiredService(); + var catalogB = scopeB.ServiceProvider.GetRequiredService(); + var clientA = scopeA.ServiceProvider.GetRequiredService(); + var clientB = scopeB.ServiceProvider.GetRequiredService(); + + Assert.IsType(providerA); + // The isolation property: a DISTINCT instance per scope. A singleton + // registration (the catastrophic regression) would make these Same and the + // first connection's identity would leak to the second. + Assert.NotSame(providerA, providerB); // per-request identity, never shared + Assert.NotSame(catalogA, catalogB); + Assert.NotSame(clientA, clientB); + + // Same scope → same instance (sanity: scoped, not transient — the catalog and + // its api client are stable WITHIN one request). + Assert.Same(providerA, scopeA.ServiceProvider.GetRequiredService()); + Assert.Same(catalogA, scopeA.ServiceProvider.GetRequiredService()); + + // NOTE: scope validation is OFF in this host (WebApplication outside + // Development), so resolving a scoped service from the ROOT does NOT throw. + // The code never does that (stateless transport resolves from the per-request + // scope — verified in BuildHttp_TwoScopes_DifferentBearers_NoCrossTalk), but + // the runtime safety-net is absent. See bug report (P3). + } + + [Fact] + public async Task BuildHttp_TwoScopes_DifferentBearers_NoCrossTalk() + { + // End-to-end through the real container: two request scopes, each with its + // OWN HttpContext bearer set on the (singleton, AsyncLocal-backed) accessor. + // The token provider resolved in each scope must read ITS scope's bearer — + // proving no scope captured the other's identity. Done sequentially (one + // logical context at a time) to mirror how a request flows. + using var app = McpHosts.BuildHttp(HttpOptions(), args: []); + var root = app.Services; + + async Task BearerInScopeAsync(string token) + { + using var scope = root.CreateScope(); + // Populate the live request for THIS scope, exactly as the middleware + // would, then resolve the scoped provider and read it. + var accessor = scope.ServiceProvider.GetRequiredService(); + var ctx = new DefaultHttpContext(); + ctx.Request.Headers.Authorization = $"Bearer {token}"; + accessor.HttpContext = ctx; + + var provider = scope.ServiceProvider.GetRequiredService(); + var result = await provider.GetTokenAsync(CancellationToken.None); + return Assert.IsType(result).AccessToken; + } + + var alice = await BearerInScopeAsync("alice-jwt"); + var bob = await BearerInScopeAsync("bob-jwt"); + + Assert.Equal("alice-jwt", alice); + Assert.Equal("bob-jwt", bob); // bob did NOT inherit alice's token + } + + // ── dual-mode startup: transport switch + http host /health ────────────────── + + [Theory] + [InlineData("http", McpTransport.Http)] + [InlineData("HTTP", McpTransport.Http)] + [InlineData("stdio", McpTransport.Stdio)] + [InlineData(null, McpTransport.Stdio)] + [InlineData("", McpTransport.Stdio)] + [InlineData("garbage", McpTransport.Stdio)] + public void ResolveTransport_FromEnv_ParsesMode(string? env, McpTransport expected) => + Assert.Equal(expected, McpBridgeOptions.ResolveTransport(env, args: null)); + + [Fact] + public void ResolveTransport_HttpFlag_SelectsHttp_EvenWithoutEnv() => + Assert.Equal(McpTransport.Http, McpBridgeOptions.ResolveTransport(envValue: null, args: ["--http"])); + + [Fact] + public void BuildStdio_Constructs_WithoutThrowing() + { + // MCP_TRANSPORT unset → stdio path. Static token avoids touching the real + // device-flow token cache file. Host must construct (DI graph resolves). + var options = new McpBridgeOptions + { + ApiBaseUrl = "https://api.example", + SiteHost = "textstack.test", + McpToken = "static-ci-token", + Transport = McpTransport.Stdio, + }; + + using var host = McpHosts.BuildStdio(options, args: []); + + Assert.NotNull(host); + } + + [Fact] + public async Task BuildHttp_StartsWebApplication_HealthReturns200() + { + var options = new McpBridgeOptions + { + ApiBaseUrl = "http://api:8080", + SiteHost = "textstack.app", + Transport = McpTransport.Http, + }; + + // Bind an ephemeral loopback port so the test never collides with a real one. + var port = FreeTcpPort(); + var ct = TestContext.Current.CancellationToken; + var app = McpHosts.BuildHttp(options, args: [$"--urls=http://127.0.0.1:{port}"]); + await app.StartAsync(ct); + try + { + // Connect via "localhost" because the TEST host environment may apply host + // filtering (so we use a host the filter accepts). The PRODUCTION MCP + // container has NO appsettings.json → AllowedHosts=* (open filter), which is + // correct: it's localhost-only (127.0.0.1:8090) behind nginx, which sets the + // Host header. So the container probe is unaffected either way. + using var client = new HttpClient { BaseAddress = new Uri($"http://localhost:{port}") }; + using var resp = await client.GetAsync("/health", ct); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + } + finally + { + await app.StopAsync(ct); + await app.DisposeAsync(); + } + } + + private static int FreeTcpPort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +}