@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.
Files changed (3) hide show
  1. package/README.md +13 -0
  2. package/dist/index.js +185 -20
  3. 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
- async function getSchema() {
66
- if (cachedSDL)
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
- const { buildClientSchema, getIntrospectionQuery, printSchema, buildASTSchema, parse: gqlParse } = require("graphql");
72
- let sdl;
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
- // Check if it's a URL or local path
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
- cachedSchemaObject = buildASTSchema(gqlParse(sdl));
83
- cachedSDL = sdl;
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(`Fetch failed: ${response.statusText}`);
150
+ throw new Error(`HTTP_${response.status}: ${response.statusText}`);
93
151
  const result = await response.json();
94
- cachedSchemaObject = buildClientSchema(result.data);
95
- cachedSDL = printSchema(cachedSchemaObject);
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
- schemaLoadError = error instanceof Error ? error : new Error(String(error));
102
- throw schemaLoadError;
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: [{ type: "text", text: JSON.stringify(data, null, 2) }]
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
- await getSchema();
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 allTypeNames = Object.keys(cachedSchemaObject.getTypeMap()).filter(t => !t.startsWith('__'));
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: [{ type: "text", text: `Schema is large. Available types: ${allTypeNames.join(", ")}` }]
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
- const filtered = (0, introspection_js_1.introspectSpecificTypes)(cachedSchemaObject, typeNames);
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
@@ -50,5 +50,5 @@
50
50
  "tsx": "^4.21.0",
51
51
  "typescript": "5.8.3"
52
52
  },
53
- "version": "3.3.0"
53
+ "version": "3.4.0"
54
54
  }