Files
sage-graphql-mcp/src/tools/read-entity.ts
2026-03-13 15:00:22 +00:00

145 lines
4.2 KiB
TypeScript

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<string, unknown>,
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<string, unknown>, 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<string, unknown> | null;
const rootData = data?.[rootType] as Record<string, unknown> | undefined;
const entityData = rootData?.[entity] as Record<string, unknown> | undefined;
const readResult = entityData?.read as Record<string, unknown> | 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,
};
}
},
);
}