T15: MCP tools search/describe/read/call + smoke test #14

Merged
dzenda merged 66 commits from t15-mcp-tools into t14-mcp-oauth 2026-11-14 17:50:13 +01:00
Owner

T15: MCP tools search / describe / read / call + smoke test

Stacked on #13 (t14-mcp-oauth) → … → #1. Merge those first.

What

  • src/mcp/tools.ts (pure, tested without a transport):
    • search({ query, limit? }): ranked registry search (English or Czech) with a "describe next" hint.
    • describe({ paths ≤ 20 }): docs, JSON schemas and examples. Unknown paths are listed separately instead of failing.
    • call({ path, args? }): runs a registry function as the signed-in user. Errors come back as isError results with { error: { code, message } } from toErrorPayload, so upstream bodies and credentials never appear. The user context is only built once the path is known to be valid.
    • read({ uri }): fit://help (overview of namespaces, functions and resources), fit://functions and fit://functions/{path}. courses://… returns a clear "not available yet" until T18. Anything else is rejected.
    • Results are capped at 60k characters, with a truncation note telling the model to narrow the query.
  • src/server/mcp-handler.ts: registers the tools with server instructions. All tools are annotated readOnlyHint: true, destructiveHint: false, idempotentHint: true, so ChatGPT and Claude don't ask for confirmation. search, describe and read are openWorldHint: false.
  • src/server/mcp-context.ts: per-request FnContext for the grant's userId, with D1 + the keyring (cached per isolate) + the token store with the FIT refresher. The user comes only from the OAuth grant props. Tool input can't choose it.
  • bun run mcp:smoke (scripts/mcp-smoke.ts): checks discovery, the 401 challenge, DCR and the anonymous /authorize redirect. --login runs the full OAuth flow (PKCE, state, a local 127.0.0.1 callback), then initialize and tools/list, plus search, describe, read and call on 5 functions.
  • docs/mcp.md: tools, error codes, the smoke test, and MCP Inspector instructions.

Verified

  • bun run lint && bun run typecheck && bun run test: 192 tests.
    • 10 new tool unit tests: search, describe with unknown paths, call success, safe errors (upstream, unknown, invalid), no context for unknown paths, context failure → generic error, read resources and rejections, truncation.
    • The end-to-end MCP OAuth test now also checks tools/list (four tools, all read-only) and tools/call → fit.status as the signed-in user, with no upstream tokens in the response.
  • bun run dev + bun run mcp:smoke: all checks pass. --login needs a real browser login, which is still waiting on the human (see T7).

Astra review (gpt-6-astra)

  • It found no cross-user leak, credential disclosure or resource traversal.
  • Fixed: (P2) the smoke script could report call checks as passing on HTTP or JSON-RPC errors. It now rejects transport and protocol errors and requires a real tool result.

🤖 Generated with Claude Code

