Skip to content

feat(mcp): require grant_id for package reads - #382

Open
tnunamak wants to merge 3 commits into
mcp/e4-invalid-token-401from
mcp/b1-grant-id-selection
Open

tnunamak wants to merge 3 commits into
mcp/e4-invalid-token-401from
mcp/b1-grant-id-selection

Conversation

@tnunamak

@tnunamak tnunamak commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

A grant package is one access token that covers several child grants. Today a hosted MCP call with a package token picks its child implicitly: with one child it falls back to that child, and otherwise it reads every child and merges the results (deduplicating hits, applying one global limit, summing counts). The MCP profile (§3) forbids both: with a package token the child is always named, and no result is computed across children.

For package tokens, each of the six data tools (schema, query_records, aggregate, search, fetch, read_record_field) now declares a required grant_id and sends the call to exactly that child's resource-server client. The server checks grant_id before any resource-server call and returns a tool result with isError: true when it is missing, not a string, malformed or not a child of the token; the error text tells the model how to recover. A valid grant_id is any non-empty string of [A-Za-z0-9._~-] (access extension §5.3). Ordinary client tokens keep their tool list and routing unchanged, and reject grant_id. Package resource templates are removed so no read can bypass the selector.

The cross-child merge layer is deleted from server/package-rs-client.ts (about 1,300 lines, plus the 39 unit tests that pinned it); vendor/mcp-server/src/package-profile.ts is new and holds the selection logic, ported from prototype PR #358 with its experiment flag and its grt_-only ID pattern removed. The server instructions and tool descriptions now tell a package-token model to choose a child by grant_id.

Consequence for users: a package-token client that relied on a merged cross-child search or schema now has to call once per child. Child discovery (list_package_children, named in the error text) and the package_child_inactive result ship in the next PR; until then a client learns its child ids from the authorization result.

Tests: package-profile.test.ts and hosted-mcp-oauth.test.ts cover missing, malformed and unknown selectors and a valid read of child A and child B for every tool (30 cases that fail on the base), a sibling record that stays unreadable after an own-child read succeeds, a revoked child, a downstream 403 grant_invalid that touches no sibling, both children surviving a token refresh, and re-consent. 508 tests and the type check pass; tools/list is 22,580 bytes for client tokens and 23,961 for package tokens, under the 24,576-byte budget. An independent review by a different model ran 90 hostile selectors (prototype keys, whitespace, Unicode, traversal, long strings); none reached a resource-server client.

Landing order: do not merge this PR without the child-discovery PR (list_package_children and package_child_inactive, the next in the stack). Alone, it would leave a package-token client that has to send grant_id with no way to learn a valid value. Merge them together, or discovery first.

What an existing client sees after this merge: a session on an ordinary client token sees no change (same tools, schemas and results). A session on a package token sees grant_id as a required argument on the six data tools in tools/list, so a model that reads the schema supplies it; once the discovery PR is in, the tool list also offers list_package_children to find the value. A client that sends no grant_id gets a tool error naming how to recover instead of a merged result.

This PR is stacked on #381 (the 401 invalid_token change) because both edit the hosted test file; retarget it after #381 merges. Not verified: the unchanged tool-footprint test still fails on the base because it reads an operator document missing from this repository.

Assisted-by: AI

An inactive bearer sent as 403 looks like a permission gap, so clients may keep sending a dead token. The server returns 401 with Bearer invalid_token for rejected presented tokens. A missing bearer gets the metadata challenge without that parameter. The response body keeps introspection-established grant_revoked, grant_expired, and grant_invalid codes. A request with an active package token and no active child grant gets 401. Another active child keeps package access.

Status follows the failure point: an inactive client bearer gets 401, while an authenticated owner request that reaches malformed grant state stays 403. Because grant_invalid can describe either case, clients must follow HTTP status. The `codeToStatus` map keeps `grant_invalid` at 403 for errors found after authentication. The test `grant-scoped polyfill state rejects malformed persisted grant bindings as invalid grants` continues to pin the owner-path 403.

The /mcp tests assert status, challenge, and body for missing and unknown tokens, revoked and expired grants, and a package whose last child was revoked. The multi-child test verifies that one active child keeps package access. REST auth-gate and metadata tests verify the rejected-token and missing-token challenges.

Updated REST tests that pinned 403 for inactive tokens now expect 401:
- `revoked grants fail with grant_revoked`
- `expired grants fail with grant_expired and preserve correlation headers`
- `native client reads reject grants missing structured storage bindings`
- `polyfill client reads fail connector-first when the persisted storage binding points to an unknown connector`
- `polyfill client introspection and reads reject persisted grants whose manifest_version no longer matches the current manifest`
- `native client introspection and reads reject persisted grants whose manifest_version no longer matches the current manifest`
- `auth-gate client read failures emit correlated query.rejected artifacts on the grant timeline`

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
Return authentication_error with a generic message when introspection rejects an inactive token for malformed grant state. Keep grant_invalid in the internal rejection event and record the actual auth-gate status there. Authenticated owner requests with malformed grant state remain 403.

Update CLI checks for inactive-token HTTP 401 and exit 3. Give expired /mcp coverage its own test so it fails independently.

Align the remaining revoked-grant test expectations and troubleshooting guidance with 401 invalid_token while retaining grant_revoked in the error body.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Package token calls previously defaulted to every grant in a package, which made the read target implicit. The MCP server now requires a valid `grant_id` on all six data tools and dispatches each call through only that child’s RS client. Missing, malformed, and unknown selectors fail before any RS request and name `list_package_children` as recovery guidance; that discovery tool ships in a later PR. Client-token schemas and routing stay unchanged.

This removes the package merge layer, including merged schema and search output, record deduplication, `source_mix`, summed scan counts, and combined `has_more`. It also removes the implicit single-child fallback and all-child default, and suppresses package resource templates. Both token lists stay under the 24,576-byte tools budget.

The old package adapter suite’s merge, fan-out, selector, and event-routing tests are replaced by child-client construction tests. The hosted OAuth multi-source picker, revoked-child, and `query_records`-by-`connection_id` tests now cover required selection, revocation isolation, and real sibling-record rejection; refresh checks both original child grants and re-consent checks the replacement child IDs. Package-profile tests cover all six tools, both children, invalid selectors, and downstream `grant_invalid` propagation with one selected-child call and zero sibling calls.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
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