@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,77 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Load the OpenTelemetry API module dynamically.
|
|
5
|
+
* @returns {import('@opentelemetry/api')} OTel API module.
|
|
6
|
+
*/
|
|
7
|
+
function loadOtel() {
|
|
8
|
+
const req = createRequire(import.meta.url);
|
|
9
|
+
return req('@opentelemetry/api');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a telemetry helper for adapter runtimes.
|
|
14
|
+
* @param {{ enabled?: boolean, serviceName: string, metricPrefix?: string, api?: import('@opentelemetry/api') }} config - Telemetry configuration.
|
|
15
|
+
* @returns {{ tracer: import('@opentelemetry/api').Tracer, meter: import('@opentelemetry/api').Meter, context: import('@opentelemetry/api').ContextAPI, propagation: import('@opentelemetry/api').PropagationAPI, metrics: { callDuration: import('@opentelemetry/api').Histogram, callErrors: import('@opentelemetry/api').Counter, validationErrors: import('@opentelemetry/api').Counter, circuitState: import('@opentelemetry/api').ObservableGauge }, setCircuitState: (session: string, state: string) => void } | null}
|
|
16
|
+
*/
|
|
17
|
+
export function createTelemetry(config) {
|
|
18
|
+
const enabled = config.enabled ?? Boolean(config.api);
|
|
19
|
+
if (!enabled) return null;
|
|
20
|
+
const prefix = typeof config.metricPrefix === 'string' && config.metricPrefix.length > 0
|
|
21
|
+
? config.metricPrefix
|
|
22
|
+
: 'adapter';
|
|
23
|
+
|
|
24
|
+
const otel = config.api ?? loadOtel();
|
|
25
|
+
const tracer = otel.trace.getTracer(config.serviceName, '1.0.0');
|
|
26
|
+
const meter = otel.metrics.getMeter(config.serviceName, '1.0.0');
|
|
27
|
+
|
|
28
|
+
const callDuration = meter.createHistogram('mcp.call.duration', {
|
|
29
|
+
description: 'Duration of MCP calls',
|
|
30
|
+
unit: 'ms'
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const callErrors = meter.createCounter('mcp.call.errors', {
|
|
34
|
+
description: 'Number of MCP call errors'
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const validationErrors = meter.createCounter(`${prefix}.validation.errors`, {
|
|
38
|
+
description: 'Number of validation errors'
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const states = new Map();
|
|
42
|
+
const circuitState = meter.createObservableGauge(`${prefix}.circuit.state`, {
|
|
43
|
+
description: 'Circuit breaker state'
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
circuitState.addCallback(function observe(obs) {
|
|
47
|
+
for (const [session, value] of states.entries()) {
|
|
48
|
+
obs.observe(value, { session });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Update circuit breaker state metric.
|
|
54
|
+
* @param {string} session - Session name.
|
|
55
|
+
* @param {string} state - Circuit breaker state.
|
|
56
|
+
* @returns {void}
|
|
57
|
+
*/
|
|
58
|
+
function setCircuitState(session, state) {
|
|
59
|
+
const map = { open: 2, half_open: 1, closed: 0 };
|
|
60
|
+
const value = Object.hasOwn(map, state) ? map[state] : 0;
|
|
61
|
+
states.set(session, value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
tracer,
|
|
66
|
+
meter,
|
|
67
|
+
context: otel.context,
|
|
68
|
+
propagation: otel.propagation,
|
|
69
|
+
metrics: {
|
|
70
|
+
callDuration,
|
|
71
|
+
callErrors,
|
|
72
|
+
validationErrors,
|
|
73
|
+
circuitState
|
|
74
|
+
},
|
|
75
|
+
setCircuitState
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import traverse from 'json-schema-traverse';
|
|
2
|
+
import safeRegex from 'safe-regex2';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check a regex pattern for safety.
|
|
6
|
+
* @param {string} pattern - Regex pattern string.
|
|
7
|
+
* @param {number} maxPatternLength - Maximum allowed pattern length.
|
|
8
|
+
* @returns {{ safe: boolean, reason?: string }}
|
|
9
|
+
*/
|
|
10
|
+
function checkPattern(pattern, maxPatternLength) {
|
|
11
|
+
if (pattern.length > maxPatternLength) {
|
|
12
|
+
return { safe: false, reason: `Regex pattern exceeds maximum length of ${maxPatternLength}` };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!safeRegex(pattern)) {
|
|
16
|
+
return { safe: false, reason: 'Schema contains potentially unsafe regex pattern' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return { safe: true };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compute the maximum depth for a JSON Schema tree.
|
|
24
|
+
* @param {Record<string, unknown>} schema - JSON Schema to traverse.
|
|
25
|
+
* @returns {number}
|
|
26
|
+
*/
|
|
27
|
+
function maxdepth(schema) {
|
|
28
|
+
let max = 0;
|
|
29
|
+
const seen = new WeakMap();
|
|
30
|
+
|
|
31
|
+
traverse(schema, function visit(node, _jsonPtr, _root, _parentJsonPtr, _parentKeyword, parentSchema) {
|
|
32
|
+
if (!node || typeof node !== 'object') return;
|
|
33
|
+
const parentDepth = parentSchema && seen.has(parentSchema) ? seen.get(parentSchema) : 0;
|
|
34
|
+
const depth = parentSchema ? parentDepth + 1 : 0;
|
|
35
|
+
seen.set(node, depth);
|
|
36
|
+
if (depth > max) max = depth;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return max;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check a JSON Schema for safety constraints.
|
|
44
|
+
* @param {Record<string, unknown>} schema - JSON Schema to inspect.
|
|
45
|
+
* @param {{ maxSchemaDepth: number, maxSchemaSize: number, maxPatternLength: number }} config - Safety limits.
|
|
46
|
+
* @returns {{ safe: boolean, reason?: string }}
|
|
47
|
+
*/
|
|
48
|
+
export function checkSchemaSafety(schema, config) {
|
|
49
|
+
const depth = maxdepth(schema);
|
|
50
|
+
if (depth > config.maxSchemaDepth) {
|
|
51
|
+
return { safe: false, reason: `Schema exceeds maximum depth of ${config.maxSchemaDepth}` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const size = JSON.stringify(schema).length;
|
|
55
|
+
if (size > config.maxSchemaSize) {
|
|
56
|
+
return { safe: false, reason: `Schema exceeds maximum size of ${config.maxSchemaSize} bytes` };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let patternError;
|
|
60
|
+
traverse(schema, function visit(node) {
|
|
61
|
+
if (patternError || !node || typeof node !== 'object') return;
|
|
62
|
+
if (typeof node.pattern === 'string') {
|
|
63
|
+
const check = checkPattern(node.pattern, config.maxPatternLength);
|
|
64
|
+
if (!check.safe) patternError = check.reason;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (patternError) {
|
|
69
|
+
return { safe: false, reason: patternError };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { safe: true };
|
|
73
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determine whether schemas from a session should be trusted.
|
|
3
|
+
* @param {import('@mcp-layer/session').Session} session - MCP session to evaluate.
|
|
4
|
+
* @param {'auto' | true | false} policy - Trust policy.
|
|
5
|
+
* @returns {boolean}
|
|
6
|
+
*/
|
|
7
|
+
export function shouldTrustSchemas(session, policy) {
|
|
8
|
+
if (policy === true) return true;
|
|
9
|
+
if (policy === false) return false;
|
|
10
|
+
|
|
11
|
+
const type = session?.transport?.constructor?.name;
|
|
12
|
+
const remote = type === 'StreamableHTTPClientTransport';
|
|
13
|
+
return !remote;
|
|
14
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import Ajv from 'ajv';
|
|
2
|
+
import addFormats from 'ajv-formats';
|
|
3
|
+
import { shouldTrustSchemas } from './trust.js';
|
|
4
|
+
import { checkSchemaSafety } from './safety.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Schema validator for MCP tool and prompt inputs.
|
|
8
|
+
*/
|
|
9
|
+
export class SchemaValidator {
|
|
10
|
+
/**
|
|
11
|
+
* @param {{ trustSchemas: 'auto' | true | false, maxSchemaDepth: number, maxSchemaSize: number, maxPatternLength: number, maxToolNameLength: number, maxTemplateParamLength: number }} config - Validation configuration.
|
|
12
|
+
* @param {import('@mcp-layer/session').Session} session - MCP session.
|
|
13
|
+
*/
|
|
14
|
+
constructor(config, session) {
|
|
15
|
+
this.#config = config;
|
|
16
|
+
this.#trusted = shouldTrustSchemas(session, config.trustSchemas);
|
|
17
|
+
this.#ajv = new Ajv({
|
|
18
|
+
allErrors: true,
|
|
19
|
+
strict: this.#trusted ? false : 'log',
|
|
20
|
+
validateFormats: true,
|
|
21
|
+
coerceTypes: 'array',
|
|
22
|
+
removeAdditional: true
|
|
23
|
+
});
|
|
24
|
+
addFormats(this.#ajv);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a tool schema for validation.
|
|
29
|
+
* @param {string} name - Tool name.
|
|
30
|
+
* @param {Record<string, unknown> | undefined} schema - JSON Schema for input.
|
|
31
|
+
* @returns {{ success: boolean, error?: string, skipped?: boolean, reason?: string }}
|
|
32
|
+
*/
|
|
33
|
+
registerToolSchema(name, schema) {
|
|
34
|
+
return this.#registerSchema(`tool:${name}`, schema);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Register a prompt schema for validation.
|
|
39
|
+
* @param {string} name - Prompt name.
|
|
40
|
+
* @param {Record<string, unknown> | undefined} schema - JSON Schema for input.
|
|
41
|
+
* @returns {{ success: boolean, error?: string, skipped?: boolean, reason?: string }}
|
|
42
|
+
*/
|
|
43
|
+
registerPromptSchema(name, schema) {
|
|
44
|
+
return this.#registerSchema(`prompt:${name}`, schema);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate a request payload against a registered schema.
|
|
49
|
+
* @param {'tool' | 'prompt'} type - Schema type.
|
|
50
|
+
* @param {string} name - Tool or prompt name.
|
|
51
|
+
* @param {unknown} input - Request payload.
|
|
52
|
+
* @returns {{ valid: boolean, errors?: Array<{ path: string, keyword?: string, message?: string, params?: Record<string, unknown> }> }}
|
|
53
|
+
*/
|
|
54
|
+
validate(type, name, input) {
|
|
55
|
+
const key = `${type}:${name}`;
|
|
56
|
+
const fn = this.#validators.get(key);
|
|
57
|
+
|
|
58
|
+
if (fn === undefined) {
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
errors: [{ path: '', message: `Unknown ${type}: ${name}` }]
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (fn === null) {
|
|
66
|
+
return { valid: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ok = fn(input);
|
|
70
|
+
if (ok) {
|
|
71
|
+
return { valid: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const errs = [];
|
|
75
|
+
const list = Array.isArray(fn.errors) ? fn.errors : [];
|
|
76
|
+
for (const err of list) {
|
|
77
|
+
errs.push({
|
|
78
|
+
path: err.instancePath || '/',
|
|
79
|
+
keyword: err.keyword,
|
|
80
|
+
message: err.message,
|
|
81
|
+
params: err.params
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { valid: false, errors: errs };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Register and compile a schema into an Ajv validator.
|
|
90
|
+
* @param {string} key - Internal map key.
|
|
91
|
+
* @param {Record<string, unknown> | undefined} schema - JSON Schema.
|
|
92
|
+
* @returns {{ success: boolean, error?: string, skipped?: boolean, reason?: string }}
|
|
93
|
+
*/
|
|
94
|
+
#registerSchema(key, schema) {
|
|
95
|
+
if (!schema) {
|
|
96
|
+
this.#validators.set(key, null);
|
|
97
|
+
return { success: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!this.#trusted) {
|
|
101
|
+
const safety = checkSchemaSafety(schema, this.#config);
|
|
102
|
+
if (!safety.safe) {
|
|
103
|
+
this.#validators.set(key, null);
|
|
104
|
+
return { success: true, skipped: true, reason: safety.reason };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const fn = this.#ajv.compile(schema);
|
|
110
|
+
this.#validators.set(key, fn);
|
|
111
|
+
return { success: true };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
114
|
+
return { success: false, error: `Schema compilation failed: ${message}` };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @type {Map<string, import('ajv').ValidateFunction | null>}
|
|
120
|
+
*/
|
|
121
|
+
#validators = new Map();
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @type {Ajv}
|
|
125
|
+
*/
|
|
126
|
+
#ajv;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @type {{ trustSchemas: 'auto' | true | false, maxSchemaDepth: number, maxSchemaSize: number, maxPatternLength: number, maxToolNameLength: number, maxTemplateParamLength: number }}
|
|
130
|
+
*/
|
|
131
|
+
#config;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @type {boolean}
|
|
135
|
+
*/
|
|
136
|
+
#trusted;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create a schema validator instance.
|
|
141
|
+
* @param {{ trustSchemas: 'auto' | true | false, maxSchemaDepth: number, maxSchemaSize: number, maxPatternLength: number, maxToolNameLength: number, maxTemplateParamLength: number }} config - Validation configuration.
|
|
142
|
+
* @param {import('@mcp-layer/session').Session} session - MCP session.
|
|
143
|
+
* @returns {SchemaValidator}
|
|
144
|
+
*/
|
|
145
|
+
export function createValidator(config, session) {
|
|
146
|
+
return new SchemaValidator(config, session);
|
|
147
|
+
}
|
package/src/version.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive an API version prefix from MCP server info.
|
|
3
|
+
* @param {{ version?: string } | undefined} info - MCP server info object.
|
|
4
|
+
* @returns {string}
|
|
5
|
+
*/
|
|
6
|
+
export function deriveApiVersion(info) {
|
|
7
|
+
if (!info || !info.version) return 'v0';
|
|
8
|
+
|
|
9
|
+
const version = String(info.version).replace(/^v/, '');
|
|
10
|
+
const semver = version.match(/^(\d+)/);
|
|
11
|
+
if (semver) return `v${semver[1]}`;
|
|
12
|
+
|
|
13
|
+
const date = version.match(/^(\d{4})-/);
|
|
14
|
+
if (date) return `v${date[1]}`;
|
|
15
|
+
|
|
16
|
+
return 'v0';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a route prefix for a runtime context.
|
|
21
|
+
* @param {string | ((version: string, info: Record<string, unknown> | undefined, name: string) => string) | undefined} prefixOption - Prefix option.
|
|
22
|
+
* @param {string} version - API version.
|
|
23
|
+
* @param {Record<string, unknown> | undefined} info - Server info.
|
|
24
|
+
* @param {string} name - Session name.
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function resolvePrefix(prefixOption, version, info, name) {
|
|
28
|
+
if (typeof prefixOption === 'function') return prefixOption(version, info, name);
|
|
29
|
+
if (typeof prefixOption === 'string') return prefixOption;
|
|
30
|
+
return `/${version}`;
|
|
31
|
+
}
|