fix(mcp-keys): creating an MCP key requires a signed-in session - #3043
Merged
Merged
Conversation
Records the rule in security.md §20.10 ahead of the change: POST /api/mcp/keys and POST /api/mcp/keys/ensure-default take a signed-in (JWT) session, and ensure-default writes the same key_create audit row as the create route.
…person, require_interactive Two allowlist rules over mcp_scope, both fail-closed on a principal without one: PERSON (a JWT session or the person's own user-scoped key, via is_person_principal) and INTERACTIVE (a JWT session only). The Depends forms make a route's rule a one-line signature change an AST guard can see. Both also refuse a principal carrying vouched_source_agent. The ent#611 ask-endings detail and predicate are unchanged, and pinned so.
POST /api/mcp/keys (every scope) and POST /api/mcp/keys/ensure-default now take Depends(require_interactive): a credential minter is at least as strict as the principal it produces. The existing per-scope checks stay as they were. ensure-default also writes the same key_create audit row as the create route, so every created key is attributable. Tests go through the real get_current_user with seeded key rows and assert the mcp_api_keys row count is unchanged on every refusal.
…t registry mcp-api-keys.md: POST /keys and ensure-default are signed-in-session only; ensure-default is audited as key_create. Adds the feature-flows index row, a learnings fragment and the two new test files to tests/registry.json.
Contributor
Author
This was referenced Sep 28, 2026
AndriiPasternak31
marked this pull request as ready for review
September 28, 2026 12:35
dolho
approved these changes
Sep 29, 2026
dolho
left a comment
Contributor
There was a problem hiding this comment.
Approving. The auth boundary is correct and nothing here blocks.
Verification. I ran the new suites plus the related auth suites (#163, #2323, #293, #1310) in a worktree: 165 passed.
What I checked
require_interactive→reject_non_interactive_principallets only a JWT through (mcp_scope is None), so a key of any scope is refused, including scopes added later. It also refuses the loopback JWT (vouched_source_agent). This is what makes PR A's PERSON rule sound: auserkey can no longer mint anotheruserkey.- Legitimate callers all use a signed-in session:
McpKeysTab.vue, for create and for ensure-default on mount.- CLI
trinity init/login, which pass the fresh JWT in both the email and--adminpaths. - The live tests, which use the admin JWT.
- Nothing in
src/mcp-server,docker/,scripts/orconfig/calls either route.
- The new ensure-default audit row can't turn a key that was created into a 500, because
platform_audit_service.logswallows its own errors. - The tests go through the real
get_current_userwith seeded key rows, and each refusal also checks that the row count didn't change. Nice.
Findings (all minor or nits)
- Minor:
docs/user-docs/api-reference/authentication.md:103,107. The scope table doesn't say that creating a key now requires a signed-in session. A script that mints keys with auserkey will now get a 403, so this needs a line in the docs and the release notes. - Minor:
routers/mcp_keys.py, revoke/delete routes. These stay onget_current_user, so auserkey can still revoke or delete its owner's keys. That's a denial of service, not escalation, and it's out of scope here. Worth a follow-up to decide whether revoke should also require a session. - Nit:
dependencies.py:1187-1206.assert_person/require_personhave no callers yet. That's expected for the M→A→B stack, but please land A soon. - Nit:
routers/mcp_keys.py:~55. Thereject_non_interactive_principalin theopsbranch is now redundant with the route-level gate. Either drop it or add a comment saying it's kept as a second layer. - Nit:
routers/mcp_keys.py:154-169. The ensure-default audit block duplicates the one in create. A small_audit_key_create()helper would stop the two drifting apart. - Nit:
routers/agent_mcp_key.py:72,88,108. These routes use plainreject_non_interactive_principal, which lacks the loopback refusal. It's harmless, because the loopback JWT is blocked where it enters, but it leaves two spellings of the "interactive" rule. Consider moving them torequire_interactivelater.
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.
Refs trinity-enterprise#718
Summary
Creating an MCP key now requires a signed-in (JWT) session.
POST /api/mcp/keys(every scope) andPOST /api/mcp/keys/ensure-defaulttakeDepends(require_interactive): every MCP key gets a 403, the person's ownuserkey included. A credential creator has to be at least as strict as the credential it produces.ensure-defaultnow writes the samekey_createaudit row asPOST /api/mcp/keys.The PR also adds the human-only primitives the next PR builds on, in
dependencies.py:assert_person/require_person(the PERSON rule: a JWT or the person's ownuserkey) andrequire_interactive(JWT only). Both are allowlists overmcp_scopeand fail closed on a principal without one. Nothing existing changes:is_person_principal,reject_non_interactive_principaland the ent#611 detail stay byte-identical.Merge order: this PR first (M → A → B)
This is PR M of three. PR A (#2996) is stacked on it: it gives a person's own
userkey the new PERSON rule, which is only sound once keys can't create auserkey. PR B (ent#712) follows A. See the PR comment for why these are three PRs.Journey Impact: none: key creation from the UI and
trinity loginboth use a JWT and are unchangedType of Change
Testing
tests/unit/test_mcp_key_creation_requires_session.pyandtests/unit/test_human_only_principals.py, through the realget_current_userwith seeded key rows.None, 1–2/243./reviewand/cso --diffboth ran.McpKeysTab.vueand the CLI (trinity login→ ensure-default) use a JWT. Nothing insrc/mcp-server,config/or the system agent calls either route with a key.After merge
trinity-enterprise#718is cross-repo, so close it by hand at the release cut.🤖 Generated with Claude Code