MOTIR-3362Done
Integration test — add_lesson through the real MCP transport: gated, tenant-only, near-duplicate surfaced
The gate proved wired rather than merely written. Enter at app/api/mcp/route.ts with a token, the way an agent does — not by calling the tool function, which skips exactly the layers that could be wrong.
Four things:
- A token without the lesson-library-change permission is refused, and no upstream request is made. Assert the absence of the call, not just the refusal — a check that runs after the request has gone out reads identically from the return value.
- A token with it creates the lesson, and the request that reaches motir-ai names the acting project.
- No caller can produce a global lesson. There is no argument for it, and a body carrying
scopeis refused. Since MOTIR-3342 (motir-core#2241) that refusal comes fromlib/mcp/strictInput.ts, which rewrites every tool'sinputSchematostrictat the registration seam — so the assertion here is thatadd_lessoninherits it, not that the tool implements anything. It would stop inheriting it the moment someone declared the schema.passthrough(), which is exactly the change this test exists to catch. - The near-duplicate refusal survives the whole way out — the existing lesson's id and title are in the tool result the agent receives, not flattened into a generic failure on one of the four hops.
A token bound to one project cannot write a lesson to another.
Provenance is stored, not displayed here: the hand-added sourceRef shows on the detail view MOTIR-3329 builds, and the list does not distinguish. That is deliberate, and if it turns out to matter it is a change to the list, not to this test.
Acceptance criteria
- Every case enters through the MCP route with a token, following
tests/mcp/route.test.tsandpermission-gate.test.ts. - The unauthorized case asserts no upstream request was issued, and fails if one is.
- The authorized case asserts the outgoing request names the acting project.
- A request carrying
scopeis refused by the strict-input seam, naming the key; no row is created. The test fails if the tool's schema is ever loosened to.passthrough(). - A token bound to project A cannot create a lesson on project B.
- The near-duplicate response reaches the caller with the existing lesson's id and title intact.
- Upstream is stubbed at the HTTP boundary, not by mocking
motirAiClient's exported function — a mock at that level would not exercise the body this feature's contract depends on.
Context refs
motir-coretests/mcp/route.test.ts,tests/mcp/permission-gate.test.ts,tests/mcp/write-tools.test.ts— the entry shape and token fixtures.motir-corelib/mcp/strictInput.ts— the registration-time schema rewrite that refuses an unknown key; landed in #2241, and the reason thescopecase needs no tool-level code.motir-corelib/mcp/permissionGate.ts,lib/mcp/rateLimitGate.ts— the layers under test.- MOTIR-3361 — the tool.
- MOTIR-3360 — the seam whose refusal ordering this pins.
- MOTIR-3339 — the sibling seam test under MOTIR-3329; same argument, read side.