From df099357812a1d5fed0d9f16210172b534d43266 Mon Sep 17 00:00:00 2001 From: haz Date: Thu, 9 Apr 2026 11:06:14 +0200 Subject: [PATCH 1/2] feat: add GraphQL schema introspection tools - fetch_catalogue_schema, fetch_discovery_schema, fetch_core_schema, fetch_shop_cart_schema - Compact SDL output strips built-in types and descriptions - Auto-summary mode for large schemas (>50k chars) - Core schema supports domain filter (order, customer, item, etc.) - Discovery and Core accept optional summary param --- src/index.ts | 2 + src/tools/schema.ts | 461 +++++++++++++++++++++++++++++++++++++++++++ tests/schema.test.ts | 323 ++++++++++++++++++++++++++++++ 3 files changed, 786 insertions(+) create mode 100644 src/tools/schema.ts create mode 100644 tests/schema.test.ts diff --git a/src/index.ts b/src/index.ts index a296525..d9c48b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { discoveryTools } from './tools/discovery.js'; import { orderTools } from './tools/orders.js'; import { customerTools } from './tools/customers.js'; import { contentTools } from './tools/content.js'; +import { schemaTools } from './tools/schema.js'; /** Access mode hierarchy: read < write < admin. */ const ACCESS_LEVELS: Record = { @@ -58,6 +59,7 @@ export function createCrystallizeMcpServer(client?: CrystallizeClient): { ...orderTools(crystallize), ...customerTools(crystallize), ...contentTools(crystallize), + ...schemaTools(crystallize), ]; // Audit logger — only active if CRYSTALLIZE_AUDIT_LOG is set diff --git a/src/tools/schema.ts b/src/tools/schema.ts new file mode 100644 index 0000000..9fe0d2b --- /dev/null +++ b/src/tools/schema.ts @@ -0,0 +1,461 @@ +/** + * Schema introspection tools — fetch and compact GraphQL schemas + * for the Catalogue, Discovery, Core, and Shop Cart APIs. + * + * Helps AI agents understand the tenant's GraphQL schema before + * writing or debugging queries. + */ + +import { z } from 'zod'; +import type { CrystallizeClient } from '../client.js'; +import type { ToolDefinition, ToolResult } from '../types.js'; + +/** + * Standard GraphQL introspection query — fetches types, fields, + * enums, inputs, and their relationships. + */ +const INTROSPECTION_QUERY = ` + query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + types { + kind + name + description + fields(includeDeprecated: false) { + name + description + type { + ...TypeRef + } + args { + name + type { ...TypeRef } + defaultValue + } + } + inputFields { + name + type { ...TypeRef } + defaultValue + } + enumValues(includeDeprecated: false) { + name + } + possibleTypes { + name + } + } + } + } + + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + } +`; + +/** Built-in GraphQL types to exclude from output. */ +const BUILTIN_TYPES = new Set([ + 'String', + 'Int', + 'Float', + 'Boolean', + 'ID', + '__Schema', + '__Type', + '__Field', + '__InputValue', + '__EnumValue', + '__Directive', + '__DirectiveLocation', + '__TypeKind', +]); + +interface IntrospectionType { + kind: string; + name: string; + description?: string; + fields?: IntrospectionField[]; + inputFields?: IntrospectionInputField[]; + enumValues?: { name: string }[]; + possibleTypes?: { name: string }[]; +} + +interface TypeRef { + kind: string; + name?: string; + ofType?: TypeRef; +} + +interface IntrospectionField { + name: string; + description?: string; + type: TypeRef; + args?: IntrospectionInputField[]; +} + +interface IntrospectionInputField { + name: string; + type: TypeRef; + defaultValue?: string; +} + +interface IntrospectionResult { + __schema: { + queryType?: { name: string }; + mutationType?: { name: string }; + types: IntrospectionType[]; + }; +} + +/** Render a type reference as a compact string (e.g. "[String!]!"). */ +function renderTypeRef(ref: TypeRef): string { + if (ref.kind === 'NON_NULL') { + return `${renderTypeRef(ref.ofType ?? { kind: 'SCALAR', name: '?' })}!`; + } + if (ref.kind === 'LIST') { + return `[${renderTypeRef(ref.ofType ?? { kind: 'SCALAR', name: '?' })}]`; + } + return ref.name ?? '?'; +} + +/** Compact an introspection result into readable SDL-like text. */ +function compactSchema( + data: IntrospectionResult, + options?: { domain?: string }, +): string { + const schema = data.__schema; + const lines: string[] = []; + + if (schema.queryType) { + lines.push(`# Query type: ${schema.queryType.name}`); + } + if (schema.mutationType) { + lines.push(`# Mutation type: ${schema.mutationType.name}`); + } + lines.push(''); + + const types = schema.types + .filter(t => !BUILTIN_TYPES.has(t.name)) + .filter(t => !t.name.startsWith('__')) + .sort((a, b) => a.name.localeCompare(b.name)); + + // If domain filter, only include types related to that domain + const filtered = options?.domain + ? types.filter( + t => + t.name.toLowerCase().includes(options.domain ?? '') || + isRootType(t.name, schema), + ) + : types; + + for (const type of filtered) { + switch (type.kind) { + case 'OBJECT': + case 'INPUT_OBJECT': + lines.push(renderObjectType(type)); + break; + case 'ENUM': + lines.push(renderEnumType(type)); + break; + case 'UNION': + lines.push(renderUnionType(type)); + break; + case 'INTERFACE': + lines.push(renderInterfaceType(type)); + break; + case 'SCALAR': + if (type.name !== 'String' && type.name !== 'Boolean') { + lines.push(`scalar ${type.name}`); + } + break; + } + lines.push(''); + } + + return lines.join('\n').trim(); +} + +function isRootType( + name: string, + schema: IntrospectionResult['__schema'], +): boolean { + return name === schema.queryType?.name || name === schema.mutationType?.name; +} + +function renderObjectType(type: IntrospectionType): string { + const keyword = type.kind === 'INPUT_OBJECT' ? 'input' : 'type'; + const fields = type.kind === 'INPUT_OBJECT' ? type.inputFields : type.fields; + + if (!fields?.length) { + return `${keyword} ${type.name} {}`; + } + + const fieldLines = fields.map(f => { + if ('args' in f && f.args?.length) { + const args = f.args + .map(a => `${a.name}: ${renderTypeRef(a.type)}`) + .join(', '); + return ` ${f.name}(${args}): ${renderTypeRef(f.type)}`; + } + return ` ${f.name}: ${renderTypeRef(f.type)}`; + }); + + return `${keyword} ${type.name} {\n${fieldLines.join('\n')}\n}`; +} + +function renderEnumType(type: IntrospectionType): string { + const values = (type.enumValues ?? []).map(v => ` ${v.name}`); + return `enum ${type.name} {\n${values.join('\n')}\n}`; +} + +function renderUnionType(type: IntrospectionType): string { + const members = (type.possibleTypes ?? []).map(t => t.name); + return `union ${type.name} = ${members.join(' | ')}`; +} + +function renderInterfaceType(type: IntrospectionType): string { + if (!type.fields?.length) { + return `interface ${type.name} {}`; + } + + const fieldLines = type.fields.map( + f => ` ${f.name}: ${renderTypeRef(f.type)}`, + ); + return `interface ${type.name} {\n${fieldLines.join('\n')}\n}`; +} + +/** Max output chars before auto-switching to summary mode. */ +const MAX_FULL_SCHEMA_LENGTH = 50_000; + +/** Generate a summary: root fields + type name list. */ +function summariseSchema(data: IntrospectionResult): string { + const schema = data.__schema; + const lines: string[] = []; + + const types = schema.types + .filter(t => !BUILTIN_TYPES.has(t.name)) + .filter(t => !t.name.startsWith('__')); + + // Root query fields + const queryType = types.find(t => t.name === schema.queryType?.name); + if (queryType?.fields?.length) { + lines.push('# Root Query Fields', ''); + for (const f of queryType.fields) { + lines.push(` ${f.name}: ${renderTypeRef(f.type)}`); + } + lines.push(''); + } + + // Root mutation fields + const mutationType = types.find(t => t.name === schema.mutationType?.name); + if (mutationType?.fields?.length) { + lines.push('# Root Mutation Fields', ''); + for (const f of mutationType.fields) { + lines.push(` ${f.name}: ${renderTypeRef(f.type)}`); + } + lines.push(''); + } + + // Group type names by kind + const grouped: Record = {}; + for (const t of types) { + if (t.name === queryType?.name || t.name === mutationType?.name) { + continue; + } + const group = grouped[t.kind] ?? []; + group.push(t.name); + grouped[t.kind] = group; + } + + for (const [kind, names] of Object.entries(grouped)) { + lines.push(`# ${kind} (${names.length})`, names.sort().join(', '), ''); + } + + return lines.join('\n').trim(); +} + +/** Run an introspection query via the given API caller. */ +async function introspect( + apiCaller: ( + query: string, + variables?: Record, + ) => Promise, + options?: { domain?: string; summary?: boolean }, +): Promise { + try { + const data = (await apiCaller(INTROSPECTION_QUERY)) as IntrospectionResult; + + if (!data.__schema) { + return { + content: [ + { + type: 'text', + text: 'Introspection query returned no schema. The API may not support introspection or authentication may be missing.', + }, + ], + isError: true, + }; + } + + const typeCount = data.__schema.types.filter( + t => !BUILTIN_TYPES.has(t.name) && !t.name.startsWith('__'), + ).length; + + const useSummary = options?.summary === true; + const compacted = useSummary + ? summariseSchema(data) + : compactSchema(data, options); + + // Auto-switch to summary if full schema is too large + if (!useSummary && compacted.length > MAX_FULL_SCHEMA_LENGTH) { + const summary = summariseSchema(data); + return { + content: [ + { + type: 'text', + text: [ + `# GraphQL Schema Summary (${typeCount} types — full schema too large at ${Math.round(compacted.length / 1000)}k chars)`, + '', + 'Use the `domain` parameter or `summary: true` to get a focused view.', + '', + summary, + ].join('\n'), + }, + ], + }; + } + + const label = useSummary ? 'Summary' : 'Schema'; + return { + content: [ + { + type: 'text', + text: [`# GraphQL ${label} (${typeCount} types)`, '', compacted].join( + '\n', + ), + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: 'text', + text: `Failed to fetch schema: ${message}`, + }, + ], + isError: true, + }; + } +} + +export function schemaTools(client: CrystallizeClient): ToolDefinition[] { + return [ + { + name: 'fetch_catalogue_schema', + description: + 'Fetch the GraphQL schema of the Crystallize Catalogue API. ' + + 'The Catalogue API is a storefront API for fetching items, products, and content — ' + + 'it provides path-based reads with strong consistency and union types for components. ' + + 'Use this to understand available queries, types, and fields before writing catalogue queries.', + schema: {}, + handler: async () => { + return introspect(q => client.api.catalogueApi(q)); + }, + }, + + { + name: 'fetch_discovery_schema', + description: + 'Fetch the GraphQL schema of the Crystallize Discovery API. ' + + 'The Discovery API is a storefront API for searching, browsing, filtering, and faceting items. ' + + 'It uses a shape-typed schema where each shape in the tenant becomes a GraphQL type. ' + + 'Use this to understand the tenant-specific types and available browse/search queries. ' + + 'Set summary to true for large tenants to get a compact overview.', + schema: { + summary: z + .boolean() + .optional() + .describe( + 'Return only root fields and type names instead of the full schema. Recommended for large tenants.', + ), + }, + handler: async params => { + const summary = + typeof params.summary === 'boolean' ? params.summary : undefined; + return introspect(q => client.api.discoveryApi(q), { + summary, + }); + }, + }, + + { + name: 'fetch_core_schema', + description: + 'Fetch the GraphQL schema of the Crystallize Core API (admin API). ' + + 'The Core API is large, so provide a domain to filter the schema (e.g. "order", "customer", "item"). ' + + 'Common domains: order, customer, subscription, subscriptionPlan, pricelist, pipeline, flow, app, user, webhook, stockLocation. ' + + 'Omit domain to get a summary of available types. Set summary to true for a compact overview.', + schema: { + domain: z + .string() + .optional() + .describe( + 'Filter schema to a specific domain (e.g. "order", "customer", "item"). ' + + 'Omit to get the full schema.', + ), + summary: z + .boolean() + .optional() + .describe( + 'Return only root fields and type names instead of the full schema.', + ), + }, + handler: async params => { + const domain = + typeof params.domain === 'string' + ? params.domain.toLowerCase() + : undefined; + const summary = + typeof params.summary === 'boolean' ? params.summary : undefined; + return introspect(q => client.api.nextPimApi(q), { + domain, + summary, + }); + }, + }, + + { + name: 'fetch_shop_cart_schema', + description: + 'Fetch the GraphQL schema of the Crystallize Shop Cart API. ' + + 'The Shop Cart API handles cart and wishlist operations — creating carts, ' + + 'adding/removing items, applying discounts, and reading cart state. ' + + 'Use this to understand available cart queries and types.', + schema: {}, + handler: async () => { + return introspect(q => client.api.shopCartApi(q)); + }, + }, + ]; +} diff --git a/tests/schema.test.ts b/tests/schema.test.ts new file mode 100644 index 0000000..a3a269a --- /dev/null +++ b/tests/schema.test.ts @@ -0,0 +1,323 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { schemaTools } from '../src/tools/schema.js'; +import { CrystallizeClient } from '../src/client.js'; + +// --- Tool metadata --- + +describe('schemaTools metadata', () => { + const client = new CrystallizeClient({ + tenantIdentifier: 'test-tenant', + accessMode: 'read', + }); + const tools = schemaTools(client); + + it('exports 4 schema introspection tools', () => { + assert.strictEqual(tools.length, 4); + const names = tools.map(t => t.name); + assert.ok(names.includes('fetch_catalogue_schema')); + assert.ok(names.includes('fetch_discovery_schema')); + assert.ok(names.includes('fetch_core_schema')); + assert.ok(names.includes('fetch_shop_cart_schema')); + }); + + it('all tools default to read mode', () => { + for (const tool of tools) { + assert.strictEqual(tool.mode ?? 'read', 'read'); + } + }); + + it('fetch_core_schema has optional domain param', () => { + const coreTool = tools.find(t => t.name === 'fetch_core_schema'); + if (!coreTool) { + throw new Error('fetch_core_schema not found'); + } + assert.ok('domain' in coreTool.schema); + }); +}); + +// --- Schema compaction --- + +describe('schema introspection with mock API', () => { + const mockIntrospection = { + __schema: { + queryType: { name: 'Query' }, + mutationType: null, + types: [ + { + kind: 'OBJECT', + name: 'Query', + description: 'Root query', + fields: [ + { + name: 'catalogue', + description: 'Get catalogue item', + type: { + kind: 'OBJECT', + name: 'Item', + ofType: null, + }, + args: [ + { + name: 'path', + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + }, + defaultValue: null, + }, + { + name: 'language', + type: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + defaultValue: null, + }, + ], + }, + ], + inputFields: null, + enumValues: null, + possibleTypes: null, + }, + { + kind: 'OBJECT', + name: 'Item', + description: 'A catalogue item', + fields: [ + { + name: 'id', + description: null, + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'SCALAR', + name: 'ID', + ofType: null, + }, + }, + args: [], + }, + { + name: 'name', + description: null, + type: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, + args: [], + }, + ], + inputFields: null, + enumValues: null, + possibleTypes: null, + }, + { + kind: 'ENUM', + name: 'ItemType', + description: null, + fields: null, + inputFields: null, + enumValues: [ + { name: 'PRODUCT' }, + { name: 'DOCUMENT' }, + { name: 'FOLDER' }, + ], + possibleTypes: null, + }, + { + kind: 'SCALAR', + name: 'String', + description: 'Built-in', + fields: null, + inputFields: null, + enumValues: null, + possibleTypes: null, + }, + { + kind: 'SCALAR', + name: 'ID', + description: 'Built-in', + fields: null, + inputFields: null, + enumValues: null, + possibleTypes: null, + }, + ], + }, + }; + + it('returns compacted schema from catalogue API', async () => { + const client = new CrystallizeClient({ + tenantIdentifier: 'test-tenant', + accessMode: 'read', + }); + + Object.defineProperty(client.api, 'catalogueApi', { + value: async () => mockIntrospection, + writable: true, + configurable: true, + }); + + const tools = schemaTools(client); + const fetchCatalogue = tools.find(t => t.name === 'fetch_catalogue_schema'); + if (!fetchCatalogue) { + throw new Error('fetch_catalogue_schema not found'); + } + + const result = await fetchCatalogue.handler({}); + assert.strictEqual(result.isError, undefined); + + const text = result.content[0].text; + assert.ok(text.includes('GraphQL Schema')); + assert.ok(text.includes('type Query')); + assert.ok(text.includes('catalogue')); + assert.ok(text.includes('type Item')); + assert.ok(text.includes('id: ID!')); + assert.ok(text.includes('name: String')); + assert.ok(text.includes('enum ItemType')); + assert.ok(text.includes('PRODUCT')); + // Built-in types should be excluded + assert.ok(!text.includes('scalar String')); + assert.ok(!text.includes('scalar ID')); + }); + + it('handles API errors gracefully', async () => { + const client = new CrystallizeClient({ + tenantIdentifier: 'test-tenant', + accessMode: 'read', + }); + + Object.defineProperty(client.api, 'catalogueApi', { + value: async () => { + throw new Error('401 Unauthorized'); + }, + writable: true, + configurable: true, + }); + + const tools = schemaTools(client); + const fetchCatalogue = tools.find(t => t.name === 'fetch_catalogue_schema'); + if (!fetchCatalogue) { + throw new Error('fetch_catalogue_schema not found'); + } + + const result = await fetchCatalogue.handler({}); + assert.strictEqual(result.isError, true); + assert.ok(result.content[0].text.includes('Failed to fetch')); + }); + + it('fetch_core_schema passes domain filter', async () => { + const client = new CrystallizeClient({ + tenantIdentifier: 'test-tenant', + accessMode: 'read', + }); + + const mockCoreSchema = { + __schema: { + queryType: { name: 'Query' }, + mutationType: null, + types: [ + { + kind: 'OBJECT', + name: 'Query', + fields: [ + { + name: 'order', + type: { + kind: 'OBJECT', + name: 'OrderQueries', + ofType: null, + }, + args: [], + }, + ], + inputFields: null, + enumValues: null, + possibleTypes: null, + }, + { + kind: 'OBJECT', + name: 'OrderQueries', + fields: [ + { + name: 'get', + type: { + kind: 'OBJECT', + name: 'Order', + ofType: null, + }, + args: [ + { + name: 'id', + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'SCALAR', + name: 'ID', + ofType: null, + }, + }, + defaultValue: null, + }, + ], + }, + ], + inputFields: null, + enumValues: null, + possibleTypes: null, + }, + { + kind: 'OBJECT', + name: 'CustomerQueries', + fields: [ + { + name: 'get', + type: { + kind: 'OBJECT', + name: 'Customer', + ofType: null, + }, + args: [], + }, + ], + inputFields: null, + enumValues: null, + possibleTypes: null, + }, + ], + }, + }; + + Object.defineProperty(client.api, 'nextPimApi', { + value: async () => mockCoreSchema, + writable: true, + configurable: true, + }); + + const tools = schemaTools(client); + const fetchCore = tools.find(t => t.name === 'fetch_core_schema'); + if (!fetchCore) { + throw new Error('fetch_core_schema not found'); + } + + const result = await fetchCore.handler({ domain: 'order' }); + assert.strictEqual(result.isError, undefined); + + const text = result.content[0].text; + assert.ok(text.includes('OrderQueries')); + assert.ok(text.includes('Query')); + // CustomerQueries should be filtered out + assert.ok(!text.includes('CustomerQueries')); + }); +}); From 44293a76931a152b5424ae42caf89ec68b3bcd1e Mon Sep 17 00:00:00 2001 From: haz Date: Fri, 10 Apr 2026 09:05:46 +0200 Subject: [PATCH 2/2] fix: simplify schema tools and fix filtered type count - Remove redundant `summary` parameter from Discovery and Core tools (auto-summary at 50k threshold is sufficient) - Show filtered type count with domain filter (e.g. "2 of 3 types") - Add auto-summary threshold test --- src/tools/schema.ts | 71 +++++++++++++++++-------------------------- tests/schema.test.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 44 deletions(-) diff --git a/src/tools/schema.ts b/src/tools/schema.ts index 9fe0d2b..c67a251 100644 --- a/src/tools/schema.ts +++ b/src/tools/schema.ts @@ -141,7 +141,7 @@ function renderTypeRef(ref: TypeRef): string { function compactSchema( data: IntrospectionResult, options?: { domain?: string }, -): string { +): { text: string; filteredCount: number; totalCount: number } { const schema = data.__schema; const lines: string[] = []; @@ -191,7 +191,11 @@ function compactSchema( lines.push(''); } - return lines.join('\n').trim(); + return { + text: lines.join('\n').trim(), + filteredCount: filtered.length, + totalCount: types.length, + }; } function isRootType( @@ -299,7 +303,7 @@ async function introspect( query: string, variables?: Record, ) => Promise, - options?: { domain?: string; summary?: boolean }, + options?: { domain?: string }, ): Promise { try { const data = (await apiCaller(INTROSPECTION_QUERY)) as IntrospectionResult; @@ -316,26 +320,29 @@ async function introspect( }; } - const typeCount = data.__schema.types.filter( - t => !BUILTIN_TYPES.has(t.name) && !t.name.startsWith('__'), - ).length; - - const useSummary = options?.summary === true; - const compacted = useSummary - ? summariseSchema(data) - : compactSchema(data, options); + const { + text: compacted, + filteredCount, + totalCount, + } = compactSchema(data, options); + const countLabel = + filteredCount < totalCount + ? `${filteredCount} of ${totalCount} types` + : `${totalCount} types`; // Auto-switch to summary if full schema is too large - if (!useSummary && compacted.length > MAX_FULL_SCHEMA_LENGTH) { + if (compacted.length > MAX_FULL_SCHEMA_LENGTH) { const summary = summariseSchema(data); return { content: [ { type: 'text', text: [ - `# GraphQL Schema Summary (${typeCount} types — full schema too large at ${Math.round(compacted.length / 1000)}k chars)`, + `# GraphQL Schema Summary (${countLabel} — full schema too large at ${Math.round(compacted.length / 1000)}k chars)`, '', - 'Use the `domain` parameter or `summary: true` to get a focused view.', + options?.domain + ? 'Try a more specific domain filter to get the full schema.' + : 'Use the `domain` parameter to get a focused view.', '', summary, ].join('\n'), @@ -344,14 +351,11 @@ async function introspect( }; } - const label = useSummary ? 'Summary' : 'Schema'; return { content: [ { type: 'text', - text: [`# GraphQL ${label} (${typeCount} types)`, '', compacted].join( - '\n', - ), + text: [`# GraphQL Schema (${countLabel})`, '', compacted].join('\n'), }, ], }; @@ -390,22 +394,10 @@ export function schemaTools(client: CrystallizeClient): ToolDefinition[] { 'Fetch the GraphQL schema of the Crystallize Discovery API. ' + 'The Discovery API is a storefront API for searching, browsing, filtering, and faceting items. ' + 'It uses a shape-typed schema where each shape in the tenant becomes a GraphQL type. ' + - 'Use this to understand the tenant-specific types and available browse/search queries. ' + - 'Set summary to true for large tenants to get a compact overview.', - schema: { - summary: z - .boolean() - .optional() - .describe( - 'Return only root fields and type names instead of the full schema. Recommended for large tenants.', - ), - }, - handler: async params => { - const summary = - typeof params.summary === 'boolean' ? params.summary : undefined; - return introspect(q => client.api.discoveryApi(q), { - summary, - }); + 'Use this to understand the tenant-specific types and available browse/search queries.', + schema: {}, + handler: async () => { + return introspect(q => client.api.discoveryApi(q)); }, }, @@ -415,7 +407,7 @@ export function schemaTools(client: CrystallizeClient): ToolDefinition[] { 'Fetch the GraphQL schema of the Crystallize Core API (admin API). ' + 'The Core API is large, so provide a domain to filter the schema (e.g. "order", "customer", "item"). ' + 'Common domains: order, customer, subscription, subscriptionPlan, pricelist, pipeline, flow, app, user, webhook, stockLocation. ' + - 'Omit domain to get a summary of available types. Set summary to true for a compact overview.', + 'Omit domain to get a summary of available types.', schema: { domain: z .string() @@ -424,23 +416,14 @@ export function schemaTools(client: CrystallizeClient): ToolDefinition[] { 'Filter schema to a specific domain (e.g. "order", "customer", "item"). ' + 'Omit to get the full schema.', ), - summary: z - .boolean() - .optional() - .describe( - 'Return only root fields and type names instead of the full schema.', - ), }, handler: async params => { const domain = typeof params.domain === 'string' ? params.domain.toLowerCase() : undefined; - const summary = - typeof params.summary === 'boolean' ? params.summary : undefined; return introspect(q => client.api.nextPimApi(q), { domain, - summary, }); }, }, diff --git a/tests/schema.test.ts b/tests/schema.test.ts index a3a269a..9839be3 100644 --- a/tests/schema.test.ts +++ b/tests/schema.test.ts @@ -179,6 +179,7 @@ describe('schema introspection with mock API', () => { const text = result.content[0].text; assert.ok(text.includes('GraphQL Schema')); + assert.ok(text.includes('3 types')); assert.ok(text.includes('type Query')); assert.ok(text.includes('catalogue')); assert.ok(text.includes('type Item')); @@ -317,7 +318,78 @@ describe('schema introspection with mock API', () => { const text = result.content[0].text; assert.ok(text.includes('OrderQueries')); assert.ok(text.includes('Query')); + // Filtered count: Query + OrderQueries = 2 of 3 + assert.ok(text.includes('2 of 3 types')); // CustomerQueries should be filtered out assert.ok(!text.includes('CustomerQueries')); }); + + it('auto-summarises when schema exceeds size threshold', async () => { + const client = new CrystallizeClient({ + tenantIdentifier: 'test-tenant', + accessMode: 'read', + }); + + // Generate a schema large enough to exceed the 50k auto-summary threshold + const largeTypes = Array.from({ length: 200 }, (_, i) => ({ + kind: 'OBJECT', + name: `GeneratedType${i}`, + fields: Array.from({ length: 20 }, (_, j) => ({ + name: `field${j}WithALongNameToInflateSize`, + type: { kind: 'SCALAR', name: 'String', ofType: null }, + args: [], + })), + inputFields: null, + enumValues: null, + possibleTypes: null, + })); + + const mockLargeSchema = { + __schema: { + queryType: { name: 'Query' }, + mutationType: null, + types: [ + { + kind: 'OBJECT', + name: 'Query', + fields: [ + { + name: 'test', + type: { kind: 'SCALAR', name: 'String', ofType: null }, + args: [], + }, + ], + inputFields: null, + enumValues: null, + possibleTypes: null, + }, + ...largeTypes, + ], + }, + }; + + Object.defineProperty(client.api, 'catalogueApi', { + value: async () => mockLargeSchema, + writable: true, + configurable: true, + }); + + const tools = schemaTools(client); + const fetchCatalogue = tools.find(t => t.name === 'fetch_catalogue_schema'); + if (!fetchCatalogue) { + throw new Error('fetch_catalogue_schema not found'); + } + + const result = await fetchCatalogue.handler({}); + assert.strictEqual(result.isError, undefined); + + const text = result.content[0].text; + // Should auto-switch to summary mode + assert.ok(text.includes('Schema Summary')); + assert.ok(text.includes('full schema too large')); + assert.ok(text.includes('domain')); + // Summary lists type names, not full field definitions + assert.ok(text.includes('GeneratedType0')); + assert.ok(!text.includes('field0WithALongNameToInflateSize')); + }); });