Skip to content

refactor(mcp): collapse tool wiring into a single registry#28

Merged
tianzhou merged 2 commits into
mainfrom
refactor/mcp-tool-registry
Jun 25, 2026
Merged

refactor(mcp): collapse tool wiring into a single registry#28
tianzhou merged 2 commits into
mainfrom
refactor/mcp-tool-registry

Conversation

@tianzhou

@tianzhou tianzhou commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

What

Each MCP tool was defined across four places that had to stay in lockstep:

  1. the TOOLS map (schema)
  2. the hardcoded selectToolNames visibility chain
  3. the dispatchTool switch
  4. the expectedPerm literal in each execute(...) call

The permission level was restated in three of them, so adding or changing a tool meant a four-site edit.

Change

Collapse all four into one descriptor per tool, where visibility is a predicate over the agent's access:

interface Access {
  hasConnection: boolean   // the agent can touch >=1 connection
  union: Set<Permission>   // union of its permissions across those connections
}

interface ToolDef {
  name: string
  description: string
  inputSchema: Record<string, unknown>
  visible: (access: Access) => boolean
  permission?: Permission  // enforced by executeTool; execution tools only
  handler: (principal, args, def) => Promise<unknown>
}

Registry entries:

{ name: 'list_connections', visible: () => true, ... }
{ name: 'list_objects',  visible: (a) => a.hasConnection, ... }
{ name: 'query',  ...requires('read'), handler: executeTool }
{ name: 'run_ddl', ...requires('ddl'), handler: executeTool }

requires(perm) returns { visible: a => a.union.has(perm), permission: perm }, so an execution tool's permission is written once and drives both visibility and enforcement (executeTool/explainQuery read def.permission). This avoids the earlier overloaded single field that mixed access-tier sentinels with real permission values — honest about the fact that permissions are disjoint, not hierarchical.

Supporting mechanics:

  • selectToolNames / listToolsFor share one visibleTools(access) filter
  • dispatchTool is a TOOLS_BY_NAME lookup instead of a switch

Behavior

No behavior change. Dispatch stays by-name (visibility filtering remains in tools/list; each handler re-checks its permission, so an unadvertised tool called directly stays gated — fail-closed intact). Exported signatures selectToolNames / dispatchTool / Principal are unchanged.

Verification

  • tests/mcp.test.ts — 32/32 pass, unchanged
  • tsc --noEmit — clean

Adding a future tool is now a single array entry.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings June 25, 2026 16:07
@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pgconsole Ready Ready Preview, Comment Jun 25, 2026 4:25pm

Copilot AI 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.

Pull request overview

Refactors the MCP tool wiring in server/mcp.ts to use a single tool registry entry per tool, consolidating schema, visibility rules, dispatch, and (for execution tools) permission enforcement into one place to reduce multi-site edits.

Changes:

  • Introduces a ToolDef registry (TOOLS) with unlock + handler and a TOOLS_BY_NAME lookup map.
  • Replaces the hardcoded selectToolNames visibility chain with a shared isUnlocked/unlockedTools filter used by both listing and selection.
  • Replaces the dispatchTool switch with registry-based dispatch and a shared executeTool handler for execution tools.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/mcp.ts
