import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import * as z from "zod/v4"; import { executeGraphQL } from "../graphql/index.js"; import { getConfig } from "../config.js"; /** * Build a GraphQL field selection string from an array of field paths. * Supports dot notation for nested fields: * ["code", "header.name", "header.type"] → * "code header { name type }" */ function buildFieldSelection(fields: string[]): string { interface FieldNode { [key: string]: FieldNode; } const tree: FieldNode = {}; for (const field of fields) { const parts = field.split("."); let current = tree; for (const part of parts) { current[part] ??= {}; current = current[part]; } } function render(node: FieldNode): string { return Object.entries(node) .map(([key, children]) => { const childKeys = Object.keys(children); return childKeys.length > 0 ? `${key} { ${render(children)} }` : key; }) .join(" "); } return render(tree); } function formatEntity( obj: Record, indent: number = 0, ): string { const prefix = " ".repeat(indent); const lines: string[] = []; for (const [key, value] of Object.entries(obj)) { if (value !== null && typeof value === "object" && !Array.isArray(value)) { lines.push(`${prefix}${key}:`); lines.push( formatEntity(value as Record, indent + 1), ); } else if (Array.isArray(value)) { lines.push(`${prefix}${key}: [${value.map(String).join(", ")}]`); } else { lines.push(`${prefix}${key}: ${String(value ?? "null")}`); } } return lines.join("\n"); } export function registerReadEntityTool(server: McpServer): void { server.registerTool( "read_entity", { title: "Read Entity", description: "Read a single Sage X3 entity by its identifier. " + "Returns the requested fields for the specified entity.", inputSchema: z.object({ rootType: z .enum([ "xtremX3Structure", "xtremX3MasterData", "xtremX3Products", "xtremX3Purchasing", "xtremX3Stock", ]) .describe("Root API module to query"), entity: z.string().describe("Entity name (e.g., 'businessPartner')"), id: z .string() .describe("Entity identifier (e.g., 'AE003')"), fields: z .array(z.string()) .describe( "Fields to return. Use dot notation for nested fields (e.g., 'header.name')", ), }), }, async (args) => { const { rootType, entity, id, fields } = args; const config = getConfig(); const fieldSelection = buildFieldSelection(fields); const query = `{ ${rootType} { ${entity} { read(_id: "${id}") { ${fieldSelection} } } } }`; try { const result = await executeGraphQL(config, query); if (result.errors?.length) { const messages = result.errors.map((e) => e.message).join("; "); return { content: [{ type: "text" as const, text: `GraphQL errors: ${messages}` }], isError: true, }; } const data = result.data as Record | null; const rootData = data?.[rootType] as Record | undefined; const entityData = rootData?.[entity] as Record | undefined; const readResult = entityData?.read as Record | undefined; if (!readResult) { return { content: [ { type: "text" as const, text: `No data found for ${entity} with id "${id}"`, }, ], }; } const formatted = formatEntity(readResult); const text = `${entity} (${id}):\n${formatted}`; return { content: [{ type: "text" as const, text }], }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true, }; } }, ); }