@letoribo/mcp-graphql-enhanced 3.9.0 → 3.9.1
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 +5 -6
- package/dist/index.js +178 -295
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,15 +23,14 @@ This is the best place to share your feedback, report issues, or suggest new "en
|
|
|
23
23
|
* ✅ **Dynamic Schema Evolution** — Smart diagnostics and gap analysis for servers that regenerate GraphQL types on-the-fly (like Neo4j).
|
|
24
24
|
* ✅ **Deep Observability** — Automatic Cypher extraction and cleaning from GraphQL extensions.
|
|
25
25
|
|
|
26
|
-
## 🚀 Multi-
|
|
27
|
-
|
|
26
|
+
## 🚀 Federated Multi-Node Architecture (v3.9.1+)
|
|
27
|
+
The server operates as a Federated GraphQL Gateway, merging independent nodes into a unified system.
|
|
28
28
|
|
|
29
29
|
* **Zero Breaking Changes**: If you provide a single URL in `ENDPOINT`, the server behaves exactly as before.
|
|
30
|
-
* **
|
|
30
|
+
* **Federated Introspection**: Scans all endpoints simultaneously to build a global capability map.
|
|
31
|
+
* **Smart Aggregation**: When multiple comma-separated URLs are provided, the server broadcasts queries and merges results using universal deep deduplication (object-level).
|
|
32
|
+
* **Conflict Handling**: Identifies structural differences in identical Type names across nodes and exposes them uniquely.
|
|
31
33
|
* **Bypass Free Tier Limits**: Perfect for users of "Free Tier" cloud databases (like Neo4j Aura). You can split your data across multiple free instances and use this bridge to query them as a **single unified graph**, effectively bypassing entity count limitations.
|
|
32
|
-
* **Deduplication**: The bridge automatically removes duplicate objects based on their unique fields to keep the AI's context window clean.
|
|
33
|
-
|
|
34
|
-
> **⚠️ Use at your own risk:** This feature assumes all endpoints share the same (or very similar) GraphQL schema. The introspection is performed against the **first** endpoint in the list.
|
|
35
34
|
|
|
36
35
|
#### 💡 Use Case: Bridging WSL and Windows (PowerShell)
|
|
37
36
|
A common challenge for Windows developers is the network isolation between the Windows Subsystem for Linux (WSL) and the host OS. This feature allows you to bridge these two worlds into a "Unified Nervous System".
|
package/dist/index.js
CHANGED
|
@@ -30,15 +30,13 @@ const getVersion = () => {
|
|
|
30
30
|
};
|
|
31
31
|
(0, deprecation_js_1.checkDeprecatedArguments)();
|
|
32
32
|
/**
|
|
33
|
-
* Environment configuration schema
|
|
33
|
+
* Environment configuration schema - Strict validation
|
|
34
34
|
*/
|
|
35
35
|
const EnvSchema = zod_1.default.object({
|
|
36
36
|
NAME: zod_1.default.string().default("mcp-graphql-enhanced"),
|
|
37
37
|
ENDPOINT: zod_1.default.preprocess((val) => {
|
|
38
|
-
if (typeof val === 'string')
|
|
39
|
-
// Support for multiple endpoints via comma-separated string
|
|
38
|
+
if (typeof val === 'string')
|
|
40
39
|
return val.trim();
|
|
41
|
-
}
|
|
42
40
|
return val;
|
|
43
41
|
}, zod_1.default.string().min(1)).default("https://mcp-neo4j-discord.vercel.app/api/graphiql"),
|
|
44
42
|
ALLOW_MUTATIONS: zod_1.default
|
|
@@ -69,57 +67,52 @@ const EnvSchema = zod_1.default.object({
|
|
|
69
67
|
.default("auto"),
|
|
70
68
|
});
|
|
71
69
|
const env = EnvSchema.parse(process.env);
|
|
70
|
+
/**
|
|
71
|
+
* Initialize MCP Server with full capabilities
|
|
72
|
+
*/
|
|
72
73
|
const server = new mcp_js_1.McpServer({
|
|
73
74
|
name: env.NAME,
|
|
74
75
|
version: getVersion(),
|
|
75
|
-
description: "
|
|
76
|
+
description: "Federated GraphQL-to-MCP bridge with broadcast introspection and full type visibility."
|
|
76
77
|
}, {
|
|
77
78
|
capabilities: {
|
|
78
79
|
prompts: {},
|
|
79
80
|
tools: {}
|
|
80
81
|
}
|
|
81
82
|
});
|
|
82
|
-
// ---
|
|
83
|
+
// --- GLOBAL STATE MANAGEMENT ---
|
|
83
84
|
let cachedSDL = null;
|
|
84
85
|
let cachedSchemaObject = null;
|
|
86
|
+
let cachedSchemas = [];
|
|
85
87
|
let schemaLoadError = null;
|
|
86
88
|
let isUpdating = false;
|
|
87
89
|
let updatePromise = null;
|
|
88
|
-
let lastKnownTypeCount = 0;
|
|
89
90
|
/**
|
|
90
|
-
*
|
|
91
|
-
* @param force If true, blocks and waits for the new schema evolution.
|
|
92
|
-
* If false, returns cache immediately and updates in background.
|
|
91
|
+
* Schema Fetcher with dependency tracking
|
|
93
92
|
*/
|
|
94
93
|
async function getSchema(force = false, requestedTypes) {
|
|
95
|
-
// 1. Hook into existing update if in progress
|
|
96
94
|
if (isUpdating && updatePromise) {
|
|
97
95
|
if (force || !cachedSDL)
|
|
98
96
|
return await updatePromise;
|
|
99
97
|
return cachedSDL;
|
|
100
98
|
}
|
|
101
|
-
// 2. Return cache if valid and not forcing
|
|
102
99
|
if (cachedSDL && !force) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
if (missing.length > 0) {
|
|
108
|
-
// Force a refresh if requested types are missing
|
|
100
|
+
if (requestedTypes && cachedSchemas.length > 0) {
|
|
101
|
+
const allTypes = new Set(cachedSchemas.flatMap(s => Object.keys(s.getTypeMap())));
|
|
102
|
+
const missing = requestedTypes.filter(t => !allTypes.has(t));
|
|
103
|
+
if (missing.length > 0)
|
|
109
104
|
return await (updatePromise = performUpdate(true));
|
|
110
|
-
}
|
|
111
105
|
}
|
|
112
|
-
return
|
|
106
|
+
return cachedSchemaObject;
|
|
113
107
|
}
|
|
114
108
|
if (force)
|
|
115
109
|
schemaLoadError = null;
|
|
116
110
|
if (schemaLoadError)
|
|
117
111
|
throw schemaLoadError;
|
|
118
|
-
// 3. Trigger update
|
|
119
112
|
updatePromise = performUpdate(force);
|
|
120
113
|
try {
|
|
121
114
|
if (force || !cachedSDL) {
|
|
122
|
-
await updatePromise;
|
|
115
|
+
await updatePromise;
|
|
123
116
|
return cachedSchemaObject;
|
|
124
117
|
}
|
|
125
118
|
return cachedSchemaObject;
|
|
@@ -129,354 +122,246 @@ async function getSchema(force = false, requestedTypes) {
|
|
|
129
122
|
}
|
|
130
123
|
}
|
|
131
124
|
/**
|
|
132
|
-
*
|
|
133
|
-
* Optimized for Multi-Endpoint Broadcast: Uses the first available URL for discovery.
|
|
125
|
+
* Federated Update: Orchestrates introspection across all endpoints
|
|
134
126
|
*/
|
|
135
127
|
async function performUpdate(force) {
|
|
136
128
|
isUpdating = true;
|
|
137
129
|
const startTime = Date.now();
|
|
138
130
|
try {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
let sdl;
|
|
144
|
-
if (env.SCHEMA.startsWith("http")) {
|
|
145
|
-
// Remote SDL File
|
|
146
|
-
const response = await fetch(env.SCHEMA);
|
|
147
|
-
if (!response.ok)
|
|
148
|
-
throw new Error(`Remote_SDL_Fetch_Failed: ${response.statusText}`);
|
|
149
|
-
sdl = await response.text();
|
|
150
|
-
}
|
|
151
|
-
else {
|
|
152
|
-
// Local SDL File via helper
|
|
153
|
-
sdl = await (0, introspection_js_1.introspectLocalSchema)(env.SCHEMA);
|
|
154
|
-
}
|
|
155
|
-
tempSchema = buildASTSchema(gqlParse(sdl));
|
|
156
|
-
}
|
|
157
|
-
else {
|
|
158
|
-
// --- BROADCAST ADAPTATION ---
|
|
159
|
-
// Extract the primary endpoint for introspection
|
|
160
|
-
const endpoints = env.ENDPOINT.split(',').map(url => url.trim());
|
|
161
|
-
const primaryEndpoint = endpoints[0];
|
|
162
|
-
// Execute Introspection against the primary target
|
|
163
|
-
const response = await fetch(primaryEndpoint, {
|
|
131
|
+
const endpoints = env.ENDPOINT.split(',').map(url => url.trim());
|
|
132
|
+
console.error(`[SYNC] Initializing broadcast to ${endpoints.length} nodes...`);
|
|
133
|
+
const results = await Promise.allSettled(endpoints.map(async (url) => {
|
|
134
|
+
const response = await fetch(url, {
|
|
164
135
|
method: "POST",
|
|
165
136
|
headers: { "Content-Type": "application/json", ...env.HEADERS },
|
|
166
|
-
body: JSON.stringify({ query: getIntrospectionQuery() }),
|
|
137
|
+
body: JSON.stringify({ query: (0, graphql_1.getIntrospectionQuery)() }),
|
|
138
|
+
signal: AbortSignal.timeout(10000)
|
|
167
139
|
});
|
|
168
140
|
if (!response.ok)
|
|
169
|
-
throw new Error(`
|
|
141
|
+
throw new Error(`HTTP Error ${response.status}`);
|
|
170
142
|
const result = await response.json();
|
|
171
143
|
if (!result.data)
|
|
172
|
-
throw new Error("
|
|
173
|
-
|
|
144
|
+
throw new Error("Empty introspection data");
|
|
145
|
+
const schema = (0, graphql_1.buildClientSchema)(result.data);
|
|
146
|
+
schema._originUrl = url;
|
|
147
|
+
return { url, schema };
|
|
148
|
+
}));
|
|
149
|
+
const successful = results
|
|
150
|
+
.filter((r) => r.status === 'fulfilled')
|
|
151
|
+
.map(r => r.value);
|
|
152
|
+
const failures = results
|
|
153
|
+
.filter((r) => r.status === 'rejected')
|
|
154
|
+
.map(r => r.reason.message);
|
|
155
|
+
if (successful.length === 0) {
|
|
156
|
+
throw new Error(`Federation failed. All nodes unreachable: ${failures.join(', ')}`);
|
|
174
157
|
}
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
158
|
+
// Update cache with all discovered schemas
|
|
159
|
+
cachedSchemas = successful.map(s => s.schema);
|
|
160
|
+
cachedSchemaObject = cachedSchemas[0]; // Baseline schema
|
|
161
|
+
cachedSDL = (0, graphql_1.printSchema)(cachedSchemaObject);
|
|
162
|
+
// Generate Node Manifest for AI reasoning (Full data, no slices)
|
|
163
|
+
global.nodeManifest = successful.map(node => {
|
|
164
|
+
const typeMap = node.schema.getTypeMap();
|
|
165
|
+
const domainTypes = Object.keys(typeMap).filter(name => {
|
|
166
|
+
const type = typeMap[name];
|
|
167
|
+
return !name.startsWith('__') && !(0, graphql_1.isScalarType)(type);
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
endpoint: node.url,
|
|
171
|
+
availableMutations: Object.keys(node.schema.getMutationType()?.getFields() || {}),
|
|
172
|
+
domainEntities: domainTypes
|
|
173
|
+
};
|
|
184
174
|
});
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
cachedSchemaObject = tempSchema;
|
|
189
|
-
if (currentSDL !== cachedSDL) {
|
|
190
|
-
cachedSDL = currentSDL;
|
|
191
|
-
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
192
|
-
const endpointLabel = env.SCHEMA ? 'Local/Remote SDL' : `Live Broadcast Node (${env.ENDPOINT.split(',').length} targets)`;
|
|
193
|
-
return [
|
|
194
|
-
`✨ SCHEMA EVOLVED (${duration}s)`,
|
|
195
|
-
`📊 Source: ${endpointLabel}`,
|
|
196
|
-
`🧬 Labels: ${businessTypes.join(', ') || 'None'}`,
|
|
197
|
-
`---`,
|
|
198
|
-
`The bridge has updated the graph model. New types are now queryable.`
|
|
199
|
-
].join('\n');
|
|
200
|
-
}
|
|
201
|
-
else {
|
|
202
|
-
return `✅ Status: Schema stable (${lastKnownTypeCount} labels).`;
|
|
203
|
-
}
|
|
175
|
+
const uniqueTypeCount = new Set(cachedSchemas.flatMap(s => Object.keys(s.getTypeMap()))).size;
|
|
176
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
177
|
+
return `✅ FEDERATION SYNCED: ${successful.length} active nodes, ${uniqueTypeCount} unique types discovered in ${duration}s.`;
|
|
204
178
|
}
|
|
205
179
|
catch (error) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
`🛠️ Action: Verify your connection and retry.`
|
|
210
|
-
].join('\n');
|
|
180
|
+
console.error(`[CRITICAL] Sync failure: ${error.message}`);
|
|
181
|
+
schemaLoadError = error;
|
|
182
|
+
return `❌ SYNC ERROR: ${error.message}`;
|
|
211
183
|
}
|
|
212
184
|
finally {
|
|
213
185
|
isUpdating = false;
|
|
214
186
|
updatePromise = null;
|
|
215
187
|
}
|
|
216
188
|
}
|
|
217
|
-
// ---
|
|
189
|
+
// --- TOOLS IMPLEMENTATION ---
|
|
218
190
|
const toolHandlers = new Map();
|
|
219
191
|
const registeredToolsMetadata = [];
|
|
220
|
-
/**
|
|
221
|
-
* History buffer for the last 5 operations to support debugging and visualization.
|
|
222
|
-
*/
|
|
223
|
-
let executionLogs = [];
|
|
224
192
|
/**
|
|
225
193
|
* Tool: query-graphql
|
|
226
|
-
*
|
|
194
|
+
* Broadcasts queries to all nodes and merges results with universal deduplication.
|
|
227
195
|
*/
|
|
228
196
|
const queryGraphqlHandler = async ({ query, variables, headers }) => {
|
|
229
197
|
try {
|
|
230
198
|
const parsedQuery = (0, language_1.parse)(query);
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
233
|
-
throw new Error("
|
|
199
|
+
const hasMutation = parsedQuery.definitions.some((def) => def.kind === "OperationDefinition" && def.operation === "mutation");
|
|
200
|
+
if (hasMutation && !env.ALLOW_MUTATIONS) {
|
|
201
|
+
throw new Error("Mutation execution is blocked by ALLOW_MUTATIONS=false.");
|
|
234
202
|
}
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
if (typeof variables === 'string')
|
|
239
|
-
parsedVariables = JSON.parse(variables);
|
|
240
|
-
// Split multiple endpoints for broadcast
|
|
203
|
+
const runtimeHeaders = headers ? JSON.parse(headers) : {};
|
|
204
|
+
const fetchHeaders = { "Content-Type": "application/json", ...env.HEADERS, ...runtimeHeaders };
|
|
205
|
+
const fetchVariables = variables ? (typeof variables === 'string' ? JSON.parse(variables) : variables) : undefined;
|
|
241
206
|
const endpoints = env.ENDPOINT.split(',').map(url => url.trim());
|
|
242
|
-
|
|
243
|
-
const settleResults = await Promise.allSettled(endpoints.map(async (url) => {
|
|
244
|
-
console.error(`[QUERY] Sending to ${url}`);
|
|
207
|
+
const executeResults = await Promise.allSettled(endpoints.map(async (url) => {
|
|
245
208
|
const response = await fetch(url, {
|
|
246
209
|
method: "POST",
|
|
247
|
-
headers:
|
|
248
|
-
body: JSON.stringify({ query, variables:
|
|
249
|
-
signal: AbortSignal.timeout(
|
|
210
|
+
headers: fetchHeaders,
|
|
211
|
+
body: JSON.stringify({ query, variables: fetchVariables }),
|
|
212
|
+
signal: AbortSignal.timeout(15000)
|
|
250
213
|
});
|
|
251
|
-
if (!response.ok)
|
|
252
|
-
throw new Error(`
|
|
253
|
-
}
|
|
254
|
-
return {
|
|
255
|
-
url,
|
|
256
|
-
data: await response.json()
|
|
257
|
-
};
|
|
214
|
+
if (!response.ok)
|
|
215
|
+
throw new Error(`Node ${url} returned ${response.status}`);
|
|
216
|
+
return { url, data: await response.json() };
|
|
258
217
|
}));
|
|
259
|
-
const
|
|
218
|
+
const successes = executeResults
|
|
260
219
|
.filter((r) => r.status === 'fulfilled')
|
|
261
220
|
.map(r => r.value);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
successfulResponses.forEach((response) => {
|
|
270
|
-
const data = response.data.data;
|
|
271
|
-
if (data && data.patterns) {
|
|
272
|
-
console.error(`[DEBUG] Received ${data.patterns.length} patterns from ${response.url}`);
|
|
273
|
-
}
|
|
274
|
-
if (!data)
|
|
221
|
+
if (successes.length === 0)
|
|
222
|
+
throw new Error("Execution failed on all available nodes.");
|
|
223
|
+
// Aggregate and Deduplicate
|
|
224
|
+
const mergedData = {};
|
|
225
|
+
successes.forEach((resp) => {
|
|
226
|
+
const nodeData = resp.data.data;
|
|
227
|
+
if (!nodeData)
|
|
275
228
|
return;
|
|
276
|
-
Object.keys(
|
|
277
|
-
if (Array.isArray(
|
|
278
|
-
|
|
279
|
-
|
|
229
|
+
Object.keys(nodeData).forEach(key => {
|
|
230
|
+
if (Array.isArray(nodeData[key])) {
|
|
231
|
+
const existing = mergedData[key] || [];
|
|
232
|
+
const combined = [...existing, ...nodeData[key]];
|
|
233
|
+
// Universal object-level deduplication
|
|
234
|
+
mergedData[key] = Array.from(new Set(combined.map(v => JSON.stringify(v))))
|
|
235
|
+
.map(s => JSON.parse(s));
|
|
280
236
|
}
|
|
281
|
-
else if (typeof
|
|
282
|
-
|
|
283
|
-
aggregatedResult[key] = { ...(aggregatedResult[key] || {}), ...data[key] };
|
|
237
|
+
else if (typeof nodeData[key] === 'object' && nodeData[key] !== null) {
|
|
238
|
+
mergedData[key] = { ...(mergedData[key] || {}), ...nodeData[key] };
|
|
284
239
|
}
|
|
285
240
|
else {
|
|
286
|
-
|
|
287
|
-
aggregatedResult[key] = data[key];
|
|
241
|
+
mergedData[key] = nodeData[key];
|
|
288
242
|
}
|
|
289
243
|
});
|
|
290
244
|
});
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
aggregatedResult.patterns.forEach((p) => unique.set(p.sticking, p));
|
|
295
|
-
aggregatedResult.patterns = Array.from(unique.values());
|
|
296
|
-
}
|
|
297
|
-
// 3. Extract Cypher queries from extensions
|
|
298
|
-
const allCypher = successfulResponses.flatMap(r => r.data.extensions?.cypher || []);
|
|
299
|
-
const cleanCypher = allCypher.map((c) => c.replace(/^CYPHER: /, '').replace(/^CYPHER 5\n/, '').replace(/\nPARAMS: \{\}$/, ''));
|
|
300
|
-
// 4. Final response for the AI agent
|
|
301
|
-
const responseForClaude = {
|
|
302
|
-
status: {
|
|
303
|
-
total: endpoints.length,
|
|
304
|
-
success: successfulResponses.length,
|
|
305
|
-
failed: failedResponses.length
|
|
306
|
-
},
|
|
307
|
-
result: aggregatedResult,
|
|
308
|
-
...(cleanCypher.length > 0 ? { cypher: Array.from(new Set(cleanCypher)) } : {}),
|
|
309
|
-
...(failedResponses.length > 0 ? { warnings: failedResponses } : {})
|
|
310
|
-
};
|
|
245
|
+
// Restore Cypher extraction logic
|
|
246
|
+
const cypherLogs = successes.flatMap(r => r.data.extensions?.cypher || []);
|
|
247
|
+
const cleanCypher = cypherLogs.map((c) => c.replace(/^CYPHER: /, '').replace(/^CYPHER 5\n/, '').replace(/\nPARAMS: \{\}$/, ''));
|
|
311
248
|
return {
|
|
312
249
|
content: [{
|
|
313
250
|
type: "text",
|
|
314
|
-
text: JSON.stringify(
|
|
251
|
+
text: JSON.stringify({
|
|
252
|
+
meta: { nodes_queried: endpoints.length, nodes_responding: successes.length },
|
|
253
|
+
data: mergedData,
|
|
254
|
+
...(cleanCypher.length > 0 ? { cypher_execution_plan: cleanCypher } : {})
|
|
255
|
+
}, null, 2)
|
|
315
256
|
}]
|
|
316
257
|
};
|
|
317
258
|
}
|
|
318
259
|
catch (error) {
|
|
319
|
-
|
|
260
|
+
return { content: [{ type: "text", text: `❌ Execution error: ${error.message}` }], isError: true };
|
|
320
261
|
}
|
|
321
262
|
};
|
|
322
263
|
toolHandlers.set("query-graphql", queryGraphqlHandler);
|
|
323
|
-
(0, tool_registry_js_1.registerTool)(server, toolHandlers, registeredToolsMetadata, "query-graphql", "Execute a GraphQL query
|
|
324
|
-
query: zod_1.default.string(),
|
|
325
|
-
variables: zod_1.default.string().optional(),
|
|
326
|
-
headers: zod_1.default.string().optional(),
|
|
264
|
+
(0, tool_registry_js_1.registerTool)(server, toolHandlers, registeredToolsMetadata, "query-graphql", "Execute a GraphQL query across all nodes in the federated system", {
|
|
265
|
+
query: zod_1.default.string().describe("The GraphQL query or mutation string"),
|
|
266
|
+
variables: zod_1.default.string().optional().describe("JSON string of variables"),
|
|
267
|
+
headers: zod_1.default.string().optional().describe("JSON string of extra headers"),
|
|
327
268
|
}, queryGraphqlHandler);
|
|
328
269
|
/**
|
|
329
270
|
* Tool: introspect-schema
|
|
330
|
-
* Provides
|
|
271
|
+
* Provides a global view of all nodes and resolves type conflicts.
|
|
331
272
|
*/
|
|
332
273
|
const introspectHandler = async ({ typeNames }) => {
|
|
333
|
-
|
|
334
|
-
if (
|
|
335
|
-
return {
|
|
336
|
-
content: [{
|
|
337
|
-
type: "text",
|
|
338
|
-
text: `❌ SCHEMA_ERROR: ${typeof result === 'string' ? result : 'GraphQL schema is not initialized yet.'}\n` +
|
|
339
|
-
`ACTION: Please wait 5-10 seconds for the backend endpoint to respond.`
|
|
340
|
-
}]
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
const schema = result;
|
|
344
|
-
const typeMap = schema.getTypeMap();
|
|
345
|
-
const queryType = schema.getQueryType();
|
|
346
|
-
const queryFields = queryType ? queryType.getFields() : {};
|
|
347
|
-
const mutationType = schema.getMutationType();
|
|
348
|
-
const mutationFields = mutationType ? mutationType.getFields() : {};
|
|
349
|
-
// Gap analysis for requested types
|
|
350
|
-
if (typeNames && typeNames.length > 0) {
|
|
351
|
-
const missing = typeNames.filter(name => {
|
|
352
|
-
const existsAsType = !!typeMap[name];
|
|
353
|
-
const existsAsMutation = !!mutationFields[name];
|
|
354
|
-
const existsAsQueryField = !!queryFields[name];
|
|
355
|
-
return !existsAsType && !existsAsMutation && !existsAsQueryField;
|
|
356
|
-
});
|
|
357
|
-
if (missing.length > 0) {
|
|
358
|
-
const internalTypes = ['Query', 'Mutation', 'Subscription'];
|
|
359
|
-
const availableEntities = Object.keys(typeMap).filter(t => !t.startsWith('__') && !internalTypes.includes(t));
|
|
360
|
-
const schemaVersion = `v${availableEntities.length}.${Math.floor(Date.now() / 10000) % 1000}`;
|
|
361
|
-
return {
|
|
362
|
-
content: [{
|
|
363
|
-
type: "text",
|
|
364
|
-
text: `❌ PARTIAL RESULTS [Schema ID: ${schemaVersion}]\n\n` +
|
|
365
|
-
`MISSING ENTITIES: ${missing.join(", ")}\n` +
|
|
366
|
-
`REASON: Requested entities not found.\n` +
|
|
367
|
-
`ACTION: Re-examine available types below.\n\n` +
|
|
368
|
-
`AVAILABLE_ENTITIES: ${availableEntities.join(", ")}`
|
|
369
|
-
}]
|
|
370
|
-
};
|
|
371
|
-
}
|
|
274
|
+
await getSchema(true);
|
|
275
|
+
if (cachedSchemas.length === 0) {
|
|
276
|
+
return { content: [{ type: "text", text: "❌ System is not initialized." }] };
|
|
372
277
|
}
|
|
373
|
-
// Return
|
|
278
|
+
// Default: Return the Manifest of all nodes
|
|
374
279
|
if (!typeNames || typeNames.length === 0) {
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
280
|
+
const manifest = global.nodeManifest || [];
|
|
281
|
+
const body = manifest.map((m) => `🌐 NODE: ${m.endpoint}\n ACTIONS: ${m.availableMutations.join(', ') || 'none'}\n ENTITIES: ${m.domainEntities.join(', ')}`).join('\n\n');
|
|
282
|
+
return { content: [{ type: "text", text: `FEDERATED SCHEMA OVERVIEW\n\n${body}` }] };
|
|
283
|
+
}
|
|
284
|
+
const resolution = {};
|
|
285
|
+
for (const name of typeNames) {
|
|
286
|
+
const variants = [];
|
|
287
|
+
for (const schema of cachedSchemas) {
|
|
288
|
+
const found = (0, introspection_js_1.introspectSpecificTypes)(schema, [name]);
|
|
289
|
+
if (found && found[name]) {
|
|
290
|
+
variants.push({ origin: schema._originUrl, data: found[name] });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (variants.length === 0)
|
|
294
|
+
continue;
|
|
295
|
+
if (variants.length === 1) {
|
|
296
|
+
resolution[name] = variants[0].data;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
// Check for structural equality
|
|
300
|
+
const baseline = JSON.stringify(variants[0].data);
|
|
301
|
+
const allMatch = variants.every(v => JSON.stringify(v.data) === baseline);
|
|
302
|
+
if (allMatch) {
|
|
303
|
+
resolution[name] = variants[0].data;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
// Conflict: Expose all variants with node origin
|
|
307
|
+
variants.forEach((v, idx) => {
|
|
308
|
+
resolution[`${name}_from_node_${idx + 1}`] = {
|
|
309
|
+
...v.data,
|
|
310
|
+
_meta: { origin_node: v.origin, conflict: "Structural difference detected across schemas" }
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
}
|
|
384
314
|
}
|
|
385
|
-
const entryPoints = Array.from(discoveredEntities).sort();
|
|
386
|
-
const allTypes = Object.keys(typeMap).filter(t => !t.startsWith('__'));
|
|
387
|
-
const schemaVersion = `v${allTypes.length}.${Math.floor(Date.now() / 10000) % 1000}`;
|
|
388
|
-
return {
|
|
389
|
-
content: [{
|
|
390
|
-
type: "text",
|
|
391
|
-
text: `GraphQL Schema Manifest [ID: ${schemaVersion}]\n\n` +
|
|
392
|
-
`ENTRY_POINT_ENTITIES: ${entryPoints.join(", ") || "None"}\n` +
|
|
393
|
-
`TOTAL_SCHEMA_TYPES: ${allTypes.length}\n\n` +
|
|
394
|
-
`ALL_AVAILABLE_TYPES: ${allTypes.join(", ")}`
|
|
395
|
-
}]
|
|
396
|
-
};
|
|
397
315
|
}
|
|
398
|
-
const filtered = (0, introspection_js_1.introspectSpecificTypes)(schema, typeNames);
|
|
399
316
|
return {
|
|
400
|
-
content: [{
|
|
317
|
+
content: [{
|
|
318
|
+
type: "text",
|
|
319
|
+
text: JSON.stringify(resolution, null, 2)
|
|
320
|
+
}]
|
|
401
321
|
};
|
|
402
322
|
};
|
|
403
323
|
toolHandlers.set("introspect-schema", introspectHandler);
|
|
404
|
-
(0, tool_registry_js_1.registerTool)(server, toolHandlers, registeredToolsMetadata, "introspect-schema", "
|
|
405
|
-
typeNames: zod_1.default.array(zod_1.default.string()).optional(),
|
|
324
|
+
(0, tool_registry_js_1.registerTool)(server, toolHandlers, registeredToolsMetadata, "introspect-schema", "Retrieve full type definitions and resolve conflicts across all system nodes", {
|
|
325
|
+
typeNames: zod_1.default.array(zod_1.default.string()).optional().describe("List of type names to introspect"),
|
|
406
326
|
}, introspectHandler);
|
|
407
|
-
// ---
|
|
408
|
-
(0, prompt_registry_js_1.registerPrompt)(server, "health
|
|
409
|
-
|
|
410
|
-
(0, prompt_registry_js_1.registerPrompt)(server, "list-scalars", "List all scalar types", "Run 'introspect-schema' and identify all scalars in the schema.");
|
|
411
|
-
// --- HTTP SERVER LOGIC ---
|
|
412
|
-
/**
|
|
413
|
-
* Local HTTP server to support GraphiQL UI and SSE transport
|
|
414
|
-
*/
|
|
327
|
+
// --- PROMPT REGISTRY ---
|
|
328
|
+
(0, prompt_registry_js_1.registerPrompt)(server, "system-health", "Check status of all nodes", "Perform a simple __typename query on all endpoints.");
|
|
329
|
+
// --- HTTP ADAPTER FOR GRAPHIQL & SSE ---
|
|
415
330
|
async function handleHttpRequest(req, res) {
|
|
416
|
-
// Standard CORS headers
|
|
417
331
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
418
332
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
419
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type
|
|
333
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
420
334
|
if (req.method === 'OPTIONS') {
|
|
421
335
|
res.writeHead(204);
|
|
422
336
|
res.end();
|
|
423
337
|
return;
|
|
424
338
|
}
|
|
425
339
|
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
|
426
|
-
// Render GraphiQL UI
|
|
427
340
|
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/graphiql')) {
|
|
428
341
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
429
|
-
return res.end((0, graphiql_js_1.renderGraphiQL)(`http://localhost
|
|
342
|
+
return res.end((0, graphiql_js_1.renderGraphiQL)(`http://localhost:${env.MCP_PORT}/mcp`, env.HEADERS));
|
|
430
343
|
}
|
|
431
|
-
// Process MCP/GraphQL requests
|
|
432
344
|
if (url.pathname === '/mcp' && req.method === 'POST') {
|
|
433
345
|
let body = '';
|
|
434
346
|
req.on('data', chunk => { body += chunk; });
|
|
435
347
|
req.on('end', async () => {
|
|
436
|
-
let requestId = null;
|
|
437
348
|
try {
|
|
438
349
|
const payload = JSON.parse(body);
|
|
439
|
-
// Handle raw GraphQL queries (e.g., from Docs or Playground)
|
|
440
|
-
if (!payload.method && payload.query) {
|
|
441
|
-
const handler = toolHandlers.get("query-graphql");
|
|
442
|
-
if (handler) {
|
|
443
|
-
const mcpResult = await handler({ query: payload.query, variables: payload.variables });
|
|
444
|
-
const parsed = JSON.parse(mcpResult.content[0].text);
|
|
445
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
446
|
-
const graphQLResponse = parsed.result.data ? parsed.result : { data: parsed.result };
|
|
447
|
-
return res.end(JSON.stringify(graphQLResponse));
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
// Standard MCP JSON-RPC handling
|
|
451
350
|
const { method, id, params } = payload;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (method === "call-tool" || method === "tools/call") {
|
|
456
|
-
targetMethod = params.name;
|
|
457
|
-
toolArgs = params.arguments;
|
|
458
|
-
}
|
|
459
|
-
const handler = toolHandlers.get(targetMethod);
|
|
351
|
+
const target = (method === "call-tool" || method === "tools/call") ? params.name : method;
|
|
352
|
+
const args = (method === "call-tool" || method === "tools/call") ? params.arguments : params;
|
|
353
|
+
const handler = toolHandlers.get(target);
|
|
460
354
|
if (!handler) {
|
|
461
355
|
res.writeHead(404);
|
|
462
|
-
return res.end(JSON.stringify({
|
|
463
|
-
jsonrpc: '2.0',
|
|
464
|
-
id: requestId,
|
|
465
|
-
error: { code: -32601, message: `Tool ${targetMethod} not found` }
|
|
466
|
-
}));
|
|
356
|
+
return res.end(JSON.stringify({ error: { code: -32601, message: "Method not found" } }));
|
|
467
357
|
}
|
|
468
|
-
const result = await handler(
|
|
358
|
+
const result = await handler(args);
|
|
469
359
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
470
|
-
res.end(JSON.stringify({ jsonrpc: '2.0', id
|
|
360
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id, result }));
|
|
471
361
|
}
|
|
472
362
|
catch (e) {
|
|
473
|
-
console.error(`[HTTP-ERROR] ${e.message}`);
|
|
474
363
|
res.writeHead(500);
|
|
475
|
-
res.end(JSON.stringify({
|
|
476
|
-
jsonrpc: '2.0',
|
|
477
|
-
id: requestId,
|
|
478
|
-
error: { code: -32603, message: e.message }
|
|
479
|
-
}));
|
|
364
|
+
res.end(JSON.stringify({ error: { message: e.message } }));
|
|
480
365
|
}
|
|
481
366
|
});
|
|
482
367
|
return;
|
|
@@ -484,34 +369,32 @@ async function handleHttpRequest(req, res) {
|
|
|
484
369
|
res.writeHead(404);
|
|
485
370
|
res.end("Not Found");
|
|
486
371
|
}
|
|
487
|
-
// ---
|
|
372
|
+
// --- SERVER LIFECYCLE ---
|
|
488
373
|
async function main() {
|
|
489
374
|
const isInspector = !!(process.env.MCP_INSPECTOR || process.env.INSPECTOR_PORT);
|
|
490
|
-
const isHttpExplicitlyEnabled = process.env.ENABLE_HTTP === "true";
|
|
491
|
-
// Enable HTTP port by default unless explicitly disabled or in inspector mode
|
|
492
375
|
if (process.env.ENABLE_HTTP !== "false" && !isInspector) {
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
if (e.code === 'EADDRINUSE')
|
|
496
|
-
console.error(`[
|
|
497
|
-
}
|
|
376
|
+
const httpSrv = node_http_1.default.createServer(handleHttpRequest);
|
|
377
|
+
httpSrv.on('error', (e) => {
|
|
378
|
+
if (e.code === 'EADDRINUSE')
|
|
379
|
+
console.error(`[ERROR] Port ${env.MCP_PORT} already in use.`);
|
|
498
380
|
});
|
|
499
|
-
|
|
500
|
-
console.error(`[SYSTEM]
|
|
501
|
-
console.error(
|
|
502
|
-
if (
|
|
503
|
-
console.error(`🎨 GraphiQL
|
|
381
|
+
httpSrv.listen(env.MCP_PORT, () => {
|
|
382
|
+
console.error(`[SYSTEM] Federated Bridge v${getVersion()} active on port ${env.MCP_PORT}`);
|
|
383
|
+
console.error(`📡 SSE Endpoint: http://localhost:${env.MCP_PORT}/mcp`);
|
|
384
|
+
if (process.env.ENABLE_HTTP === "true") {
|
|
385
|
+
console.error(`🎨 GraphiQL: http://localhost:${env.MCP_PORT}/graphiql`);
|
|
504
386
|
}
|
|
505
387
|
});
|
|
506
388
|
}
|
|
507
389
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
508
390
|
await server.connect(transport);
|
|
509
|
-
//
|
|
510
|
-
getSchema().catch(
|
|
391
|
+
// Initial background sync
|
|
392
|
+
getSchema(true).catch(err => console.error(`[BOOT-WARN] Initial sync failed: ${err.message}`));
|
|
511
393
|
}
|
|
512
|
-
|
|
513
|
-
process.on('
|
|
514
|
-
|
|
515
|
-
|
|
394
|
+
// Global process management
|
|
395
|
+
process.on('SIGINT', () => { console.error('[SYSTEM] Shutting down...'); process.exit(0); });
|
|
396
|
+
process.on('SIGTERM', () => { process.exit(0); });
|
|
397
|
+
main().catch(err => {
|
|
398
|
+
console.error(`[FATAL] Startup failed: ${err.message}`);
|
|
516
399
|
process.exit(1);
|
|
517
400
|
});
|
package/package.json
CHANGED