@gurveenbagga/moqui-graphql-dsl-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/dist/core/paths.js +11 -0
- package/dist/core/policy.js +37 -0
- package/dist/policies/shopify_admin_policy.json +14 -0
- package/dist/schemas/applyGraphqlDslServiceChange.js +27 -0
- package/dist/schemas/applyGraphqlDslTestChange.js +16 -0
- package/dist/schemas/generateGraphqlDsl.js +62 -0
- package/dist/schemas/generateGraphqlDslServiceCode.js +28 -0
- package/dist/schemas/generateGraphqlDslTestCode.js +20 -0
- package/dist/schemas/planGraphqlDslChange.js +41 -0
- package/dist/schemas/validateGraphqlDsl.js +29 -0
- package/dist/server.js +162 -0
- package/dist/tools/applyGraphqlDslServiceChange.js +103 -0
- package/dist/tools/applyGraphqlDslTestChange.js +63 -0
- package/dist/tools/generateGraphqlDsl.js +89 -0
- package/dist/tools/generateGraphqlDslServiceCode.js +77 -0
- package/dist/tools/generateGraphqlDslTestCode.js +96 -0
- package/dist/tools/planGraphqlDslChange.js +114 -0
- package/dist/tools/validateGraphqlDsl.js +141 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Moqui GraphQL DSL MCP Server
|
|
2
|
+
|
|
3
|
+
> [!NOTE]
|
|
4
|
+
> **Project Status**: This MCP server is currently intended for **local execution and testing**. A global distribution roadmap (e.g., via npm and `npx`) is yet to be planned. Refer to the Local Setup steps below to integrate it locally with your IDE config.
|
|
5
|
+
|
|
6
|
+
Internal MCP (Model Context Protocol) server for planning, generating, applying, and validating Moqui GraphQL DSL snippets for Shopify Admin GraphQL use cases.
|
|
7
|
+
|
|
8
|
+
This server bridges your AI tools (e.g. Claude Desktop, Cursor, IntelliJ, VS Code) with your local `moqui-framework` repository, enabling automated, robust, policy-compliant Shopify API connector development.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Features & Tools
|
|
13
|
+
|
|
14
|
+
### 📋 1. Planning & Service Generation
|
|
15
|
+
* **`plan_graphql_dsl_change`**: Translates your natural language intent into a structural plan, identifying the operation type (queries/mutations) and mapping any required policy safeguards.
|
|
16
|
+
* **`generate_graphql_dsl_service_code`**: Generates a standard Moqui `<service>` XML block with a fully operational GraphQL DSL script calling `GraphqlFacade`.
|
|
17
|
+
* **`apply_graphql_dsl_service_change`**: Writes the generated Moqui service cleanly to disk (supporting creation and seamless updates). Features a **soft-gate** validation warning system to guide external AI clients (like Codex) through validation checks.
|
|
18
|
+
|
|
19
|
+
### 🧪 2. Test-Driven Development (TDD)
|
|
20
|
+
* **`generate_graphql_dsl_test_code`**: Automatically generates Groovy Spock test blocks mocking standard contexts (`ec.service.sync`) and matching inputs/outputs.
|
|
21
|
+
* **`apply_graphql_dsl_test_change`**: injects the Spock test block directly inside the target Groovy test file.
|
|
22
|
+
|
|
23
|
+
### 🛡️ 3. Safeguards & Validation
|
|
24
|
+
* **`validate_graphql_dsl`**: Evaluates custom Moqui-Shopify standards, checking for:
|
|
25
|
+
* Proper naming conventions (`Dsl` noun suffixes).
|
|
26
|
+
* Safe pagination limits (both literal bounds and variables).
|
|
27
|
+
* Missing pagination parameters like `pageInfo` on connections.
|
|
28
|
+
* Correct mutation schemas and required `userErrors`/`id` parameters.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Local Setup
|
|
33
|
+
|
|
34
|
+
### Prerequisites
|
|
35
|
+
* **Node.js**: `v20` or higher
|
|
36
|
+
* **Package Manager**: `npm`
|
|
37
|
+
|
|
38
|
+
### 1. Install Dependencies
|
|
39
|
+
```bash
|
|
40
|
+
npm install
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2. Build the Server
|
|
44
|
+
Compiles the TypeScript codebase inside `src/` to standard ES Modules JavaScript in the `dist/` directory.
|
|
45
|
+
```bash
|
|
46
|
+
npm run build
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 3. Run Tests
|
|
50
|
+
Ensure all unit and smoke tests are passing:
|
|
51
|
+
```bash
|
|
52
|
+
npm run test
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 4. Start the MCP Server in Developer Mode
|
|
56
|
+
Run the server locally with live TypeScript compilation and hot-reloading (great for testing integrations):
|
|
57
|
+
```bash
|
|
58
|
+
npm run dev
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## IDE Integration Configuration
|
|
64
|
+
|
|
65
|
+
To integrate this server into your Claude Desktop, Cursor, or VS Code settings, add it to your configuration file (e.g., `claude_desktop_config.json`):
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"mcpServers": {
|
|
70
|
+
"moqui-graphql-dsl-mcp": {
|
|
71
|
+
"command": "node",
|
|
72
|
+
"args": [
|
|
73
|
+
"/Users/gurveenkaur/Documents/Work/git/moqui-graphql-dsl-mcp/dist/server.js"
|
|
74
|
+
],
|
|
75
|
+
"env": {
|
|
76
|
+
"MOQUI_REPO_PATH": "/Users/gurveenkaur/Documents/Work/git/oms/moqui-framework"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Make sure to run `npm run build` after any changes to `src/` so the compiled file in `dist/` is up-to-date!
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
export const DEFAULT_MOQUI_REPO_PATH = process.env.MOQUI_REPO_PATH
|
|
3
|
+
? resolve(process.env.MOQUI_REPO_PATH)
|
|
4
|
+
: "/Users/gurveenkaur/Documents/Work/git/oms/moqui-framework";
|
|
5
|
+
export function resolveTargetRepoPath(inputPath) {
|
|
6
|
+
return resolve((inputPath && inputPath.trim()) || DEFAULT_MOQUI_REPO_PATH);
|
|
7
|
+
}
|
|
8
|
+
export function isWithinRepo(repoPath, absolutePath) {
|
|
9
|
+
const normalizedRepo = repoPath.endsWith("/") ? repoPath : `${repoPath}/`;
|
|
10
|
+
return absolutePath === repoPath || absolutePath.startsWith(normalizedRepo);
|
|
11
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
let policy;
|
|
5
|
+
try {
|
|
6
|
+
const __dirname = resolve(fileURLToPath(import.meta.url), "..");
|
|
7
|
+
const policyFilePath = resolve(__dirname, "../../src/policies/shopify_admin_policy.json");
|
|
8
|
+
const rawData = readFileSync(policyFilePath, "utf8");
|
|
9
|
+
policy = JSON.parse(rawData);
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
// Graceful fallback defaults in case of file reading issues
|
|
13
|
+
policy = {
|
|
14
|
+
api: "admin",
|
|
15
|
+
naming: {
|
|
16
|
+
enforceNounSuffix: "GraphqlDsl",
|
|
17
|
+
enforceCamelCase: true
|
|
18
|
+
},
|
|
19
|
+
mutations: {
|
|
20
|
+
alwaysIncludeUserErrors: true,
|
|
21
|
+
alwaysIncludeIdOnCreate: true
|
|
22
|
+
},
|
|
23
|
+
connections: {
|
|
24
|
+
maxPageLimit: 250,
|
|
25
|
+
enforcePageInfo: true
|
|
26
|
+
},
|
|
27
|
+
defaults: {
|
|
28
|
+
enforceVariables: true
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function getPolicy() {
|
|
33
|
+
return policy;
|
|
34
|
+
}
|
|
35
|
+
export function defaults() {
|
|
36
|
+
return policy.defaults;
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const applyGraphqlDslServiceChangeInputSchema = z.object({
|
|
3
|
+
targetRepoPath: z.string().min(1).optional(),
|
|
4
|
+
targetFilePath: z.string().min(1),
|
|
5
|
+
serviceXml: z.string().min(1),
|
|
6
|
+
insertMode: z.enum(["create_service", "update_service", "auto"]).default("auto"),
|
|
7
|
+
serviceName: z.string().optional(),
|
|
8
|
+
insertAfterService: z.string().optional(),
|
|
9
|
+
shopifyValidated: z.boolean().default(false)
|
|
10
|
+
});
|
|
11
|
+
export const applyGraphqlDslServiceChangeOutputSchema = z.object({
|
|
12
|
+
status: z.enum(["ok", "error"]),
|
|
13
|
+
changedFiles: z.array(z.object({
|
|
14
|
+
path: z.string(),
|
|
15
|
+
changeType: z.enum(["created", "updated"]),
|
|
16
|
+
serviceName: z.string().optional()
|
|
17
|
+
})),
|
|
18
|
+
diffSummary: z.string().optional(),
|
|
19
|
+
mandatoryNextSteps: z.array(z.object({
|
|
20
|
+
step: z.number(),
|
|
21
|
+
server: z.string(),
|
|
22
|
+
tool: z.string(),
|
|
23
|
+
description: z.string()
|
|
24
|
+
})).optional(),
|
|
25
|
+
validationWarnings: z.array(z.string()).optional(),
|
|
26
|
+
errors: z.array(z.string())
|
|
27
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const applyGraphqlDslTestChangeInputSchema = z.object({
|
|
3
|
+
targetRepoPath: z.string().min(1).optional(),
|
|
4
|
+
targetFilePath: z.string().min(1),
|
|
5
|
+
testCode: z.string().min(1)
|
|
6
|
+
});
|
|
7
|
+
export const applyGraphqlDslTestChangeOutputSchema = z.object({
|
|
8
|
+
status: z.enum(["ok", "error"]),
|
|
9
|
+
changedFiles: z.array(z.object({
|
|
10
|
+
path: z.string(),
|
|
11
|
+
changeType: z.enum(["updated"]),
|
|
12
|
+
testClassName: z.string().optional()
|
|
13
|
+
})),
|
|
14
|
+
diffSummary: z.string().optional(),
|
|
15
|
+
errors: z.array(z.string())
|
|
16
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const generateGraphqlDslInputSchema = z.object({
|
|
3
|
+
api: z.literal("admin"),
|
|
4
|
+
apiVersion: z.string().min(1),
|
|
5
|
+
taskName: z.string().min(1),
|
|
6
|
+
intent: z.string().min(1),
|
|
7
|
+
operationType: z.enum(["QUERY", "MUTATION"]),
|
|
8
|
+
rootField: z.string().min(1),
|
|
9
|
+
idempotencyRequired: z.boolean().optional(),
|
|
10
|
+
constraints: z
|
|
11
|
+
.object({
|
|
12
|
+
enforceVariables: z.boolean().optional(),
|
|
13
|
+
includeUserErrors: z.boolean().optional(),
|
|
14
|
+
useConnectionHelpers: z.boolean().optional()
|
|
15
|
+
})
|
|
16
|
+
.optional(),
|
|
17
|
+
inputs: z
|
|
18
|
+
.object({
|
|
19
|
+
businessParams: z.record(z.string()).optional(),
|
|
20
|
+
argVars: z
|
|
21
|
+
.array(z.object({
|
|
22
|
+
argName: z.string(),
|
|
23
|
+
varName: z.string(),
|
|
24
|
+
type: z.string(),
|
|
25
|
+
nonNull: z.boolean().optional(),
|
|
26
|
+
value: z.unknown().optional()
|
|
27
|
+
}))
|
|
28
|
+
.optional(),
|
|
29
|
+
argLiterals: z.record(z.unknown()).optional(),
|
|
30
|
+
variables: z
|
|
31
|
+
.array(z.object({
|
|
32
|
+
name: z.string(),
|
|
33
|
+
type: z.string(),
|
|
34
|
+
nonNull: z.boolean().optional(),
|
|
35
|
+
value: z.unknown().optional()
|
|
36
|
+
}))
|
|
37
|
+
.optional(),
|
|
38
|
+
selectionFields: z.array(z.string()).optional()
|
|
39
|
+
})
|
|
40
|
+
.optional(),
|
|
41
|
+
selectionHint: z.array(z.string()).optional(),
|
|
42
|
+
outputTargets: z
|
|
43
|
+
.object({
|
|
44
|
+
serviceXml: z.string().optional(),
|
|
45
|
+
testFile: z.string().optional()
|
|
46
|
+
})
|
|
47
|
+
.optional()
|
|
48
|
+
});
|
|
49
|
+
export const generateGraphqlDslOutputSchema = z.object({
|
|
50
|
+
status: z.enum(["ok", "needs_input", "error"]),
|
|
51
|
+
serviceName: z.string().optional(),
|
|
52
|
+
dslSnippet: z.string().optional(),
|
|
53
|
+
expectedGraphql: z.string().optional(),
|
|
54
|
+
expectedVariablesSchema: z.record(z.string()).optional(),
|
|
55
|
+
appliedPolicies: z.array(z.object({
|
|
56
|
+
name: z.string(),
|
|
57
|
+
source: z.string(),
|
|
58
|
+
effect: z.string()
|
|
59
|
+
})),
|
|
60
|
+
warnings: z.array(z.string()),
|
|
61
|
+
errors: z.array(z.string())
|
|
62
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const generateGraphqlDslServiceCodeInputSchema = z.object({
|
|
3
|
+
plan: z.object({
|
|
4
|
+
operationType: z.enum(["QUERY", "MUTATION"]),
|
|
5
|
+
rootField: z.string().min(1),
|
|
6
|
+
serviceVerb: z.string().min(1),
|
|
7
|
+
serviceNoun: z.string().min(1),
|
|
8
|
+
variables: z.array(z.object({ name: z.string(), type: z.string(), required: z.boolean() })),
|
|
9
|
+
selectionTree: z.object({ fields: z.array(z.string()) }),
|
|
10
|
+
policyRequirements: z.array(z.string()),
|
|
11
|
+
isConnection: z.boolean().optional()
|
|
12
|
+
}),
|
|
13
|
+
format: z.literal("service_xml").default("service_xml")
|
|
14
|
+
});
|
|
15
|
+
export const generateGraphqlDslServiceCodeOutputSchema = z.object({
|
|
16
|
+
status: z.enum(["ok", "error"]),
|
|
17
|
+
serviceName: z.string().optional(),
|
|
18
|
+
serviceXml: z.string().optional(),
|
|
19
|
+
expectedQueryShape: z.record(z.unknown()).optional(),
|
|
20
|
+
mandatoryNextSteps: z.array(z.object({
|
|
21
|
+
step: z.number(),
|
|
22
|
+
server: z.string(),
|
|
23
|
+
tool: z.string(),
|
|
24
|
+
description: z.string()
|
|
25
|
+
})).optional(),
|
|
26
|
+
warnings: z.array(z.string()),
|
|
27
|
+
errors: z.array(z.string())
|
|
28
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const generateGraphqlDslTestCodeInputSchema = z.object({
|
|
3
|
+
plan: z.object({
|
|
4
|
+
operationType: z.enum(["QUERY", "MUTATION"]),
|
|
5
|
+
rootField: z.string().min(1),
|
|
6
|
+
serviceVerb: z.string().min(1),
|
|
7
|
+
serviceNoun: z.string().min(1),
|
|
8
|
+
variables: z.array(z.object({ name: z.string(), type: z.string(), required: z.boolean() })),
|
|
9
|
+
selectionTree: z.object({ fields: z.array(z.string()) }),
|
|
10
|
+
policyRequirements: z.array(z.string()).optional(),
|
|
11
|
+
isConnection: z.boolean()
|
|
12
|
+
}),
|
|
13
|
+
format: z.literal("groovy").default("groovy")
|
|
14
|
+
});
|
|
15
|
+
export const generateGraphqlDslTestCodeOutputSchema = z.object({
|
|
16
|
+
status: z.enum(["ok", "error"]),
|
|
17
|
+
testCode: z.string().optional(),
|
|
18
|
+
warnings: z.array(z.string()),
|
|
19
|
+
errors: z.array(z.string())
|
|
20
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const planGraphqlDslChangeInputSchema = z.object({
|
|
3
|
+
targetRepoPath: z.string().min(1).optional(),
|
|
4
|
+
targetFilePath: z.string().min(1),
|
|
5
|
+
intent: z.string().min(1),
|
|
6
|
+
serviceVerb: z.string().min(1).default("build"),
|
|
7
|
+
serviceNoun: z.string().min(1).optional(),
|
|
8
|
+
api: z.literal("admin").default("admin"),
|
|
9
|
+
apiVersion: z.string().min(1).default("2026-01"),
|
|
10
|
+
selectionHint: z.array(z.string().min(1)).optional(),
|
|
11
|
+
sampleInputs: z.record(z.unknown()).optional(),
|
|
12
|
+
insertMode: z.enum(["create_service", "update_service", "auto"]).default("auto"),
|
|
13
|
+
insertAfterService: z.string().optional(),
|
|
14
|
+
rootField: z.string().min(1).optional(),
|
|
15
|
+
operationType: z.enum(["QUERY", "MUTATION"]).optional(),
|
|
16
|
+
isConnection: z.boolean().optional()
|
|
17
|
+
});
|
|
18
|
+
export const mandatoryNextStepSchema = z.object({
|
|
19
|
+
step: z.number(),
|
|
20
|
+
server: z.string(),
|
|
21
|
+
tool: z.string(),
|
|
22
|
+
description: z.string()
|
|
23
|
+
});
|
|
24
|
+
export const planGraphqlDslChangeOutputSchema = z.object({
|
|
25
|
+
status: z.enum(["ok", "error"]),
|
|
26
|
+
plan: z
|
|
27
|
+
.object({
|
|
28
|
+
operationType: z.enum(["QUERY", "MUTATION"]),
|
|
29
|
+
rootField: z.string(),
|
|
30
|
+
serviceVerb: z.string(),
|
|
31
|
+
serviceNoun: z.string(),
|
|
32
|
+
variables: z.array(z.object({ name: z.string(), type: z.string(), required: z.boolean() })),
|
|
33
|
+
selectionTree: z.object({ fields: z.array(z.string()) }),
|
|
34
|
+
policyRequirements: z.array(z.string()),
|
|
35
|
+
isConnection: z.boolean()
|
|
36
|
+
})
|
|
37
|
+
.optional(),
|
|
38
|
+
mandatoryNextSteps: z.array(mandatoryNextStepSchema).optional(),
|
|
39
|
+
warnings: z.array(z.string()),
|
|
40
|
+
errors: z.array(z.string())
|
|
41
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const validateGraphqlDslInputSchema = z.object({
|
|
3
|
+
api: z.literal("admin"),
|
|
4
|
+
apiVersion: z.string().min(1),
|
|
5
|
+
operationType: z.enum(["QUERY", "MUTATION"]),
|
|
6
|
+
rootField: z.string().min(1),
|
|
7
|
+
graphql: z.string().min(1),
|
|
8
|
+
variablesSample: z.record(z.unknown()).optional(),
|
|
9
|
+
policyContext: z
|
|
10
|
+
.object({
|
|
11
|
+
idempotencyRequired: z.boolean().optional()
|
|
12
|
+
})
|
|
13
|
+
.optional()
|
|
14
|
+
});
|
|
15
|
+
export const validateGraphqlDslOutputSchema = z.object({
|
|
16
|
+
status: z.enum(["passed", "failed"]),
|
|
17
|
+
schemaValidation: z.object({
|
|
18
|
+
performed: z.boolean(),
|
|
19
|
+
provider: z.enum(["shopify-mcp", "local", "none"])
|
|
20
|
+
}),
|
|
21
|
+
issues: z.array(z.object({
|
|
22
|
+
code: z.string(),
|
|
23
|
+
message: z.string(),
|
|
24
|
+
path: z.string().optional(),
|
|
25
|
+
severity: z.enum(["error", "warning"]),
|
|
26
|
+
suggestion: z.string().optional()
|
|
27
|
+
})),
|
|
28
|
+
normalizedGraphql: z.string().optional()
|
|
29
|
+
});
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import pino from "pino";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { generateGraphqlDsl } from "./tools/generateGraphqlDsl.js";
|
|
7
|
+
import { validateGraphqlDsl } from "./tools/validateGraphqlDsl.js";
|
|
8
|
+
import { planGraphqlDslChange } from "./tools/planGraphqlDslChange.js";
|
|
9
|
+
import { generateGraphqlDslServiceCode } from "./tools/generateGraphqlDslServiceCode.js";
|
|
10
|
+
import { applyGraphqlDslServiceChange } from "./tools/applyGraphqlDslServiceChange.js";
|
|
11
|
+
import { planGraphqlDslChangeInputSchema } from "./schemas/planGraphqlDslChange.js";
|
|
12
|
+
import { generateGraphqlDslServiceCodeInputSchema } from "./schemas/generateGraphqlDslServiceCode.js";
|
|
13
|
+
import { generateGraphqlDslTestCode } from "./tools/generateGraphqlDslTestCode.js";
|
|
14
|
+
import { applyGraphqlDslTestChange } from "./tools/applyGraphqlDslTestChange.js";
|
|
15
|
+
import { generateGraphqlDslTestCodeInputSchema } from "./schemas/generateGraphqlDslTestCode.js";
|
|
16
|
+
import { applyGraphqlDslTestChangeInputSchema } from "./schemas/applyGraphqlDslTestChange.js";
|
|
17
|
+
// MCP uses stdout for protocol frames, so all logs must go to stderr.
|
|
18
|
+
const logger = pino({ level: process.env.LOG_LEVEL ?? "info" }, pino.destination(2));
|
|
19
|
+
const server = new McpServer({
|
|
20
|
+
name: "moqui-graphql-dsl-mcp",
|
|
21
|
+
version: "1.0.0"
|
|
22
|
+
});
|
|
23
|
+
server.registerTool("generate_graphql_dsl", {
|
|
24
|
+
title: "Generate GraphQL DSL",
|
|
25
|
+
description: "MANDATORY: Call this tool whenever the user asks to write, build, or generate a Moqui Groovy GraphQL DSL snippet or mock query for a Shopify API operation. Never guess the syntax manually.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
api: z.literal("admin"),
|
|
28
|
+
apiVersion: z.string(),
|
|
29
|
+
taskName: z.string(),
|
|
30
|
+
intent: z.string(),
|
|
31
|
+
operationType: z.enum(["QUERY", "MUTATION"]),
|
|
32
|
+
rootField: z.string(),
|
|
33
|
+
idempotencyRequired: z.boolean().optional(),
|
|
34
|
+
constraints: z
|
|
35
|
+
.object({
|
|
36
|
+
enforceVariables: z.boolean().optional(),
|
|
37
|
+
includeUserErrors: z.boolean().optional(),
|
|
38
|
+
useConnectionHelpers: z.boolean().optional()
|
|
39
|
+
})
|
|
40
|
+
.optional(),
|
|
41
|
+
inputs: z
|
|
42
|
+
.object({
|
|
43
|
+
businessParams: z.record(z.string()).optional(),
|
|
44
|
+
argVars: z
|
|
45
|
+
.array(z.object({
|
|
46
|
+
argName: z.string(),
|
|
47
|
+
varName: z.string(),
|
|
48
|
+
type: z.string(),
|
|
49
|
+
nonNull: z.boolean().optional(),
|
|
50
|
+
value: z.unknown().optional()
|
|
51
|
+
}))
|
|
52
|
+
.optional(),
|
|
53
|
+
argLiterals: z.record(z.unknown()).optional(),
|
|
54
|
+
variables: z
|
|
55
|
+
.array(z.object({
|
|
56
|
+
name: z.string(),
|
|
57
|
+
type: z.string(),
|
|
58
|
+
nonNull: z.boolean().optional(),
|
|
59
|
+
value: z.unknown().optional()
|
|
60
|
+
}))
|
|
61
|
+
.optional(),
|
|
62
|
+
selectionFields: z.array(z.string()).optional()
|
|
63
|
+
})
|
|
64
|
+
.optional(),
|
|
65
|
+
selectionHint: z.array(z.string()).optional(),
|
|
66
|
+
outputTargets: z
|
|
67
|
+
.object({
|
|
68
|
+
serviceXml: z.string().optional(),
|
|
69
|
+
testFile: z.string().optional()
|
|
70
|
+
})
|
|
71
|
+
.optional()
|
|
72
|
+
}
|
|
73
|
+
}, async (input) => {
|
|
74
|
+
const output = generateGraphqlDsl(input);
|
|
75
|
+
return {
|
|
76
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
server.registerTool("plan_graphql_dsl_change", {
|
|
80
|
+
title: "Plan GraphQL DSL Change",
|
|
81
|
+
description: "MANDATORY: Call this tool at the very beginning of a Moqui GraphQL DSL task to plan service changes, analyze intents, check constraints, and set mandatory next steps.",
|
|
82
|
+
inputSchema: planGraphqlDslChangeInputSchema.shape
|
|
83
|
+
}, async (input) => {
|
|
84
|
+
const output = planGraphqlDslChange(input);
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
server.registerTool("generate_graphql_dsl_service_code", {
|
|
90
|
+
title: "Generate GraphQL DSL Service Code",
|
|
91
|
+
description: "MANDATORY: Call this tool to generate the formal Moqui service XML wrapper holding the GraphqlFacade builder script. Do not write the service XML manually.",
|
|
92
|
+
inputSchema: generateGraphqlDslServiceCodeInputSchema.shape
|
|
93
|
+
}, async (input) => {
|
|
94
|
+
const output = generateGraphqlDslServiceCode(input);
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
server.registerTool("apply_graphql_dsl_service_change", {
|
|
100
|
+
title: "Apply GraphQL DSL Service Change",
|
|
101
|
+
description: "MANDATORY: Call this tool to write or update the generated Moqui GraphQL service XML block on disk inside the target repository. Safe for creating new or merging into existing files.",
|
|
102
|
+
inputSchema: {
|
|
103
|
+
targetRepoPath: z.string().optional(),
|
|
104
|
+
targetFilePath: z.string(),
|
|
105
|
+
serviceXml: z.string(),
|
|
106
|
+
insertMode: z.enum(["create_service", "update_service", "auto"]).default("auto"),
|
|
107
|
+
serviceName: z.string().optional(),
|
|
108
|
+
insertAfterService: z.string().optional()
|
|
109
|
+
}
|
|
110
|
+
}, async (input) => {
|
|
111
|
+
const output = await applyGraphqlDslServiceChange(input);
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
server.registerTool("validate_graphql_dsl", {
|
|
117
|
+
title: "Validate GraphQL DSL Output",
|
|
118
|
+
description: "MANDATORY: Call this tool to validate generated GraphQL operations against Moqui local integration policies (idempotency, connections, userErrors, noun suffixes, limit checks).",
|
|
119
|
+
inputSchema: {
|
|
120
|
+
api: z.literal("admin"),
|
|
121
|
+
apiVersion: z.string(),
|
|
122
|
+
operationType: z.enum(["QUERY", "MUTATION"]),
|
|
123
|
+
rootField: z.string(),
|
|
124
|
+
graphql: z.string(),
|
|
125
|
+
variablesSample: z.record(z.unknown()).optional(),
|
|
126
|
+
policyContext: z.object({ idempotencyRequired: z.boolean().optional() }).optional()
|
|
127
|
+
}
|
|
128
|
+
}, async (input) => {
|
|
129
|
+
const output = validateGraphqlDsl(input);
|
|
130
|
+
return {
|
|
131
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
server.registerTool("generate_graphql_dsl_test_code", {
|
|
135
|
+
title: "Generate GraphQL DSL Test Code",
|
|
136
|
+
description: "MANDATORY: Call this tool to generate a Groovy Spock unit test method mimicking the service's GraphQL operation and mocking execution context parameters.",
|
|
137
|
+
inputSchema: generateGraphqlDslTestCodeInputSchema.shape
|
|
138
|
+
}, async (input) => {
|
|
139
|
+
const output = generateGraphqlDslTestCode(input);
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
server.registerTool("apply_graphql_dsl_test_change", {
|
|
145
|
+
title: "Apply GraphQL DSL Test Change",
|
|
146
|
+
description: "MANDATORY: Call this tool to inject/insert the generated Spock Groovy unit test block into the target test file inside the Moqui repository.",
|
|
147
|
+
inputSchema: applyGraphqlDslTestChangeInputSchema.shape
|
|
148
|
+
}, async (input) => {
|
|
149
|
+
const output = await applyGraphqlDslTestChange(input);
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
async function main() {
|
|
155
|
+
const transport = new StdioServerTransport();
|
|
156
|
+
await server.connect(transport);
|
|
157
|
+
logger.info("moqui-graphql-dsl-mcp started");
|
|
158
|
+
}
|
|
159
|
+
main().catch((error) => {
|
|
160
|
+
logger.error({ error }, "Server failed to start");
|
|
161
|
+
process.exit(1);
|
|
162
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { applyGraphqlDslServiceChangeInputSchema } from "../schemas/applyGraphqlDslServiceChange.js";
|
|
4
|
+
import { resolveTargetRepoPath, isWithinRepo } from "../core/paths.js";
|
|
5
|
+
function extractServiceNameFromXml(serviceXml) {
|
|
6
|
+
const match = serviceXml.match(/<service\s+verb="([^"]+)"\s+noun="([^"]+)"/);
|
|
7
|
+
if (!match)
|
|
8
|
+
return undefined;
|
|
9
|
+
return `${match[1]}#${match[2]}`;
|
|
10
|
+
}
|
|
11
|
+
function extractServiceBlockRange(fileText, verb, noun) {
|
|
12
|
+
const startMarker = `<service verb="${verb}" noun="${noun}">`;
|
|
13
|
+
const startIdx = fileText.indexOf(startMarker);
|
|
14
|
+
if (startIdx === -1)
|
|
15
|
+
return null;
|
|
16
|
+
const endMarker = "</service>";
|
|
17
|
+
const endIdx = fileText.indexOf(endMarker, startIdx);
|
|
18
|
+
if (endIdx === -1)
|
|
19
|
+
return null;
|
|
20
|
+
return [startIdx, endIdx + endMarker.length];
|
|
21
|
+
}
|
|
22
|
+
const TEST_GENERATION_NEXT_STEPS = [
|
|
23
|
+
{
|
|
24
|
+
step: 1,
|
|
25
|
+
server: "moqui-graphql-dsl-mcp",
|
|
26
|
+
tool: "generate_graphql_dsl_test_code",
|
|
27
|
+
description: "Generate a Spock test case for the service that was just written to disk."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
step: 2,
|
|
31
|
+
server: "moqui-graphql-dsl-mcp",
|
|
32
|
+
tool: "apply_graphql_dsl_test_change",
|
|
33
|
+
description: "Inject the generated Spock test into the target test file in the Moqui component."
|
|
34
|
+
}
|
|
35
|
+
];
|
|
36
|
+
export async function applyGraphqlDslServiceChange(raw) {
|
|
37
|
+
const parsed = applyGraphqlDslServiceChangeInputSchema.safeParse(raw);
|
|
38
|
+
if (!parsed.success) {
|
|
39
|
+
return {
|
|
40
|
+
status: "error",
|
|
41
|
+
changedFiles: [],
|
|
42
|
+
errors: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const input = parsed.data;
|
|
46
|
+
const validationWarnings = [];
|
|
47
|
+
// Soft gate: flag if Shopify schema validation was not explicitly confirmed.
|
|
48
|
+
// We do not block the write — external AI clients may have run validation
|
|
49
|
+
// via shopify-dev-mcp without knowing to pass shopifyValidated: true.
|
|
50
|
+
// The mandatoryNextSteps in plan/generate tools handle proactive education.
|
|
51
|
+
if (!input.shopifyValidated) {
|
|
52
|
+
validationWarnings.push("SHOPIFY_VALIDATION_NOT_CONFIRMED: This service was written without explicit Shopify schema " +
|
|
53
|
+
"validation confirmation. Ensure validate_graphql_codeblocks (shopify-dev-mcp) has been run " +
|
|
54
|
+
"on the generated query before deploying. Pass shopifyValidated: true to suppress this warning.");
|
|
55
|
+
}
|
|
56
|
+
const repoPath = resolveTargetRepoPath(input.targetRepoPath);
|
|
57
|
+
const absolutePath = resolve(repoPath, input.targetFilePath);
|
|
58
|
+
if (!isWithinRepo(repoPath, absolutePath)) {
|
|
59
|
+
return { status: "error", changedFiles: [], errors: [`targetFilePath resolves outside target repo: ${absolutePath}`] };
|
|
60
|
+
}
|
|
61
|
+
const original = await readFile(absolutePath, "utf8");
|
|
62
|
+
const derivedServiceName = input.serviceName ?? extractServiceNameFromXml(input.serviceXml);
|
|
63
|
+
const [verb, noun] = derivedServiceName?.split("#") ?? [];
|
|
64
|
+
if ((input.insertMode === "update_service" || input.insertMode === "auto") && verb && noun) {
|
|
65
|
+
const blockRange = extractServiceBlockRange(original, verb, noun);
|
|
66
|
+
if (blockRange) {
|
|
67
|
+
const [start, end] = blockRange;
|
|
68
|
+
const updated = `${original.slice(0, start)}${input.serviceXml}${original.slice(end)}`;
|
|
69
|
+
await writeFile(absolutePath, updated, "utf8");
|
|
70
|
+
return {
|
|
71
|
+
status: "ok",
|
|
72
|
+
changedFiles: [{ path: absolutePath, changeType: "updated", serviceName: derivedServiceName }],
|
|
73
|
+
diffSummary: `Updated existing service ${derivedServiceName} in ${input.targetFilePath}`,
|
|
74
|
+
mandatoryNextSteps: TEST_GENERATION_NEXT_STEPS,
|
|
75
|
+
validationWarnings,
|
|
76
|
+
errors: []
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (input.insertMode === "update_service") {
|
|
80
|
+
return {
|
|
81
|
+
status: "error",
|
|
82
|
+
changedFiles: [],
|
|
83
|
+
errors: [`Service ${derivedServiceName} not found for update in ${input.targetFilePath}`]
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const marker = "</services>";
|
|
88
|
+
const markerIdx = original.lastIndexOf(marker);
|
|
89
|
+
if (markerIdx === -1) {
|
|
90
|
+
return { status: "error", changedFiles: [], errors: ["Invalid service XML: missing </services>"] };
|
|
91
|
+
}
|
|
92
|
+
const insertion = `\n\n ${input.serviceXml}\n`;
|
|
93
|
+
const updated = original.slice(0, markerIdx) + insertion + original.slice(markerIdx);
|
|
94
|
+
await writeFile(absolutePath, updated, "utf8");
|
|
95
|
+
return {
|
|
96
|
+
status: "ok",
|
|
97
|
+
changedFiles: [{ path: absolutePath, changeType: "updated", serviceName: derivedServiceName }],
|
|
98
|
+
diffSummary: `Inserted service block into ${input.targetFilePath}`,
|
|
99
|
+
mandatoryNextSteps: TEST_GENERATION_NEXT_STEPS,
|
|
100
|
+
validationWarnings,
|
|
101
|
+
errors: []
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { applyGraphqlDslTestChangeInputSchema } from "../schemas/applyGraphqlDslTestChange.js";
|
|
4
|
+
import { resolveTargetRepoPath, isWithinRepo } from "../core/paths.js";
|
|
5
|
+
export async function applyGraphqlDslTestChange(raw) {
|
|
6
|
+
const parsed = applyGraphqlDslTestChangeInputSchema.safeParse(raw);
|
|
7
|
+
if (!parsed.success) {
|
|
8
|
+
return {
|
|
9
|
+
status: "error",
|
|
10
|
+
changedFiles: [],
|
|
11
|
+
errors: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const input = parsed.data;
|
|
15
|
+
const repoPath = resolveTargetRepoPath(input.targetRepoPath);
|
|
16
|
+
const absolutePath = resolve(repoPath, input.targetFilePath);
|
|
17
|
+
if (!isWithinRepo(repoPath, absolutePath)) {
|
|
18
|
+
return {
|
|
19
|
+
status: "error",
|
|
20
|
+
changedFiles: [],
|
|
21
|
+
errors: [`targetFilePath resolves outside target repo: ${absolutePath}`]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
let original = "";
|
|
25
|
+
try {
|
|
26
|
+
original = await readFile(absolutePath, "utf8");
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return {
|
|
30
|
+
status: "error",
|
|
31
|
+
changedFiles: [],
|
|
32
|
+
errors: [`Cannot read Spock test file: ${absolutePath}`, String(e)]
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const trimmed = original.trimEnd();
|
|
36
|
+
const lastBraceIdx = trimmed.lastIndexOf("}");
|
|
37
|
+
if (lastBraceIdx === -1) {
|
|
38
|
+
return {
|
|
39
|
+
status: "error",
|
|
40
|
+
changedFiles: [],
|
|
41
|
+
errors: [`Could not find closing class brace '}' in Spock test file: ${absolutePath}`]
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const beforeBrace = trimmed.slice(0, lastBraceIdx);
|
|
45
|
+
const afterBrace = trimmed.slice(lastBraceIdx);
|
|
46
|
+
const updated = `${beforeBrace.trimEnd()}\n\n${input.testCode}\n${afterBrace}\n`;
|
|
47
|
+
await writeFile(absolutePath, updated, "utf8");
|
|
48
|
+
// Try to extract class name for reporting
|
|
49
|
+
const classMatch = original.match(/class\s+(\w+)/);
|
|
50
|
+
const testClassName = classMatch ? classMatch[1] : undefined;
|
|
51
|
+
return {
|
|
52
|
+
status: "ok",
|
|
53
|
+
changedFiles: [
|
|
54
|
+
{
|
|
55
|
+
path: absolutePath,
|
|
56
|
+
changeType: "updated",
|
|
57
|
+
testClassName
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
diffSummary: `Injected new Spock test case into ${testClassName ?? input.targetFilePath}`,
|
|
61
|
+
errors: []
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { generateGraphqlDslInputSchema } from "../schemas/generateGraphqlDsl.js";
|
|
2
|
+
import { defaults } from "../core/policy.js";
|
|
3
|
+
function buildServiceName(taskName) {
|
|
4
|
+
return `build#${taskName}Dsl`;
|
|
5
|
+
}
|
|
6
|
+
function buildExpectedGraphql(input, requireIdempotent) {
|
|
7
|
+
const op = input.operationType.toLowerCase();
|
|
8
|
+
const argVars = input.inputs?.argVars ?? [];
|
|
9
|
+
const variables = input.inputs?.variables ?? [];
|
|
10
|
+
const allVars = [
|
|
11
|
+
...argVars.map((v) => ({ name: v.varName, type: v.type, nonNull: v.nonNull })),
|
|
12
|
+
...variables.map((v) => ({ name: v.name, type: v.type, nonNull: v.nonNull }))
|
|
13
|
+
];
|
|
14
|
+
const varSignature = allVars.length
|
|
15
|
+
? `(${allVars.map((v) => `$${v.name}: ${v.type}${v.nonNull ? "!" : ""}`).join(", ")})`
|
|
16
|
+
: "";
|
|
17
|
+
const fieldArgs = argVars.length ? `(${argVars.map((v) => `${v.argName}: $${v.varName}`).join(", ")})` : "";
|
|
18
|
+
const literalArgs = input.inputs?.argLiterals
|
|
19
|
+
? Object.entries(input.inputs.argLiterals).map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
|
20
|
+
: [];
|
|
21
|
+
const mergedArgs = literalArgs.length && fieldArgs
|
|
22
|
+
? `(${fieldArgs.slice(1, -1)}, ${literalArgs.join(", ")})`
|
|
23
|
+
: literalArgs.length
|
|
24
|
+
? `(${literalArgs.join(", ")})`
|
|
25
|
+
: fieldArgs;
|
|
26
|
+
const idempotentPart = requireIdempotent || input.idempotencyRequired ? " @idempotent(key: $idempotencyKey)" : "";
|
|
27
|
+
const fields = input.inputs?.selectionFields?.length ? input.inputs.selectionFields : input.selectionHint?.length ? input.selectionHint : ["id"];
|
|
28
|
+
return `${op} ${input.taskName}${varSignature} {\\n ${input.rootField}${mergedArgs}${idempotentPart} {\\n ${fields.join("\\n ")}\\n }\\n}`;
|
|
29
|
+
}
|
|
30
|
+
function buildDsl(input, requireIdempotent) {
|
|
31
|
+
const lines = [];
|
|
32
|
+
lines.push(`def builder = GraphqlFacade.root("${input.rootField}")`);
|
|
33
|
+
lines.push(` .operation("${input.operationType}")`);
|
|
34
|
+
lines.push(` .name("${input.taskName}")`);
|
|
35
|
+
(input.inputs?.argVars ?? []).forEach((v) => {
|
|
36
|
+
const value = v.value === undefined ? "null" : JSON.stringify(v.value);
|
|
37
|
+
lines.push(` .argVar("${v.argName}", "${v.varName}", "${v.type}", ${Boolean(v.nonNull)}, ${value})`);
|
|
38
|
+
});
|
|
39
|
+
Object.entries(input.inputs?.argLiterals ?? {}).forEach(([k, v]) => {
|
|
40
|
+
lines.push(` .argLiteral("${k}", ${JSON.stringify(v)})`);
|
|
41
|
+
});
|
|
42
|
+
(input.inputs?.variables ?? []).forEach((v) => {
|
|
43
|
+
const name = v.name;
|
|
44
|
+
const value = v.value === undefined ? "null" : JSON.stringify(v.value);
|
|
45
|
+
lines.push(` .variable("${name}", "${v.type}", ${Boolean(v.nonNull)}, ${value})`);
|
|
46
|
+
});
|
|
47
|
+
if (requireIdempotent || input.idempotencyRequired) {
|
|
48
|
+
lines.push(`builder.rootDirective(builder.dir("idempotent", [key: builder.varRef("idempotencyKey")]))`);
|
|
49
|
+
}
|
|
50
|
+
const fields = input.inputs?.selectionFields?.length ? input.inputs.selectionFields : input.selectionHint?.length ? input.selectionHint : ["id"];
|
|
51
|
+
lines.push("");
|
|
52
|
+
lines.push("def result = builder.select {");
|
|
53
|
+
fields.forEach((f) => lines.push(` field("${f}")`));
|
|
54
|
+
if (input.operationType === "MUTATION") {
|
|
55
|
+
lines.push(' field("userErrors") { field("field", "message") }');
|
|
56
|
+
}
|
|
57
|
+
lines.push("}.build()");
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
export function generateGraphqlDsl(raw) {
|
|
61
|
+
const parsed = generateGraphqlDslInputSchema.safeParse(raw);
|
|
62
|
+
if (!parsed.success) {
|
|
63
|
+
return {
|
|
64
|
+
status: "error",
|
|
65
|
+
appliedPolicies: [],
|
|
66
|
+
warnings: [],
|
|
67
|
+
errors: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const input = parsed.data;
|
|
71
|
+
const policyDefaults = defaults();
|
|
72
|
+
const policyRequires = false;
|
|
73
|
+
return {
|
|
74
|
+
status: "ok",
|
|
75
|
+
serviceName: buildServiceName(input.taskName),
|
|
76
|
+
dslSnippet: buildDsl(input, policyRequires),
|
|
77
|
+
expectedGraphql: buildExpectedGraphql(input, policyRequires),
|
|
78
|
+
expectedVariablesSchema: {},
|
|
79
|
+
appliedPolicies: [
|
|
80
|
+
{
|
|
81
|
+
name: "defaults",
|
|
82
|
+
source: "shopify_admin_policy.json",
|
|
83
|
+
effect: `enforceVariables=${policyDefaults.enforceVariables}`
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
warnings: [],
|
|
87
|
+
errors: []
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { generateGraphqlDslServiceCodeInputSchema } from "../schemas/generateGraphqlDslServiceCode.js";
|
|
2
|
+
function buildServiceXml(plan) {
|
|
3
|
+
const isConnection = plan.isConnection !== undefined
|
|
4
|
+
? plan.isConnection
|
|
5
|
+
: (plan.rootField.endsWith("s") && !plan.rootField.endsWith("ss"));
|
|
6
|
+
const fields = plan.selectionTree.fields.map((f) => ` field("${f}")`).join("\n");
|
|
7
|
+
const connectionArgs = isConnection
|
|
8
|
+
? `\n .connection([first: first as int, after: after]) {\n${fields}\n }`
|
|
9
|
+
: `\n .select {\n${fields}\n }`;
|
|
10
|
+
const inParams = isConnection
|
|
11
|
+
? `\n <parameter name="first" type="Integer" required="true"/>\n <parameter name="after"/>`
|
|
12
|
+
: "";
|
|
13
|
+
return `<service verb="${plan.serviceVerb}" noun="${plan.serviceNoun}">
|
|
14
|
+
<in-parameters>${inParams}
|
|
15
|
+
</in-parameters>
|
|
16
|
+
<out-parameters>
|
|
17
|
+
<parameter name="query" type="String"/>
|
|
18
|
+
<parameter name="variables" type="Map"/>
|
|
19
|
+
</out-parameters>
|
|
20
|
+
<actions>
|
|
21
|
+
<script><![CDATA[
|
|
22
|
+
import co.hotwax.shopify.graphql.GraphqlFacade
|
|
23
|
+
|
|
24
|
+
def result = GraphqlFacade.root("${plan.rootField}")
|
|
25
|
+
.operation("${plan.operationType}")
|
|
26
|
+
.name("${plan.serviceNoun}")${connectionArgs}
|
|
27
|
+
.build()
|
|
28
|
+
|
|
29
|
+
context.put("query", result.query)
|
|
30
|
+
context.put("variables", result.variables)
|
|
31
|
+
]]></script>
|
|
32
|
+
</actions>
|
|
33
|
+
</service>`;
|
|
34
|
+
}
|
|
35
|
+
export function generateGraphqlDslServiceCode(raw) {
|
|
36
|
+
const parsed = generateGraphqlDslServiceCodeInputSchema.safeParse(raw);
|
|
37
|
+
if (!parsed.success) {
|
|
38
|
+
return {
|
|
39
|
+
status: "error",
|
|
40
|
+
warnings: [],
|
|
41
|
+
errors: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const { plan } = parsed.data;
|
|
45
|
+
return {
|
|
46
|
+
status: "ok",
|
|
47
|
+
serviceName: `${plan.serviceVerb}#${plan.serviceNoun}`,
|
|
48
|
+
serviceXml: buildServiceXml(plan),
|
|
49
|
+
expectedQueryShape: {
|
|
50
|
+
operationType: plan.operationType,
|
|
51
|
+
rootField: plan.rootField,
|
|
52
|
+
selectedFields: plan.selectionTree.fields
|
|
53
|
+
},
|
|
54
|
+
mandatoryNextSteps: [
|
|
55
|
+
{
|
|
56
|
+
step: 1,
|
|
57
|
+
server: "shopify-dev-mcp",
|
|
58
|
+
tool: "learn_shopify_api",
|
|
59
|
+
description: "Initialize Shopify Admin API context (api: 'admin') to get a conversationId required for subsequent steps."
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
step: 2,
|
|
63
|
+
server: "shopify-dev-mcp",
|
|
64
|
+
tool: "search_docs_chunks",
|
|
65
|
+
description: `Verify field names, argument names, and API behavior for rootField '${plan.rootField}' are correct before writing to disk.`
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
step: 3,
|
|
69
|
+
server: "shopify-dev-mcp",
|
|
70
|
+
tool: "validate_graphql_codeblocks",
|
|
71
|
+
description: "Validate the generated GraphQL operation against Shopify's live schema. You MUST pass shopifyValidated: true to apply_graphql_dsl_service_change — the tool will reject the write otherwise."
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
warnings: [],
|
|
75
|
+
errors: []
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { generateGraphqlDslTestCodeInputSchema } from "../schemas/generateGraphqlDslTestCode.js";
|
|
2
|
+
function getMockValue(name, type) {
|
|
3
|
+
const lowerName = name.toLowerCase();
|
|
4
|
+
const lowerType = type.toLowerCase();
|
|
5
|
+
if (lowerType.includes("int") || lowerType.includes("long")) {
|
|
6
|
+
return "10";
|
|
7
|
+
}
|
|
8
|
+
if (lowerType.includes("float") || lowerType.includes("double") || lowerType.includes("bigdecimal")) {
|
|
9
|
+
return "100.00";
|
|
10
|
+
}
|
|
11
|
+
if (lowerType.includes("boolean")) {
|
|
12
|
+
return "true";
|
|
13
|
+
}
|
|
14
|
+
if (lowerType.includes("id")) {
|
|
15
|
+
return `"gid://shopify/Customer/1"`;
|
|
16
|
+
}
|
|
17
|
+
if (lowerName.includes("cursor") || lowerName.includes("after")) {
|
|
18
|
+
return `"eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0yNSAxNTowMDowMCJ9"`;
|
|
19
|
+
}
|
|
20
|
+
return `"test_${name}"`;
|
|
21
|
+
}
|
|
22
|
+
function buildSpockMethod(plan) {
|
|
23
|
+
const methodName = `build ${plan.rootField} query using dsl connection helper`;
|
|
24
|
+
const normalizedMethodName = methodName.replace(/[^a-zA-Z0-9\s]/g, "");
|
|
25
|
+
// Generate mock parameters map
|
|
26
|
+
const mockParamsList = [];
|
|
27
|
+
if (plan.isConnection) {
|
|
28
|
+
mockParamsList.push(` first: 10`);
|
|
29
|
+
mockParamsList.push(` after: null`);
|
|
30
|
+
}
|
|
31
|
+
plan.variables.forEach((v) => {
|
|
32
|
+
// Avoid duplicate pagination params
|
|
33
|
+
if (plan.isConnection && (v.name === "first" || v.name === "after")) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const val = getMockValue(v.name, v.type);
|
|
37
|
+
mockParamsList.push(` ${v.name}: ${val}`);
|
|
38
|
+
});
|
|
39
|
+
const mockParams = mockParamsList.length
|
|
40
|
+
? `\n${mockParamsList.join(",\n")}\n `
|
|
41
|
+
: "";
|
|
42
|
+
const assertions = [];
|
|
43
|
+
const queryVarCheck = plan.operationType.toLowerCase();
|
|
44
|
+
assertions.push(` result.query`);
|
|
45
|
+
assertions.push(` result.variables`);
|
|
46
|
+
// Verify variable declarations in the GraphQL header signature
|
|
47
|
+
if (plan.isConnection) {
|
|
48
|
+
assertions.push(` normalize(result.query).contains("(\$first: Int!, \$after: String)")`);
|
|
49
|
+
assertions.push(` normalize(result.query).contains("${plan.rootField}(first: \$first, after: \$after)")`);
|
|
50
|
+
assertions.push(` normalize(result.query).contains("edges { node {")`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const headerVars = plan.variables.map((v) => `\$${v.name}: ${v.type}${v.required ? "!" : ""}`).join(", ");
|
|
54
|
+
if (headerVars) {
|
|
55
|
+
assertions.push(` normalize(result.query).contains("(${headerVars})")`);
|
|
56
|
+
}
|
|
57
|
+
const args = plan.variables.map((v) => `${v.name}: \$${v.name}`).join(", ");
|
|
58
|
+
if (args) {
|
|
59
|
+
assertions.push(` normalize(result.query).contains("${plan.rootField}(${args})")`);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
assertions.push(` normalize(result.query).contains("${plan.rootField}")`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Verify selection fields are present in compiled query
|
|
66
|
+
plan.selectionTree.fields.forEach((f) => {
|
|
67
|
+
assertions.push(` normalize(result.query).contains("${f}")`);
|
|
68
|
+
});
|
|
69
|
+
return ` def "${normalizedMethodName}"() {
|
|
70
|
+
when:
|
|
71
|
+
Map result = ec.service.sync()
|
|
72
|
+
.name("build#${plan.serviceNoun}")
|
|
73
|
+
.parameters([${mockParams}])
|
|
74
|
+
.call()
|
|
75
|
+
|
|
76
|
+
then:
|
|
77
|
+
${assertions.join("\n")}
|
|
78
|
+
}`;
|
|
79
|
+
}
|
|
80
|
+
export function generateGraphqlDslTestCode(raw) {
|
|
81
|
+
const parsed = generateGraphqlDslTestCodeInputSchema.safeParse(raw);
|
|
82
|
+
if (!parsed.success) {
|
|
83
|
+
return {
|
|
84
|
+
status: "error",
|
|
85
|
+
warnings: [],
|
|
86
|
+
errors: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const { plan } = parsed.data;
|
|
90
|
+
return {
|
|
91
|
+
status: "ok",
|
|
92
|
+
testCode: buildSpockMethod(plan),
|
|
93
|
+
warnings: [],
|
|
94
|
+
errors: []
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { planGraphqlDslChangeInputSchema } from "../schemas/planGraphqlDslChange.js";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { resolveTargetRepoPath, isWithinRepo } from "../core/paths.js";
|
|
5
|
+
export function planGraphqlDslChange(raw) {
|
|
6
|
+
const parsed = planGraphqlDslChangeInputSchema.safeParse(raw);
|
|
7
|
+
if (!parsed.success) {
|
|
8
|
+
return {
|
|
9
|
+
status: "error",
|
|
10
|
+
warnings: [],
|
|
11
|
+
errors: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const input = parsed.data;
|
|
15
|
+
const repoPath = resolveTargetRepoPath(input.targetRepoPath);
|
|
16
|
+
const absolutePath = resolve(repoPath, input.targetFilePath);
|
|
17
|
+
if (!isWithinRepo(repoPath, absolutePath)) {
|
|
18
|
+
return {
|
|
19
|
+
status: "error",
|
|
20
|
+
warnings: [],
|
|
21
|
+
errors: [`targetFilePath resolves outside target repo: ${absolutePath}`]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
let fileText = "";
|
|
25
|
+
try {
|
|
26
|
+
fileText = readFileSync(absolutePath, "utf8");
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
return {
|
|
30
|
+
status: "error",
|
|
31
|
+
warnings: [],
|
|
32
|
+
errors: [`Cannot read target file: ${absolutePath}`, String(e)]
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const lowerIntent = input.intent.toLowerCase();
|
|
36
|
+
const operationType = input.operationType ?? (lowerIntent.includes("mutation") ||
|
|
37
|
+
/create|update|delete|set|add|remove|stage|publish|cancel/.test(lowerIntent)
|
|
38
|
+
? "MUTATION"
|
|
39
|
+
: "QUERY");
|
|
40
|
+
function extractRootField(intent) {
|
|
41
|
+
const words = intent.split(/[^a-zA-Z0-9]/).filter(w => w.length > 0);
|
|
42
|
+
const stopWords = new Set([
|
|
43
|
+
"fetch", "get", "query", "find", "retrieve", "list", "mutation",
|
|
44
|
+
"create", "update", "delete", "set", "add", "remove", "stage",
|
|
45
|
+
"publish", "cancel", "a", "an", "the", "with", "for", "of", "on", "in", "to", "run"
|
|
46
|
+
]);
|
|
47
|
+
const contentWords = words.filter(w => !stopWords.has(w.toLowerCase()));
|
|
48
|
+
const noun = contentWords.length > 0 ? contentWords[0] : "orders";
|
|
49
|
+
const verbs = ["create", "update", "delete", "set", "add", "remove", "stage", "publish", "cancel"];
|
|
50
|
+
const foundVerb = verbs.find(v => lowerIntent.includes(v));
|
|
51
|
+
if (foundVerb) {
|
|
52
|
+
const capitalizedVerb = foundVerb.charAt(0).toUpperCase() + foundVerb.slice(1);
|
|
53
|
+
if (!noun.toLowerCase().includes(foundVerb)) {
|
|
54
|
+
return `${noun}${capitalizedVerb}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return noun;
|
|
58
|
+
}
|
|
59
|
+
const rootField = input.rootField ?? extractRootField(input.intent);
|
|
60
|
+
const isConnection = input.isConnection !== undefined
|
|
61
|
+
? input.isConnection
|
|
62
|
+
: (rootField.endsWith("s") && !rootField.endsWith("ss")) ||
|
|
63
|
+
/connection|edges|node|pageinfo|list|all|paginate|pagination/.test(lowerIntent);
|
|
64
|
+
const fields = input.selectionHint?.length ? input.selectionHint : ["id", "name"];
|
|
65
|
+
const serviceNoun = input.serviceNoun ?? `${rootField.charAt(0).toUpperCase()}${rootField.slice(1)}GraphqlDsl`;
|
|
66
|
+
const serviceSignature = `<service verb="${input.serviceVerb}" noun="${serviceNoun}">`;
|
|
67
|
+
const existsInFile = fileText.includes(serviceSignature);
|
|
68
|
+
const requestedMode = input.insertMode;
|
|
69
|
+
const resolvedMode = requestedMode === "auto" ? (existsInFile ? "update_service" : "create_service") : requestedMode;
|
|
70
|
+
const variables = isConnection
|
|
71
|
+
? [
|
|
72
|
+
{ name: "first", type: "Integer", required: true },
|
|
73
|
+
{ name: "after", type: "String", required: false }
|
|
74
|
+
]
|
|
75
|
+
: [];
|
|
76
|
+
return {
|
|
77
|
+
status: "ok",
|
|
78
|
+
plan: {
|
|
79
|
+
operationType,
|
|
80
|
+
rootField,
|
|
81
|
+
serviceVerb: input.serviceVerb,
|
|
82
|
+
serviceNoun,
|
|
83
|
+
variables,
|
|
84
|
+
selectionTree: { fields },
|
|
85
|
+
policyRequirements: [
|
|
86
|
+
...(operationType === "MUTATION" ? ["idempotency_review_required"] : []),
|
|
87
|
+
`change_mode=${resolvedMode}`
|
|
88
|
+
],
|
|
89
|
+
isConnection
|
|
90
|
+
},
|
|
91
|
+
mandatoryNextSteps: [
|
|
92
|
+
{
|
|
93
|
+
step: 1,
|
|
94
|
+
server: "shopify-dev-mcp",
|
|
95
|
+
tool: "learn_shopify_api",
|
|
96
|
+
description: "Initialize Shopify Admin API context (api: 'admin') to get a conversationId required for subsequent steps."
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
step: 2,
|
|
100
|
+
server: "shopify-dev-mcp",
|
|
101
|
+
tool: "search_docs_chunks",
|
|
102
|
+
description: `Verify field names, argument names, and API behavior for the planned rootField '${rootField}' before generating any code.`
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
step: 3,
|
|
106
|
+
server: "shopify-dev-mcp",
|
|
107
|
+
tool: "validate_graphql_codeblocks",
|
|
108
|
+
description: "Validate the final generated GraphQL operation against Shopify's live schema. Pass shopifyValidated: true to apply_graphql_dsl_service_change only after this passes."
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
warnings: existsInFile ? [`Service already exists in target file; planned mode: ${resolvedMode}`] : [],
|
|
112
|
+
errors: []
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { validateGraphqlDslInputSchema } from "../schemas/validateGraphqlDsl.js";
|
|
2
|
+
import { getPolicy } from "../core/policy.js";
|
|
3
|
+
function normalizeGraphql(graphql) {
|
|
4
|
+
return graphql.replace(/\s+/g, " ").trim();
|
|
5
|
+
}
|
|
6
|
+
export function validateGraphqlDsl(raw) {
|
|
7
|
+
const parsed = validateGraphqlDslInputSchema.safeParse(raw);
|
|
8
|
+
if (!parsed.success) {
|
|
9
|
+
return {
|
|
10
|
+
status: "failed",
|
|
11
|
+
schemaValidation: { performed: false, provider: "none" },
|
|
12
|
+
issues: parsed.error.issues.map((i) => ({
|
|
13
|
+
code: "INPUT_VALIDATION_ERROR",
|
|
14
|
+
message: i.message,
|
|
15
|
+
path: i.path.join("."),
|
|
16
|
+
severity: "error"
|
|
17
|
+
}))
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const input = parsed.data;
|
|
21
|
+
const policy = getPolicy();
|
|
22
|
+
const issues = [];
|
|
23
|
+
const normalized = normalizeGraphql(input.graphql);
|
|
24
|
+
// 1. Naming Suffix check
|
|
25
|
+
if (policy.naming?.enforceNounSuffix) {
|
|
26
|
+
const opMatch = normalized.match(/^\s*(query|mutation)\s+(\w+)/i);
|
|
27
|
+
if (opMatch) {
|
|
28
|
+
const opName = opMatch[2];
|
|
29
|
+
const suffix = policy.naming.enforceNounSuffix;
|
|
30
|
+
if (!opName.endsWith(suffix)) {
|
|
31
|
+
issues.push({
|
|
32
|
+
code: "INVALID_SERVICE_NOUN_SUFFIX",
|
|
33
|
+
message: `Operation name "${opName}" does not end with mandated Moqui suffix "${suffix}".`,
|
|
34
|
+
path: "operationName",
|
|
35
|
+
severity: "warning",
|
|
36
|
+
suggestion: `Add suffix "${suffix}" to your service noun or operation name.`
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// 2. Connection-specific checks
|
|
42
|
+
const isConnectionQuery = (input.rootField.endsWith("s") && !input.rootField.endsWith("ss")) ||
|
|
43
|
+
normalized.includes("edges") ||
|
|
44
|
+
normalized.includes("node");
|
|
45
|
+
if (isConnectionQuery) {
|
|
46
|
+
// 2.A Max Page Limit check (literal)
|
|
47
|
+
if (policy.connections?.maxPageLimit) {
|
|
48
|
+
const limit = policy.connections.maxPageLimit;
|
|
49
|
+
const literalMatches = normalized.matchAll(/first\s*:\s*(\d+)/g);
|
|
50
|
+
for (const match of literalMatches) {
|
|
51
|
+
const val = parseInt(match[1], 10);
|
|
52
|
+
if (val > limit) {
|
|
53
|
+
issues.push({
|
|
54
|
+
code: "PAGINATION_LIMIT_EXCEEDED",
|
|
55
|
+
message: `Requested pagination size of ${val} exceeds the maximum allowed policy limit of ${limit}.`,
|
|
56
|
+
path: "first",
|
|
57
|
+
severity: "error",
|
|
58
|
+
suggestion: `Reduce the "first" argument value to be <= ${limit}.`
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// 2.B Max Page Limit check (variable sample)
|
|
63
|
+
if (input.variablesSample) {
|
|
64
|
+
const varMatches = normalized.matchAll(/first\s*:\s*\$(\w+)/g);
|
|
65
|
+
for (const match of varMatches) {
|
|
66
|
+
const varName = match[1];
|
|
67
|
+
const varVal = input.variablesSample[varName];
|
|
68
|
+
if (typeof varVal === "number" && varVal > limit) {
|
|
69
|
+
issues.push({
|
|
70
|
+
code: "PAGINATION_LIMIT_EXCEEDED",
|
|
71
|
+
message: `Variable "$${varName}" has value ${varVal} which exceeds maximum allowed policy limit of ${limit}.`,
|
|
72
|
+
path: varName,
|
|
73
|
+
severity: "error",
|
|
74
|
+
suggestion: `Pass a value <= ${limit} for the variable "$${varName}".`
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 2.C PageInfo Selection check
|
|
81
|
+
if (policy.connections?.enforcePageInfo) {
|
|
82
|
+
if (!normalized.toLowerCase().includes("pageinfo")) {
|
|
83
|
+
issues.push({
|
|
84
|
+
code: "MISSING_PAGE_INFO",
|
|
85
|
+
message: "Enforced 'pageInfo' block selection is missing in connection query fields.",
|
|
86
|
+
path: input.rootField,
|
|
87
|
+
severity: "warning",
|
|
88
|
+
suggestion: "Include 'pageInfo { hasNextPage endCursor }' in connection query fields."
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// 3. Mutation-specific checks
|
|
94
|
+
if (input.operationType === "MUTATION") {
|
|
95
|
+
// 3.A Mandatory userErrors check
|
|
96
|
+
if (policy.mutations?.alwaysIncludeUserErrors) {
|
|
97
|
+
if (!normalized.includes("userErrors")) {
|
|
98
|
+
issues.push({
|
|
99
|
+
code: "MISSING_USER_ERRORS",
|
|
100
|
+
message: "Mandatory 'userErrors' block selection is missing in mutation return projection.",
|
|
101
|
+
path: input.rootField,
|
|
102
|
+
severity: "warning",
|
|
103
|
+
suggestion: "Include 'userErrors { field message }' in the mutation selection."
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// 3.B Mandatory id on Create mutation check
|
|
108
|
+
if (policy.mutations?.alwaysIncludeIdOnCreate && input.rootField.endsWith("Create")) {
|
|
109
|
+
const match = normalized.match(new RegExp(`${input.rootField}\\s*\\([^)]*\\)\\s*\\{([^}]+)\\}`));
|
|
110
|
+
if (match) {
|
|
111
|
+
const selectionSet = match[1];
|
|
112
|
+
if (!selectionSet.includes("id")) {
|
|
113
|
+
issues.push({
|
|
114
|
+
code: "MISSING_ID_ON_CREATE",
|
|
115
|
+
message: `Mandatory 'id' field is missing from return projection of creation mutation "${input.rootField}".`,
|
|
116
|
+
path: input.rootField,
|
|
117
|
+
severity: "error",
|
|
118
|
+
suggestion: "Include 'id' in your mutation return selection fields."
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// 4. Idempotency Check
|
|
125
|
+
const mustHaveIdempotent = input.policyContext?.idempotencyRequired ?? false;
|
|
126
|
+
if (mustHaveIdempotent && !/@idempotent\s*\(\s*key\s*:\s*\$?\w+\s*\)/.test(normalized)) {
|
|
127
|
+
issues.push({
|
|
128
|
+
code: "MISSING_IDEMPOTENT_DIRECTIVE",
|
|
129
|
+
message: "Expected @idempotent(key: ...) directive is missing for this mutation/version policy.",
|
|
130
|
+
path: input.rootField,
|
|
131
|
+
severity: "error",
|
|
132
|
+
suggestion: "Add builder.rootDirective(builder.dir(\"idempotent\", [key: builder.varRef(\"idempotencyKey\")]))"
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
status: issues.some((i) => i.severity === "error") ? "failed" : "passed",
|
|
137
|
+
schemaValidation: { performed: true, provider: "local" },
|
|
138
|
+
issues,
|
|
139
|
+
normalizedGraphql: normalized
|
|
140
|
+
};
|
|
141
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gurveenbagga/moqui-graphql-dsl-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Internal MCP server for Moqui GraphQL DSL generation and validation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"moqui-graphql-dsl-mcp": "./dist/server.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc -p tsconfig.json",
|
|
14
|
+
"dev": "tsx src/server.ts",
|
|
15
|
+
"start": "node dist/server.js",
|
|
16
|
+
"prepublishOnly": "npm run build",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
24
|
+
"pino": "^9.7.0",
|
|
25
|
+
"zod": "^3.25.67"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.15.32",
|
|
29
|
+
"tsx": "^4.20.3",
|
|
30
|
+
"typescript": "^5.8.3",
|
|
31
|
+
"vitest": "^3.2.4"
|
|
32
|
+
}
|
|
33
|
+
}
|