Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions .cursor/skills/graphql-introspect/SKILL.md
Original file line number Diff line number Diff line change
@@ -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`
7 changes: 6 additions & 1 deletion packages/mcp/mcp-server-admin/src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,5 +23,6 @@ export function getAdminTools(clients: ToolClients): ToolDefinition[] {
createAdminCreateApiKeyTool(clients),
createAdminGetPrivacyCenterTool(clients),
createAdminTestConnectionTool(clients),
createGraphqlIntrospectTool(clients.graphql),
];
}
6 changes: 4 additions & 2 deletions packages/mcp/mcp-server-admin/tests/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(),
Expand All @@ -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]);
});

Expand Down
42 changes: 42 additions & 0 deletions packages/mcp/mcp-server-core/src/clients/graphql/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,23 @@
export class SimpleLogger implements Logger {
debug(message: string, data?: unknown): void {
if (process.env.LOG_LEVEL === 'debug') {
console.error(

Check warning on line 14 in packages/mcp/mcp-server-core/src/clients/graphql/base.ts

View workflow job for this annotation

GitHub Actions / global

eslint(no-console)

Unexpected console statement.
JSON.stringify({ level: 'debug', message, data, timestamp: new Date().toISOString() }),
);
}
}
info(message: string, data?: unknown): void {
console.error(

Check warning on line 20 in packages/mcp/mcp-server-core/src/clients/graphql/base.ts

View workflow job for this annotation

GitHub Actions / global

eslint(no-console)

Unexpected console statement.
JSON.stringify({ level: 'info', message, data, timestamp: new Date().toISOString() }),
);
}
warn(message: string, data?: unknown): void {
console.error(

Check warning on line 25 in packages/mcp/mcp-server-core/src/clients/graphql/base.ts

View workflow job for this annotation

GitHub Actions / global

eslint(no-console)

Unexpected console statement.
JSON.stringify({ level: 'warn', message, data, timestamp: new Date().toISOString() }),
);
}
error(message: string, data?: unknown): void {
console.error(

Check warning on line 30 in packages/mcp/mcp-server-core/src/clients/graphql/base.ts

View workflow job for this annotation

GitHub Actions / global

eslint(no-console)

Unexpected console statement.
JSON.stringify({ level: 'error', message, data, timestamp: new Date().toISOString() }),
);
}
Expand Down Expand Up @@ -175,6 +175,48 @@
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<string, unknown>,
): 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<string, unknown>;
}>;
}> {
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<boolean> {
try {
await this.makeRequest<{ __typename: string }>('query { __typename }');
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/mcp-server-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
69 changes: 69 additions & 0 deletions packages/mcp/mcp-server-core/src/tools/graphql-introspect.ts
Original file line number Diff line number Diff line change
@@ -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.',
});
},
});
}
2 changes: 1 addition & 1 deletion packages/mcp/mcp-server/tests/umbrella-tool-count.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading