Skip to content

feat(mcp-server): authorization policy evaluator (MCP Prompt 11)#4012

Open
gilgardosh wants to merge 1 commit into
claude/mcp-prompt-10-tool-registryfrom
claude/mcp-prompt-11-policy-evaluator
Open

feat(mcp-server): authorization policy evaluator (MCP Prompt 11)#4012
gilgardosh wants to merge 1 commit into
claude/mcp-prompt-10-tool-registryfrom
claude/mcp-prompt-11-policy-evaluator

Conversation

@gilgardosh

Copy link
Copy Markdown
Collaborator

Summary

Implements Prompt 11 – Authorization policy evaluator (see
docs/mcp/spec.md §7). Evaluates each tool's
policy against the caller's auth context before execution.

Stacked PR: targets claude/mcp-prompt-10-tool-registry (Prompt 10, #4011).
Review/merge Prompts 05→10 first; this diff shows only the Prompt 11 changes.

Changes

  • src/tools/policy.ts
    • evaluateToolPolicy({ policy, auth, requestedBusinessIds }):
      • Required roles (any-of): caller must hold at least one when the policy lists any; no list ⇒ no gate.
      • Scope narrowing: the requested business ids must be a subset of the caller's memberships — any id outside is denied, never silently dropped.
      • Business-scope requirement: when requiresBusinessScope, the resolved scope must be non-empty.
      • Returns the resolved readScope when allowed, or a deterministic AUTHORIZATION_ERROR.
    • authorizeToolCall(tool, auth, requestedBusinessIds) — convenience for a registered tool.
  • Tests — allow (default + narrowed subset), deny (out-of-scope, empty scope when required, missing role), any-of roles, and no-role-gate. 128 tests total.

Validation

  • yarn workspace @accounter/mcp-server test → 128 passed
  • yarn workspace @accounter/mcp-server lint / typecheck / build → pass

Notes / open decisions

  • Required roles are treated as any-of (hold at least one). The spec says "required role set" without specifying any-of vs all-of; any-of matches typical capability-grant semantics. Easy to flip to all-of if the team prefers — flagging the choice.
  • The evaluator is pure and unit-tested; it's invoked from the tools/call execution path when the first real tool lands (Prompt 13). AUTHORIZATION_ERROR will be folded into the unified error taxonomy in Prompt 17.

🤖 Generated with Claude Code


Generated by Claude Code

@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack July 21, 2026 15:20 — with GitHub Actions Inactive
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack July 21, 2026 15:20 — with GitHub Actions Inactive

@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 introduces a per-tool authorization policy evaluator (evaluateToolPolicy and authorizeToolCall) along with corresponding unit tests to enforce role gates and business-scope constraints. The review feedback highlights a critical security concern regarding potential tenant isolation bypass if input arguments containing a businessId are not validated against the resolved authorized read scope. It is recommended to automatically validate these input arguments within authorizeToolCall and add a corresponding test case to verify this behavior.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +76 to +82
export function authorizeToolCall(
tool: ToolDefinition,
auth: McpAuthContext,
requestedBusinessIds?: readonly string[],
): PolicyDecision {
return evaluateToolPolicy({ policy: tool.policy, auth, requestedBusinessIds });
}

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.

security-high high

Security Concern: Potential Tenant Isolation Bypass

If a tool handler accepts a businessId (or business_id) in its input arguments, there is currently no automatic validation ensuring that the requested input.businessId is within the resolved context.readScope.businessIds.

If a developer forgets to manually perform this check inside the tool's handler, a caller could pass an arbitrary businessId in the tool arguments and bypass tenant isolation, even if requestedBusinessIds was correctly narrowed or restricted at the policy level.

Recommendation

Implement an automatic validation step in authorizeToolCall that automatically rejects the request if the validated input contains a businessId or business_id field that is not present in the resolved readScope.businessIds (when requiresBusinessScope is true).

Suggested change
export function authorizeToolCall(
tool: ToolDefinition,
auth: McpAuthContext,
requestedBusinessIds?: readonly string[],
): PolicyDecision {
return evaluateToolPolicy({ policy: tool.policy, auth, requestedBusinessIds });
}
export function authorizeToolCall(
tool: ToolDefinition,
auth: McpAuthContext,
requestedBusinessIds?: readonly string[],
inputArgs?: Record<string, unknown>,
): PolicyDecision {
const decision = evaluateToolPolicy({ policy: tool.policy, auth, requestedBusinessIds });
if (!decision.allowed) {
return decision;
}
if (tool.policy.requiresBusinessScope && inputArgs) {
const targetBusinessId = inputArgs.businessId ?? inputArgs.business_id;
if (typeof targetBusinessId === 'string' && !decision.readScope.businessIds.includes(targetBusinessId)) {
return deny(`Requested businessId '${targetBusinessId}' is outside your authorized read scope`);
}
}
return decision;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Declining — the tenant-isolation bypass is already prevented at the execution layer (added in the charges/report steps). executeRegisteredTool derives the requested scope from a businessId/businessIds input and passes it to evaluateToolPolicy, which narrows via narrowReadScope and returns AUTHORIZATION_ERROR when any requested id is outside the caller's memberships. So a tool that reads input.businessId can never receive one outside scope — the call is denied before the handler runs.

This is covered by tests: charges "denies a requested business outside the memberships" and reports "denies a business outside the caller memberships". Adding the check to authorizeToolCall would duplicate logic that isn't on the execution path (the executor calls evaluateToolPolicy directly). Keeping enforcement in one place.


Generated by Claude Code

Comment on lines +115 to +117
const decision = authorizeToolCall(tool, authContext([M('b1')]), ['b1']);
expect(decision).toEqual({ allowed: true, readScope: { businessIds: ['b1'] } });
});

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

Add a test case to verify that authorizeToolCall correctly denies requests when the input arguments contain a businessId outside the resolved read scope.

    const decision = authorizeToolCall(tool, authContext([M('b1')]), ['b1']);
    expect(decision).toEqual({ allowed: true, readScope: { businessIds: ['b1'] } });
  });

  it('denies when the input arguments contain a businessId outside the resolved read scope', () => {
    const tool = {
      name: 't',
      description: 'd',
      inputSchema: undefined as never,
      policy: scopedPolicy,
      handler: () => ({ content: [] }),
    } as unknown as ToolDefinition;

    const decision = authorizeToolCall(tool, authContext([M('b1')]), ['b1'], { businessId: 'b2' });
    expect(decision.allowed).toBe(false);
    if (!decision.allowed) {
      expect(decision.error.message).toMatch(/outside your authorized read scope/);
    }
  });

Enforce required roles and business-scope constraints before tool execution
(docs/mcp/spec.md §7).

- src/tools/policy.ts: evaluateToolPolicy() checks required roles (any-of) and
  resolves the request's read scope as a subset of the caller's memberships,
  denying out-of-scope requests and empty scope (when required) with a
  deterministic AUTHORIZATION_ERROR; authorizeToolCall() convenience for a
  registered tool
- unit tests for allow, role deny, out-of-scope deny, empty-scope deny, subset
  narrowing, any-of roles, and no-role-gate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo
@gilgardosh
gilgardosh force-pushed the claude/mcp-prompt-11-policy-evaluator branch from d3ea230 to 19562ff Compare July 21, 2026 16:13
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack July 21, 2026 16:13 — with GitHub Actions Inactive
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack July 21, 2026 16:13 — with GitHub Actions Inactive
@gilgardosh gilgardosh self-assigned this Jul 22, 2026
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