@letoribo/mcp-graphql-enhanced 3.8.1 → 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 +24 -0
- package/dist/index.js +207 -301
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,6 +23,30 @@ 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
|
+
## 🚀 Federated Multi-Node Architecture (v3.9.1+)
|
|
27
|
+
The server operates as a Federated GraphQL Gateway, merging independent nodes into a unified system.
|
|
28
|
+
|
|
29
|
+
* **Zero Breaking Changes**: If you provide a single URL in `ENDPOINT`, the server behaves exactly as before.
|
|
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.
|
|
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.
|
|
34
|
+
|
|
35
|
+
#### 💡 Use Case: Bridging WSL and Windows (PowerShell)
|
|
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".
|
|
37
|
+
|
|
38
|
+
Example configuration for Claude Desktop:
|
|
39
|
+
```bash
|
|
40
|
+
{
|
|
41
|
+
"ENDPOINT": "http://DESKTOP-NAME.local:2311/graphql,http://127.0.0.1:4000/graphql"
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
* **Hybrid Ecosystem**: Seamlessly query and aggregate data across Windows-native processes (PowerShell) and Linux-based environments (WSL).
|
|
45
|
+
|
|
46
|
+
* **mDNS Support**: By using .local addresses, the bridge automatically resolves the host machine's IP from within the WSL environment.
|
|
47
|
+
|
|
48
|
+
* **Transparent Aggregation**: The AI assistant interacts with a single unified schema, unaware that the data is being fetched from different operating systems simultaneously.
|
|
49
|
+
|
|
26
50
|
## 🔍 Advanced Observability & Cypher
|
|
27
51
|
The bridge provides deep insights into how the LLM interacts with your graph database.
|
|
28
52
|
|
package/dist/index.js
CHANGED
|
@@ -16,19 +16,29 @@ const deprecation_js_1 = require("./helpers/deprecation.js");
|
|
|
16
16
|
const introspection_js_1 = require("./helpers/introspection.js");
|
|
17
17
|
const tool_registry_js_1 = require("./helpers/tool-registry.js");
|
|
18
18
|
const prompt_registry_js_1 = require("./helpers/prompt-registry.js");
|
|
19
|
+
/**
|
|
20
|
+
* Retrieves the current version from package.json
|
|
21
|
+
*/
|
|
19
22
|
const getVersion = () => {
|
|
20
23
|
try {
|
|
21
24
|
const pkg = require("../package.json");
|
|
22
25
|
return pkg.version;
|
|
23
26
|
}
|
|
24
27
|
catch {
|
|
25
|
-
return "3.
|
|
28
|
+
return "3.9.0";
|
|
26
29
|
}
|
|
27
30
|
};
|
|
28
31
|
(0, deprecation_js_1.checkDeprecatedArguments)();
|
|
32
|
+
/**
|
|
33
|
+
* Environment configuration schema - Strict validation
|
|
34
|
+
*/
|
|
29
35
|
const EnvSchema = zod_1.default.object({
|
|
30
36
|
NAME: zod_1.default.string().default("mcp-graphql-enhanced"),
|
|
31
|
-
ENDPOINT: zod_1.default.preprocess((val) =>
|
|
37
|
+
ENDPOINT: zod_1.default.preprocess((val) => {
|
|
38
|
+
if (typeof val === 'string')
|
|
39
|
+
return val.trim();
|
|
40
|
+
return val;
|
|
41
|
+
}, zod_1.default.string().min(1)).default("https://mcp-neo4j-discord.vercel.app/api/graphiql"),
|
|
32
42
|
ALLOW_MUTATIONS: zod_1.default
|
|
33
43
|
.enum(["true", "false"])
|
|
34
44
|
.transform((value) => value === "true")
|
|
@@ -57,57 +67,52 @@ const EnvSchema = zod_1.default.object({
|
|
|
57
67
|
.default("auto"),
|
|
58
68
|
});
|
|
59
69
|
const env = EnvSchema.parse(process.env);
|
|
70
|
+
/**
|
|
71
|
+
* Initialize MCP Server with full capabilities
|
|
72
|
+
*/
|
|
60
73
|
const server = new mcp_js_1.McpServer({
|
|
61
74
|
name: env.NAME,
|
|
62
75
|
version: getVersion(),
|
|
63
|
-
description: "
|
|
76
|
+
description: "Federated GraphQL-to-MCP bridge with broadcast introspection and full type visibility."
|
|
64
77
|
}, {
|
|
65
78
|
capabilities: {
|
|
66
79
|
prompts: {},
|
|
67
80
|
tools: {}
|
|
68
81
|
}
|
|
69
82
|
});
|
|
70
|
-
// ---
|
|
83
|
+
// --- GLOBAL STATE MANAGEMENT ---
|
|
71
84
|
let cachedSDL = null;
|
|
72
85
|
let cachedSchemaObject = null;
|
|
86
|
+
let cachedSchemas = [];
|
|
73
87
|
let schemaLoadError = null;
|
|
74
88
|
let isUpdating = false;
|
|
75
89
|
let updatePromise = null;
|
|
76
|
-
let lastKnownTypeCount = 0;
|
|
77
90
|
/**
|
|
78
|
-
*
|
|
79
|
-
* @param force If true, blocks and waits for the new schema evolution.
|
|
80
|
-
* If false, returns cache immediately and updates in background.
|
|
91
|
+
* Schema Fetcher with dependency tracking
|
|
81
92
|
*/
|
|
82
93
|
async function getSchema(force = false, requestedTypes) {
|
|
83
|
-
// 1. Hook into existing update if in progress
|
|
84
94
|
if (isUpdating && updatePromise) {
|
|
85
95
|
if (force || !cachedSDL)
|
|
86
96
|
return await updatePromise;
|
|
87
97
|
return cachedSDL;
|
|
88
98
|
}
|
|
89
|
-
// 2. Return cache if valid and not forcing
|
|
90
99
|
if (cachedSDL && !force) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
if (missing.length > 0) {
|
|
96
|
-
// Force a refresh if requested types are missing from current cache
|
|
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)
|
|
97
104
|
return await (updatePromise = performUpdate(true));
|
|
98
|
-
}
|
|
99
105
|
}
|
|
100
|
-
return
|
|
106
|
+
return cachedSchemaObject;
|
|
101
107
|
}
|
|
102
108
|
if (force)
|
|
103
109
|
schemaLoadError = null;
|
|
104
110
|
if (schemaLoadError)
|
|
105
111
|
throw schemaLoadError;
|
|
106
|
-
// 3. Trigger update
|
|
107
112
|
updatePromise = performUpdate(force);
|
|
108
113
|
try {
|
|
109
114
|
if (force || !cachedSDL) {
|
|
110
|
-
await updatePromise;
|
|
115
|
+
await updatePromise;
|
|
111
116
|
return cachedSchemaObject;
|
|
112
117
|
}
|
|
113
118
|
return cachedSchemaObject;
|
|
@@ -117,378 +122,279 @@ async function getSchema(force = false, requestedTypes) {
|
|
|
117
122
|
}
|
|
118
123
|
}
|
|
119
124
|
/**
|
|
120
|
-
*
|
|
121
|
-
* This version uses universal business-type tracking and provides
|
|
122
|
-
* detailed diagnostic reports instead of generic error messages.
|
|
125
|
+
* Federated Update: Orchestrates introspection across all endpoints
|
|
123
126
|
*/
|
|
124
127
|
async function performUpdate(force) {
|
|
125
128
|
isUpdating = true;
|
|
126
129
|
const startTime = Date.now();
|
|
127
130
|
try {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
let sdl;
|
|
133
|
-
if (env.SCHEMA.startsWith("http")) {
|
|
134
|
-
// Remote SDL File: Fetch via HTTP
|
|
135
|
-
const response = await fetch(env.SCHEMA);
|
|
136
|
-
if (!response.ok)
|
|
137
|
-
throw new Error(`Remote_SDL_Fetch_Failed: ${response.statusText}`);
|
|
138
|
-
sdl = await response.text();
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
141
|
-
// Local SDL File: Use your custom helper (readFile inside)
|
|
142
|
-
sdl = await (0, introspection_js_1.introspectLocalSchema)(env.SCHEMA);
|
|
143
|
-
}
|
|
144
|
-
// Direct path: Convert raw SDL string to GraphQLSchema object
|
|
145
|
-
tempSchema = buildASTSchema(gqlParse(sdl));
|
|
146
|
-
}
|
|
147
|
-
else {
|
|
148
|
-
// Standard Path: Execute Introspection Query against live ENDPOINT
|
|
149
|
-
const response = await fetch(env.ENDPOINT, {
|
|
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, {
|
|
150
135
|
method: "POST",
|
|
151
136
|
headers: { "Content-Type": "application/json", ...env.HEADERS },
|
|
152
|
-
body: JSON.stringify({ query: getIntrospectionQuery() }),
|
|
137
|
+
body: JSON.stringify({ query: (0, graphql_1.getIntrospectionQuery)() }),
|
|
138
|
+
signal: AbortSignal.timeout(10000)
|
|
153
139
|
});
|
|
154
140
|
if (!response.ok)
|
|
155
|
-
throw new Error(`
|
|
141
|
+
throw new Error(`HTTP Error ${response.status}`);
|
|
156
142
|
const result = await response.json();
|
|
157
143
|
if (!result.data)
|
|
158
|
-
throw new Error("
|
|
159
|
-
|
|
160
|
-
|
|
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(', ')}`);
|
|
161
157
|
}
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
};
|
|
171
174
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// --- CACHE & NOTIFICATION ---
|
|
176
|
-
// Always update the live object if we successfully built it
|
|
177
|
-
cachedSchemaObject = tempSchema;
|
|
178
|
-
if (currentSDL !== cachedSDL) {
|
|
179
|
-
cachedSDL = currentSDL;
|
|
180
|
-
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
181
|
-
return [
|
|
182
|
-
`✨ SCHEMA EVOLVED (${duration}s)`,
|
|
183
|
-
`📊 Source: ${env.SCHEMA ? 'Local/Remote SDL' : 'Live Endpoint'}`,
|
|
184
|
-
`🧬 Labels: ${businessTypes.join(', ') || 'None'}`,
|
|
185
|
-
`---`,
|
|
186
|
-
`The bridge has updated the graph model. New types are now queryable.`
|
|
187
|
-
].join('\n');
|
|
188
|
-
}
|
|
189
|
-
else {
|
|
190
|
-
// Even if SDL string is the same, we've ensured cachedSchemaObject is set above
|
|
191
|
-
return `✅ Status: Schema stable (${lastKnownTypeCount} labels).`;
|
|
192
|
-
}
|
|
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.`;
|
|
193
178
|
}
|
|
194
179
|
catch (error) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
`🔍 Reason: ${error.message}`,
|
|
199
|
-
`🛠️ Action: Verify your ${env.SCHEMA ? 'file path' : 'endpoint connection'} and retry.`
|
|
200
|
-
].join('\n');
|
|
180
|
+
console.error(`[CRITICAL] Sync failure: ${error.message}`);
|
|
181
|
+
schemaLoadError = error;
|
|
182
|
+
return `❌ SYNC ERROR: ${error.message}`;
|
|
201
183
|
}
|
|
202
184
|
finally {
|
|
203
185
|
isUpdating = false;
|
|
204
186
|
updatePromise = null;
|
|
205
187
|
}
|
|
206
188
|
}
|
|
207
|
-
// ---
|
|
189
|
+
// --- TOOLS IMPLEMENTATION ---
|
|
208
190
|
const toolHandlers = new Map();
|
|
209
|
-
// This will store schemas for our dynamic HTTP 'list-tools' response
|
|
210
191
|
const registeredToolsMetadata = [];
|
|
211
|
-
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
192
|
+
/**
|
|
193
|
+
* Tool: query-graphql
|
|
194
|
+
* Broadcasts queries to all nodes and merges results with universal deduplication.
|
|
214
195
|
*/
|
|
215
|
-
let executionLogs = [];
|
|
216
|
-
// Tool: query-graphql
|
|
217
196
|
const queryGraphqlHandler = async ({ query, variables, headers }) => {
|
|
218
197
|
try {
|
|
219
198
|
const parsedQuery = (0, language_1.parse)(query);
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
222
|
-
throw new Error("
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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.");
|
|
202
|
+
}
|
|
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;
|
|
206
|
+
const endpoints = env.ENDPOINT.split(',').map(url => url.trim());
|
|
207
|
+
const executeResults = await Promise.allSettled(endpoints.map(async (url) => {
|
|
208
|
+
const response = await fetch(url, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: fetchHeaders,
|
|
211
|
+
body: JSON.stringify({ query, variables: fetchVariables }),
|
|
212
|
+
signal: AbortSignal.timeout(15000)
|
|
213
|
+
});
|
|
214
|
+
if (!response.ok)
|
|
215
|
+
throw new Error(`Node ${url} returned ${response.status}`);
|
|
216
|
+
return { url, data: await response.json() };
|
|
217
|
+
}));
|
|
218
|
+
const successes = executeResults
|
|
219
|
+
.filter((r) => r.status === 'fulfilled')
|
|
220
|
+
.map(r => r.value);
|
|
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)
|
|
228
|
+
return;
|
|
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));
|
|
236
|
+
}
|
|
237
|
+
else if (typeof nodeData[key] === 'object' && nodeData[key] !== null) {
|
|
238
|
+
mergedData[key] = { ...(mergedData[key] || {}), ...nodeData[key] };
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
mergedData[key] = nodeData[key];
|
|
242
|
+
}
|
|
243
|
+
});
|
|
245
244
|
});
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const responseForClaude = {
|
|
250
|
-
result: data.data,
|
|
251
|
-
// Only add the cypher field if there's actual data to show
|
|
252
|
-
...(cleanCypher.length > 0 ? { cypher: cleanCypher } : {})
|
|
253
|
-
};
|
|
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: \{\}$/, ''));
|
|
254
248
|
return {
|
|
255
249
|
content: [{
|
|
256
250
|
type: "text",
|
|
257
|
-
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)
|
|
258
256
|
}]
|
|
259
257
|
};
|
|
260
258
|
}
|
|
261
259
|
catch (error) {
|
|
262
|
-
|
|
260
|
+
return { content: [{ type: "text", text: `❌ Execution error: ${error.message}` }], isError: true };
|
|
263
261
|
}
|
|
264
262
|
};
|
|
265
263
|
toolHandlers.set("query-graphql", queryGraphqlHandler);
|
|
266
|
-
(0, tool_registry_js_1.registerTool)(server, toolHandlers, registeredToolsMetadata, "query-graphql", "Execute a GraphQL query
|
|
267
|
-
query: zod_1.default.string(),
|
|
268
|
-
variables: zod_1.default.string().optional(),
|
|
269
|
-
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"),
|
|
270
268
|
}, queryGraphqlHandler);
|
|
271
269
|
/**
|
|
272
270
|
* Tool: introspect-schema
|
|
273
|
-
*
|
|
274
|
-
* Implements "Agent Recovery" logic to guide the LLM when entities are missing.
|
|
271
|
+
* Provides a global view of all nodes and resolves type conflicts.
|
|
275
272
|
*/
|
|
276
273
|
const introspectHandler = async ({ typeNames }) => {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (!result || typeof result === 'string') {
|
|
281
|
-
return {
|
|
282
|
-
content: [{
|
|
283
|
-
type: "text",
|
|
284
|
-
text: `❌ SCHEMA_ERROR: ${typeof result === 'string' ? result : 'GraphQL schema is not initialized yet.'}\n` +
|
|
285
|
-
`ACTION: Please wait 5-10 seconds for the backend endpoint to respond.`
|
|
286
|
-
}]
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
// --- 1. INITIALIZE MAPPINGS ---
|
|
290
|
-
const schema = result;
|
|
291
|
-
const typeMap = schema.getTypeMap();
|
|
292
|
-
// Cache Root Type fields for rapid gap analysis
|
|
293
|
-
const queryType = schema.getQueryType();
|
|
294
|
-
const queryFields = queryType ? queryType.getFields() : {};
|
|
295
|
-
const mutationType = schema.getMutationType();
|
|
296
|
-
const mutationFields = mutationType ? mutationType.getFields() : {};
|
|
297
|
-
// --- 2. GAP ANALYSIS & SELF-HEALING LOOP ---
|
|
298
|
-
// If specific types were requested, verify their existence in the current schema
|
|
299
|
-
if (typeNames && typeNames.length > 0) {
|
|
300
|
-
const missing = typeNames.filter(name => {
|
|
301
|
-
const existsAsType = !!typeMap[name];
|
|
302
|
-
const existsAsMutation = !!mutationFields[name];
|
|
303
|
-
const existsAsQueryField = !!queryFields[name];
|
|
304
|
-
return !existsAsType && !existsAsMutation && !existsAsQueryField;
|
|
305
|
-
});
|
|
306
|
-
// If some requested entities are missing, provide the agent with a recovery map
|
|
307
|
-
if (missing.length > 0) {
|
|
308
|
-
// Filter out internal GraphQL types to reduce noise for the agent
|
|
309
|
-
const internalTypes = ['Query', 'Mutation', 'Subscription'];
|
|
310
|
-
const availableEntities = Object.keys(typeMap).filter(t => !t.startsWith('__') && !internalTypes.includes(t));
|
|
311
|
-
// Generate a pseudo-version ID based on schema state and time
|
|
312
|
-
const schemaVersion = `v${availableEntities.length}.${Math.floor(Date.now() / 10000) % 1000}`;
|
|
313
|
-
return {
|
|
314
|
-
content: [{
|
|
315
|
-
type: "text",
|
|
316
|
-
text: `❌ PARTIAL RESULTS [Schema ID: ${schemaVersion}]\n\n` +
|
|
317
|
-
`MISSING ENTITIES: ${missing.join(", ")}\n` +
|
|
318
|
-
`REASON: These specific types or fields were not found in the current schema.\n` +
|
|
319
|
-
`ACTION: Re-examine the available entities below and correct your query intent.\n\n` +
|
|
320
|
-
`AVAILABLE_ENTITIES: ${availableEntities.join(", ")}`
|
|
321
|
-
}]
|
|
322
|
-
};
|
|
323
|
-
}
|
|
274
|
+
await getSchema(true);
|
|
275
|
+
if (cachedSchemas.length === 0) {
|
|
276
|
+
return { content: [{ type: "text", text: "❌ System is not initialized." }] };
|
|
324
277
|
}
|
|
325
|
-
//
|
|
326
|
-
// If no typeNames provided, return a high-level overview of the entry points
|
|
278
|
+
// Default: Return the Manifest of all nodes
|
|
327
279
|
if (!typeNames || typeNames.length === 0) {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
}
|
|
338
314
|
}
|
|
339
|
-
const entryPoints = Array.from(discoveredEntities).sort();
|
|
340
|
-
const allTypes = Object.keys(typeMap).filter(t => !t.startsWith('__'));
|
|
341
|
-
const schemaVersion = `v${allTypes.length}.${Math.floor(Date.now() / 10000) % 1000}`;
|
|
342
|
-
return {
|
|
343
|
-
content: [{
|
|
344
|
-
type: "text",
|
|
345
|
-
text: `GraphQL Schema Manifest [ID: ${schemaVersion}]\n\n` +
|
|
346
|
-
`ENTRY_POINT_ENTITIES: ${entryPoints.join(", ") || "None"}\n` +
|
|
347
|
-
`TOTAL_SCHEMA_TYPES: ${allTypes.length}\n\n` +
|
|
348
|
-
`ALL_AVAILABLE_TYPES: ${allTypes.join(", ")}`
|
|
349
|
-
}]
|
|
350
|
-
};
|
|
351
315
|
}
|
|
352
|
-
// --- 4. DETAILED INTROSPECTION ---
|
|
353
|
-
// Return filtered schema metadata for the requested types or root fields
|
|
354
|
-
const filtered = (0, introspection_js_1.introspectSpecificTypes)(schema, typeNames);
|
|
355
316
|
return {
|
|
356
|
-
content: [{
|
|
317
|
+
content: [{
|
|
318
|
+
type: "text",
|
|
319
|
+
text: JSON.stringify(resolution, null, 2)
|
|
320
|
+
}]
|
|
357
321
|
};
|
|
358
322
|
};
|
|
359
323
|
toolHandlers.set("introspect-schema", introspectHandler);
|
|
360
|
-
(0, tool_registry_js_1.registerTool)(server, toolHandlers, registeredToolsMetadata, "introspect-schema", "
|
|
361
|
-
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"),
|
|
362
326
|
}, introspectHandler);
|
|
363
|
-
// ---
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
// 2. High-level overview
|
|
367
|
-
(0, prompt_registry_js_1.registerPrompt)(server, "schema-overview", "List all available types", "Run 'introspect-schema' to see all types and entry points.");
|
|
368
|
-
// 3. Data types analysis
|
|
369
|
-
(0, prompt_registry_js_1.registerPrompt)(server, "list-scalars", "List all scalar types", "Run 'introspect-schema' and identify all scalars in the schema.");
|
|
370
|
-
// --- HTTP SERVER LOGIC ---
|
|
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 ---
|
|
371
330
|
async function handleHttpRequest(req, res) {
|
|
372
|
-
// Standard CORS headers for cross-origin compatibility
|
|
373
331
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
374
|
-
res.setHeader('Access-Control-Allow-Methods', '
|
|
332
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
375
333
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
376
|
-
// Handle preflight requests
|
|
377
334
|
if (req.method === 'OPTIONS') {
|
|
378
335
|
res.writeHead(204);
|
|
379
336
|
res.end();
|
|
380
337
|
return;
|
|
381
338
|
}
|
|
382
339
|
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
|
383
|
-
// Serve Web GUI (GraphiQL) - ONLY if explicitly enabled
|
|
384
340
|
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/graphiql')) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
388
|
-
return res.end((0, graphiql_js_1.renderGraphiQL)(env.ENDPOINT, env.HEADERS));
|
|
389
|
-
}
|
|
390
|
-
else {
|
|
391
|
-
// Forbidden if not explicitly enabled
|
|
392
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
393
|
-
return res.end("Forbidden: GraphiQL UI is disabled. Start with ENABLE_HTTP=true to use it.");
|
|
394
|
-
}
|
|
341
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
342
|
+
return res.end((0, graphiql_js_1.renderGraphiQL)(`http://localhost:${env.MCP_PORT}/mcp`, env.HEADERS));
|
|
395
343
|
}
|
|
396
|
-
// Process MCP JSON-RPC Endpoint
|
|
397
344
|
if (url.pathname === '/mcp' && req.method === 'POST') {
|
|
398
345
|
let body = '';
|
|
399
346
|
req.on('data', chunk => { body += chunk; });
|
|
400
347
|
req.on('end', async () => {
|
|
401
|
-
let requestId = null; // Defined early to be accessible in catch block
|
|
402
348
|
try {
|
|
403
|
-
const
|
|
404
|
-
const { method, id, params } =
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (method === "list-tools" || method === "tools/list") {
|
|
409
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
410
|
-
return res.end(JSON.stringify({
|
|
411
|
-
jsonrpc: '2.0',
|
|
412
|
-
id: requestId,
|
|
413
|
-
result: { tools: registeredToolsMetadata }
|
|
414
|
-
}));
|
|
415
|
-
}
|
|
416
|
-
// --- TOOL EXECUTION (CALL) ---
|
|
417
|
-
let targetMethod = method;
|
|
418
|
-
let toolArgs = params;
|
|
419
|
-
// Support both direct calls and standard MCP "call-tool" structure
|
|
420
|
-
if (method === "call-tool" || method === "tools/call") {
|
|
421
|
-
targetMethod = params.name;
|
|
422
|
-
toolArgs = params.arguments;
|
|
423
|
-
}
|
|
424
|
-
const handler = toolHandlers.get(targetMethod);
|
|
349
|
+
const payload = JSON.parse(body);
|
|
350
|
+
const { method, id, params } = payload;
|
|
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);
|
|
425
354
|
if (!handler) {
|
|
426
355
|
res.writeHead(404);
|
|
427
|
-
return res.end(JSON.stringify({
|
|
428
|
-
jsonrpc: '2.0',
|
|
429
|
-
id: requestId,
|
|
430
|
-
error: { code: -32601, message: `Tool ${targetMethod} not found` }
|
|
431
|
-
}));
|
|
356
|
+
return res.end(JSON.stringify({ error: { code: -32601, message: "Method not found" } }));
|
|
432
357
|
}
|
|
433
|
-
|
|
434
|
-
const result = await handler(toolArgs);
|
|
358
|
+
const result = await handler(args);
|
|
435
359
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
436
|
-
res.end(JSON.stringify({
|
|
437
|
-
jsonrpc: '2.0',
|
|
438
|
-
id: requestId,
|
|
439
|
-
result
|
|
440
|
-
}));
|
|
360
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id, result }));
|
|
441
361
|
}
|
|
442
362
|
catch (e) {
|
|
443
|
-
console.error(`[HTTP-ERROR] ${e.message}`);
|
|
444
363
|
res.writeHead(500);
|
|
445
|
-
res.end(JSON.stringify({
|
|
446
|
-
jsonrpc: '2.0',
|
|
447
|
-
id: requestId,
|
|
448
|
-
error: { code: -32603, message: e.message || "Internal Server Error" }
|
|
449
|
-
}));
|
|
364
|
+
res.end(JSON.stringify({ error: { message: e.message } }));
|
|
450
365
|
}
|
|
451
366
|
});
|
|
452
367
|
return;
|
|
453
368
|
}
|
|
454
|
-
// Health check endpoint for monitoring
|
|
455
|
-
if (url.pathname === '/health') {
|
|
456
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
457
|
-
return res.end(JSON.stringify({ status: "ok", server: env.NAME }));
|
|
458
|
-
}
|
|
459
|
-
// Default 404 for unknown paths
|
|
460
369
|
res.writeHead(404);
|
|
461
370
|
res.end("Not Found");
|
|
462
371
|
}
|
|
463
|
-
// ---
|
|
372
|
+
// --- SERVER LIFECYCLE ---
|
|
464
373
|
async function main() {
|
|
465
374
|
const isInspector = !!(process.env.MCP_INSPECTOR || process.env.INSPECTOR_PORT);
|
|
466
|
-
const isHttpExplicitlyEnabled = process.env.ENABLE_HTTP === "true";
|
|
467
|
-
// Open HTTP port by default for MCP SSE, unless explicitly disabled
|
|
468
375
|
if (process.env.ENABLE_HTTP !== "false" && !isInspector) {
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
if (e.code === 'EADDRINUSE')
|
|
472
|
-
console.error(`[
|
|
473
|
-
}
|
|
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.`);
|
|
474
380
|
});
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
console.error(
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if (isHttpExplicitlyEnabled) {
|
|
481
|
-
console.error(`🎨 GraphiQL UI: http://localhost:${env.MCP_PORT}/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`);
|
|
482
386
|
}
|
|
483
387
|
});
|
|
484
388
|
}
|
|
485
389
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
486
390
|
await server.connect(transport);
|
|
487
|
-
|
|
391
|
+
// Initial background sync
|
|
392
|
+
getSchema(true).catch(err => console.error(`[BOOT-WARN] Initial sync failed: ${err.message}`));
|
|
488
393
|
}
|
|
489
|
-
|
|
490
|
-
process.on('
|
|
491
|
-
|
|
492
|
-
|
|
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}`);
|
|
493
399
|
process.exit(1);
|
|
494
400
|
});
|
package/package.json
CHANGED