@mcp-layer/graphql 0.1.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/LICENSE +21 -0
- package/README.md +788 -0
- package/package.json +57 -0
- package/src/config/defaults.js +24 -0
- package/src/config/validate.js +76 -0
- package/src/index.js +3 -0
- package/src/mapping.js +12 -0
- package/src/plugin.js +650 -0
- package/src/schema.js +418 -0
package/src/plugin.js
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import fp from 'fastify-plugin';
|
|
2
|
+
import mercurius from 'mercurius';
|
|
3
|
+
import { GraphQLError } from 'graphql';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { LayerError } from '@mcp-layer/error';
|
|
6
|
+
import { createCallContext, createRuntime, executeWithBreaker } from '@mcp-layer/gateway';
|
|
7
|
+
import { schema as buildSchema } from './schema.js';
|
|
8
|
+
import { validateOptions } from './config/validate.js';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const templateLib = require('uri-template');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Map JSON-RPC/MCP numeric errors to GraphQL extension codes.
|
|
15
|
+
* @type {Record<number, { code: string, title: string }>}
|
|
16
|
+
*/
|
|
17
|
+
const MCP_ERROR_MAP = {
|
|
18
|
+
[-32700]: { code: 'BAD_REQUEST', title: 'Parse Error' },
|
|
19
|
+
[-32600]: { code: 'BAD_REQUEST', title: 'Invalid Request' },
|
|
20
|
+
[-32601]: { code: 'NOT_FOUND', title: 'Method Not Found' },
|
|
21
|
+
[-32602]: { code: 'BAD_USER_INPUT', title: 'Invalid Parameters' },
|
|
22
|
+
[-32603]: { code: 'INTERNAL_SERVER_ERROR', title: 'Internal Error' },
|
|
23
|
+
[-32000]: { code: 'INTERNAL_SERVER_ERROR', title: 'Server Error' },
|
|
24
|
+
[-32001]: { code: 'TIMEOUT', title: 'Request Timeout' },
|
|
25
|
+
[-32002]: { code: 'NOT_FOUND', title: 'Resource Not Found' }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Ensure an input value is a plain object.
|
|
30
|
+
* @param {unknown} value - Candidate value.
|
|
31
|
+
* @returns {Record<string, unknown>}
|
|
32
|
+
*/
|
|
33
|
+
function asRecord(value) {
|
|
34
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
35
|
+
return /** @type {Record<string, unknown>} */ (value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalize tool call payload for GraphQL response shape.
|
|
43
|
+
* @param {Record<string, unknown> | null | undefined} value - Tool call payload.
|
|
44
|
+
* @returns {{ content: unknown, isError: boolean, structuredContent?: unknown }}
|
|
45
|
+
*/
|
|
46
|
+
function toolPayload(value) {
|
|
47
|
+
const payload = value && typeof value === 'object' ? value : {};
|
|
48
|
+
return {
|
|
49
|
+
content: Array.isArray(payload.content) ? payload.content : [],
|
|
50
|
+
isError: false,
|
|
51
|
+
structuredContent: payload.structuredContent
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalize prompt payload for GraphQL response shape.
|
|
57
|
+
* @param {Record<string, unknown> | null | undefined} value - Prompt payload.
|
|
58
|
+
* @returns {{ messages: unknown, payload: unknown }}
|
|
59
|
+
*/
|
|
60
|
+
function promptPayload(value) {
|
|
61
|
+
const payload = value && typeof value === 'object' ? value : {};
|
|
62
|
+
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
|
63
|
+
return {
|
|
64
|
+
messages,
|
|
65
|
+
payload
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Normalize resource payload for GraphQL response shape.
|
|
71
|
+
* @param {Record<string, unknown> | null | undefined} value - Resource payload.
|
|
72
|
+
* @returns {{ contents: unknown, text?: string, mimeType?: string, payload: unknown }}
|
|
73
|
+
*/
|
|
74
|
+
function resourcePayload(value) {
|
|
75
|
+
const payload = value && typeof value === 'object' ? value : {};
|
|
76
|
+
const contents = Array.isArray(payload.contents) ? payload.contents : [];
|
|
77
|
+
const first = contents.length > 0 ? contents[0] : null;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
contents,
|
|
81
|
+
text: typeof first?.text === 'string' ? first.text : undefined,
|
|
82
|
+
mimeType: typeof first?.mimeType === 'string' ? first.mimeType : undefined,
|
|
83
|
+
payload
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate template parameter value sizes.
|
|
89
|
+
* @param {Record<string, unknown>} params - Parameter values.
|
|
90
|
+
* @param {number} max - Maximum allowed parameter length.
|
|
91
|
+
* @returns {Array<{ path: string, message: string }>}
|
|
92
|
+
*/
|
|
93
|
+
function validateTemplateParams(params, max) {
|
|
94
|
+
const errors = [];
|
|
95
|
+
|
|
96
|
+
for (const [key, value] of Object.entries(params)) {
|
|
97
|
+
if (value === undefined || value === null) continue;
|
|
98
|
+
const text = typeof value === 'string' ? value : String(value);
|
|
99
|
+
if (text.length > max) {
|
|
100
|
+
errors.push({
|
|
101
|
+
path: `params.${key}`,
|
|
102
|
+
message: `Parameter exceeds maximum length of ${max}.`
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return errors;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Expand an RFC6570 URI template with argument values.
|
|
112
|
+
* @param {string} template - URI template expression.
|
|
113
|
+
* @param {Record<string, unknown>} values - Template variables.
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function expandTemplate(template, values) {
|
|
117
|
+
const parsed = templateLib.parse(template);
|
|
118
|
+
return parsed.expand(values);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build a GraphiQL redirect path for a runtime prefix.
|
|
123
|
+
* @param {string} prefix - Runtime route prefix.
|
|
124
|
+
* @returns {string}
|
|
125
|
+
*/
|
|
126
|
+
function graphiqlRedirect(prefix) {
|
|
127
|
+
const base = prefix === '/' ? '' : prefix.replace(/\/+$/, '');
|
|
128
|
+
return `${base}/graphiql`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Create Mercurius context factory for a runtime session.
|
|
133
|
+
* @param {{ session: import('@mcp-layer/session').Session, validator: import('@mcp-layer/gateway').SchemaValidator, telemetry: ReturnType<import('@mcp-layer/gateway').createTelemetry> | null, resolve: (request: import('fastify').FastifyRequest) => Promise<{ session: import('@mcp-layer/session').Session, breaker: import('opossum') | null }>, normalize: (error: Error & { code?: string | number }, instance: string, requestId?: string) => unknown }} runtime - Runtime context.
|
|
134
|
+
* @param {{ validation: { maxTemplateParamLength: number }, errors: { exposeDetails: boolean } }} config - GraphQL plugin config.
|
|
135
|
+
* @returns {(request: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<{ request: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply, callTool: (name: string, input: Record<string, unknown>) => Promise<{ content: unknown, isError: boolean, structuredContent?: unknown }>, getPrompt: (name: string, input: Record<string, unknown>) => Promise<{ messages: unknown, payload: unknown }>, readResource: (uri: string) => Promise<{ contents: unknown, text?: string, mimeType?: string, payload: unknown }>, readTemplate: (template: string, params: Record<string, unknown>) => Promise<{ contents: unknown, text?: string, mimeType?: string, payload: unknown }> }>}
|
|
136
|
+
*/
|
|
137
|
+
function createContextFactory(runtime, config) {
|
|
138
|
+
/**
|
|
139
|
+
* Create context object for a GraphQL request.
|
|
140
|
+
* @param {import('fastify').FastifyRequest} request - Fastify request.
|
|
141
|
+
* @param {import('fastify').FastifyReply} reply - Fastify reply.
|
|
142
|
+
* @returns {Promise<{ request: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply, callTool: (name: string, input: Record<string, unknown>) => Promise<{ content: unknown, isError: boolean, structuredContent?: unknown }>, getPrompt: (name: string, input: Record<string, unknown>) => Promise<{ messages: unknown, payload: unknown }>, readResource: (uri: string) => Promise<{ contents: unknown, text?: string, mimeType?: string, payload: unknown }>, readTemplate: (template: string, params: Record<string, unknown>) => Promise<{ contents: unknown, text?: string, mimeType?: string, payload: unknown }> }>}
|
|
143
|
+
*/
|
|
144
|
+
async function createContext(request, reply) {
|
|
145
|
+
/**
|
|
146
|
+
* Execute an MCP tool call.
|
|
147
|
+
* @param {string} name - Tool name.
|
|
148
|
+
* @param {Record<string, unknown>} input - Tool input.
|
|
149
|
+
* @returns {Promise<{ content: unknown, isError: boolean, structuredContent?: unknown }>}
|
|
150
|
+
*/
|
|
151
|
+
async function callTool(name, input) {
|
|
152
|
+
const requestId = request.id;
|
|
153
|
+
const instance = request.url;
|
|
154
|
+
let trace;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const args = asRecord(input);
|
|
158
|
+
const check = runtime.validator.validate('tool', name, args);
|
|
159
|
+
if (!check.valid) {
|
|
160
|
+
throw new LayerError({
|
|
161
|
+
name: 'graphql',
|
|
162
|
+
method: 'callTool',
|
|
163
|
+
message: 'Request payload failed schema validation.',
|
|
164
|
+
code: 'GRAPHQL_VALIDATION',
|
|
165
|
+
details: check.errors ?? [],
|
|
166
|
+
instance,
|
|
167
|
+
requestId
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const resolved = await runtime.resolve(request);
|
|
172
|
+
|
|
173
|
+
trace = createCallContext({
|
|
174
|
+
telemetry: runtime.telemetry,
|
|
175
|
+
spanName: 'mcp.tools/call',
|
|
176
|
+
attributes: {
|
|
177
|
+
'mcp.tool.name': name,
|
|
178
|
+
'mcp.session.name': resolved.session.name,
|
|
179
|
+
'http.request.id': requestId
|
|
180
|
+
},
|
|
181
|
+
labels: { tool: name, session: resolved.session.name },
|
|
182
|
+
validationLabels: { tool: name }
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const result = await executeWithBreaker(resolved.breaker, resolved.session, 'tools/call', {
|
|
186
|
+
name,
|
|
187
|
+
arguments: args
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (result?.isError) {
|
|
191
|
+
trace.recordStatus('tool_error', 'tool_error');
|
|
192
|
+
throw new LayerError({
|
|
193
|
+
name: 'graphql',
|
|
194
|
+
method: 'callTool',
|
|
195
|
+
message: 'Tool "{tool}" reported an error.',
|
|
196
|
+
vars: { tool: name },
|
|
197
|
+
code: 'GRAPHQL_TOOL',
|
|
198
|
+
tool: name,
|
|
199
|
+
session: resolved.session.name,
|
|
200
|
+
result,
|
|
201
|
+
instance,
|
|
202
|
+
requestId
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
trace.recordSuccess();
|
|
207
|
+
return toolPayload(result);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
const runtimeError = /** @type {Error & { code?: string | number, details?: unknown, result?: unknown, session?: unknown, tool?: unknown, package?: string, method?: string, sessionName?: string }} */ (error);
|
|
210
|
+
if (runtimeError.code !== 'GRAPHQL_TOOL' && runtimeError.code !== 'GRAPHQL_VALIDATION') trace?.recordError(runtimeError);
|
|
211
|
+
|
|
212
|
+
if (runtimeError.code === 'GRAPHQL_VALIDATION') {
|
|
213
|
+
throw new GraphQLError('Request payload failed schema validation.', {
|
|
214
|
+
extensions: {
|
|
215
|
+
code: 'BAD_USER_INPUT',
|
|
216
|
+
type: 'error-validation',
|
|
217
|
+
instance,
|
|
218
|
+
requestId,
|
|
219
|
+
errors: Array.isArray(runtimeError.details) ? runtimeError.details : []
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (runtimeError.code === 'GRAPHQL_TOOL') {
|
|
225
|
+
throw new GraphQLError(`Tool "${runtimeError.tool}" reported an error.`, {
|
|
226
|
+
extensions: {
|
|
227
|
+
code: 'TOOL_ERROR',
|
|
228
|
+
type: 'error-tool',
|
|
229
|
+
instance,
|
|
230
|
+
requestId,
|
|
231
|
+
tool: typeof runtimeError.tool === 'string' ? runtimeError.tool : 'unknown',
|
|
232
|
+
session: typeof runtimeError.session === 'string' ? runtimeError.session : 'unknown',
|
|
233
|
+
toolError: {
|
|
234
|
+
content: Array.isArray(runtimeError.result?.content) ? runtimeError.result.content : [],
|
|
235
|
+
isError: true
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const normalized = runtime.normalize(runtimeError, instance, requestId);
|
|
242
|
+
if (normalized instanceof GraphQLError) {
|
|
243
|
+
const extensions = normalized.extensions ? { ...normalized.extensions } : undefined;
|
|
244
|
+
|
|
245
|
+
throw new GraphQLError(normalized.message, { extensions });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const isManagerIdentityError = runtimeError.name === 'LayerError'
|
|
249
|
+
&& runtimeError.package === '@mcp-layer/manager'
|
|
250
|
+
&& runtimeError.method === 'identity';
|
|
251
|
+
|
|
252
|
+
const isAuthRequired = runtimeError.code === 'AUTH_REQUIRED'
|
|
253
|
+
|| (isManagerIdentityError && runtimeError.message.includes('Authorization header is required.'));
|
|
254
|
+
const isAuthInvalid = runtimeError.code === 'AUTH_INVALID'
|
|
255
|
+
|| (isManagerIdentityError && runtimeError.message.includes('Authorization header must use '));
|
|
256
|
+
|
|
257
|
+
if (isAuthRequired || isAuthInvalid) {
|
|
258
|
+
throw new GraphQLError('Authorization failed.', {
|
|
259
|
+
extensions: {
|
|
260
|
+
code: 'UNAUTHENTICATED',
|
|
261
|
+
type: 'error-auth',
|
|
262
|
+
instance,
|
|
263
|
+
requestId
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (runtimeError.code === 'CIRCUIT_OPEN') {
|
|
269
|
+
throw new GraphQLError('Service temporarily unavailable.', {
|
|
270
|
+
extensions: {
|
|
271
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
272
|
+
type: 'error-circuit-open',
|
|
273
|
+
instance,
|
|
274
|
+
requestId,
|
|
275
|
+
session: runtimeError.sessionName
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const numericCode = typeof runtimeError.code === 'number' ? runtimeError.code : -32603;
|
|
281
|
+
const mapped = MCP_ERROR_MAP[numericCode] ?? MCP_ERROR_MAP[-32603];
|
|
282
|
+
const detail = config.errors.exposeDetails ? runtimeError.message : 'Upstream service error';
|
|
283
|
+
|
|
284
|
+
throw new GraphQLError(detail, {
|
|
285
|
+
extensions: {
|
|
286
|
+
code: mapped.code,
|
|
287
|
+
title: mapped.title,
|
|
288
|
+
type: 'error-runtime',
|
|
289
|
+
instance,
|
|
290
|
+
requestId,
|
|
291
|
+
mcpErrorCode: numericCode
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
} finally {
|
|
295
|
+
trace?.finish();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Execute an MCP prompt request.
|
|
301
|
+
* @param {string} name - Prompt name.
|
|
302
|
+
* @param {Record<string, unknown>} input - Prompt input.
|
|
303
|
+
* @returns {Promise<{ messages: unknown, payload: unknown }>}
|
|
304
|
+
*/
|
|
305
|
+
async function getPrompt(name, input) {
|
|
306
|
+
const requestId = request.id;
|
|
307
|
+
const instance = request.url;
|
|
308
|
+
let trace;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const args = asRecord(input);
|
|
312
|
+
const check = runtime.validator.validate('prompt', name, args);
|
|
313
|
+
if (!check.valid) {
|
|
314
|
+
throw new LayerError({
|
|
315
|
+
name: 'graphql',
|
|
316
|
+
method: 'getPrompt',
|
|
317
|
+
message: 'Request payload failed schema validation.',
|
|
318
|
+
code: 'GRAPHQL_VALIDATION',
|
|
319
|
+
details: check.errors ?? [],
|
|
320
|
+
instance,
|
|
321
|
+
requestId
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const resolved = await runtime.resolve(request);
|
|
326
|
+
|
|
327
|
+
trace = createCallContext({
|
|
328
|
+
telemetry: runtime.telemetry,
|
|
329
|
+
spanName: 'mcp.prompts/get',
|
|
330
|
+
attributes: {
|
|
331
|
+
'mcp.prompt.name': name,
|
|
332
|
+
'mcp.session.name': resolved.session.name,
|
|
333
|
+
'http.request.id': requestId
|
|
334
|
+
},
|
|
335
|
+
labels: { prompt: name, session: resolved.session.name },
|
|
336
|
+
validationLabels: { prompt: name }
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const result = await executeWithBreaker(resolved.breaker, resolved.session, 'prompts/get', {
|
|
340
|
+
name,
|
|
341
|
+
arguments: args
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
trace.recordSuccess();
|
|
345
|
+
return promptPayload(result);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
const runtimeError = /** @type {Error & { code?: string | number, details?: unknown, package?: string, method?: string, sessionName?: string }} */ (error);
|
|
348
|
+
if (runtimeError.code !== 'GRAPHQL_VALIDATION') trace?.recordError(runtimeError);
|
|
349
|
+
|
|
350
|
+
if (runtimeError.code === 'GRAPHQL_VALIDATION') {
|
|
351
|
+
throw new GraphQLError('Request payload failed schema validation.', {
|
|
352
|
+
extensions: {
|
|
353
|
+
code: 'BAD_USER_INPUT',
|
|
354
|
+
type: 'error-validation',
|
|
355
|
+
instance,
|
|
356
|
+
requestId,
|
|
357
|
+
errors: Array.isArray(runtimeError.details) ? runtimeError.details : []
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const normalized = runtime.normalize(runtimeError, instance, requestId);
|
|
363
|
+
if (normalized instanceof GraphQLError) {
|
|
364
|
+
const extensions = normalized.extensions ? { ...normalized.extensions } : undefined;
|
|
365
|
+
|
|
366
|
+
throw new GraphQLError(normalized.message, { extensions });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const isManagerIdentityError = runtimeError.name === 'LayerError'
|
|
370
|
+
&& runtimeError.package === '@mcp-layer/manager'
|
|
371
|
+
&& runtimeError.method === 'identity';
|
|
372
|
+
|
|
373
|
+
const isAuthRequired = runtimeError.code === 'AUTH_REQUIRED'
|
|
374
|
+
|| (isManagerIdentityError && runtimeError.message.includes('Authorization header is required.'));
|
|
375
|
+
const isAuthInvalid = runtimeError.code === 'AUTH_INVALID'
|
|
376
|
+
|| (isManagerIdentityError && runtimeError.message.includes('Authorization header must use '));
|
|
377
|
+
|
|
378
|
+
if (isAuthRequired || isAuthInvalid) {
|
|
379
|
+
throw new GraphQLError('Authorization failed.', {
|
|
380
|
+
extensions: {
|
|
381
|
+
code: 'UNAUTHENTICATED',
|
|
382
|
+
type: 'error-auth',
|
|
383
|
+
instance,
|
|
384
|
+
requestId
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (runtimeError.code === 'CIRCUIT_OPEN') {
|
|
390
|
+
throw new GraphQLError('Service temporarily unavailable.', {
|
|
391
|
+
extensions: {
|
|
392
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
393
|
+
type: 'error-circuit-open',
|
|
394
|
+
instance,
|
|
395
|
+
requestId,
|
|
396
|
+
session: runtimeError.sessionName
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const numericCode = typeof runtimeError.code === 'number' ? runtimeError.code : -32603;
|
|
402
|
+
const mapped = MCP_ERROR_MAP[numericCode] ?? MCP_ERROR_MAP[-32603];
|
|
403
|
+
const detail = config.errors.exposeDetails ? runtimeError.message : 'Upstream service error';
|
|
404
|
+
|
|
405
|
+
throw new GraphQLError(detail, {
|
|
406
|
+
extensions: {
|
|
407
|
+
code: mapped.code,
|
|
408
|
+
title: mapped.title,
|
|
409
|
+
type: 'error-runtime',
|
|
410
|
+
instance,
|
|
411
|
+
requestId,
|
|
412
|
+
mcpErrorCode: numericCode
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
} finally {
|
|
416
|
+
trace?.finish();
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Execute an MCP resource read.
|
|
422
|
+
* @param {string} uri - Resource uri.
|
|
423
|
+
* @returns {Promise<{ contents: unknown, text?: string, mimeType?: string, payload: unknown }>}
|
|
424
|
+
*/
|
|
425
|
+
async function readResource(uri) {
|
|
426
|
+
const requestId = request.id;
|
|
427
|
+
const instance = request.url;
|
|
428
|
+
let trace;
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const resolved = await runtime.resolve(request);
|
|
432
|
+
|
|
433
|
+
trace = createCallContext({
|
|
434
|
+
telemetry: runtime.telemetry,
|
|
435
|
+
spanName: 'mcp.resources/read',
|
|
436
|
+
attributes: {
|
|
437
|
+
'mcp.resource.uri': uri,
|
|
438
|
+
'mcp.session.name': resolved.session.name,
|
|
439
|
+
'http.request.id': requestId
|
|
440
|
+
},
|
|
441
|
+
labels: { resource: uri, session: resolved.session.name }
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const result = await executeWithBreaker(resolved.breaker, resolved.session, 'resources/read', { uri });
|
|
445
|
+
trace.recordSuccess();
|
|
446
|
+
return resourcePayload(result);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
const runtimeError = /** @type {Error & { code?: string | number, package?: string, method?: string, sessionName?: string }} */ (error);
|
|
449
|
+
trace?.recordError(runtimeError);
|
|
450
|
+
|
|
451
|
+
const normalized = runtime.normalize(runtimeError, instance, requestId);
|
|
452
|
+
if (normalized instanceof GraphQLError) {
|
|
453
|
+
const extensions = normalized.extensions ? { ...normalized.extensions } : undefined;
|
|
454
|
+
|
|
455
|
+
throw new GraphQLError(normalized.message, { extensions });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const isManagerIdentityError = runtimeError.name === 'LayerError'
|
|
459
|
+
&& runtimeError.package === '@mcp-layer/manager'
|
|
460
|
+
&& runtimeError.method === 'identity';
|
|
461
|
+
|
|
462
|
+
const isAuthRequired = runtimeError.code === 'AUTH_REQUIRED'
|
|
463
|
+
|| (isManagerIdentityError && runtimeError.message.includes('Authorization header is required.'));
|
|
464
|
+
const isAuthInvalid = runtimeError.code === 'AUTH_INVALID'
|
|
465
|
+
|| (isManagerIdentityError && runtimeError.message.includes('Authorization header must use '));
|
|
466
|
+
|
|
467
|
+
if (isAuthRequired || isAuthInvalid) {
|
|
468
|
+
throw new GraphQLError('Authorization failed.', {
|
|
469
|
+
extensions: {
|
|
470
|
+
code: 'UNAUTHENTICATED',
|
|
471
|
+
type: 'error-auth',
|
|
472
|
+
instance,
|
|
473
|
+
requestId
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (runtimeError.code === 'CIRCUIT_OPEN') {
|
|
479
|
+
throw new GraphQLError('Service temporarily unavailable.', {
|
|
480
|
+
extensions: {
|
|
481
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
482
|
+
type: 'error-circuit-open',
|
|
483
|
+
instance,
|
|
484
|
+
requestId,
|
|
485
|
+
session: runtimeError.sessionName
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const numericCode = typeof runtimeError.code === 'number' ? runtimeError.code : -32603;
|
|
491
|
+
const mapped = MCP_ERROR_MAP[numericCode] ?? MCP_ERROR_MAP[-32603];
|
|
492
|
+
const detail = config.errors.exposeDetails ? runtimeError.message : 'Upstream service error';
|
|
493
|
+
|
|
494
|
+
throw new GraphQLError(detail, {
|
|
495
|
+
extensions: {
|
|
496
|
+
code: mapped.code,
|
|
497
|
+
title: mapped.title,
|
|
498
|
+
type: 'error-runtime',
|
|
499
|
+
instance,
|
|
500
|
+
requestId,
|
|
501
|
+
mcpErrorCode: numericCode
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
} finally {
|
|
505
|
+
trace?.finish();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Execute an MCP template read.
|
|
511
|
+
* @param {string} template - URI template.
|
|
512
|
+
* @param {Record<string, unknown>} params - Template parameters.
|
|
513
|
+
* @returns {Promise<{ contents: unknown, text?: string, mimeType?: string, payload: unknown }>}
|
|
514
|
+
*/
|
|
515
|
+
async function readTemplate(template, params) {
|
|
516
|
+
const requestId = request.id;
|
|
517
|
+
const instance = request.url;
|
|
518
|
+
const values = asRecord(params);
|
|
519
|
+
const errors = validateTemplateParams(values, config.validation.maxTemplateParamLength);
|
|
520
|
+
|
|
521
|
+
if (errors.length > 0) {
|
|
522
|
+
throw new GraphQLError('Request payload failed schema validation.', {
|
|
523
|
+
extensions: {
|
|
524
|
+
code: 'BAD_USER_INPUT',
|
|
525
|
+
type: 'error-validation',
|
|
526
|
+
instance,
|
|
527
|
+
requestId,
|
|
528
|
+
errors
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const uri = expandTemplate(template, values);
|
|
534
|
+
|
|
535
|
+
return readResource(uri);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
request,
|
|
540
|
+
reply,
|
|
541
|
+
callTool,
|
|
542
|
+
getPrompt,
|
|
543
|
+
readResource,
|
|
544
|
+
readTemplate
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return createContext;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Register GraphQL routes for a runtime context.
|
|
553
|
+
* @param {import('fastify').FastifyInstance} fastify - Fastify instance.
|
|
554
|
+
* @param {{ catalog: { items?: Array<Record<string, unknown>> }, prefix: string, session: import('@mcp-layer/session').Session, validator: import('@mcp-layer/gateway').SchemaValidator, telemetry: ReturnType<import('@mcp-layer/gateway').createTelemetry> | null, resolve: (request: import('fastify').FastifyRequest) => Promise<{ session: import('@mcp-layer/session').Session, breaker: import('opossum') | null }>, normalize: (error: Error & { code?: string | number }, instance: string, requestId?: string) => unknown }} runtime - Runtime context.
|
|
555
|
+
* @param {{ endpoint: string, ide: { enabled: boolean, path: string }, operations: { generated: boolean, generic: boolean }, validation: { maxTemplateParamLength: number }, errors: { exposeDetails: boolean } }} config - Plugin config.
|
|
556
|
+
* @returns {Promise<void>}
|
|
557
|
+
*/
|
|
558
|
+
async function registerContext(fastify, runtime, config) {
|
|
559
|
+
const built = buildSchema(runtime.catalog, {
|
|
560
|
+
operations: config.operations
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const contextFactory = createContextFactory(runtime, config);
|
|
564
|
+
const graphiql = config.ide.enabled;
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Register scoped GraphQL endpoint.
|
|
568
|
+
* @param {import('fastify').FastifyInstance} scoped - Scoped instance.
|
|
569
|
+
* @returns {Promise<void>}
|
|
570
|
+
*/
|
|
571
|
+
async function registerScoped(scoped) {
|
|
572
|
+
const graphiqlPath = graphiqlRedirect(runtime.prefix);
|
|
573
|
+
|
|
574
|
+
await scoped.register(mercurius, {
|
|
575
|
+
schema: built.schema,
|
|
576
|
+
path: config.endpoint,
|
|
577
|
+
graphiql,
|
|
578
|
+
context: contextFactory
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
if (config.ide.enabled && config.ide.path !== '/graphiql') {
|
|
582
|
+
/**
|
|
583
|
+
* Redirect custom IDE path to Mercurius default graphiql endpoint.
|
|
584
|
+
* @param {import('fastify').FastifyRequest} request - Fastify request.
|
|
585
|
+
* @param {import('fastify').FastifyReply} reply - Fastify reply.
|
|
586
|
+
* @returns {Promise<void>}
|
|
587
|
+
*/
|
|
588
|
+
async function ideAlias(request, reply) {
|
|
589
|
+
reply.redirect(graphiqlPath);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
scoped.get(config.ide.path, ideAlias);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
await fastify.register(registerScoped, { prefix: runtime.prefix });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Fastify plugin that exposes MCP sessions as GraphQL endpoints.
|
|
601
|
+
* @param {import('fastify').FastifyInstance} fastify - Fastify instance.
|
|
602
|
+
* @param {Record<string, unknown>} opts - Plugin options.
|
|
603
|
+
* @returns {Promise<void>}
|
|
604
|
+
*/
|
|
605
|
+
async function mcpGraphqlPlugin(fastify, opts) {
|
|
606
|
+
const config = validateOptions(opts);
|
|
607
|
+
const runtime = await createRuntime(
|
|
608
|
+
{
|
|
609
|
+
...config,
|
|
610
|
+
telemetry: {
|
|
611
|
+
...config.telemetry,
|
|
612
|
+
metricPrefix: 'graphql'
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
name: 'graphql',
|
|
617
|
+
serviceName: config.telemetry.serviceName
|
|
618
|
+
}
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
if (!fastify.mcpBreakers) {
|
|
622
|
+
fastify.decorate('mcpBreakers', runtime.breakers);
|
|
623
|
+
} else {
|
|
624
|
+
fastify.mcpBreakers = runtime.breakers;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
for (const context of runtime.contexts) {
|
|
628
|
+
await registerContext(fastify, context, config);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Close runtime resources on plugin close.
|
|
633
|
+
* @returns {Promise<void>}
|
|
634
|
+
*/
|
|
635
|
+
async function onClose() {
|
|
636
|
+
await runtime.close();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
fastify.addHook('onClose', onClose);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const plugin = fp(mcpGraphqlPlugin, {
|
|
643
|
+
fastify: '5.x',
|
|
644
|
+
name: '@mcp-layer/graphql',
|
|
645
|
+
decorators: { fastify: [] },
|
|
646
|
+
dependencies: []
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
export default plugin;
|
|
650
|
+
export { plugin };
|