feat(mcp-server): hardened upstream GraphQL client (MCP Prompt 12)#4013
feat(mcp-server): hardened upstream GraphQL client (MCP Prompt 12)#4013gilgardosh wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a hardened, read-only upstream GraphQL client (UpstreamGraphQLClient) with built-in timeouts, bounded retries, header propagation, and error sanitization, along with comprehensive unit tests and a memoized default client instance. The review feedback highlights critical security and robustness improvements: addressing a potential bypass in the read-only query validation regex, adding defensive checks in sanitizeGraphQLErrors to prevent TypeErrors on malformed error arrays, and wrapping response.json() in a try-catch block to handle non-JSON responses gracefully.
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 sanitizeGraphQLErrors(errors: Array<{ message?: unknown }>): string { | ||
| const messages = errors | ||
| .map(error => (typeof error.message === 'string' ? error.message : '')) | ||
| .map(message => message.trim()) | ||
| .filter(Boolean) | ||
| .slice(0, 3); | ||
| return messages.length > 0 ? messages.join('; ') : 'Upstream GraphQL error'; | ||
| } |
There was a problem hiding this comment.
If any element in the errors array is null or not an object, accessing error.message will throw a raw TypeError, bypassing the error sanitization layer. Adding a safety check ensures robustness against malformed upstream error payloads.
| function sanitizeGraphQLErrors(errors: Array<{ message?: unknown }>): string { | |
| const messages = errors | |
| .map(error => (typeof error.message === 'string' ? error.message : '')) | |
| .map(message => message.trim()) | |
| .filter(Boolean) | |
| .slice(0, 3); | |
| return messages.length > 0 ? messages.join('; ') : 'Upstream GraphQL error'; | |
| } | |
| function sanitizeGraphQLErrors(errors: Array<{ message?: unknown }>): string { | |
| const messages = errors | |
| .map(error => (error && typeof error === 'object' && typeof error.message === 'string' ? error.message : '')) | |
| .map(message => message.trim()) | |
| .filter(Boolean) | |
| .slice(0, 3); | |
| return messages.length > 0 ? messages.join('; ') : 'Upstream GraphQL error'; | |
| } |
d3ea230 to
19562ff
Compare
Add the shared read-only upstream GraphQL client used by tool handlers (docs/mcp/spec.md §5.3, §10.3, §10.4). - src/upstream/graphql-client.ts: UpstreamGraphQLClient with strict timeout + cancellation (AbortController), bounded retries for idempotent read failures only (network/timeout/5xx; never 4xx or GraphQL errors), correlation-id and Authorization header propagation (token never logged), and sanitized UpstreamError (UPSTREAM_ERROR/TIMEOUT_ERROR, no internal details) - read-only guard refuses mutations/subscriptions; createReadOperation exposes only typed read wrappers to tools (no generic execute surface) - src/upstream/default-client.ts: env-backed, memoized client factory - unit tests for success/header propagation, read-only guard, timeout, retry eligibility (503 retried, 400/401 not, exhaustion), and error sanitization Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo
Address review: - read-only guard rejects mutation/subscription anywhere in the document (not just leading), preventing a multi-operation + operationName bypass - sanitizeGraphQLErrors tolerates null/non-object entries in the errors array - wrap response.json() and guard the body shape so a non-JSON/invalid response becomes a sanitized UpstreamError instead of a raw SyntaxError/TypeError Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SPMszz9gdZFhNjobRm6Ndo
e95d30d to
2806ce8
Compare
Summary
Implements Prompt 12 – GraphQL upstream client with timeout/retry guardrails
(see
docs/mcp/spec.md§5.3, §10.3, §10.4). Addsthe single shared client that tool handlers use to reach the Accounter GraphQL
server.
Changes
src/upstream/graphql-client.ts—UpstreamGraphQLClient:AbortController(a per-call budget); an aborted request maps to a retryableTIMEOUT_ERROR.maxRetries(default 2); 4xx (auth/validation) and GraphQL-level errors are never retried.X-Correlation-Idand the caller'sAuthorizationbearer token are forwarded; the raw token is never logged.UpstreamError(UPSTREAM_ERROR/TIMEOUT_ERROR,retryableflag) with business-safe messages only (GraphQL error messages collapsed, capped; no stack traces / SQL).createReadOperationis the only surface tools use — there is no generic execute-anything API.src/upstream/default-client.ts— env-backed, memoized client factory (endpoint + timeout from config).createReadOperationround-trip. 140 tests total.Validation
yarn workspace @accounter/mcp-server test→ 140 passedyarn workspace @accounter/mcp-server lint/typecheck/build→ passNotes / open decisions
Authorizationforwarding. The prompt asks to forward the caller's bearer token upstream. The client accepts it viaUpstreamRequestContext.authorization; populating it from the authenticated request (the token captured at verification time) is wired when the first real tool executes (Prompt 13). Forwarding the user's own token means upstream re-applies the exact same identity/tenant checks — defense in depth over the connector's own policy evaluator.fetch-based (Node global) with an injectablefetchImplfor hermetic tests — nographql-requestdependency, keeping full control over timeout/retry/headers.🤖 Generated with Claude Code
Generated by Claude Code