@mcp-layer/gateway 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 +468 -0
- package/package.json +60 -0
- package/src/call.js +96 -0
- package/src/config/defaults.js +39 -0
- package/src/config/validate.js +165 -0
- package/src/index.js +11 -0
- package/src/map.js +158 -0
- package/src/resilience/breaker.js +97 -0
- package/src/runtime.js +194 -0
- package/src/telemetry/index.js +77 -0
- package/src/validation/safety.js +73 -0
- package/src/validation/trust.js +14 -0
- package/src/validation/validator.js +147 -0
- package/src/version.js +31 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { defaults } from './defaults.js';
|
|
2
|
+
import { LayerError } from '@mcp-layer/error';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Test whether a value is a plain object.
|
|
6
|
+
* @param {unknown} value - Value to test.
|
|
7
|
+
* @returns {value is Record<string, unknown>}
|
|
8
|
+
*/
|
|
9
|
+
function isrecord(value) {
|
|
10
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Merge a shallow configuration object over defaults.
|
|
15
|
+
* @param {Record<string, unknown>} base - Default config object.
|
|
16
|
+
* @param {Record<string, unknown> | undefined} next - Override config object.
|
|
17
|
+
* @returns {Record<string, unknown>}
|
|
18
|
+
*/
|
|
19
|
+
function merge(base, next) {
|
|
20
|
+
if (!isrecord(next)) return { ...base };
|
|
21
|
+
return { ...base, ...next };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Assert that a value is a finite positive number.
|
|
26
|
+
* @param {string} name - Option name.
|
|
27
|
+
* @param {unknown} value - Option value.
|
|
28
|
+
* @param {string} pack - Package identifier for errors.
|
|
29
|
+
* @returns {number}
|
|
30
|
+
*/
|
|
31
|
+
function requirePositiveNumber(name, value, pack) {
|
|
32
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
33
|
+
throw new LayerError({
|
|
34
|
+
name: pack,
|
|
35
|
+
method: 'requirePositiveNumber',
|
|
36
|
+
message: '"{option}" must be a positive number.',
|
|
37
|
+
vars: { option: name }
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate trustSchemas value.
|
|
45
|
+
* @param {unknown} value - trustSchemas input.
|
|
46
|
+
* @param {string} pack - Package identifier for errors.
|
|
47
|
+
* @returns {'auto' | true | false}
|
|
48
|
+
*/
|
|
49
|
+
function trustMode(value, pack) {
|
|
50
|
+
if (value === 'auto' || value === true || value === false) return value;
|
|
51
|
+
throw new LayerError({
|
|
52
|
+
name: pack,
|
|
53
|
+
method: 'trustMode',
|
|
54
|
+
message: 'validation.trustSchemas must be "auto", true, or false.',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate base runtime options and apply defaults.
|
|
60
|
+
* @param {Record<string, unknown>} opts - User-supplied options.
|
|
61
|
+
* @param {{ name?: string, serviceName?: string }} [meta] - Validation metadata.
|
|
62
|
+
* @returns {{ session: unknown, manager?: { get: (request: import('fastify').FastifyRequest) => Promise<import('@mcp-layer/session').Session>, close?: () => Promise<void> }, prefix?: string | ((version: string, info: Record<string, unknown> | undefined, name: string) => string), validation: { trustSchemas: 'auto' | true | false, maxSchemaDepth: number, maxSchemaSize: number, maxPatternLength: number, maxToolNameLength: number, maxTemplateParamLength: number }, resilience: { enabled: boolean, timeout: number, errorThresholdPercentage: number, resetTimeout: number, volumeThreshold: number }, telemetry: { enabled: boolean, serviceName: string, metricPrefix: string, api?: import('@opentelemetry/api') }, errors: { exposeDetails: boolean }, normalizeError?: (error: Error & { code?: string | number }, instance: string, requestId?: string, options?: { exposeDetails?: boolean }) => unknown }}
|
|
63
|
+
*/
|
|
64
|
+
export function validateRuntimeOptions(opts, meta = {}) {
|
|
65
|
+
const pack = typeof meta.name === 'string' && meta.name.length > 0 ? meta.name : 'gateway';
|
|
66
|
+
const serviceName = typeof meta.serviceName === 'string' && meta.serviceName.length > 0
|
|
67
|
+
? meta.serviceName
|
|
68
|
+
: 'mcp-layer-gateway';
|
|
69
|
+
const base = defaults(serviceName);
|
|
70
|
+
const input = isrecord(opts) ? opts : {};
|
|
71
|
+
const session = input.session;
|
|
72
|
+
const manager = input.manager;
|
|
73
|
+
|
|
74
|
+
if (!session && !manager) {
|
|
75
|
+
throw new LayerError({
|
|
76
|
+
name: pack,
|
|
77
|
+
method: 'validateRuntimeOptions',
|
|
78
|
+
message: 'session or manager option is required.',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (manager !== undefined) {
|
|
83
|
+
if (!isrecord(manager) || typeof manager.get !== 'function') {
|
|
84
|
+
throw new LayerError({
|
|
85
|
+
name: pack,
|
|
86
|
+
method: 'validateRuntimeOptions',
|
|
87
|
+
message: 'manager must be an object with a get(request) function.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (!session) {
|
|
91
|
+
throw new LayerError({
|
|
92
|
+
name: pack,
|
|
93
|
+
method: 'validateRuntimeOptions',
|
|
94
|
+
message: 'session is required when manager is provided (used for catalog bootstrap).',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(session)) {
|
|
98
|
+
throw new LayerError({
|
|
99
|
+
name: pack,
|
|
100
|
+
method: 'validateRuntimeOptions',
|
|
101
|
+
message: 'manager does not support multiple sessions. Register multiple plugins instead.',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (input.prefix !== undefined && typeof input.prefix !== 'string' && typeof input.prefix !== 'function') {
|
|
107
|
+
throw new LayerError({
|
|
108
|
+
name: pack,
|
|
109
|
+
method: 'validateRuntimeOptions',
|
|
110
|
+
message: 'prefix must be a string or function.',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (input.errors !== undefined && !isrecord(input.errors)) {
|
|
115
|
+
throw new LayerError({
|
|
116
|
+
name: pack,
|
|
117
|
+
method: 'validateRuntimeOptions',
|
|
118
|
+
message: 'errors must be an object.',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (input.validation !== undefined && !isrecord(input.validation)) {
|
|
123
|
+
throw new LayerError({
|
|
124
|
+
name: pack,
|
|
125
|
+
method: 'validateRuntimeOptions',
|
|
126
|
+
message: 'validation must be an object.',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (input.normalizeError !== undefined && typeof input.normalizeError !== 'function') {
|
|
131
|
+
throw new LayerError({
|
|
132
|
+
name: pack,
|
|
133
|
+
method: 'validateRuntimeOptions',
|
|
134
|
+
message: 'normalizeError must be a function.',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const validation = merge(base.validation, input.validation);
|
|
139
|
+
const resilience = merge(base.resilience, input.resilience);
|
|
140
|
+
const telemetry = merge(base.telemetry, input.telemetry);
|
|
141
|
+
const errors = merge(base.errors, input.errors);
|
|
142
|
+
const normalizedValidation = /** @type {typeof base.validation} */ (validation);
|
|
143
|
+
|
|
144
|
+
normalizedValidation.trustSchemas = trustMode(normalizedValidation.trustSchemas, pack);
|
|
145
|
+
normalizedValidation.maxSchemaDepth = requirePositiveNumber('validation.maxSchemaDepth', normalizedValidation.maxSchemaDepth, pack);
|
|
146
|
+
normalizedValidation.maxSchemaSize = requirePositiveNumber('validation.maxSchemaSize', normalizedValidation.maxSchemaSize, pack);
|
|
147
|
+
normalizedValidation.maxPatternLength = requirePositiveNumber('validation.maxPatternLength', normalizedValidation.maxPatternLength, pack);
|
|
148
|
+
normalizedValidation.maxToolNameLength = requirePositiveNumber('validation.maxToolNameLength', normalizedValidation.maxToolNameLength, pack);
|
|
149
|
+
normalizedValidation.maxTemplateParamLength = requirePositiveNumber('validation.maxTemplateParamLength', normalizedValidation.maxTemplateParamLength, pack);
|
|
150
|
+
|
|
151
|
+
if (typeof telemetry.metricPrefix !== 'string' || telemetry.metricPrefix.length === 0) {
|
|
152
|
+
telemetry.metricPrefix = base.telemetry.metricPrefix;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
session,
|
|
157
|
+
manager: /** @type {{ get: (request: import('fastify').FastifyRequest) => Promise<import('@mcp-layer/session').Session>, close?: () => Promise<void> } | undefined} */ (manager),
|
|
158
|
+
prefix: input.prefix,
|
|
159
|
+
validation: normalizedValidation,
|
|
160
|
+
resilience: /** @type {typeof base.resilience} */ (resilience),
|
|
161
|
+
telemetry: /** @type {typeof base.telemetry} */ (telemetry),
|
|
162
|
+
errors: /** @type {typeof base.errors} */ (errors),
|
|
163
|
+
normalizeError: /** @type {((error: Error & { code?: string | number }, instance: string, requestId?: string, options?: { exposeDetails?: boolean }) => unknown) | undefined} */ (input.normalizeError)
|
|
164
|
+
};
|
|
165
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createRuntime } from './runtime.js';
|
|
2
|
+
export { createMap, TYPES } from './map.js';
|
|
3
|
+
export { deriveApiVersion, resolvePrefix } from './version.js';
|
|
4
|
+
export { createCallContext } from './call.js';
|
|
5
|
+
export { createTelemetry } from './telemetry/index.js';
|
|
6
|
+
export { createCircuitBreaker, executeWithBreaker } from './resilience/breaker.js';
|
|
7
|
+
export { createValidator, SchemaValidator } from './validation/validator.js';
|
|
8
|
+
export { shouldTrustSchemas } from './validation/trust.js';
|
|
9
|
+
export { checkSchemaSafety } from './validation/safety.js';
|
|
10
|
+
export { defaults } from './config/defaults.js';
|
|
11
|
+
export { validateRuntimeOptions } from './config/validate.js';
|
package/src/map.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const TYPES = ['tool', 'prompt', 'resource', 'resource-template'];
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize a raw value into a GraphQL-safe field base.
|
|
5
|
+
* @param {string} type - Catalog item type.
|
|
6
|
+
* @param {string} value - Raw field candidate.
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
function sanitize(type, value) {
|
|
10
|
+
const raw = value.replace(/[^A-Za-z0-9_]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
|
|
11
|
+
const fallback = raw.length > 0 ? raw : `${type}_item`;
|
|
12
|
+
if (/^[A-Za-z_]/.test(fallback)) return fallback;
|
|
13
|
+
return `${type}_${fallback}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a deterministic unique field name.
|
|
18
|
+
* @param {Set<string>} used - Used field names for the root type.
|
|
19
|
+
* @param {string} base - Base field candidate.
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function unique(used, base) {
|
|
23
|
+
let next = base;
|
|
24
|
+
let index = 2;
|
|
25
|
+
|
|
26
|
+
while (used.has(next)) {
|
|
27
|
+
next = `${base}_${index}`;
|
|
28
|
+
index += 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
used.add(next);
|
|
32
|
+
return next;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build catalog entries for a specific type.
|
|
37
|
+
* @param {Array<Record<string, unknown>>} list - Catalog items.
|
|
38
|
+
* @param {string} type - Desired type.
|
|
39
|
+
* @returns {Array<Record<string, unknown>>}
|
|
40
|
+
*/
|
|
41
|
+
function bytype(list, type) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const item of list) {
|
|
44
|
+
if (item && item.type === type) out.push(item);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sort entries in a stable deterministic way.
|
|
51
|
+
* @param {Array<Record<string, unknown>>} list - Input list.
|
|
52
|
+
* @returns {Array<Record<string, unknown>>}
|
|
53
|
+
*/
|
|
54
|
+
function sortstable(list) {
|
|
55
|
+
return list
|
|
56
|
+
.map(function wrap(item, index) {
|
|
57
|
+
return { item, index };
|
|
58
|
+
})
|
|
59
|
+
.sort(function compare(a, b) {
|
|
60
|
+
const left = typeof a.item.name === 'string' ? a.item.name : '';
|
|
61
|
+
const right = typeof b.item.name === 'string' ? b.item.name : '';
|
|
62
|
+
if (left < right) return -1;
|
|
63
|
+
if (left > right) return 1;
|
|
64
|
+
return a.index - b.index;
|
|
65
|
+
})
|
|
66
|
+
.map(function unwrap(entry) {
|
|
67
|
+
return entry.item;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Create a deterministic catalog map for adapters.
|
|
73
|
+
* @param {{ items?: Array<Record<string, unknown>> }} catalog - Extracted catalog.
|
|
74
|
+
* @returns {{ tools: Array<Record<string, unknown>>, prompts: Array<Record<string, unknown>>, resources: Array<Record<string, unknown>>, templates: Array<Record<string, unknown>>, entries: Array<{ type: string, root: 'query' | 'mutation', name: string, field: string, item: Record<string, unknown> }>, byType: (type: string) => Array<Record<string, unknown>>, find: (type: string, name: string) => Record<string, unknown> | undefined, findField: (type: string, name: string) => string | undefined }}
|
|
75
|
+
*/
|
|
76
|
+
export function createMap(catalog = {}) {
|
|
77
|
+
const items = Array.isArray(catalog.items) ? catalog.items : [];
|
|
78
|
+
const tools = sortstable(bytype(items, 'tool'));
|
|
79
|
+
const prompts = sortstable(bytype(items, 'prompt'));
|
|
80
|
+
const resources = sortstable(bytype(items, 'resource'));
|
|
81
|
+
const templates = sortstable(bytype(items, 'resource-template'));
|
|
82
|
+
|
|
83
|
+
const usedMutation = new Set(['callTool', 'getPrompt']);
|
|
84
|
+
const usedQuery = new Set(['catalog', 'readResource', 'readTemplate']);
|
|
85
|
+
const entries = [];
|
|
86
|
+
const lookup = new Map();
|
|
87
|
+
const fields = new Map();
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Register entry metadata for deterministic lookup.
|
|
91
|
+
* @param {'query' | 'mutation'} root - GraphQL root type.
|
|
92
|
+
* @param {string} type - Catalog item type.
|
|
93
|
+
* @param {Record<string, unknown>} item - Catalog item.
|
|
94
|
+
* @returns {void}
|
|
95
|
+
*/
|
|
96
|
+
function add(root, type, item) {
|
|
97
|
+
const name = typeof item.name === 'string' ? item.name : `${type}_item`;
|
|
98
|
+
const base = sanitize(type, name);
|
|
99
|
+
const field = root === 'mutation'
|
|
100
|
+
? unique(usedMutation, base)
|
|
101
|
+
: unique(usedQuery, base);
|
|
102
|
+
|
|
103
|
+
entries.push({ type, root, name, field, item });
|
|
104
|
+
lookup.set(`${type}:${name}`, item);
|
|
105
|
+
fields.set(`${type}:${name}`, field);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const item of tools) add('mutation', 'tool', item);
|
|
109
|
+
for (const item of prompts) add('mutation', 'prompt', item);
|
|
110
|
+
for (const item of resources) add('query', 'resource', item);
|
|
111
|
+
for (const item of templates) add('query', 'resource-template', item);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Select items by a known catalog type.
|
|
115
|
+
* @param {string} type - Catalog type.
|
|
116
|
+
* @returns {Array<Record<string, unknown>>}
|
|
117
|
+
*/
|
|
118
|
+
function selectByType(type) {
|
|
119
|
+
if (type === 'tool') return tools;
|
|
120
|
+
if (type === 'prompt') return prompts;
|
|
121
|
+
if (type === 'resource') return resources;
|
|
122
|
+
if (type === 'resource-template') return templates;
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Find a catalog item by type and name.
|
|
128
|
+
* @param {string} type - Catalog type.
|
|
129
|
+
* @param {string} name - Item name.
|
|
130
|
+
* @returns {Record<string, unknown> | undefined}
|
|
131
|
+
*/
|
|
132
|
+
function find(type, name) {
|
|
133
|
+
return lookup.get(`${type}:${name}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Find a generated GraphQL field by type and item name.
|
|
138
|
+
* @param {string} type - Catalog type.
|
|
139
|
+
* @param {string} name - Item name.
|
|
140
|
+
* @returns {string | undefined}
|
|
141
|
+
*/
|
|
142
|
+
function findField(type, name) {
|
|
143
|
+
return fields.get(`${type}:${name}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
tools,
|
|
148
|
+
prompts,
|
|
149
|
+
resources,
|
|
150
|
+
templates,
|
|
151
|
+
entries,
|
|
152
|
+
byType: selectByType,
|
|
153
|
+
find,
|
|
154
|
+
findField
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export { TYPES };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import CircuitBreaker from 'opossum';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build MCP client request options from a breaker instance.
|
|
5
|
+
* @param {CircuitBreaker | null} breaker - Breaker instance or null.
|
|
6
|
+
* @returns {import('@modelcontextprotocol/sdk/shared/protocol.js').RequestOptions | undefined}
|
|
7
|
+
*/
|
|
8
|
+
function requestOptions(breaker) {
|
|
9
|
+
// Pass timeout through to the MCP client so underlying calls are canceled
|
|
10
|
+
// instead of lingering after the breaker timed out.
|
|
11
|
+
if (!breaker || !breaker.options || typeof breaker.options.timeout !== 'number') return undefined;
|
|
12
|
+
return { timeout: breaker.options.timeout };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a circuit breaker for an MCP session.
|
|
17
|
+
* @param {import('@mcp-layer/session').Session} session - MCP session.
|
|
18
|
+
* @param {{ timeout: number, errorThresholdPercentage: number, resetTimeout: number, volumeThreshold: number }} config - Breaker configuration.
|
|
19
|
+
* @returns {CircuitBreaker}
|
|
20
|
+
*/
|
|
21
|
+
export function createCircuitBreaker(session, config) {
|
|
22
|
+
const breaker = new CircuitBreaker(
|
|
23
|
+
async function call(task) {
|
|
24
|
+
const options = task.options;
|
|
25
|
+
if (task.method === 'tools/call') return session.client.callTool(task.params, undefined, options);
|
|
26
|
+
if (task.method === 'prompts/get') return session.client.getPrompt(task.params, options);
|
|
27
|
+
if (task.method === 'resources/read') return session.client.readResource(task.params, options);
|
|
28
|
+
return session.client.request({ method: task.method, params: task.params }, undefined, options);
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
timeout: config.timeout,
|
|
32
|
+
errorThresholdPercentage: config.errorThresholdPercentage,
|
|
33
|
+
resetTimeout: config.resetTimeout,
|
|
34
|
+
volumeThreshold: config.volumeThreshold,
|
|
35
|
+
name: `mcp-${session.name}`
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
breaker.on('open', function onOpen() {
|
|
40
|
+
if (session.log && typeof session.log.warn === 'function') {
|
|
41
|
+
session.log.warn({ session: session.name }, 'Circuit breaker OPENED - failing fast');
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
breaker.on('halfOpen', function onHalfOpen() {
|
|
46
|
+
if (session.log && typeof session.log.info === 'function') {
|
|
47
|
+
session.log.info({ session: session.name }, 'Circuit breaker HALF-OPEN - testing recovery');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
breaker.on('close', function onClose() {
|
|
52
|
+
if (session.log && typeof session.log.info === 'function') {
|
|
53
|
+
session.log.info({ session: session.name }, 'Circuit breaker CLOSED - normal operation');
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
breaker.on('timeout', function onTimeout() {
|
|
58
|
+
if (session.log && typeof session.log.warn === 'function') {
|
|
59
|
+
session.log.warn({ session: session.name }, 'Circuit breaker request timed out');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
breaker.on('reject', function onReject() {
|
|
64
|
+
if (session.log && typeof session.log.debug === 'function') {
|
|
65
|
+
session.log.debug({ session: session.name }, 'Circuit breaker rejected request (open)');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return breaker;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Execute an MCP call through a circuit breaker if enabled.
|
|
74
|
+
* @param {CircuitBreaker | null} breaker - Breaker instance or null if disabled.
|
|
75
|
+
* @param {import('@mcp-layer/session').Session} session - MCP session.
|
|
76
|
+
* @param {string} method - MCP method name.
|
|
77
|
+
* @param {Record<string, unknown>} params - MCP parameters.
|
|
78
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
79
|
+
*/
|
|
80
|
+
export async function executeWithBreaker(breaker, session, method, params) {
|
|
81
|
+
const options = requestOptions(breaker);
|
|
82
|
+
if (!breaker) {
|
|
83
|
+
if (method === 'tools/call') return session.client.callTool(params);
|
|
84
|
+
if (method === 'prompts/get') return session.client.getPrompt(params);
|
|
85
|
+
if (method === 'resources/read') return session.client.readResource(params);
|
|
86
|
+
return session.client.request({ method, params });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (breaker.opened) {
|
|
90
|
+
const error = new Error('Circuit breaker is open');
|
|
91
|
+
error.code = 'CIRCUIT_OPEN';
|
|
92
|
+
error.sessionName = breaker.name.replace('mcp-', '');
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return breaker.fire({ method, params, options });
|
|
97
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { extract } from '@mcp-layer/schema';
|
|
2
|
+
import { createValidator } from './validation/validator.js';
|
|
3
|
+
import { createCircuitBreaker, executeWithBreaker } from './resilience/breaker.js';
|
|
4
|
+
import { deriveApiVersion, resolvePrefix } from './version.js';
|
|
5
|
+
import { createTelemetry } from './telemetry/index.js';
|
|
6
|
+
import { validateRuntimeOptions } from './config/validate.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Normalize a session input into an array.
|
|
10
|
+
* @param {import('@mcp-layer/session').Session | Array<import('@mcp-layer/session').Session>} session - Session or session list.
|
|
11
|
+
* @returns {Array<import('@mcp-layer/session').Session>}
|
|
12
|
+
*/
|
|
13
|
+
function list(session) {
|
|
14
|
+
return Array.isArray(session) ? session : [session];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register tool and prompt validators from a catalog.
|
|
19
|
+
* @param {import('./validation/validator.js').SchemaValidator} validator - Schema validator.
|
|
20
|
+
* @param {{ items?: Array<Record<string, unknown>> }} catalog - Extracted catalog.
|
|
21
|
+
* @returns {void}
|
|
22
|
+
*/
|
|
23
|
+
function registerCatalogValidators(validator, catalog) {
|
|
24
|
+
const items = Array.isArray(catalog.items) ? catalog.items : [];
|
|
25
|
+
|
|
26
|
+
for (const item of items) {
|
|
27
|
+
if (item.type === 'tool' && item.name && item.detail?.input?.json) {
|
|
28
|
+
validator.registerToolSchema(String(item.name), item.detail.input.json);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (item.type === 'prompt' && item.name && item.detail?.input?.json) {
|
|
32
|
+
validator.registerPromptSchema(String(item.name), item.detail.input.json);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ensure a circuit breaker exists for a session.
|
|
39
|
+
* @param {Map<string, import('opossum')>} breakers - Runtime breaker map.
|
|
40
|
+
* @param {import('@mcp-layer/session').Session} session - Target session.
|
|
41
|
+
* @param {{ enabled: boolean, timeout: number, errorThresholdPercentage: number, resetTimeout: number, volumeThreshold: number }} config - Resilience config.
|
|
42
|
+
* @param {ReturnType<import('./telemetry/index.js').createTelemetry> | null} telemetry - Telemetry helper.
|
|
43
|
+
* @returns {import('opossum') | null}
|
|
44
|
+
*/
|
|
45
|
+
function ensureBreaker(breakers, session, config, telemetry) {
|
|
46
|
+
if (!config.enabled) return null;
|
|
47
|
+
|
|
48
|
+
const existing = breakers.get(session.name);
|
|
49
|
+
if (existing) return existing;
|
|
50
|
+
|
|
51
|
+
const breaker = createCircuitBreaker(session, config);
|
|
52
|
+
breakers.set(session.name, breaker);
|
|
53
|
+
|
|
54
|
+
if (telemetry) {
|
|
55
|
+
telemetry.setCircuitState(session.name, 'closed');
|
|
56
|
+
breaker.on('open', function onOpen() {
|
|
57
|
+
telemetry.setCircuitState(session.name, 'open');
|
|
58
|
+
});
|
|
59
|
+
breaker.on('halfOpen', function onHalfOpen() {
|
|
60
|
+
telemetry.setCircuitState(session.name, 'half_open');
|
|
61
|
+
});
|
|
62
|
+
breaker.on('close', function onClose() {
|
|
63
|
+
telemetry.setCircuitState(session.name, 'closed');
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return breaker;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve request-scoped session and breaker.
|
|
72
|
+
* @param {import('@mcp-layer/session').Session} bootstrap - Bootstrap session.
|
|
73
|
+
* @param {{ manager?: { get: (request: import('fastify').FastifyRequest) => Promise<import('@mcp-layer/session').Session> }, resilience: { enabled: boolean, timeout: number, errorThresholdPercentage: number, resetTimeout: number, volumeThreshold: number } }} config - Runtime config.
|
|
74
|
+
* @param {Map<string, import('opossum')>} breakers - Breaker storage.
|
|
75
|
+
* @param {ReturnType<import('./telemetry/index.js').createTelemetry> | null} telemetry - Telemetry helper.
|
|
76
|
+
* @param {import('fastify').FastifyRequest} request - Incoming request.
|
|
77
|
+
* @returns {Promise<{ session: import('@mcp-layer/session').Session, breaker: import('opossum') | null }>}
|
|
78
|
+
*/
|
|
79
|
+
async function resolveSession(bootstrap, config, breakers, telemetry, request) {
|
|
80
|
+
const target = config.manager ? await config.manager.get(request) : bootstrap;
|
|
81
|
+
const breaker = ensureBreaker(breakers, target, config.resilience, telemetry);
|
|
82
|
+
return { session: target, breaker };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create shared runtime contexts for MCP adapters.
|
|
87
|
+
* @param {Record<string, unknown>} opts - Runtime options.
|
|
88
|
+
* @param {{ name?: string, serviceName?: string }} [meta] - Runtime metadata.
|
|
89
|
+
* @returns {Promise<{ config: ReturnType<typeof validateRuntimeOptions>, contexts: Array<{ session: import('@mcp-layer/session').Session, catalog: { server?: { info?: Record<string, unknown> }, items?: Array<Record<string, unknown>> }, info: Record<string, unknown> | undefined, version: string, prefix: string, validator: import('./validation/validator.js').SchemaValidator, telemetry: ReturnType<typeof createTelemetry> | null, resolve: (request: import('fastify').FastifyRequest) => Promise<{ session: import('@mcp-layer/session').Session, breaker: import('opossum') | null }>, execute: (request: import('fastify').FastifyRequest, method: string, params: Record<string, unknown>) => Promise<Record<string, unknown>>, normalize: (error: Error & { code?: string | number }, instance: string, requestId?: string) => unknown }>, breakers: Map<string, import('opossum')>, normalize: (error: Error & { code?: string | number }, instance: string, requestId?: string) => unknown, close: () => Promise<void> }>}
|
|
90
|
+
*/
|
|
91
|
+
export async function createRuntime(opts, meta = {}) {
|
|
92
|
+
const config = validateRuntimeOptions(opts, meta);
|
|
93
|
+
const sessions = config.manager ? [config.session] : list(config.session);
|
|
94
|
+
const breakers = new Map();
|
|
95
|
+
const contexts = [];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Normalize adapter errors using configured mapper.
|
|
99
|
+
* @param {Error & { code?: string | number }} error - Runtime error.
|
|
100
|
+
* @param {string} instance - Request instance identifier.
|
|
101
|
+
* @param {string} [requestId] - Request id.
|
|
102
|
+
* @returns {unknown}
|
|
103
|
+
*/
|
|
104
|
+
function normalize(error, instance, requestId) {
|
|
105
|
+
if (config.normalizeError) {
|
|
106
|
+
return config.normalizeError(error, instance, requestId, config.errors);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
error,
|
|
111
|
+
instance,
|
|
112
|
+
requestId,
|
|
113
|
+
options: config.errors
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const session of sessions) {
|
|
118
|
+
const catalog = await extract(session);
|
|
119
|
+
const info = catalog.server?.info;
|
|
120
|
+
const version = deriveApiVersion(info);
|
|
121
|
+
const prefix = resolvePrefix(config.prefix, version, info, session.name);
|
|
122
|
+
const validator = createValidator(config.validation, session);
|
|
123
|
+
const telemetry = createTelemetry(config.telemetry);
|
|
124
|
+
|
|
125
|
+
registerCatalogValidators(validator, catalog);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve session context for a request.
|
|
129
|
+
* @param {import('fastify').FastifyRequest} request - Incoming request.
|
|
130
|
+
* @returns {Promise<{ session: import('@mcp-layer/session').Session, breaker: import('opossum') | null }>}
|
|
131
|
+
*/
|
|
132
|
+
async function resolve(request) {
|
|
133
|
+
return resolveSession(session, config, breakers, telemetry, request);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Execute an MCP method with request-scoped session resolution.
|
|
138
|
+
* @param {import('fastify').FastifyRequest} request - Incoming request.
|
|
139
|
+
* @param {string} method - MCP method.
|
|
140
|
+
* @param {Record<string, unknown>} params - MCP params.
|
|
141
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
142
|
+
*/
|
|
143
|
+
async function execute(request, method, params) {
|
|
144
|
+
const resolved = await resolve(request);
|
|
145
|
+
return executeWithBreaker(resolved.breaker, resolved.session, method, params);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Normalize a runtime error.
|
|
150
|
+
* @param {Error & { code?: string | number }} error - Runtime error.
|
|
151
|
+
* @param {string} instance - Request instance identifier.
|
|
152
|
+
* @param {string} [requestId] - Request id.
|
|
153
|
+
* @returns {unknown}
|
|
154
|
+
*/
|
|
155
|
+
function contextNormalize(error, instance, requestId) {
|
|
156
|
+
return normalize(error, instance, requestId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
contexts.push({
|
|
160
|
+
session,
|
|
161
|
+
catalog,
|
|
162
|
+
info,
|
|
163
|
+
version,
|
|
164
|
+
prefix,
|
|
165
|
+
validator,
|
|
166
|
+
telemetry,
|
|
167
|
+
resolve,
|
|
168
|
+
execute,
|
|
169
|
+
normalize: contextNormalize
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Close runtime resources.
|
|
175
|
+
* @returns {Promise<void>}
|
|
176
|
+
*/
|
|
177
|
+
async function close() {
|
|
178
|
+
for (const breaker of breakers.values()) {
|
|
179
|
+
breaker.shutdown();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (config.manager && typeof config.manager.close === 'function') {
|
|
183
|
+
await config.manager.close();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
config,
|
|
189
|
+
contexts,
|
|
190
|
+
breakers,
|
|
191
|
+
normalize,
|
|
192
|
+
close
|
|
193
|
+
};
|
|
194
|
+
}
|