/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import path from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { buildSchema, introspectionFromSchema } from "graphql"; import { describe, expect, it } from "vitest"; import { atomicWriteJson } from "../../../lib/fs-utils.js"; import { schemaCacheKeyForInstanceUrl, schemaDir, type SchemaMetadata, } from "../../../lib/introspect.js"; import { type PrimeDeps } from "../../../lib/prime-schema.js"; import { primeSchemaCache } from "../../../lib/walker.js"; import { registerSfGqlDetailTool } from "../sf-gql-detail.js"; const ORG = "test-tool-detail"; const ORG_URL = "https://test-tool-detail.my.salesforce.com"; const SCHEMA = buildSchema(` type Query { uiapi: UIAPI! } type UIAPI { query: RecordQuery! } type RecordQuery { Account(first: Int, where: Account_Filter): AccountConnection! } input Account_Filter { Id: IDOperators } input IDOperators { eq: ID, ne: ID } enum Order { ASC DESC } input OrderByClause { order: Order!, nulls: NullsOrder } enum NullsOrder { FIRST LAST } input Contact_OrderBy { LastName: OrderByClause, Title: OrderByClause } type AccountConnection { edges: [AccountEdge!]! } type AccountEdge { node: Account! } type Account { Id: ID! Name: StringValue Contacts(first: Int, orderBy: Contact_OrderBy): ContactConnection } type ContactConnection { edges: [ContactEdge!]! } type ContactEdge { node: Contact! } type Contact { Id: ID!, LastName: StringValue } type StringValue { value: String } `); primeSchemaCache(ORG, SCHEMA); primeSchemaCache(ORG_URL, SCHEMA); const primeDeps: PrimeDeps = { getOrgAuth: async () => ({ alias: ORG, username: "u", instanceUrl: ORG_URL, accessToken: "t", orgId: "00D", }), downloadSchema: async (auth) => { const cacheKey = schemaCacheKeyForInstanceUrl(auth.instanceUrl); const filePath = path.join(schemaDir(), `${cacheKey}.json`); atomicWriteJson(filePath, { data: introspectionFromSchema(SCHEMA) }); const meta: SchemaMetadata = { cacheKey, instanceUrl: auth.instanceUrl, typeCount: 0, downloadedAt: new Date().toISOString(), filePath, }; return meta; }, }; async function connect(): Promise<{ client: Client; server: McpServer }> { const server = new McpServer({ name: "graphiti-mcp", version: "test" }); registerSfGqlDetailTool(server, { primeDeps }); const [c, s] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "test", version: "0.0.0" }); await Promise.all([server.connect(s), client.connect(c)]); return { client, server }; } describe("mcp/tools/sf-gql-detail", () => { it("tools/list advertises sf_gql_detail with detail-shaped properties", async () => { const { client, server } = await connect(); try { const list = await client.listTools(); const tool = list.tools.find((t) => t.name === "sf_gql_detail"); expect(tool).toBeDefined(); const props = (tool!.inputSchema as { properties?: Record }).properties ?? {}; expect(props.org).toBeDefined(); expect(props.object).toBeDefined(); expect(props.fields).toBeDefined(); expect(props.idVariable).toBeDefined(); // Negative: detail does not advertise list/aggregate-shaped knobs. expect(props.filter).toBeUndefined(); expect(props.orderBy).toBeUndefined(); expect(props.scope).toBeUndefined(); expect(props.first).toBeUndefined(); } finally { await client.close(); await server.close(); } }); it("tools/call sf_gql_detail returns ToolOutput envelope with $id: ID! required", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_detail", arguments: { org: ORG, object: "Account", fields: ["Id", "Name"] }, }); const content = result.content as { type: string; text?: string }[]; expect(content[0]?.type).toBe("text"); const parsed = JSON.parse(content[0]?.text ?? "{}") as { query: string; variables: { name: string; type: string; required: boolean }[]; types: string; warnings: string[]; }; expect(parsed.query).toMatch(/\bAccountDetail\b/); expect(parsed.query).toMatch(/Name\s+@optional\s*\{\s*value\s*\}/); expect(parsed.query).toMatch(/first\s*:\s*1\b/); const id = parsed.variables.find((v) => v.name === "id"); expect(id).toBeDefined(); expect(id!.type).toBe("ID!"); expect(id!.required).toBe(true); } finally { await client.close(); await server.close(); } }); it("tools/call sf_gql_detail with custom idVariable round-trips", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_detail", arguments: { org: ORG, object: "Account", fields: ["Id"], idVariable: "accountId", }, }); const content = result.content as { type: string; text?: string }[]; const parsed = JSON.parse(content[0]?.text ?? "{}") as { query: string; variables: { name: string; type: string }[]; }; expect(parsed.query).toMatch(/\$accountId\s*:\s*ID!/); expect(parsed.query).toMatch(/eq\s*:\s*\$accountId/); expect(parsed.variables.find((v) => v.name === "accountId")).toBeDefined(); expect(parsed.variables.find((v) => v.name === "id")).toBeUndefined(); } finally { await client.close(); await server.close(); } }); it("tools/call sf_gql_detail with missing fields returns validation error", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_detail", arguments: { org: ORG, object: "Account" }, }); expect(result.isError).toBe(true); const content = result.content as { type: string; text?: string }[]; expect(content[0]?.text ?? "").toMatch(/fields/); } finally { await client.close(); await server.close(); } }); it("tools/call rejects invalid GraphQL Names in object/idVariable/operationName", async () => { const { client, server } = await connect(); try { for (const args of [ { org: ORG, object: "Order Item", fields: ["Id"] }, { org: ORG, object: "Account", fields: ["Id"], idVariable: "my-id" }, { org: ORG, object: "Account", fields: ["Id"], operationName: "1Bad" }, ]) { const result = await client.callTool({ name: "sf_gql_detail", arguments: args }); expect(result.isError).toBe(true); const content = result.content as { type: string; text?: string }[]; expect(content[0]?.text ?? "").toMatch(/GraphQL Name/); } } finally { await client.close(); await server.close(); } }); it("tools/list advertises childRelationships.orderBy as a singleton object or whole-arg string (FR-6.3)", async () => { const { client, server } = await connect(); try { const list = await client.listTools(); const tool = list.tools.find((t) => t.name === "sf_gql_detail"); const props = (tool!.inputSchema as { properties?: Record }).properties ?? {}; const child = props.childRelationships as { items?: { properties?: { orderBy?: { type?: string; anyOf?: unknown[] } } }; }; const orderBy = child?.items?.properties?.orderBy; expect(orderBy).toBeDefined(); // After Task 2, orderBy accepts both singleton objects and whole-argument $var strings. expect(orderBy?.anyOf).toBeDefined(); expect(Array.isArray(orderBy?.anyOf)).toBe(true); expect(orderBy?.anyOf?.length).toBeGreaterThanOrEqual(2); } finally { await client.close(); await server.close(); } }); it("tools/call still accepts childRelationships.orderBy as an array at runtime (FR-6.2 shim)", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_detail", arguments: { org: ORG, object: "Account", fields: ["Id"], childRelationships: [ { relationshipName: "Contacts", fields: ["Id"], orderBy: [{ LastName: { order: "ASC" } }, { Title: { order: "DESC" } }], }, ], }, }); expect(result.isError).toBeFalsy(); // Array was collapsed before reaching the renderer; only the first key shows up. const content = result.content as { type: string; text?: string }[]; const parsed = JSON.parse(content[0]?.text ?? "{}") as { query: string }; expect(parsed.query).toMatch(/orderBy\s*:\s*\{\s*LastName/); expect(parsed.query).not.toMatch(/Title/); } finally { await client.close(); await server.close(); } }); });