## T15: MCP tools `search` / `describe` / `read` / `call` + smoke test **Stacked on #13 (`t14-mcp-oauth`) → … → #1.** Merge those first. ### What - `src/mcp/tools.ts` (pure, tested without a transport): - **`search({ query, limit? })`:** ranked registry search (English or Czech) with a "describe next" hint. - **`describe({ paths ≤ 20 })`:** docs, JSON schemas and examples. Unknown paths are listed separately instead of failing. - **`call({ path, args? })`:** runs a registry function as the signed-in user. Errors come back as `isError` results with `{ error: { code, message } }` from `toErrorPayload`, so upstream bodies and credentials never appear. The user context is only built once the path is known to be valid. - **`read({ uri })`:** `fit://help` (overview of namespaces, functions and resources), `fit://functions` and `fit://functions/{path}`. `courses://…` returns a clear "not available yet" until T18. Anything else is rejected. - Results are capped at 60k characters, with a truncation note telling the model to narrow the query. - `src/server/mcp-handler.ts`: registers the tools with server `instructions`. **All tools are annotated `readOnlyHint: true, destructiveHint: false, idempotentHint: true`**, so ChatGPT and Claude don't ask for confirmation. `search`, `describe` and `read` are `openWorldHint: false`. - `src/server/mcp-context.ts`: per-request `FnContext` for the grant's `userId`, with D1 + the keyring (cached per isolate) + the token store with the FIT refresher. **The user comes only from the OAuth grant props.** Tool input can't choose it. - **`bun run mcp:smoke`** (`scripts/mcp-smoke.ts`): checks discovery, the 401 challenge, DCR and the anonymous `/authorize` redirect. `--login` runs the full OAuth flow (PKCE, state, a local 127.0.0.1 callback), then `initialize` and `tools/list`, plus `search`, `describe`, `read` and `call` on 5 functions. - **`docs/mcp.md`:** tools, error codes, the smoke test, and MCP Inspector instructions. ### Verified - `bun run lint && bun run typecheck && bun run test`: 192 tests. - 10 new tool unit tests: search, describe with unknown paths, `call` success, safe errors (upstream, unknown, invalid), no context for unknown paths, context failure → generic error, `read` resources and rejections, truncation. - The end-to-end MCP OAuth test now also checks **`tools/list` (four tools, all read-only)** and **`tools/call` → `fit.status`** as the signed-in user, with no upstream tokens in the response. - `bun run dev` + `bun run mcp:smoke`: all checks pass. `--login` needs a real browser login, which is still waiting on the human (see T7). ### Astra review (gpt-6-astra) - It found no cross-user leak, credential disclosure or resource traversal. - **Fixed:** *(P2)* the smoke script could report `call` checks as passing on HTTP or JSON-RPC errors. It now rejects transport and protocol errors and requires a real tool result. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fixtures:record: reads the encrypted token from local D1, GET-only, never refreshes,
  scrubs PII into git-ignored test/fixtures/live/, prints shapes only
- Marast: FIT token goes in Api-Key (Bearer is 401); only_open is 401 → filter locally
- Usermap: fields may be null (preferredEmail) → nullish schemas
- Grades: precise error for missing OAuth app resource; docs on scopes vs. resources

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also fixes a production bug found while smoke-testing: createFnContext stored the global
fetch on the context object, so ctx.fetch(...) threw "Illegal invocation" in workerd. That
broke every registry call in the Worker (including all MCP tool calls); Node tests could
not see it. Regression test emulates workerd's this-check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ALLOWED_USERS enforced for MCP too (shared policy); excluded users' grants revoked on use
- telemetry: streaming 512-byte cap before parsing
- POST /oauth/register rate-limited per IP and instance-wide (Workers rate-limit bindings)
- FIT API bodies capped at 2 MB (streaming); Grades tree walk iterative and bounded
- readBounded moved to src/lib/bounded.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Logos: FIT service marks vendored in public/services (see README there); assistant marks
from @lobehub/icons (MIT), simple-icons (CC0) and devicon (MIT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- service_settings table (migration 0002); MCP calls to a switched-off service fail with
  service_disabled, aggregates skip it with a warning, fit.status lists it
- setServiceEnabledFn: same-origin + session only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- fit.sirius.courseEvents: every parallel of a course (room, teachers, capacity/occupied),
  shared paging helper with fit.sirius.events; respects the Sirius MCP switch
- getCourseWeek server fn for the calendar overlay; upcoming deadlines (60 days) beside the week

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- layoutLanes/pragueMinutes in src/lib/calendar.ts (tested)
- shared EventDetails popover; agenda list stays on mobile
- agentSetupPrompt in src/mcp/setup.ts (tested)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Referrer-Policy: same-origin (no-referrer made browsers send Origin: null on our form POSTs,
  so every management form was rejected with 403); shared isSameOriginRequest accepts
  Origin: null only with Sec-Fetch-Site: same-origin
- No COOP on OAuth/consent/login routes (popup-based web MCP clients keep window.opener)
- fit.sirius.icalUrl is websiteOnly: excluded from the MCP registry (URL embeds a credential)
- docs: telemetry wording (operator-enabled, consent advice), CSRF/COOP/credential notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewed-on: #25
Reviewed-on: #24
Reviewed-on: #23
Reviewed-on: #22
Reviewed-on: #21
Reviewed-on: #20
Reviewed-on: #19
Reviewed-on: #18
Reviewed-on: #17
Reviewed-on: #16
Reviewed-on: #15
dzenda merged commit b91dd738a2 into t14-mcp-oauth 2026-11-14 17:50:13 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
dzenda/os!14
No description provided.