@letoribo/mcp-graphql-enhanced 3.3.0 → 3.4.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 +13 -0
- package/dist/index.js +185 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,6 +11,19 @@ An **enhanced MCP (Model Context Protocol) server for GraphQL** that fixes real-
|
|
|
11
11
|
* ✅ **Filtered introspection** — request only specific types (e.g., `typeNames: ["Query", "User"]`) to reduce LLM context noise
|
|
12
12
|
* ✅ **Full MCP compatibility** — works with **Claude Desktop**, **Cursor**, **Glama**
|
|
13
13
|
* ✅ **Secure by default** — mutations disabled unless explicitly enabled
|
|
14
|
+
* ✅ **Dynamic Schema Evolution** — Smart diagnostics and gap analysis for servers that regenerate GraphQL types on-the-fly (like Neo4j).
|
|
15
|
+
* ✅ **Deep Observability** — Automatic Cypher extraction and cleaning from GraphQL extensions.
|
|
16
|
+
|
|
17
|
+
## 🔍 Advanced Observability & Cypher
|
|
18
|
+
The bridge provides deep insights into how the LLM interacts with your graph database.
|
|
19
|
+
|
|
20
|
+
### 🕸️ Automated Cypher Extraction
|
|
21
|
+
For GraphQL server implementations that return query execution plans (like `@neo4j/graphql`), the bridge automatically:
|
|
22
|
+
1. **Detects** `extensions.cypher` in the response.
|
|
23
|
+
2. **Sanitizes** the output by stripping internal headers (like `CYPHER 5` or empty `PARAMS`).
|
|
24
|
+
3. **Injects** a clean Cypher block directly into the tool's output for the AI to analyze.
|
|
25
|
+
|
|
26
|
+
> **Note:** This feature requires your GraphQL server to be configured to include debug information in the response extensions.
|
|
14
27
|
|
|
15
28
|
---
|
|
16
29
|
## 🎨 Visual Command Center (GraphiQL)
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
|
10
10
|
const language_1 = require("graphql/language");
|
|
11
11
|
const zod_1 = __importDefault(require("zod"));
|
|
12
12
|
const graphiql_js_1 = require("./helpers/graphiql.js");
|
|
13
|
+
const graphql_1 = require("graphql");
|
|
13
14
|
// Helper imports
|
|
14
15
|
const deprecation_js_1 = require("./helpers/deprecation.js");
|
|
15
16
|
const introspection_js_1 = require("./helpers/introspection.js");
|
|
@@ -62,48 +63,147 @@ const server = new mcp_js_1.McpServer({
|
|
|
62
63
|
let cachedSDL = null;
|
|
63
64
|
let cachedSchemaObject = null;
|
|
64
65
|
let schemaLoadError = null;
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
let isUpdating = false;
|
|
67
|
+
let updatePromise = null;
|
|
68
|
+
let lastKnownTypeCount = 0;
|
|
69
|
+
let expectEmptySchema = false; // Intent flag for intentional purges
|
|
70
|
+
/**
|
|
71
|
+
* Smart Hybrid Schema Fetcher (Zero-Error Version)
|
|
72
|
+
* @param force If true, blocks and waits for the new schema evolution.
|
|
73
|
+
* If false, returns cache immediately and updates in background.
|
|
74
|
+
*/
|
|
75
|
+
async function getSchema(force = false, requestedTypes) {
|
|
76
|
+
// 1. Hook into existing update if in progress
|
|
77
|
+
if (isUpdating && updatePromise) {
|
|
78
|
+
if (force || !cachedSDL)
|
|
79
|
+
return await updatePromise;
|
|
67
80
|
return cachedSDL;
|
|
81
|
+
}
|
|
82
|
+
// 2. Return cache if valid and not forcing
|
|
83
|
+
if (cachedSDL && !force) {
|
|
84
|
+
// Validation check: If user wants specific types but they aren't in the cache
|
|
85
|
+
if (requestedTypes && cachedSchemaObject) {
|
|
86
|
+
const typeMap = cachedSchemaObject.getTypeMap();
|
|
87
|
+
const missing = requestedTypes.filter(t => !typeMap[t]);
|
|
88
|
+
if (missing.length > 0) {
|
|
89
|
+
// Force a refresh if requested types are missing from current cache
|
|
90
|
+
return await (updatePromise = performUpdate(true));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return cachedSDL;
|
|
94
|
+
}
|
|
95
|
+
if (force)
|
|
96
|
+
schemaLoadError = null;
|
|
68
97
|
if (schemaLoadError)
|
|
69
98
|
throw schemaLoadError;
|
|
99
|
+
// 3. Trigger update
|
|
100
|
+
updatePromise = performUpdate(force);
|
|
70
101
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
102
|
+
if (force || !cachedSDL) {
|
|
103
|
+
return await updatePromise;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
updatePromise.catch(err => console.error("[SCHEMA] Background update failed:", err));
|
|
107
|
+
return cachedSDL;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
// Promise reference is cleared inside performUpdate's 'finally' block
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Internal logic for schema introspection and building.
|
|
116
|
+
* This version uses universal business-type tracking and provides
|
|
117
|
+
* detailed diagnostic reports instead of generic error messages.
|
|
118
|
+
*/
|
|
119
|
+
async function performUpdate(force) {
|
|
120
|
+
isUpdating = true;
|
|
121
|
+
const startTime = Date.now();
|
|
122
|
+
try {
|
|
123
|
+
const { buildClientSchema, getIntrospectionQuery, printSchema, buildASTSchema, parse: gqlParse, isObjectType } = require("graphql");
|
|
124
|
+
let tempSchema;
|
|
125
|
+
// --- FETCHING LOGIC (Unified Source) ---
|
|
73
126
|
if (env.SCHEMA) {
|
|
74
|
-
|
|
127
|
+
let sdl;
|
|
75
128
|
if (env.SCHEMA.startsWith("http")) {
|
|
129
|
+
// Remote SDL File: Fetch via HTTP
|
|
76
130
|
const response = await fetch(env.SCHEMA);
|
|
131
|
+
if (!response.ok)
|
|
132
|
+
throw new Error(`Remote_SDL_Fetch_Failed: ${response.statusText}`);
|
|
77
133
|
sdl = await response.text();
|
|
78
134
|
}
|
|
79
135
|
else {
|
|
136
|
+
// Local SDL File: Use your custom helper (readFile inside)
|
|
80
137
|
sdl = await (0, introspection_js_1.introspectLocalSchema)(env.SCHEMA);
|
|
81
138
|
}
|
|
82
|
-
|
|
83
|
-
|
|
139
|
+
// Direct path: Convert raw SDL string to GraphQLSchema object
|
|
140
|
+
tempSchema = buildASTSchema(gqlParse(sdl));
|
|
84
141
|
}
|
|
85
142
|
else {
|
|
143
|
+
// Standard Path: Execute Introspection Query against live ENDPOINT
|
|
86
144
|
const response = await fetch(env.ENDPOINT, {
|
|
87
145
|
method: "POST",
|
|
88
146
|
headers: { "Content-Type": "application/json", ...env.HEADERS },
|
|
89
147
|
body: JSON.stringify({ query: getIntrospectionQuery() }),
|
|
90
148
|
});
|
|
91
149
|
if (!response.ok)
|
|
92
|
-
throw new Error(`
|
|
150
|
+
throw new Error(`HTTP_${response.status}: ${response.statusText}`);
|
|
93
151
|
const result = await response.json();
|
|
94
|
-
|
|
95
|
-
|
|
152
|
+
if (!result.data)
|
|
153
|
+
throw new Error("Invalid GraphQL response: Missing 'data' field.");
|
|
154
|
+
// Build Schema object from introspection JSON
|
|
155
|
+
tempSchema = buildClientSchema(result.data);
|
|
156
|
+
}
|
|
157
|
+
// --- UNIFIED STRUCTURAL ANALYSIS (For AI Report) ---
|
|
158
|
+
const typeMap = tempSchema.getTypeMap();
|
|
159
|
+
// Filter "Business Labels" (Nodes) while ignoring internal scalars/system types
|
|
160
|
+
const businessTypes = Object.keys(typeMap).filter(typeName => {
|
|
161
|
+
const type = typeMap[typeName];
|
|
162
|
+
return (!typeName.startsWith('__') &&
|
|
163
|
+
!['Query', 'Mutation', 'Subscription'].includes(typeName) &&
|
|
164
|
+
!['String', 'Int', 'Float', 'Boolean', 'ID', 'BigInt', 'DateTime'].includes(typeName) &&
|
|
165
|
+
isObjectType(type));
|
|
166
|
+
});
|
|
167
|
+
// Maintain state for "Gap Analysis"
|
|
168
|
+
lastKnownTypeCount = businessTypes.length;
|
|
169
|
+
const currentSDL = printSchema(tempSchema);
|
|
170
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
171
|
+
// --- CACHE & NOTIFICATION ---
|
|
172
|
+
if (currentSDL !== cachedSDL) {
|
|
173
|
+
cachedSDL = currentSDL;
|
|
174
|
+
cachedSchemaObject = tempSchema; // Store the live Schema object for tool execution
|
|
175
|
+
return [
|
|
176
|
+
`✨ SCHEMA EVOLVED (${duration}s)`,
|
|
177
|
+
`📊 Source: ${env.SCHEMA ? 'Local/Remote SDL' : 'Live Endpoint'}`,
|
|
178
|
+
`🧬 Labels: ${businessTypes.join(', ') || 'None'}`,
|
|
179
|
+
`---`,
|
|
180
|
+
`The bridge has updated the graph model. New types are now queryable.`
|
|
181
|
+
].join('\n');
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
return `✅ Status: Schema stable (${lastKnownTypeCount} labels).`;
|
|
96
185
|
}
|
|
97
|
-
console.error(`[SCHEMA] Successfully loaded and cached GraphQL schema`);
|
|
98
|
-
return cachedSDL;
|
|
99
186
|
}
|
|
100
187
|
catch (error) {
|
|
101
|
-
|
|
102
|
-
|
|
188
|
+
// Informative error report to prevent "AI confusion"
|
|
189
|
+
return [
|
|
190
|
+
`❌ SCHEMA SYNC FAILED`,
|
|
191
|
+
`🔍 Reason: ${error.message}`,
|
|
192
|
+
`🛠️ Action: Verify your ${env.SCHEMA ? 'file path' : 'endpoint connection'} and retry.`
|
|
193
|
+
].join('\n');
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
isUpdating = false;
|
|
197
|
+
updatePromise = null;
|
|
103
198
|
}
|
|
104
199
|
}
|
|
105
200
|
// --- TOOL HANDLERS ---
|
|
106
201
|
const toolHandlers = new Map();
|
|
202
|
+
/** * executionLogs stores the last 5 GraphQL operations.
|
|
203
|
+
* This allows the AI to "inspect" its own generated queries and the raw data
|
|
204
|
+
* for debugging or bridging to 3D visualization tools.
|
|
205
|
+
*/
|
|
206
|
+
let executionLogs = [];
|
|
107
207
|
// Tool: query-graphql
|
|
108
208
|
const queryGraphqlHandler = async ({ query, variables, headers }) => {
|
|
109
209
|
try {
|
|
@@ -121,9 +221,32 @@ const queryGraphqlHandler = async ({ query, variables, headers }) => {
|
|
|
121
221
|
headers: allHeaders,
|
|
122
222
|
body: JSON.stringify({ query, variables: parsedVariables }),
|
|
123
223
|
});
|
|
124
|
-
const data = await response.json();
|
|
224
|
+
const data = (await response.json());
|
|
225
|
+
// 1. Extract and sanitize Cypher if present in extensions
|
|
226
|
+
const rawCypher = data.extensions?.cypher || [];
|
|
227
|
+
const cleanCypher = rawCypher.map((c) => c.replace(/^CYPHER: /, '')
|
|
228
|
+
.replace(/^CYPHER 5\n/, '')
|
|
229
|
+
.replace(/\nPARAMS: \{\}$/, ''));
|
|
230
|
+
// 2. Update execution history for internal server state
|
|
231
|
+
executionLogs.push({
|
|
232
|
+
query,
|
|
233
|
+
variables: parsedVariables,
|
|
234
|
+
response: data,
|
|
235
|
+
timestamp: new Date().toISOString()
|
|
236
|
+
});
|
|
237
|
+
if (executionLogs.length > 5)
|
|
238
|
+
executionLogs.shift();
|
|
239
|
+
// 3. Prepare optimized response for Claude
|
|
240
|
+
const responseForClaude = {
|
|
241
|
+
result: data.data,
|
|
242
|
+
// Only add the cypher field if there's actual data to show
|
|
243
|
+
...(cleanCypher.length > 0 ? { cypher: cleanCypher } : {})
|
|
244
|
+
};
|
|
125
245
|
return {
|
|
126
|
-
content: [{
|
|
246
|
+
content: [{
|
|
247
|
+
type: "text",
|
|
248
|
+
text: JSON.stringify(responseForClaude, null, 2)
|
|
249
|
+
}]
|
|
127
250
|
};
|
|
128
251
|
}
|
|
129
252
|
catch (error) {
|
|
@@ -138,14 +261,56 @@ server.tool("query-graphql", "Execute a GraphQL query against the endpoint", {
|
|
|
138
261
|
}, queryGraphqlHandler);
|
|
139
262
|
// Tool: introspect-schema
|
|
140
263
|
const introspectHandler = async ({ typeNames }) => {
|
|
141
|
-
|
|
264
|
+
// 1. Always pull the latest schema state
|
|
265
|
+
// The report from performUpdate is captured to show evolution details
|
|
266
|
+
const evolutionSummary = await getSchema(true);
|
|
267
|
+
const schema = cachedSchemaObject;
|
|
268
|
+
const typeMap = schema.getTypeMap();
|
|
269
|
+
// 2. Generate a structural fingerprint
|
|
270
|
+
const typeKeys = Object.keys(typeMap).filter(t => !t.startsWith('__'));
|
|
271
|
+
const schemaVersion = `v${typeKeys.length}.${Math.floor(Date.now() / 10000) % 1000}`;
|
|
272
|
+
// --- GAP ANALYSIS: Check if requested types are actually in the map ---
|
|
273
|
+
if (typeNames && typeNames.length > 0) {
|
|
274
|
+
const missing = typeNames.filter(name => !typeMap[name]);
|
|
275
|
+
if (missing.length > 0) {
|
|
276
|
+
return {
|
|
277
|
+
content: [{
|
|
278
|
+
type: "text",
|
|
279
|
+
text: `❌ PARTIAL RESULTS [ID: ${schemaVersion}]\n\n` +
|
|
280
|
+
`MISSING TYPES: ${missing.join(", ")}\n` +
|
|
281
|
+
`REASON: The database has been updated, but the GraphQL Schema is still regenerating these specific types.\n` +
|
|
282
|
+
`ACTION: Please wait 3 seconds and retry 'introspect-schema' to see the full graph.\n\n` +
|
|
283
|
+
`CURRENTLY AVAILABLE: ${typeKeys.filter(t => !['Query', 'Mutation', 'Report'].includes(t)).join(", ")}`
|
|
284
|
+
}]
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
142
288
|
if (!typeNames || typeNames.length === 0) {
|
|
143
|
-
const
|
|
289
|
+
const queryType = schema.getQueryType();
|
|
290
|
+
const discoveredEntities = new Set();
|
|
291
|
+
if (queryType) {
|
|
292
|
+
const queryFields = queryType.getFields();
|
|
293
|
+
Object.values(queryFields).forEach((field) => {
|
|
294
|
+
const namedType = (0, graphql_1.getNamedType)(field.type);
|
|
295
|
+
if ((0, graphql_1.isObjectType)(namedType) && !namedType.name.startsWith('__')) {
|
|
296
|
+
discoveredEntities.add(namedType.name);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
const coreEntities = Array.from(discoveredEntities).sort();
|
|
144
301
|
return {
|
|
145
|
-
content: [{
|
|
302
|
+
content: [{
|
|
303
|
+
type: "text",
|
|
304
|
+
text: `${evolutionSummary}\n\n` + // Include the report from performUpdate
|
|
305
|
+
`GraphQL Schema Manifest [ID: ${schemaVersion}]\n\n` +
|
|
306
|
+
`ENTRY_POINT_ENTITIES: ${coreEntities.join(", ") || "None"}\n` +
|
|
307
|
+
`TOTAL_SCHEMA_TYPES: ${typeKeys.length}\n\n` +
|
|
308
|
+
`ALL_AVAILABLE_TYPES: ${typeKeys.join(", ")}`
|
|
309
|
+
}]
|
|
146
310
|
};
|
|
147
311
|
}
|
|
148
|
-
|
|
312
|
+
// 3. Detailed introspection for specific types
|
|
313
|
+
const filtered = (0, introspection_js_1.introspectSpecificTypes)(schema, typeNames);
|
|
149
314
|
return {
|
|
150
315
|
content: [{ type: "text", text: JSON.stringify(filtered, null, 2) }]
|
|
151
316
|
};
|
package/package.json
CHANGED