Skip to content

feat(mcp-server): charges search tool + registry wiring (MCP Prompt 13)#4014

Open
gilgardosh wants to merge 2 commits into
claude/mcp-prompt-12-graphql-clientfrom
claude/mcp-prompt-13-charges-tool
Open

feat(mcp-server): charges search tool + registry wiring (MCP Prompt 13)#4014
gilgardosh wants to merge 2 commits into
claude/mcp-prompt-12-graphql-clientfrom
claude/mcp-prompt-13-charges-tool

Conversation

@gilgardosh

Copy link
Copy Markdown
Collaborator

Summary

Implements Prompt 13 – Tool 1: charges search/browse (read-only) (see
docs/mcp/spec.md §8). Adds the first
production tool and wires the curated registry into the live POST /mcp path.

Stacked PR: targets claude/mcp-prompt-12-graphql-client (Prompt 12, #4013).
Review/merge Prompts 09→12 first; this diff shows only the Prompt 13 changes.

Changes

  • src/tools/charges.tsaccounter_search_charges: read-only allCharges query, always scoped to the caller's authorized businesses (byBusinesses = resolved read scope). Strict input: optional businessIds (scope narrowing), fromDate/toDate (bounded to 366 days, format-checked), tags, freeText, flow (ALL/INCOME/EXPENSE), and bounded pagination (pageSize50). Returns normalized charges + pagination metadata.
  • src/tools/execute.tsexecuteRegisteredTool: validation → policy → handler, mapping failures to MCP tool results with isError and { code, message, correlationId, retryable? } (VALIDATION_ERROR, AUTHORIZATION_ERROR, UPSTREAM_ERROR, TIMEOUT_ERROR, INTERNAL_ERROR). ToolInputError lets handlers raise domain validation (e.g. date bounds).
  • src/tools/registry-instance.ts — process-wide registry with the charges tool registered.
  • src/mcp/handler.ts — async dispatch (dispatchMcpBody/dispatchMcpRequest): tools/list now returns the smoke tool plus the registry's tools, and tools/call executes registered tools. ToolExecutionContext carries the upstream client + authorization; the client is built lazily so unauthenticated/basic paths never touch upstream config.
  • Tests — charges: successful read (normalized), empty results, invalid filters (unknown field, bad date, inverted range, oversized page), scope narrowing + enforcement, and upstream-failure mapping; dispatch lists the tool and rejects unknown names. 153 tests total.

Validation

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

Notes / open decisions

  • GraphQL query accuracy. The query targets the real server schema (allCharges(filters: ChargeFilter, page, limit): PaginatedCharges, PageInfo, FinancialAmount) verified from packages/server. Tests mock the upstream, so field selections aren't live-validated — worth a smoke check against a running server before release.
  • Tool-error convention. Execution failures are returned as tools/call results with isError: true (per MCP), not JSON-RPC protocol errors; only unknown-tool / malformed params stay protocol-level (InvalidParams). The unified error mapper (Prompt 17) will formalize this shape.
  • Scope narrowing input. By convention a tool's optional businessIds field drives policy scope narrowing; the resolved scope is what the handler queries with. Happy to move this to an MCP-level param instead if preferred.
  • The smoke tool remains listed alongside the production tool; it's removed at final wiring (Prompt 24).

🤖 Generated with Claude Code


Generated by Claude Code

@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack July 21, 2026 15:54 — with GitHub Actions Inactive
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack July 21, 2026 15:54 — 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 the first production tool, accounter_search_charges, which allows read-only charges searching and browsing within authorized businesses. It implements the core tool execution flow—including strict input validation, authorization policy evaluation, and error mapping—and integrates it into the MCP HTTP handler. Feedback on these changes highlights a validation gap in assertDateRange where providing only one date bypasses the NaN check, and an observability gap where unexpected tool execution errors are caught and returned as INTERNAL_ERROR without being logged.

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 +95 to +110
function assertDateRange(input: SearchChargesInput): void {
if (!input.fromDate || !input.toDate) {
return;
}
const from = Date.parse(input.fromDate);
const to = Date.parse(input.toDate);
if (Number.isNaN(from) || Number.isNaN(to)) {
throw new ToolInputError('Invalid fromDate/toDate');
}
if (from > to) {
throw new ToolInputError('fromDate must be on or before toDate');
}
if ((to - from) / DAY_MS > MAX_DATE_RANGE_DAYS) {
throw new ToolInputError(`Date range must not exceed ${MAX_DATE_RANGE_DAYS} days`);
}
}

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

In assertDateRange, if only one of fromDate or toDate is provided, the function returns early and completely bypasses the NaN check. This allows invalid date values (e.g., '2026-02-31' or '2026-00-00') that match the regex but are invalid calendar dates to be sent directly to the upstream GraphQL server, potentially causing unhandled upstream errors.

Additionally, calculating the day difference using (to - from) / DAY_MS is safer when wrapped in Math.round to guard against any potential timezone/DST parsing discrepancies if the date parsing behavior is ever modified.

function assertDateRange(input: SearchChargesInput): void {
  const from = input.fromDate ? Date.parse(input.fromDate) : undefined;
  const to = input.toDate ? Date.parse(input.toDate) : undefined;

  if (from !== undefined && Number.isNaN(from)) {
    throw new ToolInputError('Invalid fromDate');
  }
  if (to !== undefined && Number.isNaN(to)) {
    throw new ToolInputError('Invalid toDate');
  }

  if (from !== undefined && to !== undefined) {
    if (from > to) {
      throw new ToolInputError('fromDate must be on or before toDate');
    }
    const daysDifference = Math.round((to - from) / DAY_MS);
    if (daysDifference > MAX_DATE_RANGE_DAYS) {
      throw new ToolInputError('Date range must not exceed ' + MAX_DATE_RANGE_DAYS + ' days');
    }
  }
}

Comment on lines +1 to +3
import type { McpAuthContext } from '../auth/identity.js';
import { UpstreamError } from '../upstream/graphql-client.js';
import type { UpstreamGraphQLClient } from '../upstream/graphql-client.js';

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

Import log from ../logger.js to enable logging of unexpected tool execution errors.

Suggested change
import type { McpAuthContext } from '../auth/identity.js';
import { UpstreamError } from '../upstream/graphql-client.js';
import type { UpstreamGraphQLClient } from '../upstream/graphql-client.js';
import type { McpAuthContext } from '../auth/identity.js';
import { log } from '../logger.js';
import { UpstreamError } from '../upstream/graphql-client.js';
import type { UpstreamGraphQLClient } from '../upstream/graphql-client.js';

Comment on lines +150 to +154
return toolErrorResult({
code: 'INTERNAL_ERROR',
message: 'Tool execution failed',
correlationId,
});

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

Unexpected errors thrown during tool execution are caught and mapped to a generic INTERNAL_ERROR with the message 'Tool execution failed'. However, the actual error (including its message and stack trace) is never logged. This creates a significant observability gap, making it extremely difficult to diagnose and debug unexpected production failures.

We should log the unexpected error before returning the generic error result.

    log('error', 'Unexpected error during tool execution', {
      tool: tool.name,
      correlationId,
      error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
    });
    return toolErrorResult({
      code: 'INTERNAL_ERROR',
      message: 'Tool execution failed',
      correlationId,
    });

claude added 2 commits July 21, 2026 16:14
Add the first production tool and wire the curated registry into POST /mcp
(docs/mcp/spec.md §8).

- src/tools/charges.ts: accounter_search_charges — read-only allCharges query
  scoped to the caller's authorized businesses; strict input (date range bounded
  to 366 days, pageSize <= 50, tags/freeText/flow filters); normalized output +
  pagination metadata
