Skip to content

feat(hooks): hook-driven request context propagation in tool dispatch#19

Merged
bntvllnt merged 4 commits intomainfrom
feat/v0.3.0-context-propagation
Apr 27, 2026
Merged

feat(hooks): hook-driven request context propagation in tool dispatch#19
bntvllnt merged 4 commits intomainfrom
feat/v0.3.0-context-propagation

Conversation

@bntvllnt
Copy link
Copy Markdown
Contributor

@bntvllnt bntvllnt commented Apr 27, 2026

Closes #15, #16, #17, #18.

What

Two coupled additions enabling per-tool server-side context injection.

`extendArgs` (#16)

`OnCallResult` gains an optional `extendArgs?: Record<string, unknown>` field. When the `onToolCall` hook returns it during the `before` phase, the framework merges the key/value pairs into args before `client[type](toolDef.ref, args)`.

  • On collision: server-side wins (consistent with the design discussion in v0.3.0: Hook-driven request context propagation in tool dispatch #15).
  • Empty / undefined is a no-op (no allocation, no merge).
  • Only the `before` phase honors it. Returning `extendArgs` from `success` or `error` is silently ignored.
  • Aborts win over extendArgs — when the hook returns `{ abort: true, extendArgs: {...} }`, dispatch never happens.

Reserved `_` prefix (#17)

Arg keys starting with underscore are framework-controlled. Two layers protect them:

  1. Schema layer — `prepareTools` strips `*` keys from the published `zodShape`. The MCP SDK's Zod validator silently strips `*` keys from incoming requests via Zod's default strip mode, so spoofed values never reach the dispatched function.
  2. Handler layer — The registered tool handler explicitly rejects any `_*` key reaching it with a structured "Reserved arg keys not allowed" error. This defends against transports that bypass the SDK's schema validation.

Hook-supplied `_*` keys via `extendArgs` are exempt from both layers — the server is trusted by definition.

Why

The `onToolCall` hook already receives `apiKey`, but currently has no way to pass that or any server-resolved context downstream. This blocks a class of common MCP server patterns:

Use case What `extendArgs` enables
Per-action authorization Server validates apiKey + scope, action re-validates as defense-in-depth
Request tracing Hook injects request ID; action correlates its own logs with framework events
Multi-tenancy Server-resolved tenant ID enforced via index queries — caller cannot spoof
Per-key feature flags Hook computes flag map, action branches without re-fetching from DB
Audit metadata Hook injects caller label/keyId, action records in domain audit log
Per-key quotas Hook injects remaining quota, action enforces or warns near limit

The framework stays neutral: it doesn't define WHAT goes in `extendArgs`, only HOW. The reserved-prefix protection is what makes it safe.

Files changed

File Lines Why
`src/tools/types.ts` +32 `OnCallResult.extendArgs` field with TSDoc + example
`src/tools/register.ts` +53/-5 `isReservedKey` helper, `stripReservedFromShape` in `prepareTools`, reject loop at handler entry, `extendArgs` merge before dispatch
`tests/context-propagation.test.ts` +494 (NEW) 12 tests covering all behaviors
`CHANGELOG.md` +8 v0.3.0 "Added" section
`llms.txt` +9 New "Request Context Propagation" section
`docs/security.md` +72 Defense-in-depth section with 4 use-case patterns + anti-patterns

Backward compatibility

Strictly additive:

  • `OnCallResult` gets a new optional field. Existing hooks unchanged.
  • `prepareTools` strips `*` from the published shape. Verified zero impact across vllnt-owned consumers (no current consumer publishes `*` args).
  • The handler-level `*` reject is new behavior. Any consumer accepting `*` request args today (none expected) would now get a structured rejection.

Test plan

Documentation coverage (full)

All v0.3.0 doc surfaces updated in commit `2822ef5`:

  • `README.md` — Security section + Known Limitations updated
  • `llms.txt` — Request Context Propagation section
  • `llms-full.txt` — Full Lifecycle Hooks & Request Context Propagation section (~120 lines), all 5 patterns + anti-patterns; Types section expanded with `LifecycleHooks`, `CallContext`, `OnCallResult`, expanded `ToolDef`
  • `docs/api-reference.md` — `LifecycleHooks`, `CallContext`, `OnCallResult` reference sections; reserved-prefix subsection; `hooks` field added to `ServerConfig` table
  • `docs/examples.md` — 4 worked patterns (per-action auth, request tracing, multi-tenancy, per-key feature flags) + anti-patterns table
  • `docs/security.md` — defense-in-depth section with 4 use-case patterns + anti-patterns
  • `CHANGELOG.md` — v0.3.0 Added section

Motivation

Surfaced by an audit of a real-world Convex MCP server where ~80 ops/control-plane actions were publicly callable. The framework hook validated apiKey + scope, but the action handlers had no way to re-validate — so any caller bypassing the MCP HTTP layer hit zero authorization. Per-action re-validation requires the framework to propagate apiKey into args. This PR ships that primitive.

The design is intentionally generic — auth is one of six documented patterns. Anyone building a Convex+MCP server with per-action authorization, request tracing, multi-tenancy, audit, feature flags, or quotas can use this.

…gation

Closes #15, #16, #17. Closes part of #18 (security.md + llms.txt updated;
api-reference.md, examples.md, llms-full.txt deferred to a docs follow-up).

## What

Two coupled additions enabling per-tool server-side context injection:

1. **extendArgs (#16)** — \`OnCallResult\` gains an optional
   \`extendArgs?: Record<string, unknown>\` field. When the \`onToolCall\`
   hook returns it during the \`before\` phase, the framework merges the
   key/value pairs into args before \`client[type](toolDef.ref, args)\`.
   On collision: server-side wins. Empty / undefined is a no-op.

2. **Reserved \`_\` prefix (#17)** — Arg keys starting with underscore are
   framework-controlled. Two layers protect them:
   - \`prepareTools\` strips \`_*\` keys from the published JSON Schema
     (\`zodShape\`). The MCP SDK's Zod validator silently strips \`_*\`
     keys from incoming requests via Zod's default strip mode.
   - The registered tool handler explicitly rejects any \`_*\` key
     reaching it with a structured "Reserved arg keys not allowed"
     error (defense-in-depth for non-SDK transports).
   Hook-supplied \`_*\` keys via \`extendArgs\` are exempt — server is trusted.

## Why

Today the \`onToolCall\` hook receives \`apiKey\` but cannot pass it (or any
server-resolved context) downstream. Per-action authorization, request
tracing, multi-tenancy, audit metadata, per-key feature flags, and per-key
quotas all require server-injected context. Without this primitive,
consumers either fork the framework or replace the dispatch transport.

This is the framework primitive — not coupled to apiKey, not coupled to
auth at all. \`extendArgs\` carries arbitrary server-resolved key/value
pairs; the consumer decides what to put there. See \`docs/security.md\`
for 4 example patterns.

## Backward compatibility

Strictly additive:
- \`OnCallResult\` gets a new optional field. Existing hooks unchanged.
- \`prepareTools\` strips \`_*\` from the published shape. Consumers who
  publish \`_*\` keys today get them removed from the schema (verify
  zero impact: search across vllnt-owned consumers turned up zero).
- The handler-level \`_*\` reject is new behavior. Any consumer accepting
  \`_*\` request args today (none expected) would now get a structured
  rejection instead.

## Tests

12 new tests in \`tests/context-propagation.test.ts\`:
- merges extendArgs into dispatched args (query, mutation, action)
- server-side wins on key collision
- undefined / empty extendArgs is a no-op (no allocation)
- abort wins over extendArgs
- only \`before\` phase honors extendArgs
- handler rejects \`_*\` request args before hook runs
- handler error message lists every reserved key
- hook-supplied \`_*\` via extendArgs bypasses reject (server trusted)
- only top-level keys are rejected (nested \`_*\` passes through)
- published JSON Schema strips \`_*\` properties + required
- end-to-end: SDK schema-strip + extendArgs injection produces
  trusted args at the dispatched function

All 127 tests pass (115 existing + 12 new). Lint + typecheck clean.
@bntvllnt bntvllnt self-assigned this Apr 27, 2026
@bntvllnt bntvllnt changed the title feat(hooks): extendArgs + reserved _ prefix for context propagation (closes #15) feat(hooks): hook-driven request context propagation in tool dispatch Apr 27, 2026
Closes #18.

Adds full v0.3.0 coverage to docs that were missed in the initial PR:
- README.md: brief mention of per-action authorization + reserved prefix
  in the Security section, link to docs/security.md
- llms-full.txt: full Lifecycle Hooks & Request Context Propagation
  section (~120 lines) with all 5 patterns + anti-patterns; Types
  section updated with LifecycleHooks, CallContext, OnCallResult,
  expanded ToolDef
- docs/api-reference.md: LifecycleHooks, CallContext, OnCallResult
  reference sections; reserved-prefix subsection; hooks field added
  to ServerConfig table; new types added to Exported Types
- docs/examples.md: 4 worked patterns (per-action authorization with
  defense-in-depth, request tracing, multi-tenancy, per-key feature
  flags) + anti-patterns table

Verification:
- 127/127 tests pass, lint + typecheck green
- 0 references to any specific downstream consumer across all docs
- docs/security.md: document hook fail-open behavior; require `_*` action
  validators to be non-optional so Convex's own validator is the safety net
  if the framework hook throws or is bypassed
- register.ts: log `[convex-mcp] reserved-key reject` (with requestId, tool,
  keys) when handler-layer rejection fires
- register.ts + server.ts: emit one construction-time `console.warn` listing
  tools that declare `_*` args without an `onToolCall` hook configured,
  catching the silent "stripped from schema, never injected" footgun
- tests: replace `as ConvexValidator` cast with `satisfies`; add coverage
  for findToolsWithReservedArgs, both warn branches, and the reject log

Quality gates: lint PASS, typecheck PASS, tests 132/132, coverage 100%
on statements/branches/functions/lines, build PASS.
Move specs/active/2026-03-24-lifecycle-hooks.md → specs/shipped/ and append
history.log entry. Spec covers the full v0.3.0 hook + context-propagation
work: lifecycle hooks, per-tool config, X-Request-Id, extendArgs, reserved
`_` prefix, and the review-driven hardening (security doc callout,
construction-time warn, reject log).
@bntvllnt bntvllnt merged commit 79fae4f into main Apr 27, 2026
2 checks passed
@bntvllnt bntvllnt deleted the feat/v0.3.0-context-propagation branch April 27, 2026 20:29
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.

v0.3.0: Hook-driven request context propagation in tool dispatch

1 participant