diff --git a/package-lock.json b/package-lock.json index 3f6aebf..c17d490 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", + "graphql": "^16.11.0", "zod": "^3.24.2" }, "bin": { @@ -5037,6 +5038,15 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index 47b9f81..80fa8d1 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "description": "A command line tool for setting up Shopify Dev MCP server", "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", + "graphql": "^16.11.0", "zod": "^3.24.2" }, "devDependencies": { diff --git a/src/tools/index.test.ts b/src/tools/index.test.ts index ffd53a6..8014f83 100644 --- a/src/tools/index.test.ts +++ b/src/tools/index.test.ts @@ -8,16 +8,39 @@ import { afterAll, afterEach, } from "vitest"; +import { GraphQLError } from "graphql"; global.fetch = vi.fn(); -import { shopifyTools, searchShopifyDocs } from "./index.js"; import { instrumentationData, isInstrumentationDisabled, } from "../instrumentation.js"; import { searchShopifyAdminSchema } from "./shopify-admin-schema.js"; +// Mock the graphql validation +vi.mock("./shopify-graphql-validation.js", () => { + return { + validateGraphQL: vi.fn(), + formatValidationErrors: vi.fn((errors: Array<{ message: string }>) => { + return errors + .map((error, index: number) => `${index + 1}. ${error.message}`) + .join("\n"); + }), + }; +}); + +// Now import the modules to test +import { + searchShopifyDocs, + validateShopifyGraphQL, + shopifyTools, +} from "./index.js"; +import { + validateGraphQL, + formatValidationErrors, +} from "./shopify-graphql-validation.js"; + const originalConsoleError = console.error; const originalConsoleWarn = console.warn; console.error = vi.fn(); @@ -268,6 +291,32 @@ describe("recordUsage", () => { }); }); +// Prepare mock validation results +const validResult = { + isValid: true, + errors: undefined, +}; + +// Create actual GraphQLError instances for the invalid result +const graphqlError1 = new GraphQLError( + "Cannot query field 'invalid' on type 'Product'", +); +const graphqlError2 = new GraphQLError("Unknown type 'NonExistentType'"); + +// Add locations manually +Object.defineProperty(graphqlError1, "locations", { + value: [{ line: 3, column: 5 }], +}); + +Object.defineProperty(graphqlError2, "locations", { + value: [{ line: 7, column: 10 }], +}); + +const invalidResult = { + isValid: false, + errors: [graphqlError1, graphqlError2], +}; + describe("searchShopifyDocs", () => { let fetchMock: any; @@ -415,6 +464,110 @@ describe("searchShopifyDocs", () => { }); }); +describe("validateShopifyGraphQL", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock for validateGraphQL + vi.mocked(validateGraphQL).mockResolvedValue(validResult); + vi.mocked(console.error).mockClear(); + }); + + test("calls local validateGraphQL function with correct code", async () => { + const graphqlCode = ` + query GetProduct { + product(id: "gid://shopify/Product/123") { + title + description + } + } + `; + + await validateShopifyGraphQL(graphqlCode); + + // Verify validateGraphQL was called with the code + expect(validateGraphQL).toHaveBeenCalledTimes(1); + expect(validateGraphQL).toHaveBeenCalledWith(graphqlCode); + }); + + test("returns properly formatted result for valid GraphQL", async () => { + const graphqlCode = `query { product(id: "123") { title } }`; + + const result = await validateShopifyGraphQL(graphqlCode); + + expect(result.success).toBe(true); + expect(result.isValid).toBe(true); + expect(result.formattedText).toContain("## GraphQL Validation Results"); + expect(result.formattedText).toContain("**Valid:** ✅ Yes"); + expect(result.rawResponse).toEqual({ is_valid: true, errors: undefined }); + }); + + test("returns properly formatted result for invalid GraphQL with errors", async () => { + // Mock an invalid validation result + vi.mocked(validateGraphQL).mockResolvedValue(invalidResult); + + // Mock formatValidationErrors to return formatted errors + vi.mocked(formatValidationErrors).mockReturnValue( + "1. Cannot query field 'invalid' on type 'Product' (Line 3, Column 5)\n" + + "2. Unknown type 'NonExistentType' (Line 7, Column 10)", + ); + + const graphqlCode = ` + query GetProduct { + product(id: "gid://shopify/Product/123") { + invalid + title + someType { + field: NonExistentType + } + } + } + `; + + const result = await validateShopifyGraphQL(graphqlCode); + + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.formattedText).toContain("## GraphQL Validation Results"); + expect(result.formattedText).toContain("**Valid:** ❌ No"); + expect(result.formattedText).toContain("**Errors:**"); + expect(result.formattedText).toContain( + "Cannot query field 'invalid' on type 'Product'", + ); + expect(result.formattedText).toContain("Unknown type 'NonExistentType'"); + + // Verify formatValidationErrors was called with the errors + expect(formatValidationErrors).toHaveBeenCalledTimes(1); + expect(formatValidationErrors).toHaveBeenCalledWith(invalidResult.errors); + }); + + test("handles validation errors gracefully", async () => { + // Clear mocks before this specific test + vi.clearAllMocks(); + + // Mock a validation function error + vi.mocked(validateGraphQL).mockRejectedValue( + new Error("Validation function failed"), + ); + + const graphqlCode = "query { invalid syntax }"; + + const result = await validateShopifyGraphQL(graphqlCode); + + expect(result.success).toBe(false); + expect(result.isValid).toBe(false); + expect(result.formattedText).toContain( + "Error validating GraphQL: Validation function failed", + ); + + // Verify error was logged once + expect(console.error).toHaveBeenCalledTimes(1); + expect(vi.mocked(console.error).mock.calls[0][0]).toContain( + "Error validating GraphQL", + ); + }); +}); + describe("fetchGettingStartedApis", () => { let fetchMock: any; @@ -609,3 +762,78 @@ describe("get_started tool behavior", () => { expect(result.content[0].text).toContain("Network failure"); }); }); + +describe("shopifyTools tool registration", () => { + let mockServer: any; + + beforeEach(() => { + vi.clearAllMocks(); + + // Setup fetch mock for getting_started_apis endpoint + const fetchMock = global.fetch as any; + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { + forEach: (callback: (value: string, key: string) => void) => { + callback("application/json", "content-type"); + }, + }, + text: async () => JSON.stringify(sampleGettingStartedApisResponse), + }); + + // Create a mock server that captures registered tools + mockServer = { + registeredTools: new Map(), + tool: vi.fn((name, description, schema, handler) => { + mockServer.registeredTools.set(name, { description, schema, handler }); + }), + }; + }); + + test("registers validate_graphql tool correctly", async () => { + // Import and register tools + const { shopifyTools } = await import("./index.js"); + await shopifyTools(mockServer); + + // Verify the validate_graphql tool was registered + expect(mockServer.registeredTools.has("validate_graphql")).toBe(true); + + const tool = mockServer.registeredTools.get("validate_graphql"); + expect(tool).toBeDefined(); + expect(tool.description).toContain("validates GraphQL code"); + expect(tool.description).toContain( + "ALWAYS MAKE SURE THAT THE GRAPHQL CODE YOU GENERATE IS VALID", + ); + + // Verify the handler function exists + expect(tool.handler).toBeDefined(); + expect(typeof tool.handler).toBe("function"); + }); + + test("validate_graphql tool executes correctly", async () => { + // Mock the validation functions + vi.mocked(validateGraphQL).mockResolvedValue({ + isValid: true, + errors: undefined, + }); + + // Import and register tools + const { shopifyTools } = await import("./index.js"); + await shopifyTools(mockServer); + + // Get the handler + const tool = mockServer.registeredTools.get("validate_graphql"); + const handler = tool.handler; + + // Execute the handler + const result = await handler({ code: "query { shop { name } }" }); + + // Verify the result + expect(result.content).toBeDefined(); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("## GraphQL Validation Results"); + expect(result.content[0].text).toContain("✅ Yes"); + }); +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 4bcb896..348d282 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,6 +6,11 @@ import { isInstrumentationDisabled, } from "../instrumentation.js"; +import { + validateGraphQL, + formatValidationErrors, +} from "./shopify-graphql-validation.js"; + const SHOPIFY_BASE_URL = process.env.DEV ? "https://shopify-dev.myshopify.io/" : "https://shopify.dev/"; @@ -136,6 +141,54 @@ export async function searchShopifyDocs(prompt: string) { } } +/** + * Validates GraphQL code against the Shopify Admin API schema + * @param code The GraphQL code to validate + * @returns Validation results including whether the code is valid and any errors + */ +export async function validateShopifyGraphQL(code: string) { + try { + // Use the local validation function + const validationResult = await validateGraphQL(code); + + // Format the response + let formattedResponse = `## GraphQL Validation Results\n\n`; + formattedResponse += `**Valid:** ${validationResult.isValid ? "✅ Yes" : "❌ No"}\n\n`; + + // Add errors if any + if ( + !validationResult.isValid && + validationResult.errors && + validationResult.errors.length > 0 + ) { + formattedResponse += `**Errors:**\n\n`; + formattedResponse += formatValidationErrors(validationResult.errors); + } + + return { + success: true, + isValid: validationResult.isValid, + formattedText: formattedResponse, + rawResponse: { + is_valid: validationResult.isValid, + errors: validationResult.errors, + }, + }; + } catch (error) { + // Log the error once + console.error(`[validate-graphql] Error validating GraphQL: ${error}`); + + return { + success: false, + isValid: false, + formattedText: `Error validating GraphQL: ${ + error instanceof Error ? error.message : String(error) + }`, + error: error instanceof Error ? error.message : String(error), + }; + } +} + export async function shopifyTools(server: McpServer): Promise { server.tool( "introspect_admin_schema", @@ -176,10 +229,40 @@ export async function shopifyTools(server: McpServer): Promise { }, ); + server.tool( + "validate_graphql", + `This tool validates GraphQL code against the Shopify Admin API GraphQL schema and returns validation results including any errors. + ALWAYS MAKE SURE THAT THE GRAPHQL CODE YOU GENERATE IS VALID WITH THIS TOOL. + + It takes one argument: + - code: The GraphQL code to validate`, + { + code: z.string().describe("The GraphQL code to validate"), + }, + async ({ code }) => { + const result = await validateShopifyGraphQL(code); + + recordUsage("validate_graphql", code, result.formattedText).catch( + () => {}, + ); + + return { + content: [ + { + type: "text" as const, + text: result.formattedText, + }, + ], + }; + }, + ); + server.tool( "search_dev_docs", `This tool will take in the user prompt, search shopify.dev, and return relevant documentation and code examples that will help answer the user's question. + YOU MUST CALL THIS TOOL AT LEAST ONCE FOR A SHOPIFY RELATED QUESTION TO CHECK IF THE DOCUMENTATION HAS ANY EXAMPLES AVAILABLE. + It takes one argument: prompt, which is the search query for Shopify documentation.`, { prompt: z.string().describe("The search query for Shopify documentation"), @@ -263,7 +346,7 @@ export async function shopifyTools(server: McpServer): Promise { server.tool( "get_started", ` - YOU MUST CALL THIS TOOL FIRST WHENEVER YOU ARE IN A SHOPIFY APP AND THE USER WANTS TO LEARN OR INTERACT WITH ANY OF THESE APIS: + YOU MUST CALL THIS TOOL FIRST WHENEVER YOU ARE IN A SHOPIFY APP AND/OR THE USER WANTS TO LEARN OR INTERACT WITH ANY OF THESE APIS: Valid arguments for \`api\` are: ${gettingStartedApis.map((api) => ` - ${api.name}: ${api.description}`).join("\n")} @@ -282,13 +365,16 @@ ${gettingStartedApis.map((api) => ` - ${api.name}: ${api.description}`).join( const text = `Please specify which Shopify API you are building for. Valid options are: ${options}.`; return { - content: [{ type: "text", text }], + content: [{ type: "text" as const, text }], }; } try { const response = await fetch( - `${SHOPIFY_BASE_URL}/mcp/getting_started?api=${api}`, + new URL( + `/mcp/getting_started?api=${api}`, + SHOPIFY_BASE_URL, + ).toString(), ); if (!response.ok) { diff --git a/src/tools/shopify-admin-schema.ts b/src/tools/shopify-admin-schema.ts index fd1e02b..c1b4455 100644 --- a/src/tools/shopify-admin-schema.ts +++ b/src/tools/shopify-admin-schema.ts @@ -141,6 +141,23 @@ export const formatSchemaType = (item: any): string => { } more input fields`; } } + // For ENUM types, list enum values + else if ( + item.kind === "ENUM" && + item.enumValues && + item.enumValues.length > 0 + ) { + result += "\n Enum Values:"; + for (const value of item.enumValues) { + result += `\n ${value.name}`; + if (value.isDeprecated) { + result += ` @deprecated`; + if (value.deprecationReason) { + result += ` (${value.deprecationReason})`; + } + } + } + } // For regular object types, use fields else if (item.fields && item.fields.length > 0) { result += "\n Fields:"; diff --git a/src/tools/shopify-graphql-validation.test.ts b/src/tools/shopify-graphql-validation.test.ts new file mode 100644 index 0000000..074b5f2 --- /dev/null +++ b/src/tools/shopify-graphql-validation.test.ts @@ -0,0 +1,275 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { + validateGraphQL, + formatValidationErrors, +} from "./shopify-graphql-validation.js"; +import { GraphQLError } from "graphql"; + +describe("GraphQL validation", () => { + test("validates a correct GraphQL query", async () => { + const validQuery = ` + query GetProduct { + product(id: "gid://shopify/Product/123") { + id + title + } + } + `; + + const result = await validateGraphQL(validQuery); + + expect(result.isValid).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + test("reports errors for invalid fields", async () => { + const invalidQuery = ` + query GetProduct { + product(id: "gid://shopify/Product/123") { + id + title + nonExistentField + } + } + `; + + const result = await validateGraphQL(invalidQuery); + + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors?.length).toBeGreaterThan(0); + expect(result.errors?.[0].message).toMatch(/Cannot query field/); + }); + + test("reports errors for missing required arguments", async () => { + const invalidQuery = ` + query GetProduct { + product { + id + title + } + } + `; + + const result = await validateGraphQL(invalidQuery); + + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors?.length).toBeGreaterThan(0); + expect(result.errors?.[0].message).toMatch(/argument.*is required/); + }); + + test("handles syntax errors in GraphQL", async () => { + const invalidSyntax = ` + query GetProduct { + product(id: "gid://shopify/Product/123") { + id + title + // Missing closing brace + `; + + const result = await validateGraphQL(invalidSyntax); + + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors?.length).toBe(1); + expect(result.errors?.[0].message).toMatch( + /Syntax Error|GraphQL validation error/, + ); + }); + + test("validates mutations correctly", async () => { + const mutation = ` + mutation CreateProduct($input: ProductCreateInput!) { + productCreate(product: $input) { + product { + id + title + } + userErrors { + field + message + } + } + } + `; + + const result = await validateGraphQL(mutation); + + expect(result.isValid).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + test("handles multiple operations in one document", async () => { + const multipleOps = ` + query GetProduct($id: ID!) { + product(id: $id) { + title + } + } + + query GetShop { + shop { + name + } + } + `; + + const result = await validateGraphQL(multipleOps); + + expect(result.isValid).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + test("validates fragments correctly", async () => { + const queryWithFragment = ` + fragment ProductFields on Product { + id + title + description + } + + query GetProduct($id: ID!) { + product(id: $id) { + ...ProductFields + } + } + `; + + const result = await validateGraphQL(queryWithFragment); + + expect(result.isValid).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + test("handles empty or whitespace-only input", async () => { + const emptyQuery = " \n \n "; + + const result = await validateGraphQL(emptyQuery); + + expect(result.isValid).toBe(false); + expect(result.errors?.[0].message).toMatch(/Syntax Error|Expected/); + }); + + test("reports multiple validation errors", async () => { + const queryWithMultipleErrors = ` + query BadQuery { + product(wrongArg: "123") { + nonExistentField1 + nonExistentField2 + } + nonExistentQuery { + field + } + } + `; + + const result = await validateGraphQL(queryWithMultipleErrors); + + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors!.length).toBeGreaterThan(1); + }); + + test("validates complex nested queries", async () => { + const complexQuery = ` + query GetProductWithVariants($id: ID!) { + product(id: $id) { + id + title + variants(first: 10) { + edges { + node { + id + title + price + } + } + } + } + } + `; + + const result = await validateGraphQL(complexQuery); + + expect(result.isValid).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + test("validates queries with directives", async () => { + const queryWithDirectives = ` + query GetProductConditional($id: ID!, $includeVariants: Boolean!) { + product(id: $id) { + id + title + variants(first: 10) @include(if: $includeVariants) { + edges { + node { + id + title + } + } + } + } + } + `; + + const result = await validateGraphQL(queryWithDirectives); + + expect(result.isValid).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + test("reports errors for incorrect variable types", async () => { + const queryWithWrongVarType = ` + query GetProduct($id: String!) { + product(id: $id) { + id + title + } + } + `; + + const result = await validateGraphQL(queryWithWrongVarType); + + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toMatch( + /Variable.*used in position expecting type/, + ); + }); +}); + +describe("formatValidationErrors", () => { + test("formats errors with locations", () => { + // Create errors with locations manually + const error1 = new GraphQLError("Field does not exist"); + const error2 = new GraphQLError("Invalid argument"); + + // Add locations manually to match the interface + Object.defineProperty(error1, "locations", { + value: [{ line: 3, column: 5 }], + }); + + Object.defineProperty(error2, "locations", { + value: [{ line: 10, column: 12 }], + }); + + const formatted = formatValidationErrors([error1, error2]); + + expect(formatted).toContain("1. Field does not exist (Line 3, Column 5)"); + expect(formatted).toContain("2. Invalid argument (Line 10, Column 12)"); + }); + + test("formats errors without locations", () => { + const errors = [ + new GraphQLError("Parser error"), + new GraphQLError("Validation error"), + ]; + + const formatted = formatValidationErrors(errors); + + expect(formatted).toContain("1. Parser error"); + expect(formatted).toContain("2. Validation error"); + }); +}); diff --git a/src/tools/shopify-graphql-validation.ts b/src/tools/shopify-graphql-validation.ts new file mode 100644 index 0000000..153f22f --- /dev/null +++ b/src/tools/shopify-graphql-validation.ts @@ -0,0 +1,78 @@ +import { buildClientSchema, validate, parse, GraphQLError } from "graphql"; +import { loadSchemaContent } from "./shopify-admin-schema.js"; +import { SCHEMA_FILE_PATH } from "./shopify-admin-schema.js"; + +/** + * Interface for GraphQL validation result + */ +export interface ValidationResult { + isValid: boolean; + errors?: readonly GraphQLError[]; +} + +/** + * Validates GraphQL code against the local Shopify Admin GraphQL schema + * @param code The GraphQL code to validate + * @returns Promise resolving to a ValidationResult object containing validation status and any errors + */ +export async function validateGraphQL(code: string): Promise { + try { + // Load schema content from the local schema file + const schemaContent = await loadSchemaContent(SCHEMA_FILE_PATH); + + // Parse the schema content + const schemaJson = JSON.parse(schemaContent); + + // Build a GraphQL schema from the JSON + const schema = buildClientSchema(schemaJson.data); + + // Parse the GraphQL code into an AST + const documentAST = parse(code); + + // Validate the document against the schema + const validationErrors = validate(schema, documentAST); + + // Return validation result + return { + isValid: validationErrors.length === 0, + errors: validationErrors.length > 0 ? validationErrors : undefined, + }; + } catch (error) { + // Handle parsing or other errors + const errorMessage = error instanceof Error ? error.message : String(error); + + // Create a GraphQL error to maintain consistent error format + const graphqlError = new GraphQLError( + `GraphQL validation error: ${errorMessage}`, + { originalError: error instanceof Error ? error : undefined }, + ); + + return { + isValid: false, + errors: [graphqlError], + }; + } +} + +/** + * Format validation errors into a human-readable string + * @param errors Array of GraphQL errors + * @returns Formatted error message string + */ +export function formatValidationErrors( + errors: readonly GraphQLError[], +): string { + return errors + .map((error, index) => { + let message = `${index + 1}. ${error.message}`; + + // Add location information if available + if (error.locations && error.locations.length > 0) { + const location = error.locations[0]; + message += ` (Line ${location.line}, Column ${location.column})`; + } + + return message; + }) + .join("\n"); +}