Skip to content

fix(mcp): list verb tools for table-less Resources to any authenticated user - #1943

Merged
kriszyp merged 2 commits into
mainfrom
fix/mcp-tool-visibility-1940
Jul 31, 2026
Merged

fix(mcp): list verb tools for table-less Resources to any authenticated user#1943
kriszyp merged 2 commits into
mainfrom
fix/mcp-tool-visibility-1940

Conversation

@kylebernhardy

@kylebernhardy kylebernhardy commented Jul 24, 2026

Copy link
Copy Markdown
Member

Closes #1940.

Problem

makeVisibleTo gated tools/list visibility on the Resource having a backing database and table:

if (!databaseName || !tableName) return false;

A purely programmatic Resource — a Resource subclass overriding the verbs, or one aggregating across tables — has neither, so no non-super user saw any of its verb tools. That is precisely the class of Resource #1920/#1921 just taught to emit rich inputSchema / outputSchema, so the feature was invisible to the realistic MCP caller.

The gate also bought no access control. tools/call does not consult visibleTotoolRegistry.ts's own docblock says so ("explicitly NOT a security boundary") and transport.ts dispatches straight from getTool(name, profile) to the handler, with an existing pass-through test for a hallucinated name. So hiding these tools cost discoverability and nothing else.

Change

Table-less Resources are listed to any authenticated caller; their allow* predicates enforce at call time. Table-backed Resources are untouched — still getUserTablePermissions per mode.

Two corrections to my first commit (review found both)

The first commit returned true unconditionally, which included anonymous callers. tools/list has no auth gate: the MCP handler mounts { after: 'authentication' }, which is ordering, not a requirement, so an unauthenticated request arrives as { username: '' } and optional-chains past the super-user check. Every exported programmatic Resource's field names, docstrings, and enum values would have been published to anyone who could reach the application port. Fixed in 8a8eb36ff with an explicit isAuthenticated helper, plus a regression test covering { username: '' }, undefined, and {}.

The "parity with custom mcpTools" argument was too broad and I've dropped it. Custom tools do list to anonymous sessions — deliberately, for the public-docs case (#1609) — but declaring static mcpTools is an author opt-in, a conscious act of publishing. Verb tools are generated for every exported Resource with no author action, so the defaults are not equivalent and shouldn't be aligned by pointing at each other.

Where to look

  • The authenticated-vs-anonymous line is the security judgment. Authenticated users with zero grants now see descriptors (names, descriptions, property descriptions, enum values) for table-less Resources they previously couldn't enumerate. Data access is unchanged — that was always the allow* predicates' job — but descriptor text is a real disclosure surface: a withSchema contract copies author-declared property schemas verbatim, and an enum can encode business data (tenant codes, tier names). If you'd rather this be opt-in rather than opt-out, say so and I'll switch it to a static mcp flag.
  • Three tests: table-less Resource listed to a no-grants user across four verbs; the anonymous case; and a guard that the table-backed path still denies.

Full MCP unit suite (477 tests) passes.

Related, not fixed here

Review also surfaced that detectVerbs manufactures a create_* tool for every Resource subclass — it checks typeof p.post === 'function' with no base-prototype comparison, unlike hasClassLevelVerbs one file over — which reaches Harper's own login Resource. Filed as #1945; independent of this change (that's about which tools exist, this is about who sees them).

Docs follow-up in documentation#605, which documents the old super-user-only behavior.

PR description generated by kAIle (Claude Opus 4.8).

