Skip to content

test: MCP shape through the tenant lane on the strict-pack harness - #127

Merged
howethomas merged 2 commits into
mainfrom
m13-mcp-shape
Nov 13, 2026
Merged

howethomas merged 2 commits into
mainfrom
m13-mcp-shape

Conversation

@howethomas

Copy link
Copy Markdown
Contributor

Adds the MCP shape for the tenant lane on the strict-pack harness. It is the real-database test that #124 left as a TODO.

Stacked on #126 (base m12-strict-pack). It retargets to main once #126 merges.

What it runs

tests/tenant-isolation/mcp-shape.ts starts the stack from stack.ts with the fixtures from fixtures.ts and the strict pack applied. It serves PostgREST as the lane's own (startRest('lane'): a login role in authenticated only, no anon role, public only) and puts a small proxy in front that strips the /rest/v1 prefix supabase-js adds. Then it runs src/index.ts as a child process with TENANT_MODE=lane, no service key and no anon key. A real MCP client calls it over Streamable HTTP with tokens signed by the stack's secret, and the lane reads through real supabase-js and real pgvector.

What it asserts, as tenant A

  • get_vcon of B's uuid, of B's vCon with tenantless children, of the no-tenant vCon and of the quarantined vCon: not found. get_vcon of A's own: A's canary. B's own token reads B's vCon, so the not-found comes from the tenant filter and not from a broken read.

  • search_vcons: ids_only with a count, with and without dates, metadata, a tags filter and a subject filter. Each returns A's two vCons only, and the count is 2. B's own tag value finds nothing.

  • search_vcons_content: A's canary finds A. B's canary, the canary in B's tenantless children, the no-tenant canary and the quarantine canary find nothing. "zebra", which is in every fixture, returns A only.

  • search_vcons_semantic with a precomputed embedding (ids_only, metadata, and with a tags filter): A only, though every fixture has the same vector. With query text and no embedding provider it reports itself unavailable and makes no store call.

  • delete_vcon: B's uuid and the no-tenant vCon are not found and stay. A's own second vCon is deleted along with every child row in all ten tables. Everything else is untouched.

  • Tokens that must read nothing, each through all five tools including delete_vcon of A's uuid:

    • a _quarantine claim: not found or empty
    • a missing tenant claim, a service_role claim, and service_role with tenant A: 401 from the lane, never reaching the store
    • a wrong signature and an expired token: the store's 401, reported as unauthorized

    A's vCon survives all of them.

  • tools/list lists exactly the five. The other 41 tools and an unknown name get -32601 with no store call. Resources and prompts are refused.

  • Every store request carried a caller's token as both Authorization and apikey. The lane called nothing outside /rest/v1, logged no token, and logged the tenant.

  • The lane refuses to start with a service key or an anon key.

Baseline

--baseline runs the same calls on the schema the migrations leave. It passes only while the known leaks show through the lane, and it fails if it sees none. Locally it reproduced 15. All of them come from rows with no tenant or a quarantine claim: the no-tenant vCon through get, every search path and delete, and a quarantine claim reading the no-tenant and quarantined rows. Tenant B's own rows stay hidden in both modes.

Runs

  • npm run test:tenant:mcp: 76 passed, 0 failed, about 20 s locally.
  • npm run test:tenant:mcp:baseline: 76 passed, 15 leaks reproduced.
  • CI=true npm test: 85 files passed, 2 skipped; 1410 tests passed, 23 skipped. npx tsc --noEmit passes.

CI

Two new jobs in the Tenant isolation workflow, mcp and mcp-baseline, each with npm ci. The workflow's pull_request trigger no longer filters on the base branch, so this stacked PR gets the isolation jobs. Without that it would get none until it targets main. The unit-test workflow still runs only on PRs to main and develop.

Not covered

Kong in the path, connection poolers and load.

🤖 Generated with Claude Code

@howethomas
howethomas force-pushed the m13-mcp-shape branch 2 times, most recently from c07b3ec to cd2264a Compare November 13, 2026 00:05
howethomas and others added 2 commits November 12, 2026 20:55
tests/tenant-isolation/mcp-shape.ts starts the stack with the fixtures,
serves PostgREST as the lane's own (authenticated only, no anon role),
puts a proxy in front that strips /rest/v1, and runs src/index.ts as a
child with TENANT_MODE=lane and no key. A real MCP client then calls it,
and the lane reads through real supabase-js and real pgvector.

As tenant A: each of the five tools against B's uuid and B's canary text
finds nothing, A's own canary comes back through each, semantic search
with a precomputed embedding (and unavailable with query text and no
provider), delete_vcon of B's uuid is not found and B stays, own delete
removes the vCon and every child row. A quarantine claim, a missing
claim, a wrong signature, a service_role claim and an expired token
each read nothing. Every other tool, resources and prompts are refused.
Every store request carried the caller's token and nothing else, and
the lane refuses to start with a service or anon key.

--baseline runs the same calls on the unmodified schema and passes only
while the known leaks show through the lane (15 do), failing if none
does. Two jobs in the Tenant isolation workflow, which now runs on pull
requests whatever their base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lete

The marker scan read only a tool result's text content. It now reads the
whole answer: every content part, structuredContent, _meta, any other
result field, and an error's message, code and data. A self-check plants
a foreign marker in each of those and requires an isolated read to fail,
with a clean control that passes.

delete_vcon's zero-row branch (VConService.delete, removed === 0) is now
reached through the lane on the real database: a restrictive policy
refuses the DELETE of A's own vCon while the read is allowed, and the
lane must answer not found and leave the row. A direct unit test drives
the lane's query layer, the service and the handler against a store
whose existence read finds the row and whose delete removes none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@howethomas
howethomas changed the base branch from m12-strict-pack to main November 13, 2026 01:55
@howethomas howethomas closed this Nov 13, 2026
@howethomas howethomas reopened this Nov 13, 2026
@howethomas
howethomas merged commit 453d4c8 into main Nov 13, 2026
14 checks passed
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