Comment on lines +486 to +488
function executeTool(principal: Principal, args: Record<string, unknown>, def: ToolDef) {
return execute(principal, def.name, def.unlock as Permission, args)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 25cf6e7. Note this thread reviewed the first commit (e25c751): the unlock: 'always' | 'accessible' | Permission sentinel union is gone — visibility is now a visible predicate and execution tools carry an optional permission set via requires(), so a sentinel string can no longer reach the permission path. The residual non-null assertion (def.permission!) is now narrowed through a toolPermission(def) guard that throws a clear "misconfigured" error and removes the cast.

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown

Greptile Summary

This PR collapses the four-site MCP tool wiring pattern (schema map, visibility list, dispatch switch, inline expectedPerm) into a single ToolDef registry array where each entry declares its own unlock rule and handler. Exported signatures (selectToolNames, dispatchTool, Principal) are unchanged and all 32 tests continue to pass.

  • The TOOLS array and a derived TOOLS_BY_NAME map replace the former TOOLS object + switch statement; isUnlocked / unlockedTools are shared by both selectToolNames and listToolsFor, eliminating the previous duplication of permission logic.
  • executeTool is a new shared handler for the three execution tools (query / write_data / run_ddl) that reads the enforced permission directly from def.unlock, but uses an as Permission cast that the type system cannot verify — a misused entry would produce a confusing error rather than a compile-time failure.

Confidence Score: 4/5

Safe to merge. The refactor preserves all security boundaries — per-connection permission re-checks in every handler, fail-closed dispatch for unknown tools — with no observable behavior change.

The change is a well-contained structural refactor of a single file. All existing enforcement paths (requireOne, requireAccessible, requireAll) remain intact in the handlers, and the TOOLS_BY_NAME lookup preserves the MethodNotFound guard from the old switch default. The one rough edge is the unguarded as Permission cast in executeTool: if a future registry entry pairs executeTool with unlock: 'always' or unlock: 'accessible', TypeScript won't warn and the runtime error will be opaque. Current entries are all correct, so this is a future-extensibility concern rather than a present defect.

No files require special attention beyond the executeTool cast noted in the inline comment.

Important Files Changed

Filename Overview
server/mcp.ts All tool wiring collapsed into a single ToolDef registry array; selectToolNames, listToolsFor, and dispatchTool now drive off one isUnlocked predicate and a TOOLS_BY_NAME map; the only flag is the unguarded as Permission cast in executeTool.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client as MCP Client
    participant Router as mcpRouter
    participant Server as buildServer
    participant Dispatch as dispatchTool
    participant Registry as TOOLS_BY_NAME
    participant Handler as ToolDef.handler
    participant Enforce as execute / requireOne

    Client->>Router: POST /mcp (Bearer token)
    Router->>Server: buildServer(principal)
    Client->>Server: tools/list
    Server->>Registry: unlockedTools(hasAccessible, union)
    Registry-->>Server: filtered ToolDef[] (name, description, inputSchema only)
    Server-->>Client: advertised tools

    Client->>Server: "tools/call { name, arguments }"
    Server->>Dispatch: dispatchTool(principal, name, args)
    Dispatch->>Registry: TOOLS_BY_NAME.get(name)
    alt unknown tool
        Registry-->>Dispatch: undefined
        Dispatch-->>Client: McpError(MethodNotFound)
    else known tool
        Registry-->>Dispatch: ToolDef (with unlock + handler)
        Dispatch->>Handler: def.handler(principal, args, def)
        Handler->>Enforce: requireOne / requireAccessible (re-check per connection)
        Enforce-->>Handler: throws if denied (fail-closed)
        Handler-->>Dispatch: result
        Dispatch-->>Client: textResult(result)
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client as MCP Client
    participant Router as mcpRouter
    participant Server as buildServer
    participant Dispatch as dispatchTool
    participant Registry as TOOLS_BY_NAME
    participant Handler as ToolDef.handler
    participant Enforce as execute / requireOne

    Client->>Router: POST /mcp (Bearer token)
    Router->>Server: buildServer(principal)
    Client->>Server: tools/list
    Server->>Registry: unlockedTools(hasAccessible, union)
    Registry-->>Server: filtered ToolDef[] (name, description, inputSchema only)
    Server-->>Client: advertised tools

    Client->>Server: "tools/call { name, arguments }"
    Server->>Dispatch: dispatchTool(principal, name, args)
    Dispatch->>Registry: TOOLS_BY_NAME.get(name)
    alt unknown tool
        Registry-->>Dispatch: undefined
        Dispatch-->>Client: McpError(MethodNotFound)
    else known tool
        Registry-->>Dispatch: ToolDef (with unlock + handler)
        Dispatch->>Handler: def.handler(principal, args, def)
        Handler->>Enforce: requireOne / requireAccessible (re-check per connection)
        Enforce-->>Handler: throws if denied (fail-closed)
        Handler-->>Dispatch: result
        Dispatch-->>Client: textResult(result)
    end
Loading

Reviews (1): Last reviewed commit: "refactor(mcp): collapse tool wiring into..." | Re-trigger Greptile

Comment thread server/mcp.ts
Comment on lines +484 to +488
// Shared handler for the execution tools (query/write_data/run_ddl). The disjoint permission to
// enforce is the tool's own `unlock`, so it lives in one place — the registry entry.
function executeTool(principal: Principal, args: Record<string, unknown>, def: ToolDef) {
return execute(principal, def.name, def.unlock as Permission, args)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The as Permission cast in executeTool silently allows any future ToolDef with unlock: 'always' or unlock: 'accessible' to pass those sentinel strings directly into the permission enforcement path. TypeScript won't catch the misuse, and the resulting runtime error (Permission denied: ... requires 'always' permission) will be confusing. A narrow overload or a dedicated ExecuteToolDef type with unlock: Permission would make this invariant compiler-enforced.

Suggested change
// Shared handler for the execution tools (query/write_data/run_ddl). The disjoint permission to
// enforce is the tool's own `unlock`, so it lives in one place — the registry entry.
function executeTool(principal: Principal, args: Record<string, unknown>, def: ToolDef) {
return execute(principal, def.name, def.unlock as Permission, args)
}
// Shared handler for the execution tools (query/write_data/run_ddl). The disjoint permission to
// enforce is the tool's own `unlock`, so it lives in one place — the registry entry.
// `unlock` must be a Permission here; a sentinel ('always'/'accessible') would produce a
// confusing runtime error. Narrow the type so the compiler enforces the invariant.
interface ExecuteToolDef extends ToolDef {
unlock: Permission
}
function executeTool(principal: Principal, args: Record<string, unknown>, def: ExecuteToolDef) {
return execute(principal, def.name, def.unlock, args)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 25cf6e7. The as Permission cast no longer exists (this reviewed e25c751, before the sentinel union was replaced by a visible predicate + optional permission). On the suggested ExecuteToolDef extends ToolDef narrowing: dispatchTool invokes every handler through the generic ToolDef['handler'] signature, so a handler typed to the narrower subtype won't cleanly assign under strictFunctionTypes. Instead, toolPermission(def) fails fast with a clear message if an execution tool is wired without a permission.

Copilot AI 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.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.

Comment thread server/mcp.ts
Comment thread server/mcp.ts Outdated
Each MCP tool was defined across four places — the TOOLS map, the
hardcoded selectToolNames visibility chain, the dispatchTool switch, and
the expectedPerm literal in each execute() call — with the permission
level restated in three of them. Adding or changing a tool meant editing
all four in lockstep.

Replace them with one ToolDef per tool: { name, description, inputSchema,
unlock, handler }. `unlock` is declared once and drives both visibility
and enforcement:
  - 'always'     → always advertised (list_connections)
  - 'accessible' → needs >=1 reachable connection (catalog browsing)
  - a Permission → advertised when held on >=1 connection, and reused as
                   the disjoint permission execute() enforces

selectToolNames/listToolsFor share one isUnlocked filter; dispatchTool is
a TOOLS_BY_NAME lookup instead of a switch; the three execution tools
share one executeTool handler that reads the enforced permission from
def.unlock. No behavior change — exported signatures preserved, all 32
mcp tests pass, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address PR review: the registry's TOOLS_BY_NAME map would silently
overwrite on a duplicate `name`, and executeTool/explainQuery asserted
`def.permission!`. Build the map with an explicit duplicate check, and
narrow the permission through a `toolPermission()` guard that throws a
clear "misconfigured" error instead of letting `undefined` flow into the
enforcement path. Both are registry-construction invariants, caught at
module load / call time rather than as opaque downstream errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

@tianzhou
tianzhou merged commit e30dc64 into main Jun 25, 2026
4 checks passed
@tianzhou
tianzhou deleted the refactor/mcp-tool-registry branch June 29, 2026 09:04
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.

2 participants