…ed user (#1940)

`makeVisibleTo` returned false for a Resource with no databaseName/tableName,
so every non-super user saw none of its verb tools in tools/list. That hid
exactly the Resources #1920/#1921 taught to produce rich schemas, and hid
nothing meaningful: tools/call already accepts these tools by name, so the
gate cost discoverability without buying access control.

Return true instead and let the Resource's own allow* predicates enforce at
call time — the contract custom `mcpTools` have always had (they have never
had a listing filter beyond authentication). The table-backed path is
unchanged and still gates on per-table permissions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
@kylebernhardy kylebernhardy self-assigned this Jul 24, 2026
@kylebernhardy
kylebernhardy requested a review from kriszyp July 24, 2026 23:04
@kylebernhardy kylebernhardy added this to the v5.2 milestone Jul 24, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the visibility logic for MCP tools to make table-less resources visible to any authenticated user, with corresponding unit tests added to verify this behavior. The review feedback suggests using assert.strictEqual instead of assert.equal in the new tests to align with the repository's style guide.

Comment on lines +347 to +348
assert.equal(tool.visibleTo(NOBODY), true, `${name} is listed for a user with no table grants`);
assert.equal(tool.visibleTo(SUPER), true, `${name} is listed for super_user`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository style guide, we should explicitly use assert.strictEqual instead of assert.equal for strict equality assertions.

Suggested change
assert.equal(tool.visibleTo(NOBODY), true, `${name} is listed for a user with no table grants`);
assert.equal(tool.visibleTo(SUPER), true, `${name} is listed for super_user`);
assert.strictEqual(tool.visibleTo(NOBODY), true, name + " is listed for a user with no table grants");
assert.strictEqual(tool.visibleTo(SUPER), true, name + " is listed for super_user");
References
  1. Use assert.strictEqual/assert.deepStrictEqual explicitly where strict semantics are needed. (link)

Comment on lines +357 to +358
assert.equal(getTool('get_Product').visibleTo(NOBODY), false);
assert.equal(getTool('delete_Product').visibleTo(ALICE_READ), false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository style guide, we should explicitly use assert.strictEqual instead of assert.equal for strict equality assertions.

Suggested change
assert.equal(getTool('get_Product').visibleTo(NOBODY), false);
assert.equal(getTool('delete_Product').visibleTo(ALICE_READ), false);
assert.strictEqual(getTool('get_Product').visibleTo(NOBODY), false);
assert.strictEqual(getTool('delete_Product').visibleTo(ALICE_READ), false);
References
  1. Use assert.strictEqual/assert.deepStrictEqual explicitly where strict semantics are needed. (link)

@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

The first commit's `return true` was reached by anonymous callers, not just
authenticated ones. `tools/list` has no auth gate — the MCP handler mounts
`{ after: 'authentication' }`, which is ordering, not a requirement — so an
unauthenticated request arrives as `{ username: '' }` and optional-chains
straight past the super-user check into the table-less branch. Every exported
programmatic Resource's field names, docstrings, and enum values would have
been published on the application port to anyone who could reach it.

Gate on an explicit `isAuthenticated` instead. Custom `mcpTools` do list to
anonymous sessions, but that is an author opt-in — declaring `static mcpTools`
is a deliberate act of publishing — whereas verb tools are generated for every
exported Resource with no author action, so the two are not equivalent and the
original parity argument was too broad.

Replaces the second test, which duplicated existing table-backed assertions,
with the anonymous case that actually pins this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
@kylebernhardy

Copy link
Copy Markdown
Member Author

Runtime note from verifying the sibling PR (#1944) against a booted instance — relevant to this PR's rationale.

Confirmed: on the pre-#1943 build, an anonymous MCP session gets tools/list0 tools for table-less Resources, while an authenticated super-user gets all of them. So the old return false genuinely was gating anonymous listing, and the first commit here would have opened it — the isAuthenticated fix in 8a8eb36ff is load-bearing, not defensive.

Correction to this PR's description: I justified the widening partly by saying OpenAPI "is unfiltered entirely." That overstates it — a default prod install returns 403 for an unauthenticated GET /openapi. The document is global (no per-user filtering, so any authorized caller sees every docstring), but it is not anonymously public by default. The descriptor-disclosure tradeoff is therefore narrower than I framed it; the decision for a reviewer is still whether authenticated-with-no-grants users should see these descriptors.

Also worth knowing when reading this PR: my verification fixture defined its verbs as static methods and implements no prototype post, yet still got create_* tools registered — a live reproduction of #1945.

Comment generated by kAIle (Claude Opus 4.8).

@kylebernhardy
kylebernhardy marked this pull request as ready for review July 27, 2026 17:13
@kriszyp
kriszyp merged commit b7c4072 into main Jul 31, 2026
45 of 47 checks passed
@kriszyp
kriszyp deleted the fix/mcp-tool-visibility-1940 branch July 31, 2026 19:02
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.

[MCP] Verb tools for table-less programmatic Resources are listed only to super-users

3 participants