- src/tools/execute.ts: executeRegisteredTool runs validation -> policy ->
  handler and maps failures to tool-result errors (VALIDATION/AUTHORIZATION/
  UPSTREAM/TIMEOUT/INTERNAL) with correlation id + retryability
- src/tools/registry-instance.ts: process-wide registry with the charges tool
- handler: async dispatch (dispatchMcpBody/dispatchMcpRequest) so tools/list
  includes registry tools and tools/call executes them; upstream client built
  lazily; ToolExecutionContext carries client + authorization
- integration tests: success, empty, invalid filters/date/pageSize, scope
  narrowing + enforcement, upstream failure; dispatch lists the tool

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo
Address review:
- charges assertDateRange validates each supplied date even when only one is
  present, rejecting impossible-but-format-matching values; day-diff via
  Math.round
- executeRegisteredTool logs unexpected (non-Upstream) errors with the tool
  name, correlation id, message, and stack before returning INTERNAL_ERROR,
  closing an observability gap; the caller still sees only a generic message

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-13-charges-tool branch from b165a5a to 0e6e0f0 Compare July 21, 2026 16:16
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack July 21, 2026 16:16 — with GitHub Actions Inactive
@gilgardosh
gilgardosh temporarily deployed to accounter-fullstack July 21, 2026 16:16 — 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