From b38cfeead08ecfca093db2336797c2f01da650a8 Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Thu, 9 Apr 2026 18:35:41 -0700 Subject: [PATCH] feat: add graphql_introspect tool for schema validation Add a new graphql_introspect MCP tool that validates GraphQL queries against the live Transcend API schema. Returns exact validation errors (unknown fields, invalid arguments, type mismatches) without executing the query. Useful for auditing GQL definitions and verifying schema compatibility. --- .cursor/skills/graphql-introspect/SKILL.md | 152 ++++++++++++++++++ .../mcp/mcp-server-admin/src/tools/index.ts | 7 +- .../mcp/mcp-server-admin/tests/admin.test.ts | 6 +- .../src/clients/graphql/base.ts | 42 +++++ packages/mcp/mcp-server-core/src/index.ts | 1 + .../src/tools/graphql-introspect.ts | 69 ++++++++ .../mcp-server/tests/umbrella-tool-count.ts | 2 +- 7 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 .cursor/skills/graphql-introspect/SKILL.md create mode 100644 packages/mcp/mcp-server-core/src/tools/graphql-introspect.ts diff --git a/.cursor/skills/graphql-introspect/SKILL.md b/.cursor/skills/graphql-introspect/SKILL.md new file mode 100644 index 00000000..5642897a --- /dev/null +++ b/.cursor/skills/graphql-introspect/SKILL.md @@ -0,0 +1,152 @@ +--- +name: graphql-introspect +description: >- + Validate and audit GraphQL queries against the live Transcend API schema. + Use when adding or modifying GQL definitions in the SDK, when MCP tools fail + with GRAPHQL_VALIDATION_FAILED errors, or when auditing schema drift. +--- + +# GraphQL Schema Validation + +Validate GraphQL queries against the live Transcend API schema using the +`graphql_introspect` MCP tool. This tool sends queries to the API and reports +schema validation errors without executing them. + +## When to Use + +- Before adding new fields to GQL query/mutation strings +- Before adding new arguments to existing queries +- When an MCP tool call fails with `GRAPHQL_VALIDATION_FAILED` +- When auditing existing GQL definitions for schema drift +- When extending TypeScript response interfaces with new fields + +## The Tool + +`graphql_introspect` is registered on the **transcend-admin** MCP server +(`project-0-tools-transcend-admin`). It accepts a `query` string and optional +`variables`, sends them to the live API, and returns structured validation +results. + +### Parameters + +| Parameter | Type | Description | +| ----------- | ------ | -------------------------------------------------- | +| `query` | string | The GraphQL query/mutation string to validate | +| `variables` | object | Optional dummy variables for parameterized queries | + +### Response Shape + +```json +{ + "valid": true | false, + "validationErrors": [{ "message": "...", "locations": [...] }], + "executionErrors": ["..."], + "note": "..." +} +``` + +- `valid: false` + `validationErrors` = schema mismatch (fields/args/types wrong) +- `valid: true` + `executionErrors` = schema is fine, runtime errors from dummy vars +- `valid: true` + `note` = everything passed + +## Validation Workflow + +### 1. Validate a Single Query + +Send the exact query string from the codebase: + +``` +graphql_introspect { + query: "query Test($input: AirgapBundleInput!, $first: Int!) { dataFlows(input: $input, first: $first, offset: 0) { totalCount nodes { id value } } }" +} +``` + +If `valid: false`, the `validationErrors` will say exactly what's wrong, e.g.: + +- `Cannot query field "pageInfo" on type "DataFlowsPayload"` +- `Unknown argument "filterBy" on field "Query.cookieStats"` + +### 2. Check if a Field Exists + +To check whether a specific field exists on a type, include it in a minimal +query and see if validation passes: + +``` +graphql_introspect { + query: "query Test($input: AirgapBundleInput!) { cookieStats(input: $input) { liveCount needReviewCount junkCount newFieldName } }" +} +``` + +### 3. Check if an Argument Exists + +``` +graphql_introspect { + query: "query Test($input: AirgapBundleInput!, $filterBy: CookiesFiltersInput) { cookieStats(input: $input, filterBy: $filterBy) { liveCount } }" +} +``` + +## Extending GQL Definitions + +When adding new fields or arguments to the SDK's GraphQL definitions, follow +this sequence: + +### Step 1: Validate the New Field/Argument + +Use `graphql_introspect` to confirm the field or argument exists in the live +schema before writing any code. + +### Step 2: Update the GQL String + +Edit the query/mutation constant in `packages/sdk/src/consent/gqls/`. Add the +new field to the selection set or the new argument to the variable definitions. + +### Step 3: Update the TypeScript Interface + +Add the corresponding property to the response interface in the same file. +Every property must have a `/** JSDoc */` comment per project rules. + +### Step 4: Update the Tool Handler + +If the new field is user-facing, update the MCP tool handler in +`packages/mcp/mcp-server-consent/src/tools/` to expose it. + +### Step 5: Build and Test + +```bash +pnpm run --dir packages/sdk build +pnpm run --dir packages/mcp/mcp-server-consent build +pnpm run --dir packages/mcp/mcp-server-consent test +``` + +## Full Audit Workflow + +To audit all GQL definitions for schema drift: + +1. Read each file in `packages/sdk/src/consent/gqls/` +2. Extract each `gql` tagged template string +3. Send each to `graphql_introspect` (batch in parallel where possible) +4. Collect any `valid: false` results +5. Fix the GQL strings and TypeScript interfaces +6. Rebuild and re-test + +### Files to Audit + +| File | Queries/Mutations | +| -------------------------- | --------------------------------------------------------------------------- | +| `cookies.ts` | `COOKIES`, `UPDATE_OR_CREATE_COOKIES`, `DELETE_COOKIES` | +| `dataFlows.ts` | `DATA_FLOWS`, `CREATE_DATA_FLOWS`, `UPDATE_DATA_FLOWS`, `DELETE_DATA_FLOWS` | +| `experiences.ts` | `EXPERIENCES`, `UPDATE_CONSENT_EXPERIENCE`, `CREATE_CONSENT_EXPERIENCE` | +| `purposes.ts` | `PURPOSES` | +| `stats.ts` | `COOKIE_STATS`, `DATA_FLOW_STATS` | +| `partitions.ts` | `CONSENT_PARTITIONS`, `CREATE_CONSENT_PARTITION` | +| `consentManager.ts` | `FETCH_CONSENT_MANAGER`, `FETCH_CONSENT_MANAGER_ID`, etc. | +| `consentManagerMetrics.ts` | `CONSENT_MANAGER_ANALYTICS_DATA` | +| `policy.ts` | `POLICIES`, `UPDATE_POLICIES` | +| `privacyCenter.ts` | `PRIVACY_CENTER`, `FETCH_PRIVACY_CENTER_ID`, etc. | +| `processingPurpose.ts` | `PROCESSING_PURPOSE_SUB_CATEGORIES`, etc. | + +## Key Directories + +- GQL definitions: `packages/sdk/src/consent/gqls/` +- MCP tool handlers: `packages/mcp/mcp-server-consent/src/tools/` +- GraphQL client: `packages/mcp/mcp-server-core/src/clients/graphql/base.ts` diff --git a/packages/mcp/mcp-server-admin/src/tools/index.ts b/packages/mcp/mcp-server-admin/src/tools/index.ts index b4cbffa9..357403b1 100644 --- a/packages/mcp/mcp-server-admin/src/tools/index.ts +++ b/packages/mcp/mcp-server-admin/src/tools/index.ts @@ -1,4 +1,8 @@ -import type { ToolDefinition, ToolClients } from '@transcend-io/mcp-server-core'; +import { + createGraphqlIntrospectTool, + type ToolDefinition, + type ToolClients, +} from '@transcend-io/mcp-server-core'; import { createAdminCreateApiKeyTool } from './admin_create_api_key.js'; import { createAdminGetCurrentUserTool } from './admin_get_current_user.js'; @@ -19,5 +23,6 @@ export function getAdminTools(clients: ToolClients): ToolDefinition[] { createAdminCreateApiKeyTool(clients), createAdminGetPrivacyCenterTool(clients), createAdminTestConnectionTool(clients), + createGraphqlIntrospectTool(clients.graphql), ]; } diff --git a/packages/mcp/mcp-server-admin/tests/admin.test.ts b/packages/mcp/mcp-server-admin/tests/admin.test.ts index f88c7dfb..5b7dfad6 100644 --- a/packages/mcp/mcp-server-admin/tests/admin.test.ts +++ b/packages/mcp/mcp-server-admin/tests/admin.test.ts @@ -11,6 +11,7 @@ const EXPECTED_TOOL_NAMES = [ 'admin_create_api_key', 'admin_get_privacy_center', 'admin_test_connection', + 'graphql_introspect', ] as const; describe('Admin Tools', () => { @@ -42,6 +43,7 @@ describe('Admin Tools', () => { getPrivacyCenter: vi.fn(), testConnection: vi.fn(), getBaseUrl: vi.fn().mockReturnValue('https://api.transcend.io'), + validateQuery: vi.fn(), }; mockRest = { testConnection: vi.fn(), @@ -55,9 +57,9 @@ describe('Admin Tools', () => { graphql: mockGraphql, }); - it('registers exactly 8 tools with expected names', () => { + it(`registers exactly ${EXPECTED_TOOL_NAMES.length} tools with expected names`, () => { const tools = getTools(); - expect(tools).toHaveLength(8); + expect(tools).toHaveLength(EXPECTED_TOOL_NAMES.length); expect(tools.map((t) => t.name)).toEqual([...EXPECTED_TOOL_NAMES]); }); diff --git a/packages/mcp/mcp-server-core/src/clients/graphql/base.ts b/packages/mcp/mcp-server-core/src/clients/graphql/base.ts index ca3e8a76..8822d57c 100644 --- a/packages/mcp/mcp-server-core/src/clients/graphql/base.ts +++ b/packages/mcp/mcp-server-core/src/clients/graphql/base.ts @@ -175,6 +175,48 @@ export class TranscendGraphQLBase { throw lastError || new Error('GraphQL request failed after all retries'); } + /** + * Send a query to the server and return the raw response including any + * validation errors, without throwing. Useful for schema validation probes + * where we want to inspect GRAPHQL_VALIDATION_FAILED errors. + */ + async validateQuery( + query: string, + variables?: Record, + ): Promise<{ + /** Response data, if any */ + data?: unknown; + /** GraphQL errors from the server */ + errors?: Array<{ + /** Error message */ + message: string; + /** Source locations in the query */ + locations?: Array<{ + /** Line number */ + line: number; + /** Column number */ + column: number; + }>; + /** Error extensions (contains code, validationErrorCode, etc.) */ + extensions?: Record; + }>; + }> { + await this.rateLimitWait(); + const url = `${this.baseUrl}/graphql`; + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ query, variables: variables ?? {} }), + }); + + return (await response.json()) as any; + } + async testConnection(): Promise { try { await this.makeRequest<{ __typename: string }>('query { __typename }'); diff --git a/packages/mcp/mcp-server-core/src/index.ts b/packages/mcp/mcp-server-core/src/index.ts index 0ce8d67d..cfb052f0 100644 --- a/packages/mcp/mcp-server-core/src/index.ts +++ b/packages/mcp/mcp-server-core/src/index.ts @@ -12,6 +12,7 @@ export type { ToolAnnotations, ToolDefinition, ToolClients } from './tools/types export { defineTool } from './tools/types.js'; export { createToolResult, createErrorResult, createListResult, groupBy } from './tools/helpers.js'; +export { createGraphqlIntrospectTool } from './tools/graphql-introspect.js'; export { createMCPServer } from './server/create-server.js'; export type { MCPServerOptions } from './server/create-server.js'; diff --git a/packages/mcp/mcp-server-core/src/tools/graphql-introspect.ts b/packages/mcp/mcp-server-core/src/tools/graphql-introspect.ts new file mode 100644 index 00000000..f86bbe71 --- /dev/null +++ b/packages/mcp/mcp-server-core/src/tools/graphql-introspect.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +import type { TranscendGraphQLBase } from '../clients/graphql/base.js'; +import { createToolResult } from './helpers.js'; +import { defineTool } from './types.js'; + +export const GraphqlIntrospectSchema = z.object({ + query: z + .string() + .describe( + 'A GraphQL query or mutation string to validate against the live schema. ' + + 'The server will return GRAPHQL_VALIDATION_FAILED errors listing exactly ' + + 'which fields, arguments, or types are invalid. Use dummy variable values ' + + 'since the goal is schema validation, not data fetching.', + ), + variables: z + .record(z.string(), z.unknown()) + .optional() + .describe('Optional variables to send with the query (use minimal dummy values).'), +}); + +export function createGraphqlIntrospectTool(graphql: TranscendGraphQLBase) { + return defineTool({ + name: 'graphql_introspect', + description: + 'Validate a GraphQL query against the live Transcend API schema. ' + + 'Sends the query and reports whether it passes schema validation. ' + + 'On failure, returns the exact validation errors (unknown fields, invalid arguments, type mismatches). ' + + 'Use this to audit GQL definitions or verify new fields/arguments before adding them.', + category: 'Schema', + readOnly: true, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + zodSchema: GraphqlIntrospectSchema, + handler: async ({ query, variables }) => { + const result = await graphql.validateQuery(query, variables); + + if (result.errors?.length) { + const validationErrors = result.errors.filter( + (e) => e.extensions?.code === 'GRAPHQL_VALIDATION_FAILED', + ); + const otherErrors = result.errors.filter( + (e) => e.extensions?.code !== 'GRAPHQL_VALIDATION_FAILED', + ); + + if (validationErrors.length > 0) { + return createToolResult(true, { + valid: false, + validationErrors: validationErrors.map((e) => ({ + message: e.message, + locations: e.locations, + })), + ...(otherErrors.length > 0 ? { otherErrors: otherErrors.map((e) => e.message) } : {}), + }); + } + + return createToolResult(true, { + valid: true, + note: 'Schema valid. Execution errors are expected with dummy variables.', + executionErrors: otherErrors.map((e) => e.message), + }); + } + + return createToolResult(true, { + valid: true, + note: 'Query passed schema validation and executed successfully.', + }); + }, + }); +} diff --git a/packages/mcp/mcp-server/tests/umbrella-tool-count.ts b/packages/mcp/mcp-server/tests/umbrella-tool-count.ts index 17d7fb39..4a0c72b4 100644 --- a/packages/mcp/mcp-server/tests/umbrella-tool-count.ts +++ b/packages/mcp/mcp-server/tests/umbrella-tool-count.ts @@ -2,4 +2,4 @@ * Single source of truth for the expected number of tools in the umbrella server. * Update this constant when tools are added or removed from any domain package. */ -export const EXPECTED_UMBRELLA_TOOL_COUNT = 70; +export const EXPECTED_UMBRELLA_TOOL_COUNT = 71;