diff --git a/package-lock.json b/package-lock.json index 3f6aebf..de625c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", + "execa": "^9.6.0", + "graphql": "^16.11.0", + "graphql-request": "^7.2.0", "zod": "^3.24.2" }, "bin": { @@ -1351,6 +1354,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3160,6 +3172,12 @@ "win32" ] }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3167,6 +3185,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -4217,7 +4247,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4563,6 +4592,32 @@ "node": ">=18.0.0" } }, + "node_modules/execa": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz", + "integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -4727,6 +4782,21 @@ "bser": "2.1.1" } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4952,6 +5022,22 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5037,6 +5123,27 @@ "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/graphql-request": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-7.2.0.tgz", + "integrity": "sha512-0GR7eQHBFYz372u9lxS16cOtEekFlZYB2qOyq8wDvzRmdRSJ0mgUVX1tzNcIzk3G+4NY+mGtSz411wZdeDF/+A==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5104,6 +5211,15 @@ "human-id": "dist/cli.js" } }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/hyperdyperid": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", @@ -5216,12 +5332,36 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-subdir": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", @@ -5235,6 +5375,18 @@ "node": ">=4" } }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -5249,7 +5401,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -5949,6 +6100,34 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6087,6 +6266,18 @@ "quansync": "^0.2.7" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6127,7 +6318,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6315,6 +6505,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", + "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -6880,7 +7085,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6893,7 +7097,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6995,7 +7198,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -7182,6 +7384,18 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -7484,6 +7698,18 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -7851,7 +8077,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8022,6 +8247,18 @@ "node": ">=6" } }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.24.2", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", diff --git a/package.json b/package.json index 47b9f81..43ee801 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "description": "A command line tool for setting up Shopify Dev MCP server", "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", + "execa": "^9.6.0", + "graphql": "^16.11.0", + "graphql-request": "^7.2.0", "zod": "^3.24.2" }, "devDependencies": { diff --git a/src/tools/index.ts b/src/tools/index.ts index 4bcb896..3383f46 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,10 +1,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { searchShopifyAdminSchema } from "./shopify-admin-schema.js"; +import { + searchShopifyAdminSchema, + validateShopifyAdminGraphQLQuery, +} from "./shopify-admin-schema.js"; import { instrumentationData, isInstrumentationDisabled, } from "../instrumentation.js"; +import { addCliTools } from "./shopify-cli.js"; const SHOPIFY_BASE_URL = process.env.DEV ? "https://shopify-dev.myshopify.io/" @@ -176,6 +180,37 @@ export async function shopifyTools(server: McpServer): Promise { }, ); + server.tool( + "validate_admin_graphql", + "This tool validates a GraphQL query against the Shopify Admin API schema, ensuring its total accuracy. Use this to ensure ALL graphql Admin API queries you construct are valid prior to running them.", + { + query: z.string().describe("The GraphQL query to validate"), + }, + async ({ query }) => { + const result = await validateShopifyAdminGraphQLQuery(query); + + if (result.success) { + return { + content: [ + { + type: "text" as const, + text: result.responseText, + }, + ], + }; + } + + return { + content: [ + { + type: "text" as const, + text: result.responseText, + }, + ], + }; + }, + ); + 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. @@ -317,6 +352,8 @@ ${gettingStartedApis.map((api) => ` - ${api.name}: ${api.description}`).join( } }, ); + + addCliTools(server); } /** diff --git a/src/tools/shopify-admin-schema.ts b/src/tools/shopify-admin-schema.ts index fd1e02b..2b0e7ea 100644 --- a/src/tools/shopify-admin-schema.ts +++ b/src/tools/shopify-admin-schema.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import zlib from "node:zlib"; +import { buildSchema, GraphQLError, parse, validate } from "graphql"; // Path to the schema file in the data folder export const SCHEMA_FILE_PATH = fileURLToPath( @@ -349,3 +350,310 @@ export async function searchShopifyAdminSchema( }; } } + +// Function to load and parse GraphQL schema from JSON to GraphQLSchema object +async function loadGraphQLSchema() { + try { + const schemaContent = await loadSchemaContent(SCHEMA_FILE_PATH); + const schemaJson = JSON.parse(schemaContent); + + // Extract SDL (Schema Definition Language) from the IntrospectionQuery result + if (schemaJson.data && schemaJson.data.__schema) { + console.error( + `[shopify-admin-schema-tool] Query type name from schema: ${schemaJson.data.__schema.queryType?.name}`, + ); + + // Convert introspection result to SDL + const sdl = introspectionToSDL(schemaJson.data); + + try { + console.error(`[shopify-admin-schema-tool] Building schema from SDL`); + const schema = buildSchema(sdl); + console.error(`[shopify-admin-schema-tool] Schema built successfully`); + return schema; + } catch (buildError) { + console.error( + `[shopify-admin-schema-tool] Error building schema: ${buildError}`, + ); + // Log the first 500 characters of the SDL for debugging + console.error( + `[shopify-admin-schema-tool] SDL snippet: ${sdl.substring(0, 500)}...`, + ); + throw buildError; + } + } + + throw new Error("Invalid schema format: missing __schema field"); + } catch (error) { + console.error( + `[shopify-admin-schema-tool] Error loading GraphQL schema: ${error}`, + ); + throw error; + } +} + +// Helper function to convert introspection result to SDL +function introspectionToSDL(introspectionData: any): string { + // This is a simplified version - in a real implementation, you would need a more + // comprehensive approach to convert introspection data to SDL + + let sdl = ""; + + // First, extract and declare all scalar types + const customScalars = introspectionData.__schema.types + .filter( + (type: any) => type.kind === "SCALAR" && !isBuiltInScalar(type.name), + ) + .map((type: any) => type.name); + + // Add scalar declarations + for (const scalar of customScalars) { + sdl += `scalar ${scalar}\n\n`; + } + + // Explicitly define root types - these are known for Shopify Admin API + const queryTypeName = "QueryRoot"; + const mutationTypeName = "Mutation"; + + // Define the schema with root types + sdl += `schema {\n`; + sdl += ` query: ${queryTypeName}\n`; + sdl += ` mutation: ${mutationTypeName}\n`; + sdl += `}\n\n`; + + // Process types + if (introspectionData.__schema.types) { + // First ensure query and mutation types are present and have at least one field + let hasQueryType = false; + let hasMutationType = false; + + for (const type of introspectionData.__schema.types) { + if (type.name === queryTypeName) hasQueryType = true; + if (type.name === mutationTypeName) hasMutationType = true; + } + + // If query type is not found, add a placeholder + if (!hasQueryType) { + sdl += `type ${queryTypeName} {\n _placeholder: String\n}\n\n`; + } + + // If mutation type is not found, add a placeholder + if (!hasMutationType) { + sdl += `type ${mutationTypeName} {\n _placeholder: String\n}\n\n`; + } + + // Process all other types + for (const type of introspectionData.__schema.types) { + // Skip built-in types and scalars (already processed) + if (type.name.startsWith("__") || type.kind === "SCALAR") continue; + + // Add type definition based on kind + switch (type.kind) { + case "OBJECT": + sdl += `type ${type.name} {\n`; + if (type.fields && type.fields.length > 0) { + for (const field of type.fields) { + sdl += ` ${field.name}`; + + // Add arguments if any + if (field.args && field.args.length > 0) { + sdl += "("; + sdl += field.args + .map((arg: any) => `${arg.name}: ${formatType(arg.type)}`) + .join(", "); + sdl += ")"; + } + + sdl += `: ${formatType(field.type)}\n`; + } + } else { + // Add placeholder field if no fields + sdl += ` _placeholder: String\n`; + } + sdl += "}\n\n"; + break; + + case "INPUT_OBJECT": + sdl += `input ${type.name} {\n`; + if (type.inputFields && type.inputFields.length > 0) { + for (const field of type.inputFields) { + sdl += ` ${field.name}: ${formatType(field.type)}\n`; + } + } else { + // Add placeholder field if no fields + sdl += ` _placeholder: String\n`; + } + sdl += "}\n\n"; + break; + + case "ENUM": + sdl += `enum ${type.name} {\n`; + if (type.enumValues && type.enumValues.length > 0) { + for (const value of type.enumValues) { + sdl += ` ${value.name}\n`; + } + } else { + // Add placeholder value if no enum values + sdl += ` PLACEHOLDER\n`; + } + sdl += "}\n\n"; + break; + + case "INTERFACE": + sdl += `interface ${type.name} {\n`; + if (type.fields && type.fields.length > 0) { + for (const field of type.fields) { + sdl += ` ${field.name}`; + + // Add arguments if any + if (field.args && field.args.length > 0) { + sdl += "("; + sdl += field.args + .map((arg: any) => `${arg.name}: ${formatType(arg.type)}`) + .join(", "); + sdl += ")"; + } + + sdl += `: ${formatType(field.type)}\n`; + } + } else { + // Add placeholder field if no fields + sdl += ` _placeholder: String\n`; + } + sdl += "}\n\n"; + break; + + case "UNION": + if (type.possibleTypes && type.possibleTypes.length > 0) { + sdl += `union ${type.name} = ${type.possibleTypes.map((t: any) => t.name).join(" | ")}\n\n`; + } else { + // Skip unions with no possible types as they're invalid + console.error( + `[shopify-admin-schema-tool] Skipping union type ${type.name} with no possible types`, + ); + } + break; + } + } + } + + console.error( + `[shopify-admin-schema-tool] Generated SDL with query root type: ${queryTypeName}`, + ); + return sdl; +} + +// Check if a scalar is a built-in GraphQL scalar type +function isBuiltInScalar(name: string): boolean { + return ["String", "Int", "Float", "Boolean", "ID"].includes(name); +} + +/** + * Validates a GraphQL query against the Shopify Admin API schema + * @param query The GraphQL query to validate + * @returns The validation result or error message + */ +export async function validateShopifyAdminGraphQLQuery(query: string) { + try { + console.error(`[shopify-admin-validate] Validating GraphQL query`); + console.error( + `[shopify-admin-validate] Query (truncated): ${query.substring(0, 100)}${query.length > 100 ? "..." : ""}`, + ); + + const result = await validateShopifyAdminQuery(query); + + if (result.success) { + return { + success: true, + responseText: result.validationMessage || "Query is valid.", + }; + } else { + return { + success: false, + responseText: result.validationMessage || "Query validation failed.", + errors: result.errors, + }; + } + } catch (error) { + console.error( + `[shopify-admin-validate] Error validating GraphQL query: ${error}`, + ); + + return { + success: false, + responseText: `Error validating Shopify Admin GraphQL query: ${ + error instanceof Error ? error.message : String(error) + }`, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +// Function to validate a GraphQL query against the Shopify Admin schema +async function validateShopifyAdminQuery(queryString: string): Promise<{ + success: boolean; + errors?: readonly GraphQLError[]; + validationMessage?: string; +}> { + try { + console.error( + `[shopify-admin-schema-tool] Validating GraphQL query against schema`, + ); + + // Parse the query string + let queryDocument; + try { + queryDocument = parse(queryString); + } catch (parseError) { + return { + success: false, + errors: [parseError as GraphQLError], + validationMessage: `Query parsing error: ${(parseError as Error).message}`, + }; + } + + // Get the schema - we ignore apiVersion for now since we only have one schema + const schemaContent = await loadSchemaContent(SCHEMA_FILE_PATH); + const schemaJson = JSON.parse(schemaContent); + + try { + // Convert introspection schema to GraphQL schema + const schema = await loadGraphQLSchema(); + + // Validate the query against the schema + const validationErrors = validate(schema, queryDocument); + + const significantErrors = validationErrors; + + if (significantErrors.length === 0) { + // If there are no significant errors, consider it valid + return { + success: true, + validationMessage: + validationErrors.length === 0 + ? "Query is valid against the Shopify Admin API schema." + : "Query is likely valid. Some unknown types were detected but are being ignored.", + }; + } else { + return { + success: false, + errors: significantErrors, + validationMessage: `Validation errors:\n${significantErrors.map((e: GraphQLError) => `- ${e.message}`).join("\n")}`, + }; + } + } catch (validationError) { + return { + success: false, + validationMessage: `Schema validation error: ${(validationError as Error).message}`, + }; + } + } catch (error) { + console.error( + `[shopify-admin-schema-tool] Error validating GraphQL query: ${error}`, + ); + return { + success: false, + validationMessage: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} diff --git a/src/tools/shopify-cli.ts b/src/tools/shopify-cli.ts new file mode 100644 index 0000000..7f092f7 --- /dev/null +++ b/src/tools/shopify-cli.ts @@ -0,0 +1,83 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import path from "node:path"; +import fs from "node:fs/promises"; + +export function addCliTools(server: McpServer) { + server.tool( + "check_app_status", + "Using the running Shopify `app dev` command, check the status of the current app's work in progress, getting feedback on whether changes made are valid or not, any logs, and the breakdown of the Shopify extensions included in the app.", + { + projectPath: z + .string() + .describe("The absolute path to the Shopify app project to check"), + cursor: z + .string() + .optional() + .describe( + "Use this to filter outputs to only show changes made since last time. Leave it blank if this is the first time you're calling this tool.", + ), + }, + async ({ cursor, projectPath }) => { + async function getStatus(port: number) { + try { + const response = await fetch(`http://localhost:${port}/dev-status`, { + method: "GET", + }); + + const data = await response.json(); + + return data; + } catch (e) { + console.error(e); + return null; + } + } + + // first get the port. it'll be in the projectPath/.shopify/dev-control-port.lock; contents will be the port number + const portLockPath = path.join( + projectPath, + ".shopify/dev-control-port.lock", + ); + let port; + if (await fs.stat(portLockPath).catch(() => false)) { + const portLock = await fs.readFile(portLockPath, "utf8"); + port = Number.parseInt(portLock.trim()); + } else { + return { + content: [ + { + type: "text", + text: "No running Shopify CLI app found\nRun `shopify app dev --path ` first, in a standalone/background process", + }, + ], + }; + } + + // keep trying getstatus until it works, or until 4 seconds have passed + const startTime = Date.now(); + while (Date.now() - startTime < 4000) { + const status = await getStatus(port); + if (status) { + return { + content: [ + { + type: "text", + text: JSON.stringify(status, null, 2), + }, + ], + }; + } + } + + return { + content: [ + { + type: "text" as const, + text: "Unable to get app status", + }, + ], + }; + }, + ); +}