Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ jobs:
- name: Search tests
run: dotnet test tests/TextStack.Search.Tests --no-build

# MCP over-the-wire integration suite (AI-053) — hermetic (loopback only, no
# Postgres/Docker). In the solution, so --no-build reuses the Build step above.
- name: MCP integration tests
run: dotnet test tests/TextStack.Ai.Mcp.Tests --no-build

# Unit tests (incl. the AI-047..050 MCP unit suites) are NOT in textstack.sln,
# so the Build step doesn't compile them — run WITHOUT --no-build. This closes a
# real CI gap: these were never executed by an explicit dotnet test step before.
- name: Unit tests
run: dotnet test tests/TextStack.UnitTests

frontend:
runs-on: ubuntu-latest

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

### Phase 8 — MCP server over-the-wire integration tests (AI-053) (2026-06-17)

Closes the last test gap in the MCP stack: the AI-047..050 unit tests use fake `HttpMessageHandler`s and never exercise the protocol / `McpHosts.BuildHttp` / the SDK wire framing. AI-053 drives the **REAL** MCP server through the **REAL** wire protocol (`initialize` → `tools/list` → `tools/call`) for all 7 tools + a one-session e2e, hermetically (no Docker, no live API, no external network).

- **Harness** — in-process **loopback** host (NOT WAF in-memory): the real `BuildHttp` `WebApplication` (`MapMcp("/mcp")`, streamable HTTP, `Stateless=true`) is started on a 127.0.0.1 ephemeral port and driven by the real `ModelContextProtocol.Client.McpClient` over an `HttpClientTransport` (`HttpTransportMode.StreamableHttp`, `AdditionalHeaders["Authorization"]="Bearer <test-jwt>"` for user-scoped tools). The bridge's `TextStackApiClient` is pointed (via `McpBridgeOptions.ApiBaseUrl`) at a **recording stub backend** — a second loopback `WebApplication` with minimal-API endpoints returning canned camelCase JSON matching the client's local DTOs (Dracula hit), which records the last request per route (method, path+query, Authorization, Host, body) for outbound-call assertions. Loopback chosen over `WebApplicationFactory` to mirror the existing `BuildHttp_StartsWebApplication_HealthReturns200` test and avoid TestServer streamable-HTTP quirks — still fully hermetic.
- **Tests** (`tests/TextStack.Ai.Mcp.Tests/`, new xUnit.v3 project, references ONLY `TextStack.Ai.Mcp` → zero `ITool` in the assembly, StudyBuddy set-equality safe by construction): per-tool over the wire (search_books, get_book mapped editionId, get_chapter, list_my_highlights Bearer, list_my_vocabulary Bearer, save_highlight WRITE with synthesized anchor in body, ask_book Bearer) asserting both the mapped MCP result AND that the stub saw the right upstream call; the ask_book `Insufficient` spoiler-gate variant (clean non-error text); protocol (`initialize`→serverInfo name=="textstack", `tools/list`→exactly 7); negatives over the wire (user-scoped without bearer → `IsError` "authentication required" + ZERO upstream hits; missing required arg → tool `IsError` + no upstream call; unreachable upstream → clean "upstream unavailable" `IsError`, NOT a JSON-RPC protocol fault); a one-session e2e (single `McpClient`/one initialize → tools/list → get_chapter → save_highlight → ask_book); and a **stdio subprocess smoke** (`StdioClientTransport` spawning the built `TextStack.Ai.Mcp.dll` with env config → initialize + tools/list returns 7, **skippable** if the DLL isn't built). 15 tests, all green, no network.
- **CI gap closed** (`.github/workflows/ci.yml` `backend` job). The MCP unit tests in `TextStack.UnitTests` were NEVER run by an explicit `dotnet test` step (only Search.Tests + IntegrationTests ran). Added two hermetic steps: `dotnet test tests/TextStack.Ai.Mcp.Tests --no-build` (new project, in the solution so the Build step compiles it) and `dotnet test tests/TextStack.UnitTests` (NOT in the solution — runs without `--no-build` — so the AI-047..050 MCP unit suites + everything in UnitTests actually execute in CI). New project added to `textstack.sln`.

### Phase 8 — enable remote MCP in the prod deploy (AI-049 follow-up) (2026-06-17)

Makes the remote MCP endpoint actually go live. The `mcp-server` Docker service is profile-gated (`profiles:[mcp]`) so CI's bare `compose up` never builds/runs it — which also meant the prod deploy didn't bring it up. Fixed: `deploy.yml` now runs `docker compose --profile mcp ...` so prod opts the service in (CI still excludes it). The nginx `/mcp` block already syncs via the existing "Sync nginx config" step. Added a **Health check MCP server** deploy step (fail-loud): asserts `mcp-server` `/health` is up AND a real MCP `initialize` POST through nginx `/mcp` returns 200. After this merges + deploys, `https://textstack.app/mcp` is the remote streamable-HTTP MCP endpoint (for ChatGPT / Cursor / remote Claude), authenticated per-request via the device-flow JWT. Verified the http transport locally (`/health`→200, `POST /mcp` initialize→`text/event-stream` 200).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ public sealed class HttpContextTokenProvider : IMcpTokenProvider
{
private const string BearerScheme = "Bearer ";

// Detail only — the catalog wrapper already prefixes "authentication required — ",
// so we must NOT repeat it here (avoids "authentication required — authentication required — …").
private static readonly TokenResult.Failed NoBearer = new(
"authentication required — set Authorization: Bearer <token> in your MCP client");
"set Authorization: Bearer <token> in your MCP client");

private readonly IHttpContextAccessor _accessor;

Expand Down
Loading
Loading