/** * 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 { registerSfGqlListTool } from "../sf-gql-list.js"; const ORG = "test-tool-list"; const ORG_URL = "https://test-tool-list.my.salesforce.com"; const SCHEMA = buildSchema(` type Query { uiapi: UIAPI! } type UIAPI { query: RecordQuery! } type RecordQuery { Account(first: Int, after: String, where: Account_Filter, orderBy: Account_OrderBy): AccountConnection! } type AccountConnection { edges: [AccountEdge!]!, pageInfo: PageInfo! } type AccountEdge { node: Account! } type PageInfo { hasNextPage: Boolean!, endCursor: String } type Account { Id: ID!, Name: StringValue } type StringValue { value: String } input Account_Filter { Name: StringOperators } input StringOperators { eq: String } input Account_OrderBy { Name: OrderByValue } input OrderByValue { order: 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" }); registerSfGqlListTool(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-list", () => { it("tools/list advertises sf_gql_list with org/object/fields properties", async () => { const { client, server } = await connect(); try { const list = await client.listTools(); const tool = list.tools.find((t) => t.name === "sf_gql_list"); expect(tool).toBeDefined(); const props = (tool!.inputSchema as { properties?: Record }).properties ?? {}; expect(props.org).toBeDefined(); expect(props.object).toBeDefined(); expect(props.fields).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_]*$", ); } finally { await client.close(); await server.close(); } }); it("tools/call sf_gql_list returns ToolOutput JSON envelope", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_list", 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: unknown[]; types: string; warnings: string[]; }; expect(parsed.query).toMatch(/\bAccountList\b/); expect(parsed.query).toMatch(/Name\s+@optional\s*\{\s*value\s*\}/); expect(Array.isArray(parsed.variables)).toBe(true); expect(typeof parsed.types).toBe("string"); } finally { await client.close(); await server.close(); } }); it("tools/call sf_gql_list with missing required arg returns validation error", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_list", 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/operationName", async () => { const { client, server } = await connect(); try { for (const args of [ { org: ORG, object: "1Bad", fields: ["Id", "Name"] }, { org: ORG, object: "Account", fields: ["Id", "Name"], operationName: "1Bad" }, ]) { const result = await client.callTool({ name: "sf_gql_list", 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("accepts a whole-argument $filter string and renders where: $filter", async () => { const { client, server } = await connect(); try { const res = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], filter: "$filter" }, }); const content = res.content as { type: string; text?: string }[]; const out = JSON.parse(content[0]?.text ?? "{}") as { query: string; variables: { name: string }[]; }; expect(out.query).toMatch(/where\s*:\s*\$filter\b/); expect(out.variables.find((v) => v.name === "filter")).toBeDefined(); } finally { await client.close(); await server.close(); } }); it("rejects an invalid whole-argument placeholder", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], filter: "$1bad" }, }); expect(result.isError).toBe(true); } finally { await client.close(); await server.close(); } }); it("accepts a whole-argument $first string", async () => { const { client, server } = await connect(); try { const res = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], first: "$first" }, }); const content = res.content as { type: string; text?: string }[]; const out = JSON.parse(content[0]?.text ?? "{}") as { query: string; variables: { name: string }[]; }; expect(out.query).toMatch(/first\s*:\s*\$first\b/); expect(out.variables.find((v) => v.name === "first")).toBeDefined(); } finally { await client.close(); await server.close(); } }); it("rejects an invalid $first placeholder", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], first: "$1bad" }, }); expect(result.isError).toBe(true); } finally { await client.close(); await server.close(); } }); it("tools/list advertises orderBy/filter/first $var branch inline (no lost $ref branches)", async () => { const { client, server } = await connect(); try { const list = await client.listTools(); const tool = list.tools.find((t) => t.name === "sf_gql_list"); const props = (tool!.inputSchema as { properties: Record }).properties; // The advertised JSON Schema must be self-contained: the whole serialized // schema string must not rely on $ref to express these unions, because the // MCP SDK's converter resolves $refs in ways that have dropped the string // branch (regressing client-side validation of "$var" inputs). const whole = JSON.stringify(tool!.inputSchema); expect(whole.includes("$ref")).toBe(false); // Each of these args must advertise a string branch carrying the $var pattern. for (const key of ["orderBy", "filter", "first"]) { const schema = JSON.stringify(props[key]); expect(schema, `${key} must inline a string $var branch`).toMatch(/"type":"string"/); expect(schema, `${key} must carry the $var regex pattern`).toContain("\\\\$"); // pattern ^\$... } } finally { await client.close(); await server.close(); } }); it("accepts a JSON-stringified filter object (model serialization tolerance)", async () => { const { client, server } = await connect(); try { const res = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], filter: '{"Name":{"eq":"$q"}}' }, }); const content = res.content as { type: string; text?: string }[]; const out = JSON.parse(content[0]?.text ?? "{}") as { query: string; variables: { name: string }[]; }; // coerced to object, leaf $q promoted expect(out.variables.find((v) => v.name === "q")).toBeDefined(); expect(out.query).toMatch(/Name\s*:\s*\{\s*eq\s*:\s*\$q/); } finally { await client.close(); await server.close(); } }); it("accepts a JSON-stringified orderBy object", async () => { const { client, server } = await connect(); try { const res = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], orderBy: '{"Name":{"order":"DESC"}}', }, }); const content = res.content as { type: string; text?: string }[]; const out = JSON.parse(content[0]?.text ?? "{}") as { query: string }; expect(out.query).toMatch(/orderBy\s*:\s*\{\s*Name/); } finally { await client.close(); await server.close(); } }); it("accepts a stringified number for first", async () => { const { client, server } = await connect(); try { const res = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], first: "25" }, }); const content = res.content as { type: string; text?: string }[]; const out = JSON.parse(content[0]?.text ?? "{}") as { query: string }; expect(out.query).toMatch(/first\s*:\s*25\b/); } finally { await client.close(); await server.close(); } }); it("still promotes a whole-arg $var (coercion leaves $var strings alone)", async () => { const { client, server } = await connect(); try { const res = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], orderBy: "$orderBy", filter: "$filter", }, }); const content = res.content as { type: string; text?: string }[]; const out = JSON.parse(content[0]?.text ?? "{}") as { query: string }; expect(out.query).toMatch(/orderBy\s*:\s*\$orderBy\b/); expect(out.query).toMatch(/where\s*:\s*\$filter\b/); } finally { await client.close(); await server.close(); } }); it("rejects a non-JSON garbage string for filter", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], filter: "not json{" }, }); expect(result.isError).toBe(true); } finally { await client.close(); await server.close(); } }); it("rejects pure garbage string for filter (hello)", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], filter: "hello" }, }); expect(result.isError).toBe(true); } finally { await client.close(); await server.close(); } }); it("accepts a double-quoted $var placeholder (model double-stringify)", async () => { const { client, server } = await connect(); try { const res = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], filter: '"$filter"', orderBy: '"$orderBy"', }, }); const content = res.content as { type: string; text?: string }[]; const out = JSON.parse(content[0]?.text ?? "{}") as { query: string; variables: { name: string }[]; }; expect(out.query).toMatch(/where\s*:\s*\$filter\b/); expect(out.query).toMatch(/orderBy\s*:\s*\$orderBy\b/); expect(out.variables.find((v) => v.name === "filter")).toBeDefined(); } finally { await client.close(); await server.close(); } }); it("accepts a double-quoted first placeholder and a quoted number", async () => { const { client, server } = await connect(); try { const res1 = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], first: '"$first"' }, }); const content1 = res1.content as { type: string; text?: string }[]; const out1 = JSON.parse(content1[0]?.text ?? "{}") as { query: string }; expect(out1.query).toMatch(/first\s*:\s*\$first\b/); const res2 = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], first: '"25"' }, }); const content2 = res2.content as { type: string; text?: string }[]; const out2 = JSON.parse(content2[0]?.text ?? "{}") as { query: string }; expect(out2.query).toMatch(/first\s*:\s*25\b/); } finally { await client.close(); await server.close(); } }); it("still rejects quoted garbage", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], filter: '"hello"' }, }); expect(result.isError).toBe(true); } finally { await client.close(); await server.close(); } }); it("rejects garbage string for first (abc)", async () => { const { client, server } = await connect(); try { const result = await client.callTool({ name: "sf_gql_list", arguments: { org: ORG, object: "Account", fields: ["Id"], first: "abc" }, }); expect(result.isError).toBe(true); } finally { await client.close(); await server.close(); } }); });