feat(mcp-server): charges search tool + registry wiring (MCP Prompt 13)#4014
feat(mcp-server): charges search tool + registry wiring (MCP Prompt 13)#4014gilgardosh wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
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.
| 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`); | ||
| } | ||
| } |
There was a problem hiding this comment.
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');
}
}
}| import type { McpAuthContext } from '../auth/identity.js'; | ||
| import { UpstreamError } from '../upstream/graphql-client.js'; | ||
| import type { UpstreamGraphQLClient } from '../upstream/graphql-client.js'; |
There was a problem hiding this comment.
Import log from ../logger.js to enable logging of unexpected tool execution errors.
| 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'; |
| return toolErrorResult({ | ||
| code: 'INTERNAL_ERROR', | ||
| message: 'Tool execution failed', | ||
| correlationId, | ||
| }); |
There was a problem hiding this comment.
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,
});e95d30d to
2806ce8
Compare
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
b165a5a to
0e6e0f0
Compare
Summary
Implements Prompt 13 – Tool 1: charges search/browse (read-only) (see
docs/mcp/spec.md§8). Adds the firstproduction tool and wires the curated registry into the live
POST /mcppath.Changes
src/tools/charges.ts—accounter_search_charges: read-onlyallChargesquery, always scoped to the caller's authorized businesses (byBusinesses= resolved read scope). Strict input: optionalbusinessIds(scope narrowing),fromDate/toDate(bounded to 366 days, format-checked),tags,freeText,flow(ALL/INCOME/EXPENSE), and bounded pagination (pageSize≤ 50). Returns normalized charges + pagination metadata.src/tools/execute.ts—executeRegisteredTool: validation → policy → handler, mapping failures to MCP tool results withisErrorand{ code, message, correlationId, retryable? }(VALIDATION_ERROR,AUTHORIZATION_ERROR,UPSTREAM_ERROR,TIMEOUT_ERROR,INTERNAL_ERROR).ToolInputErrorlets 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/listnow returns the smoke tool plus the registry's tools, andtools/callexecutes registered tools.ToolExecutionContextcarries the upstream client +authorization; the client is built lazily so unauthenticated/basic paths never touch upstream config.Validation
yarn workspace @accounter/mcp-server test→ 153 passedyarn workspace @accounter/mcp-server lint/typecheck/build→ passNotes / open decisions
allCharges(filters: ChargeFilter, page, limit): PaginatedCharges,PageInfo,FinancialAmount) verified frompackages/server. Tests mock the upstream, so field selections aren't live-validated — worth a smoke check against a running server before release.tools/callresults withisError: 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.businessIdsfield 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.🤖 Generated with Claude Code
Generated by Claude Code