Repository navigation
test: MCP shape through the tenant lane on the strict-pack harness - #127
Merged
Merged
Conversation
howethomas
force-pushed
the
m13-mcp-shape
branch
2 times, most recently
from
November 13, 2026 00:05
c07b3ec to
cd2264a
Compare
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
force-pushed
the
m13-mcp-shape
branch
from
November 13, 2026 01:55
cd2264a to
6fc7fa7
Compare
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.
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 tomainonce #126 merges.What it runs
tests/tenant-isolation/mcp-shape.tsstarts the stack fromstack.tswith the fixtures fromfixtures.tsand the strict pack applied. It serves PostgREST as the lane's own (startRest('lane'): a login role inauthenticatedonly, no anon role,publiconly) and puts a small proxy in front that strips the/rest/v1prefix supabase-js adds. Then it runssrc/index.tsas a child process withTENANT_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_vconof B's uuid, of B's vCon with tenantless children, of the no-tenant vCon and of the quarantined vCon: not found.get_vconof 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_semanticwith 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_vconof A's uuid:_quarantineclaim: not found or emptyservice_roleclaim, andservice_rolewith tenant A: 401 from the lane, never reaching the storeA's vCon survives all of them.
tools/listlists 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
Authorizationandapikey. 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
--baselineruns 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 --noEmitpasses.CI
Two new jobs in the
Tenant isolationworkflow,mcpandmcp-baseline, each withnpm ci. The workflow'spull_requesttrigger no longer filters on the base branch, so this stacked PR gets the isolation jobs. Without that it would get none until it targetsmain. The unit-test workflow still runs only on PRs tomainanddevelop.Not covered
Kong in the path, connection poolers and load.
🤖 Generated with Claude Code