/** * 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 { registerSfGqlDeleteTool } from "../sf-gql-delete.js"; const ORG = "test-tool-delete"; const ORG_URL = "https://test-tool-delete.my.salesforce.com"; const SCHEMA = buildSchema(` type Query { _placeholder: Boolean } type Mutation { uiapi(input: UIAPIMutationsInput): UIAPIMutations! } input UIAPIMutationsInput { allOrNone: Boolean } type UIAPIMutations { AccountDelete(input: RecordDeleteInput!): RecordDeletePayload } input RecordDeleteInput { Id: ID! } type RecordDeletePayload { Id: ID } type Account { Id: ID!, Name: 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" }); registerSfGqlDeleteTool(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-delete", () => { it("tools/list advertises sf_gql_delete with org/object/inputVariable/operationName properties", async () => { const { client, server } = await connect(); try { const list = await client.listTools(); const tool = list.tools.find((t) => t.name === "sf_gql_delete"); expect(tool).toBeDefined(); const props = (tool!.inputSchema as { properties?: Record }).properties ?? {}; expect(props.org).toBeDefined(); expect(props.object).toBeDefined(); expect(props.inputVariable).toBeDefined(); expect(props.operationName).toBeDefined(); // W-22899736: object + operationName advertise the GraphQL Name pattern in tools/list. expect((props.object as { pattern?: string }).pattern).toBe("^[A-Za-z_][A-Za-z0-9_]*$"); expect((props.operationName as { pattern?: string }).pattern).toBe( "^[A-Za-z_][A-Za-z0-9_]*$", ); // Delete has no returnFields — result is always Id only. expect(props.returnFields).toBeUndefined(); } finally { await client.close(); await server.close(); } }); it("tools/call sf_gql_delete returns ToolOutput with RecordDeleteInput! and Id-only selection", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_delete", arguments: { org: ORG, object: "Account" }, }); 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: unknown[]; types: string; }; expect(parsed.query).toMatch(/mutation DeleteAccount/); expect(parsed.query).toMatch(/\$input:\s*RecordDeleteInput!/); expect(parsed.query).toMatch(/AccountDelete\(input:\s*\$input\)/); expect(parsed.query).toMatch(/\bId\b/); // Id is a plain scalar on the payload — never a value wrapper, never a Record path. expect(parsed.query).not.toMatch(/Id\s*\{\s*value/s); expect(parsed.query).not.toMatch(/\bRecord\s*\{/); expect(parsed.variables).toHaveLength(1); expect(parsed.variables[0]).toEqual({ name: "input", type: "RecordDeleteInput!", required: true, }); expect(typeof parsed.types).toBe("string"); } finally { await client.close(); await server.close(); } }); it("tools/call sf_gql_delete with custom inputVariable declares that variable", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_delete", arguments: { org: ORG, object: "Account", inputVariable: "acctInput" }, }); const content = result.content as { type: string; text?: string }[]; const parsed = JSON.parse(content[0]?.text ?? "{}"); expect(parsed.query).toMatch(/\$acctInput:\s*RecordDeleteInput!/); expect(parsed.query).toMatch(/input:\s*\$acctInput/); } finally { await client.close(); await server.close(); } }); it("tools/call sf_gql_delete with custom operationName uses it", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_delete", arguments: { org: ORG, object: "Account", operationName: "RemoveAccount" }, }); const content = result.content as { type: string; text?: string }[]; const parsed = JSON.parse(content[0]?.text ?? "{}"); expect(parsed.query).toMatch(/mutation RemoveAccount/); } finally { await client.close(); await server.close(); } }); it("tools/call sf_gql_delete strips leading $ from inputVariable", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_delete", arguments: { org: ORG, object: "Account", inputVariable: "$myInput" }, }); expect(result.isError).toBeFalsy(); const content = result.content as { type: string; text?: string }[]; const parsed = JSON.parse(content[0]?.text ?? "{}"); expect(parsed.query).toMatch(/\$myInput:\s*RecordDeleteInput!/); expect(parsed.query).toMatch(/input:\s*\$myInput/); expect(parsed.query).not.toMatch(/\$\$myInput/); } finally { await client.close(); await server.close(); } }); it("tools/call rejects invalid GraphQL Names in object/operationName", async () => { const { client, server } = await connect(); try { for (const args of [ { org: ORG, object: "1Bad" }, { org: ORG, object: "Account", operationName: "1Bad" }, ]) { const result = await client.callTool({ name: "sf_gql_delete", 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/call sf_gql_delete with an invalid object name returns error", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_delete", arguments: { org: ORG, object: "my-object" }, }); 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(); } }); });