diff --git a/README.md b/README.md index 8ff3b46..38d6029 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,12 @@ On Windows, you might need to use this alternative configuration: This MCP server provides the following tools: -| Tool Name | Description | -| ----------------------- | ---------------------------------------------- | -| search_dev_docs | Search shopify.dev documentation | -| introspect_admin_schema | Access and search Shopify Admin GraphQL schema | +| Tool Name | Description | +| ----------------------- | ------------------------------------------------------------------------------- | +| search_dev_docs | Search shopify.dev documentation | +| introspect_admin_schema | Access and search Shopify Admin GraphQL schema | +| fetch_docs_by_path | Retrieve documents from shopify.dev | +| get_started | Get started with Shopify APIs | ## Available prompts diff --git a/src/index.ts b/src/index.ts index 3e6f337..d53b71b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,7 +31,7 @@ async function main() { ); // Register Shopify tools - shopifyTools(server); + await shopifyTools(server); // Register Shopify prompts shopifyPrompts(server); diff --git a/src/tools/index.test.ts b/src/tools/index.test.ts new file mode 100644 index 0000000..0eebe18 --- /dev/null +++ b/src/tools/index.test.ts @@ -0,0 +1,718 @@ +// Import vitest first +import { describe, test, expect, beforeEach, vi, afterAll } from "vitest"; + +// Mock fetch globally +global.fetch = vi.fn(); + +// Now import the modules to test +import { searchShopifyDocs, validateShopifyGraphQL } from "./index.js"; + +// Mock console.error and console.warn +const originalConsoleError = console.error; +const originalConsoleWarn = console.warn; +console.error = vi.fn(); +console.warn = vi.fn(); + +// Clean up after tests +afterAll(() => { + console.error = originalConsoleError; + console.warn = originalConsoleWarn; +}); + +// Sample response data for mocking +const sampleDocsResponse = [ + { + filename: "api/admin/graphql/reference/products.md", + score: 0.85, + content: + "The products API allows you to manage products in your Shopify store.", + }, + { + filename: "apps/tools/product-listings.md", + score: 0.72, + content: + "Learn how to display and manage product listings in your Shopify app.", + }, +]; + +// Sample response for getting_started_apis +const sampleGettingStartedApisResponse = [ + { + name: "app-ui", + description: "App Home, Admin Extensions, Checkout Extensions, Customer Account Extensions, Polaris Web Components" + }, + { + name: "admin", + description: "Admin API, Admin API GraphQL Schema, Admin API REST Schema" + }, + { + name: "functions", + description: "Shopify Functions, Shopify Functions API" + } +]; + +// Sample getting started guide response +const sampleGettingStartedGuide = `# Getting Started with Admin API +This guide walks you through the first steps of using the Shopify Admin API. + +## Authentication +Learn how to authenticate your app with OAuth. + +## Making API Calls +Examples of common API calls with the Admin API.`; + +// Sample GraphQL validation responses +const sampleValidGraphQLResponse = { + is_valid: true +}; + +const sampleInvalidGraphQLResponse = { + is_valid: false, + errors: [ + { + message: "Cannot query field 'invalid' on type 'Product'", + locations: [{ line: 3, column: 5 }] + }, + { + message: "Unknown type 'NonExistentType'", + locations: [{ line: 7, column: 10 }] + } + ] +}; + +describe("searchShopifyDocs", () => { + let fetchMock: any; + + beforeEach(() => { + vi.clearAllMocks(); + + // Setup the mock for fetch + fetchMock = global.fetch as any; + + // By default, mock successful response + 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(sampleDocsResponse), + }); + }); + + test("returns formatted JSON response correctly", async () => { + // Call the function directly with test parameters + const result = await searchShopifyDocs("product listings"); + + // Verify the fetch was called with correct URL + expect(fetchMock).toHaveBeenCalledTimes(1); + const fetchUrl = fetchMock.mock.calls[0][0]; + expect(fetchUrl).toContain("/mcp/search"); + expect(fetchUrl).toContain("query=product+listings"); + + // Check that the response is formatted JSON + expect(result.success).toBe(true); + + // The response should be properly formatted with indentation + expect(result.formattedText).toContain("{\n"); + expect(result.formattedText).toContain(' "filename":'); + + // Parse the response and verify it matches our sample data + const parsedResponse = JSON.parse(result.formattedText); + expect(parsedResponse).toEqual(sampleDocsResponse); + expect(parsedResponse[0].filename).toBe( + "api/admin/graphql/reference/products.md", + ); + expect(parsedResponse[0].score).toBe(0.85); + }); + + test("handles HTTP error", async () => { + // Mock an error response + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + headers: { + forEach: (callback: (value: string, key: string) => void) => { + callback("text/plain", "content-type"); + }, + }, + }); + + // Call the function directly + const result = await searchShopifyDocs("product"); + + // Check that the error was handled + expect(result.success).toBe(false); + expect(result.formattedText).toContain( + "Error searching Shopify documentation", + ); + expect(result.formattedText).toContain("500"); + }); + + test("handles fetch error", async () => { + // Mock a network error + fetchMock.mockRejectedValue(new Error("Network error")); + + // Call the function directly + const result = await searchShopifyDocs("product"); + + // Check that the error was handled + expect(result.success).toBe(false); + expect(result.formattedText).toContain( + "Error searching Shopify documentation: Network error", + ); + }); + + test("handles non-JSON response gracefully", async () => { + // Mock a non-JSON response + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { + forEach: (callback: (value: string, key: string) => void) => { + callback("text/plain", "content-type"); + }, + }, + text: async () => "This is not valid JSON", + }); + + // Clear the mocks before the test + vi.mocked(console.warn).mockClear(); + + // Call the function directly + const result = await searchShopifyDocs("product"); + + // Check that the error was handled and raw text is returned + expect(result.success).toBe(true); + expect(result.formattedText).toBe("This is not valid JSON"); + + // Verify that console.warn was called with the JSON parsing error + expect(console.warn).toHaveBeenCalledTimes(1); + expect(vi.mocked(console.warn).mock.calls[0][0]).toContain( + "Error parsing JSON response", + ); + }); +}); + +describe("validateShopifyGraphQL", () => { + let fetchMock: any; + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock = global.fetch as any; + + // By default, mock a successful validation response + 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(sampleValidGraphQLResponse), + }); + }); + + test("validates valid GraphQL code successfully", async () => { + const validGraphQL = ` + query GetProduct { + product(id: "gid://shopify/Product/123") { + title + description + variants { + edges { + node { + id + price + } + } + } + } + } + `; + + // Call the function directly + const result = await validateShopifyGraphQL("admin", validGraphQL); + + // Verify the fetch was called with correct URL and method + expect(fetchMock).toHaveBeenCalledTimes(1); + const fetchCall = fetchMock.mock.calls[0]; + const [url, options] = fetchCall; + + expect(url).toContain("/mcp/validate_graphql"); + expect(url).toContain("api=admin"); + expect(options.method).toBe("POST"); + expect(options.body).toBe(JSON.stringify({ code: validGraphQL })); + expect(options.headers["Content-Type"]).toBe("application/json"); + + // Check the response + 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(sampleValidGraphQLResponse); + }); + + test("reports errors for invalid GraphQL code", async () => { + // Mock an invalid GraphQL response + 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(sampleInvalidGraphQLResponse), + }); + + const invalidGraphQL = ` + query GetProduct { + product(id: "gid://shopify/Product/123") { + invalid + title + someType { + field: NonExistentType + } + } + } + `; + + // Call the function + const result = await validateShopifyGraphQL("admin", invalidGraphQL); + + // Verify the fetch was called correctly + expect(fetchMock).toHaveBeenCalledTimes(1); + const fetchUrl = fetchMock.mock.calls[0][0]; + expect(fetchUrl).toContain("/mcp/validate_graphql"); + expect(fetchUrl).toContain("api=admin"); + + // Check the response + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + 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("Line 3, Column 5"); + expect(result.formattedText).toContain("Unknown type 'NonExistentType'"); + expect(result.formattedText).toContain("Line 7, Column 10"); + expect(result.rawResponse).toEqual(sampleInvalidGraphQLResponse); + }); + + test("handles HTTP error", async () => { + // Mock an error response + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + headers: { + forEach: (callback: (value: string, key: string) => void) => { + callback("text/plain", "content-type"); + }, + }, + }); + + // Call the function + const result = await validateShopifyGraphQL("admin", "query { something }"); + + // Check that the error was handled + expect(result.success).toBe(false); + expect(result.isValid).toBe(false); + expect(result.formattedText).toContain("Error validating GraphQL"); + expect(result.formattedText).toContain("500"); + }); + + test("handles fetch error", async () => { + // Mock a network error + fetchMock.mockRejectedValue(new Error("Network error")); + + // Call the function + const result = await validateShopifyGraphQL("admin", "query { products { edges { node { id } } } }"); + + // Check that the error was handled + expect(result.success).toBe(false); + expect(result.isValid).toBe(false); + expect(result.formattedText).toContain("Error validating GraphQL: Network error"); + }); + + test("handles non-JSON response gracefully", async () => { + // Mock a non-JSON response + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { + forEach: (callback: (value: string, key: string) => void) => { + callback("text/plain", "content-type"); + }, + }, + text: async () => "This is not valid JSON", + }); + + // Clear the mocks before the test + vi.mocked(console.warn).mockClear(); + + // Call the function + const result = await validateShopifyGraphQL("admin", "query { something }"); + + // Check that the error was handled and raw text is returned + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.formattedText).toContain("Unable to parse validation response"); + expect(result.formattedText).toContain("This is not valid JSON"); + + // Verify that console.warn was called with the JSON parsing error + expect(console.warn).toHaveBeenCalledTimes(1); + expect(vi.mocked(console.warn).mock.calls[0][0]).toContain( + "Error parsing JSON response", + ); + }); +}); + +describe("fetchGettingStartedApis", () => { + let fetchMock: any; + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock = global.fetch as any; + + // Mock successful response by default + 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), + }); + }); + + test("fetches and validates API information successfully", async () => { + // Since this function is not directly exposed, we need to test it indirectly + // We'll check that fetch was called with the right URL when shopifyTools is executed + // This is a bit of a placeholder test since we can't easily test the unexported function + + // Import the function we want to test - note that this is a circular import + // that would normally be avoided, but it's okay for testing purposes + const { shopifyTools } = await import("./index.js"); + + // Create a mock server + const mockServer = { + tool: vi.fn(), + }; + + // Call the function + await shopifyTools(mockServer as any); + + // Verify fetch was called to get the APIs + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/mcp/getting_started_apis"), + expect.any(Object) + ); + }); +}); + +describe("get_started tool behavior", () => { + let fetchMock: any; + let mockServer: any; + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock = global.fetch as any; + + // First response is for API list + // Second response is for getting started guide + fetchMock.mockImplementation((url: string) => { + if (url.includes("/mcp/getting_started_apis")) { + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + headers: { + forEach: (callback: (value: string, key: string) => void) => { + callback("application/json", "content-type"); + }, + }, + text: async () => JSON.stringify(sampleGettingStartedApisResponse), + }); + } else if (url.includes("/mcp/getting_started")) { + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + headers: { + forEach: (callback: (value: string, key: string) => void) => { + callback("text/plain", "content-type"); + }, + }, + text: async () => sampleGettingStartedGuide, + }); + } + return Promise.reject(new Error("Unexpected URL")); + }); + + // Create a mock server that captures the registered tools + mockServer = { + tool: vi.fn((name, description, schema, handler) => { + // Store the handler for testing + if (name === "get_started") { + mockServer.getStartedHandler = handler; + } + }), + getStartedHandler: null, + }; + }); + + test("fetches getting started guide successfully", async () => { + // Import the function and register the tools + const { shopifyTools } = await import("./index.js"); + await shopifyTools(mockServer); + + // Ensure the handler was registered + expect(mockServer.getStartedHandler).not.toBeNull(); + + // Now we can test the handler directly + const result = await mockServer.getStartedHandler({ api: "admin" }); + + // Check that the fetch was called with the correct URL + const getStartedCalls = fetchMock.mock.calls.filter((call: [string, any]) => + call[0].includes("/mcp/getting_started?api=admin") + ); + expect(getStartedCalls.length).toBe(1); + + // Verify the response content + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe(sampleGettingStartedGuide); + }); + + test("handles HTTP error when fetching guide", async () => { + // Set up a failure for the getting started endpoint + fetchMock.mockImplementation((url: string) => { + if (url.includes("/mcp/getting_started_apis")) { + return Promise.resolve({ + ok: true, + status: 200, + headers: { + forEach: (callback: (value: string, key: string) => void) => { + callback("application/json", "content-type"); + }, + }, + text: async () => JSON.stringify(sampleGettingStartedApisResponse), + }); + } else if (url.includes("/mcp/getting_started")) { + return Promise.resolve({ + ok: false, + status: 500, + statusText: "Internal Server Error", + headers: { + forEach: () => {}, + }, + }); + } + return Promise.reject(new Error("Unexpected URL")); + }); + + // Register the tools + const { shopifyTools } = await import("./index.js"); + await shopifyTools(mockServer); + + // Test the handler + const result = await mockServer.getStartedHandler({ api: "admin" }); + + // Verify error handling + expect(result.content[0].text).toContain("Error fetching getting started information"); + expect(result.content[0].text).toContain("500"); + }); + + test("handles network error", async () => { + // Set up a network failure + fetchMock.mockImplementation((url: string) => { + if (url.includes("/mcp/getting_started_apis")) { + return Promise.resolve({ + ok: true, + status: 200, + headers: { + forEach: (callback: (value: string, key: string) => void) => { + callback("application/json", "content-type"); + }, + }, + text: async () => JSON.stringify(sampleGettingStartedApisResponse), + }); + } else if (url.includes("/mcp/getting_started")) { + return Promise.reject(new Error("Network failure")); + } + return Promise.reject(new Error("Unexpected URL")); + }); + + // Register the tools + const { shopifyTools } = await import("./index.js"); + await shopifyTools(mockServer); + + // Test the handler + const result = await mockServer.getStartedHandler({ api: "admin" }); + + // Verify error handling + expect(result.content[0].text).toContain("Error fetching getting started information"); + expect(result.content[0].text).toContain("Network failure"); + }); +}); + +describe("validate_graphql tool behavior", () => { + let fetchMock: any; + let mockServer: any; + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock = global.fetch as any; + + // Mock the fetch implementations for APIs list and validate_graphql endpoint + fetchMock.mockImplementation((url: string, options: any) => { + if (url.includes("/mcp/getting_started_apis")) { + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + headers: { + forEach: (callback: (value: string, key: string) => void) => { + callback("application/json", "content-type"); + }, + }, + text: async () => JSON.stringify(sampleGettingStartedApisResponse), + }); + } else if (url.includes("/mcp/validate_graphql")) { + // Check if the GraphQL code contains errors to determine mock response + const hasErrors = options.body.includes("invalid") || options.body.includes("NonExistentType"); + return Promise.resolve({ + ok: true, + status: 200, + statusText: "OK", + headers: { + forEach: (callback: (value: string, key: string) => void) => { + callback("application/json", "content-type"); + }, + }, + text: async () => JSON.stringify( + hasErrors ? sampleInvalidGraphQLResponse : sampleValidGraphQLResponse + ), + }); + } + return Promise.reject(new Error("Unexpected URL")); + }); + + // Create a mock server that captures the registered tools + mockServer = { + tool: vi.fn((name, description, schema, handler) => { + // Store the handler for testing + if (name === "validate_graphql") { + mockServer.validateGraphQLHandler = handler; + } + }), + validateGraphQLHandler: null, + }; + }); + + test("registers validate_graphql tool correctly", async () => { + // Import the function and register the tools + const { shopifyTools } = await import("./index.js"); + await shopifyTools(mockServer); + + // Verify the tool was registered with correct parameters + expect(mockServer.tool).toHaveBeenCalledWith( + "validate_graphql", + expect.stringContaining("validates GraphQL code"), + expect.objectContaining({ + api: expect.any(Object), + code: expect.any(Object), + }), + expect.any(Function) + ); + + // Ensure the handler was registered + expect(mockServer.validateGraphQLHandler).not.toBeNull(); + }); + + test("validates valid GraphQL code successfully via tool", async () => { + // Register the tools + const { shopifyTools } = await import("./index.js"); + await shopifyTools(mockServer); + + // Valid GraphQL code + const validGraphQL = ` + query GetProduct { + product(id: "gid://shopify/Product/123") { + title + description + } + } + `; + + // Test the handler + const result = await mockServer.validateGraphQLHandler({ + api: "admin", + code: validGraphQL, + }); + + // Check that the fetch was called with the correct URL and data + const validateCalls = fetchMock.mock.calls.filter((call: [string, any]) => + call[0].includes("/mcp/validate_graphql?api=admin") + ); + expect(validateCalls.length).toBe(1); + expect(JSON.parse(validateCalls[0][1].body)).toEqual({ code: validGraphQL }); + expect(validateCalls[0][1].headers["Content-Type"]).toBe("application/json"); + + // Verify the response content + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("GraphQL Validation Results"); + expect(result.content[0].text).toContain("**Valid:** ✅ Yes"); + }); + + test("reports validation errors in invalid GraphQL code", async () => { + // Register the tools + const { shopifyTools } = await import("./index.js"); + await shopifyTools(mockServer); + + // Invalid GraphQL code with errors + const invalidGraphQL = ` + query GetProduct { + product(id: "gid://shopify/Product/123") { + invalid + title + someType { + field: NonExistentType + } + } + } + `; + + // Test the handler + const result = await mockServer.validateGraphQLHandler({ + api: "admin", + code: invalidGraphQL, + }); + + // Check that the fetch was called with the correct URL and data + const validateCalls = fetchMock.mock.calls.filter((call: [string, any]) => + call[0].includes("/mcp/validate_graphql?api=admin") + ); + expect(validateCalls.length).toBe(1); + expect(JSON.parse(validateCalls[0][1].body)).toEqual({ code: invalidGraphQL }); + + // Verify the response content shows errors + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("**Valid:** ❌ No"); + expect(result.content[0].text).toContain("**Errors:**"); + expect(result.content[0].text).toContain("Cannot query field 'invalid' on type 'Product'"); + expect(result.content[0].text).toContain("Unknown type 'NonExistentType'"); + }); +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 51faee7..b032411 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,6 +6,13 @@ const SHOPIFY_BASE_URL = process.env.DEV ? "https://shopify-dev.myshopify.io/" : "https://shopify.dev/"; +const GettingStartedAPISchema = z.object({ + name: z.string(), + description: z.string(), +}); + +type GettingStartedAPI = z.infer; + /** * Searches Shopify documentation with the given query * @param prompt The search query for Shopify documentation @@ -88,7 +95,110 @@ export async function searchShopifyDocs(prompt: string) { } } -export function shopifyTools(server: McpServer) { +/** + * Validates GraphQL code against a specified Shopify API surface + * @param api The Shopify API surface to validate against (admin) + * @param code The GraphQL code to validate + * @returns Validation results including whether the code is valid and any errors + */ +export async function validateShopifyGraphQL(api: string, code: string) { + try { + // Prepare the URL with query parameters + const url = new URL("/mcp/validate_graphql", SHOPIFY_BASE_URL); + url.searchParams.append("api", api); + url.searchParams.append("code", code); + + console.error(`[validate-graphql] Making GET request to: ${url.toString()}`); + + // Make the GET request with the GraphQL code as query parameter + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Cache-Control": "no-cache", + "X-Shopify-Surface": "mcp", + }, + }); + + console.error( + `[validate-graphql] Response status: ${response.status} ${response.statusText}`, + ); + + // Convert headers to object for logging + const headersObj: Record = {}; + response.headers.forEach((value, key) => { + headersObj[key] = value; + }); + console.error( + `[validate-graphql] Response headers: ${JSON.stringify(headersObj)}`, + ); + + if (!response.ok) { + console.error(`[validate-graphql] HTTP error status: ${response.status}`); + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Read and process the response + const responseText = await response.text(); + console.error( + `[validate-graphql] Response text (truncated): ${ + responseText.substring(0, 200) + + (responseText.length > 200 ? "..." : "") + }`, + ); + + // Parse and format the JSON for human readability + try { + const jsonData = JSON.parse(responseText); + const isValid = jsonData.is_valid === true; + + let formattedResponse = `## GraphQL Validation Results\n\n`; + formattedResponse += `**Valid:** ${isValid ? "✅ Yes" : "❌ No"}\n\n`; + + if (!isValid && jsonData.errors && jsonData.errors.length > 0) { + formattedResponse += `**Errors:**\n\n`; + jsonData.errors.forEach((error: any, index: number) => { + formattedResponse += `${index + 1}. ${error.message}`; + if (error.locations && error.locations.length > 0) { + const location = error.locations[0]; + formattedResponse += ` (Line ${location.line}, Column ${location.column})`; + } + formattedResponse += `\n`; + }); + } + + return { + success: true, + isValid, + formattedText: formattedResponse, + rawResponse: jsonData, + }; + } catch (e) { + console.warn(`[validate-graphql] Error parsing JSON response: ${e}`); + // If parsing fails, return the raw text + return { + success: true, + isValid: false, + formattedText: `Unable to parse validation response. Raw response: ${responseText}`, + rawResponse: responseText, + }; + } + } catch (error) { + 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", `This tool introspects and returns the portion of the Shopify Admin API GraphQL schema relevant to the user prompt. Only use this for the Shopify Admin API, and not any other APIs like the Shopify Storefront API or the Shopify Functions API. @@ -108,28 +218,49 @@ export function shopifyTools(server: McpServer) { "Filter results to show specific sections. Can include 'types', 'queries', 'mutations', or 'all' (default)", ), }, - async ({ query, filter }, extra) => { + async ({ query, filter }) => { const result = await searchShopifyAdminSchema(query, { filter }); - if (result.success) { - return { - content: [ - { - type: "text" as const, - text: result.responseText, - }, - ], - }; - } else { - return { - content: [ - { - type: "text" as const, - text: `Error processing Shopify Admin GraphQL schema: ${result.error}. Make sure the schema file exists.`, - }, - ], - }; - } + return { + content: [ + { + type: "text" as const, + text: result.success + ? result.responseText + : `Error processing Shopify Admin GraphQL schema: ${result.error}. Make sure the schema file exists.` + }, + ], + }; + }, + ); + + server.tool( + "validate_graphql", + `This tool validates GraphQL code against a specified Shopify API surface and returns validation results including any errors. + ALWAYS MAKE SURE THAT THE GRAPHQL CODE YOU GENERATE IS VALID WITH THIS TOOL. + + It takes two arguments: + - api: The Shopify API surface to validate against ('admin') + - code: The GraphQL code to validate`, + { + api: z + .enum(["admin"]) + .describe("The Shopify API surface to validate against ('admin')"), + code: z + .string() + .describe("The GraphQL code to validate"), + }, + async ({ api, code }) => { + const result = await validateShopifyGraphQL(api, code); + + return { + content: [ + { + type: "text" as const, + text: result.formattedText, + }, + ], + }; }, ); @@ -141,7 +272,7 @@ export function shopifyTools(server: McpServer) { { prompt: z.string().describe("The search query for Shopify documentation"), }, - async ({ prompt }, extra) => { + async ({ prompt }) => { const result = await searchShopifyDocs(prompt); return { @@ -155,108 +286,175 @@ export function shopifyTools(server: McpServer) { }, ); - if (process.env.POLARIS_UNIFIED) { - server.tool( - "read_polaris_surface_docs", - `Use this tool to retrieve a list of documents from shopify.dev. - - Args: - paths: The paths to the documents to read, in a comma separated list. - Paths should be relative to the root of the developer documentation site.`, - { - paths: z - .array(z.string()) - .describe("The paths to the documents to read"), - }, - async ({ paths }) => { - async function fetchDocText(path: string): Promise<{ - text: string; - path: string; - success: boolean; - }> { - try { - const appendedPath = path.endsWith(".txt") ? path : `${path}.txt`; - const url = new URL(appendedPath, SHOPIFY_BASE_URL); - const response = await fetch(url.toString()); - const text = await response.text(); - return { text: `## ${path}\n\n${text}\n\n`, path, success: true }; - } catch (error) { - return { - text: `Error fetching document at ${path}: ${error}`, - path, - success: false, - }; - } - } + server.tool( + "fetch_docs_by_path", + `Use this tool to retrieve a list of documents from shopify.dev. - const fetchDocs = paths.map((path) => fetchDocText(path)); - const results = await Promise.all(fetchDocs); + Args: + paths: The paths to the documents to read, i.e. ["/docs/api/app-home", "/docs/api/functions"]. + Paths should be relative to the root of the developer documentation site.`, + { + paths: z + .array(z.string()) + .describe("The paths to the documents to read"), + }, + async ({ paths }) => { + type DocResult = { + text: string; + path: string; + success: boolean; + }; - return { - content: [ - { - type: "text" as const, - text: results.map(({ text }) => text).join("---\n\n"), - }, - ], - }; - }, - ); + async function fetchDocText(path: string): Promise { + try { + const appendedPath = path.endsWith(".txt") ? path : `${path}.txt`; + const url = new URL(appendedPath, SHOPIFY_BASE_URL); + const response = await fetch(url.toString()); - const surfaces = [ - "app-home", - "admin-extensions", - "checkout-extensions", - "customer-account-extensions", - ] as const; - server.tool( - "get_started", - ` - 1. Ask user for the surface they are building for. - 2. Use read_polaris_surface_docs tool to read the docs for the surface. - - Whenever the user asks about Polaris web components, always use this tool first to provide the most accurate and up-to-date documentation. - - valid arguments for this tool are: - - "app-home" - - "admin-extensions" - - "checkout-extensions" - - "customer-account-extensions" - - Once you determine the surface, you should then use the read_polaris_surface_docs tool to learn about more specific details. Overviews are not comprehensive, so this is important. - - DON'T SEARCH THE WEB WHEN REFERENCING INFORMATION FROM THIS DOCUMENTATION. IT WILL NOT BE ACCURATE. ONLY USE THE read_polaris_surface_docs TOOLS TO RETRIEVE INFORMATION FROM THE DEVELOPER DOCUMENTATION SITE. - `, - { - surface: z - .enum(surfaces) - .describe("The Shopify surface you are building for"), - }, - async function cb({ surface }) { - if (!surfaces.includes(surface)) { - const options = surfaces.map((s) => `- ${s}`).join("\n"); - const text = `Please specify which Shopify surface you are building for. Valid options are: ${options}.`; + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const text = await response.text(); + return { text: `## ${path}\n\n${text}\n\n`, path, success: true }; + } catch (error) { + console.error(`Error fetching document at ${path}: ${error}`); return { - content: [{ type: "text" as const, text }], + text: `Error fetching document at ${path}: ${error instanceof Error ? error.message : String(error)}`, + path, + success: false, }; } + } + + const results = await Promise.all(paths.map(fetchDocText)); + + return { + content: [ + { + type: "text" as const, + text: results.map(({ text }) => text).join("---\n\n"), + }, + ], + }; + }, + ); - const docEntrypointsBySurface: Record = { - "app-home": "/docs/api/app-home/using-polaris-components", - "admin-extensions": "/docs/api/admin-extensions", - "checkout-extensions": "/docs/api/checkout-ui-extensions", - "customer-account-extensions": - "/docs/api/customer-account-ui-extensions", + const gettingStartedApis = await fetchGettingStartedApis(); + const filteredApis = !process.env.POLARIS_UNIFIED + ? gettingStartedApis.filter((api) => api.name !== "app-ui") + : gettingStartedApis; + + const gettingStartedApiNames = filteredApis.map((api) => api.name); + + server.tool( + "get_started", + ` + Use this tool first whenever you're interacting with any of these Shopify APIs. + + Valid arguments for \`api\` are: +${filteredApis.map((api) => ` - ${api.name}: ${api.description}`).join('\n')} + + 1. Look at the getting started guide for the selected API. + 2. Use the fetch_docs_by_path tool to read additional docs for the API. + + DON'T SEARCH THE WEB WHEN REFERENCING INFORMATION FROM THIS DOCUMENTATION. IT WILL NOT BE ACCURATE. + ONLY USE THE fetch_docs_by_path TOOL TO RETRIEVE INFORMATION FROM THE DEVELOPER DOCUMENTATION SITE. + `, + { + api: z + .enum(gettingStartedApiNames as [string, ...string[]]) + .describe("The Shopify API you are building for"), + }, + async ({ api }) => { + if (!gettingStartedApiNames.includes(api)) { + const options = gettingStartedApiNames.map(s => `- ${s}`).join("\n"); + const text = `Please specify which Shopify API you are building for. Valid options are: ${options}.`; + + return { + content: [{ type: "text" as const, text }], }; + } - const docPath = docEntrypointsBySurface[surface]; - const text = await fetchDocText(docPath); + try { + const response = await fetch( + `${SHOPIFY_BASE_URL}/mcp/getting_started?api=${api}`, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const text = await response.text(); return { content: [{ type: "text" as const, text }], }; + } catch (error) { + console.error(`Error fetching getting started information for ${api}: ${error}`); + return { + content: [{ + type: "text" as const, + text: `Error fetching getting started information for ${api}: ${error instanceof Error ? error.message : String(error)}` + }], + }; + } + }, + ); +} + +/** + * Fetches and validates information about available APIs from the getting_started_apis endpoint + * @returns An array of validated API information objects with name and description properties, or an empty array on error + */ +async function fetchGettingStartedApis(): Promise { + try { + const url = new URL("/mcp/getting_started_apis", SHOPIFY_BASE_URL); + + console.error(`[api-information] Making GET request to: ${url.toString()}`); + + // Make the GET request + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Accept: "application/json", + "Cache-Control": "no-cache", + "X-Shopify-Surface": "mcp", }, + }); + + console.error( + `[api-information] Response status: ${response.status} ${response.statusText}`, + ); + + if (!response.ok) { + console.error(`[api-information] HTTP error status: ${response.status}`); + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Read and process the response + const responseText = await response.text(); + console.error( + `[api-information] Response text (truncated): ${ + responseText.substring(0, 200) + + (responseText.length > 200 ? "..." : "") + }`, ); + + try { + const jsonData = JSON.parse(responseText); + // Parse and validate with Zod schema + const validatedData = z.array(GettingStartedAPISchema).parse(jsonData); + return validatedData; + } catch (e) { + console.warn(`[api-information] Error parsing JSON response: ${e}`); + return []; + } + } catch (error) { + console.error( + `[api-information] Error fetching API information: ${error}`, + ); + return []; } } + diff --git a/src/tools/shopify.test.ts b/src/tools/shopify.test.ts deleted file mode 100644 index aeda3f2..0000000 --- a/src/tools/shopify.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Import vitest first -import { describe, test, expect, beforeEach, vi, afterAll } from "vitest"; - -// Mock fetch globally -global.fetch = vi.fn(); - -// Now import the modules to test -import { searchShopifyDocs } from "./index.js"; - -// Mock console.error and console.warn -const originalConsoleError = console.error; -const originalConsoleWarn = console.warn; -console.error = vi.fn(); -console.warn = vi.fn(); - -// Clean up after tests -afterAll(() => { - console.error = originalConsoleError; - console.warn = originalConsoleWarn; -}); - -// Sample response data for mocking -const sampleDocsResponse = [ - { - filename: "api/admin/graphql/reference/products.md", - score: 0.85, - content: - "The products API allows you to manage products in your Shopify store.", - }, - { - filename: "apps/tools/product-listings.md", - score: 0.72, - content: - "Learn how to display and manage product listings in your Shopify app.", - }, -]; - -describe("searchShopifyDocs", () => { - let fetchMock: any; - - beforeEach(() => { - vi.clearAllMocks(); - - // Setup the mock for fetch - fetchMock = global.fetch as any; - - // By default, mock successful response - 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(sampleDocsResponse), - }); - }); - - test("returns formatted JSON response correctly", async () => { - // Call the function directly with test parameters - const result = await searchShopifyDocs("product listings"); - - // Verify the fetch was called with correct URL - expect(fetchMock).toHaveBeenCalledTimes(1); - const fetchUrl = fetchMock.mock.calls[0][0]; - expect(fetchUrl).toContain("/mcp/search"); - expect(fetchUrl).toContain("query=product+listings"); - - // Check that the response is formatted JSON - expect(result.success).toBe(true); - - // The response should be properly formatted with indentation - expect(result.formattedText).toContain("{\n"); - expect(result.formattedText).toContain(' "filename":'); - - // Parse the response and verify it matches our sample data - const parsedResponse = JSON.parse(result.formattedText); - expect(parsedResponse).toEqual(sampleDocsResponse); - expect(parsedResponse[0].filename).toBe( - "api/admin/graphql/reference/products.md", - ); - expect(parsedResponse[0].score).toBe(0.85); - }); - - test("handles HTTP error", async () => { - // Mock an error response - fetchMock.mockResolvedValue({ - ok: false, - status: 500, - statusText: "Internal Server Error", - headers: { - forEach: (callback: (value: string, key: string) => void) => { - callback("text/plain", "content-type"); - }, - }, - }); - - // Call the function directly - const result = await searchShopifyDocs("product"); - - // Check that the error was handled - expect(result.success).toBe(false); - expect(result.formattedText).toContain( - "Error searching Shopify documentation", - ); - expect(result.formattedText).toContain("500"); - }); - - test("handles fetch error", async () => { - // Mock a network error - fetchMock.mockRejectedValue(new Error("Network error")); - - // Call the function directly - const result = await searchShopifyDocs("product"); - - // Check that the error was handled - expect(result.success).toBe(false); - expect(result.formattedText).toContain( - "Error searching Shopify documentation: Network error", - ); - }); - - test("handles non-JSON response gracefully", async () => { - // Mock a non-JSON response - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: { - forEach: (callback: (value: string, key: string) => void) => { - callback("text/plain", "content-type"); - }, - }, - text: async () => "This is not valid JSON", - }); - - // Clear the mocks before the test - vi.mocked(console.warn).mockClear(); - - // Call the function directly - const result = await searchShopifyDocs("product"); - - // Check that the error was handled and raw text is returned - expect(result.success).toBe(true); - expect(result.formattedText).toBe("This is not valid JSON"); - - // Verify that console.warn was called with the JSON parsing error - expect(console.warn).toHaveBeenCalledTimes(1); - expect(vi.mocked(console.warn).mock.calls[0][0]).toContain( - "Error parsing JSON response", - ); - }); -});