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;