mcp4openapi 0.3.0 → 0.3.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/dist/src/argument-normalizer.d.ts +5 -0
- package/dist/src/argument-normalizer.d.ts.map +1 -0
- package/dist/src/argument-normalizer.js +61 -0
- package/dist/src/argument-normalizer.js.map +1 -0
- package/dist/src/auth/oauth-provider.d.ts.map +1 -1
- package/dist/src/auth/oauth-provider.js +5 -2
- package/dist/src/auth/oauth-provider.js.map +1 -1
- package/dist/src/cli-config.d.ts +9 -0
- package/dist/src/cli-config.d.ts.map +1 -0
- package/dist/src/cli-config.js +111 -0
- package/dist/src/cli-config.js.map +1 -0
- package/dist/src/composite-executor.d.ts +77 -0
- package/dist/src/composite-executor.d.ts.map +1 -0
- package/dist/src/composite-executor.js +193 -0
- package/dist/src/composite-executor.js.map +1 -0
- package/dist/src/constants.d.ts +85 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +85 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/core/cli-config.d.ts.map +1 -1
- package/dist/src/core/cli-config.js +1 -0
- package/dist/src/core/cli-config.js.map +1 -1
- package/dist/src/core/index.d.ts.map +1 -1
- package/dist/src/core/index.js +1 -0
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/dag-executor.d.ts +49 -0
- package/dist/src/dag-executor.d.ts.map +1 -0
- package/dist/src/dag-executor.js +138 -0
- package/dist/src/dag-executor.js.map +1 -0
- package/dist/src/errors.d.ts +59 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +119 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/filtering.d.ts +19 -0
- package/dist/src/filtering.d.ts.map +1 -0
- package/dist/src/filtering.js +292 -0
- package/dist/src/filtering.js.map +1 -0
- package/dist/src/generated-schemas.d.ts +45 -0
- package/dist/src/generated-schemas.d.ts.map +1 -1
- package/dist/src/generated-schemas.js +3 -0
- package/dist/src/generated-schemas.js.map +1 -1
- package/dist/src/http-client-factory.d.ts +62 -0
- package/dist/src/http-client-factory.d.ts.map +1 -0
- package/dist/src/http-client-factory.js +133 -0
- package/dist/src/http-client-factory.js.map +1 -0
- package/dist/src/http-transport-config.d.ts +6 -0
- package/dist/src/http-transport-config.d.ts.map +1 -0
- package/dist/src/http-transport-config.js +47 -0
- package/dist/src/http-transport-config.js.map +1 -0
- package/dist/src/http-transport.d.ts +316 -0
- package/dist/src/http-transport.d.ts.map +1 -0
- package/dist/src/http-transport.js +2412 -0
- package/dist/src/http-transport.js.map +1 -0
- package/dist/src/index.js +0 -0
- package/dist/src/interceptors.d.ts +116 -0
- package/dist/src/interceptors.d.ts.map +1 -0
- package/dist/src/interceptors.js +392 -0
- package/dist/src/interceptors.js.map +1 -0
- package/dist/src/jsonrpc-validator.d.ts +27 -0
- package/dist/src/jsonrpc-validator.d.ts.map +1 -0
- package/dist/src/jsonrpc-validator.js +58 -0
- package/dist/src/jsonrpc-validator.js.map +1 -0
- package/dist/src/logger.d.ts +59 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +177 -0
- package/dist/src/logger.js.map +1 -0
- package/dist/src/mcp-server-manager.d.ts +20 -0
- package/dist/src/mcp-server-manager.d.ts.map +1 -0
- package/dist/src/mcp-server-manager.js +38 -0
- package/dist/src/mcp-server-manager.js.map +1 -0
- package/dist/src/mcp-server.d.ts +203 -0
- package/dist/src/mcp-server.d.ts.map +1 -0
- package/dist/src/mcp-server.js +1369 -0
- package/dist/src/mcp-server.js.map +1 -0
- package/dist/src/metrics.d.ts +97 -0
- package/dist/src/metrics.d.ts.map +1 -0
- package/dist/src/metrics.js +273 -0
- package/dist/src/metrics.js.map +1 -0
- package/dist/src/naming-warnings.d.ts +23 -0
- package/dist/src/naming-warnings.d.ts.map +1 -0
- package/dist/src/naming-warnings.js +83 -0
- package/dist/src/naming-warnings.js.map +1 -0
- package/dist/src/naming.d.ts +58 -0
- package/dist/src/naming.d.ts.map +1 -0
- package/dist/src/naming.js +510 -0
- package/dist/src/naming.js.map +1 -0
- package/dist/src/oauth-provider.d.ts +131 -0
- package/dist/src/oauth-provider.d.ts.map +1 -0
- package/dist/src/oauth-provider.js +836 -0
- package/dist/src/oauth-provider.js.map +1 -0
- package/dist/src/openapi/openapi-parser.d.ts.map +1 -1
- package/dist/src/openapi/openapi-parser.js +22 -0
- package/dist/src/openapi/openapi-parser.js.map +1 -1
- package/dist/src/openapi-parser.d.ts +70 -0
- package/dist/src/openapi-parser.d.ts.map +1 -0
- package/dist/src/openapi-parser.js +436 -0
- package/dist/src/openapi-parser.js.map +1 -0
- package/dist/src/profile/profile-loader.d.ts.map +1 -1
- package/dist/src/profile/profile-loader.js +8 -1
- package/dist/src/profile/profile-loader.js.map +1 -1
- package/dist/src/profile/profile-registry.d.ts +2 -1
- package/dist/src/profile/profile-registry.d.ts.map +1 -1
- package/dist/src/profile/profile-registry.js +18 -1
- package/dist/src/profile/profile-registry.js.map +1 -1
- package/dist/src/profile/profile-resolver.d.ts +16 -0
- package/dist/src/profile/profile-resolver.d.ts.map +1 -1
- package/dist/src/profile/profile-resolver.js +120 -0
- package/dist/src/profile/profile-resolver.js.map +1 -1
- package/dist/src/profile-loader.d.ts +78 -0
- package/dist/src/profile-loader.d.ts.map +1 -0
- package/dist/src/profile-loader.js +483 -0
- package/dist/src/profile-loader.js.map +1 -0
- package/dist/src/profile-registry.d.ts +18 -0
- package/dist/src/profile-registry.d.ts.map +1 -0
- package/dist/src/profile-registry.js +26 -0
- package/dist/src/profile-registry.js.map +1 -0
- package/dist/src/profile-resolver.d.ts +19 -0
- package/dist/src/profile-resolver.d.ts.map +1 -0
- package/dist/src/profile-resolver.js +167 -0
- package/dist/src/profile-resolver.js.map +1 -0
- package/dist/src/proxy-executor.d.ts +86 -0
- package/dist/src/proxy-executor.d.ts.map +1 -0
- package/dist/src/proxy-executor.js +497 -0
- package/dist/src/proxy-executor.js.map +1 -0
- package/dist/src/schema-validator.d.ts +30 -0
- package/dist/src/schema-validator.d.ts.map +1 -0
- package/dist/src/schema-validator.js +128 -0
- package/dist/src/schema-validator.js.map +1 -0
- package/dist/src/startup-profile.d.ts +17 -0
- package/dist/src/startup-profile.d.ts.map +1 -0
- package/dist/src/startup-profile.js +30 -0
- package/dist/src/startup-profile.js.map +1 -0
- package/dist/src/startup-validation.d.ts +11 -0
- package/dist/src/startup-validation.d.ts.map +1 -0
- package/dist/src/startup-validation.js +21 -0
- package/dist/src/startup-validation.js.map +1 -0
- package/dist/src/tool-filter.d.ts +65 -0
- package/dist/src/tool-filter.d.ts.map +1 -0
- package/dist/src/tool-filter.js +471 -0
- package/dist/src/tool-filter.js.map +1 -0
- package/dist/src/tool-generator.d.ts +67 -0
- package/dist/src/tool-generator.d.ts.map +1 -0
- package/dist/src/tool-generator.js +182 -0
- package/dist/src/tool-generator.js.map +1 -0
- package/dist/src/tooling/composite-executor.d.ts.map +1 -1
- package/dist/src/tooling/composite-executor.js +7 -2
- package/dist/src/tooling/composite-executor.js.map +1 -1
- package/dist/src/tooling/proxy-executor.d.ts.map +1 -1
- package/dist/src/tooling/proxy-executor.js +4 -0
- package/dist/src/tooling/proxy-executor.js.map +1 -1
- package/dist/src/tooling/tool-generator.d.ts.map +1 -1
- package/dist/src/tooling/tool-generator.js +36 -3
- package/dist/src/tooling/tool-generator.js.map +1 -1
- package/dist/src/transport/http-transport-config.d.ts.map +1 -1
- package/dist/src/transport/http-transport-config.js +1 -0
- package/dist/src/transport/http-transport-config.js.map +1 -1
- package/dist/src/transport/http-transport.d.ts +5 -0
- package/dist/src/transport/http-transport.d.ts.map +1 -1
- package/dist/src/transport/http-transport.js +63 -1
- package/dist/src/transport/http-transport.js.map +1 -1
- package/dist/src/transport/profile-index.d.ts +84 -0
- package/dist/src/transport/profile-index.d.ts.map +1 -0
- package/dist/src/transport/profile-index.js +405 -0
- package/dist/src/transport/profile-index.js.map +1 -0
- package/dist/src/types/http-transport.d.ts +1 -0
- package/dist/src/types/http-transport.d.ts.map +1 -1
- package/dist/src/types/openapi.d.ts +3 -0
- package/dist/src/types/openapi.d.ts.map +1 -1
- package/dist/src/types/profile.d.ts +3 -0
- package/dist/src/types/profile.d.ts.map +1 -1
- package/dist/src/validation/validation-utils.d.ts.map +1 -1
- package/dist/src/validation/validation-utils.js +1 -0
- package/dist/src/validation/validation-utils.js.map +1 -1
- package/dist/src/validation-utils.d.ts +49 -0
- package/dist/src/validation-utils.d.ts.map +1 -0
- package/dist/src/validation-utils.js +138 -0
- package/dist/src/validation-utils.js.map +1 -0
- package/html/profile-index.html +386 -0
- package/package.json +2 -1
- package/profile-schema.json +14 -0
- package/profiles/gitlab/developer-profile-oauth.json +1 -1
- package/profiles/gitlab/developer-profile.json +1508 -0
- package/profiles/gitlab/developer-profile.test.json +3432 -0
- package/profiles/n8n/profile-optimized.json +1 -1
- package/profiles/n8n/profile.json +1 -1
- package/profiles/n8n-nodes/profile-nodes.json +1 -1
- package/profiles/semgrep/profile.json +1 -1
- package/profiles/youtrack/profile.json +1 -1
|
@@ -0,0 +1,1369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main MCP server implementation
|
|
3
|
+
*
|
|
4
|
+
* Why: Coordinates OpenAPI parser, profile loader, tool generator, and request execution.
|
|
5
|
+
* Single entry point for tool registration and invocation.
|
|
6
|
+
*/
|
|
7
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import { OpenAPIParser } from './openapi-parser.js';
|
|
11
|
+
import { ProfileLoader } from './profile-loader.js';
|
|
12
|
+
import { ToolGenerator } from './tool-generator.js';
|
|
13
|
+
import { normalizeArguments } from './argument-normalizer.js';
|
|
14
|
+
import { CompositeExecutor } from './composite-executor.js';
|
|
15
|
+
import { ProxyDownloadExecutor } from './proxy-executor.js';
|
|
16
|
+
import { enforceFiltering, parseFilteringHeader } from './filtering.js';
|
|
17
|
+
import { ConfigurationError, OperationNotFoundError, ResourceNotFoundError, ValidationError, AuthenticationError, AuthorizationError, RateLimitError, NetworkError, generateCorrelationId } from './errors.js';
|
|
18
|
+
import { OAUTH_RATE_LIMIT } from './constants.js';
|
|
19
|
+
import { HttpClientFactory } from './http-client-factory.js';
|
|
20
|
+
import { SchemaValidator } from './schema-validator.js';
|
|
21
|
+
import { ConsoleLogger, JsonLogger } from './logger.js';
|
|
22
|
+
import { isInitializeRequest, isToolCallRequest } from './jsonrpc-validator.js';
|
|
23
|
+
import { generateNameWarnings } from './naming-warnings.js';
|
|
24
|
+
import { NamingStrategy } from './naming.js';
|
|
25
|
+
import { isSafePropertyName } from './validation-utils.js';
|
|
26
|
+
import { ToolFilterService, EnvConfigParser, HeaderConfigParser, RegexCompiler, RegexValidator, OperationClassifier, OpenAPIOperationResolver, OperationDetector, applySessionToolFilter, } from './tool-filter/index.js';
|
|
27
|
+
import { buildHttpTransportBaseConfig } from './http-transport-config.js';
|
|
28
|
+
export class MCPServer {
|
|
29
|
+
/**
|
|
30
|
+
* Execute a tools/call request via the JSON-RPC handler.
|
|
31
|
+
* Intended for internal use and tests to avoid accessing private methods.
|
|
32
|
+
*/
|
|
33
|
+
async callToolRpc(name, args, sessionId, requestId = 1) {
|
|
34
|
+
const message = {
|
|
35
|
+
jsonrpc: '2.0',
|
|
36
|
+
id: requestId,
|
|
37
|
+
method: 'tools/call',
|
|
38
|
+
params: {
|
|
39
|
+
name,
|
|
40
|
+
arguments: args,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
return this.handleToolCall(message, sessionId);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Filter response payload to include only specified fields.
|
|
47
|
+
*
|
|
48
|
+
* Supports YouTrack-style field selectors like:
|
|
49
|
+
* - "author(id,login)"
|
|
50
|
+
* - "comments(id,text,author(id,login))"
|
|
51
|
+
*
|
|
52
|
+
* Recurses into nested objects and arrays when subfields are specified.
|
|
53
|
+
*/
|
|
54
|
+
filterFields(data, fields) {
|
|
55
|
+
const selection = this.parseFieldSelection(fields);
|
|
56
|
+
return this.applyFieldSelection(data, selection);
|
|
57
|
+
}
|
|
58
|
+
parseFieldSelection(fields) {
|
|
59
|
+
const root = Object.create(null);
|
|
60
|
+
for (const field of fields) {
|
|
61
|
+
const trimmed = field.trim();
|
|
62
|
+
if (!trimmed)
|
|
63
|
+
continue;
|
|
64
|
+
this.mergeFieldSelector(root, trimmed);
|
|
65
|
+
}
|
|
66
|
+
return root;
|
|
67
|
+
}
|
|
68
|
+
mergeFieldSelector(target, selector) {
|
|
69
|
+
const baseName = selector.split('(')[0].trim();
|
|
70
|
+
if (!baseName)
|
|
71
|
+
return;
|
|
72
|
+
if (!isSafePropertyName(baseName))
|
|
73
|
+
return;
|
|
74
|
+
const openParen = selector.indexOf('(');
|
|
75
|
+
if (openParen === -1) {
|
|
76
|
+
target[baseName] = true;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const closeParen = selector.lastIndexOf(')');
|
|
80
|
+
if (closeParen === -1 || closeParen <= openParen) {
|
|
81
|
+
target[baseName] = true;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const inner = selector.slice(openParen + 1, closeParen).trim();
|
|
85
|
+
const subSelectors = this.splitTopLevel(inner);
|
|
86
|
+
const subTree = Object.create(null);
|
|
87
|
+
for (const sub of subSelectors) {
|
|
88
|
+
this.mergeFieldSelector(subTree, sub);
|
|
89
|
+
}
|
|
90
|
+
const existing = target[baseName];
|
|
91
|
+
if (existing === true)
|
|
92
|
+
return;
|
|
93
|
+
if (!existing) {
|
|
94
|
+
target[baseName] = subTree;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.mergeSelectionTrees(existing, subTree);
|
|
98
|
+
}
|
|
99
|
+
mergeSelectionTrees(target, incoming) {
|
|
100
|
+
for (const [key, val] of Object.entries(incoming)) {
|
|
101
|
+
if (!isSafePropertyName(key))
|
|
102
|
+
continue;
|
|
103
|
+
const existing = target[key];
|
|
104
|
+
if (!existing) {
|
|
105
|
+
target[key] = val;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (existing === true || val === true) {
|
|
109
|
+
target[key] = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
this.mergeSelectionTrees(existing, val);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
splitTopLevel(input) {
|
|
116
|
+
const result = [];
|
|
117
|
+
let depth = 0;
|
|
118
|
+
let current = '';
|
|
119
|
+
for (const ch of input) {
|
|
120
|
+
if (ch === '(')
|
|
121
|
+
depth += 1;
|
|
122
|
+
if (ch === ')')
|
|
123
|
+
depth = Math.max(0, depth - 1);
|
|
124
|
+
if (ch === ',' && depth === 0) {
|
|
125
|
+
const trimmed = current.trim();
|
|
126
|
+
if (trimmed)
|
|
127
|
+
result.push(trimmed);
|
|
128
|
+
current = '';
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
current += ch;
|
|
132
|
+
}
|
|
133
|
+
const last = current.trim();
|
|
134
|
+
if (last)
|
|
135
|
+
result.push(last);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
applyFieldSelection(data, selection) {
|
|
139
|
+
if (!data || typeof data !== 'object') {
|
|
140
|
+
return data;
|
|
141
|
+
}
|
|
142
|
+
if (Array.isArray(data)) {
|
|
143
|
+
return data.map(item => this.applyFieldSelection(item, selection));
|
|
144
|
+
}
|
|
145
|
+
const obj = data;
|
|
146
|
+
const filtered = Object.create(null);
|
|
147
|
+
for (const [key, sel] of Object.entries(selection)) {
|
|
148
|
+
if (!isSafePropertyName(key))
|
|
149
|
+
continue;
|
|
150
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key))
|
|
151
|
+
continue;
|
|
152
|
+
const value = obj[key];
|
|
153
|
+
if (sel === true) {
|
|
154
|
+
filtered[key] = value;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
filtered[key] = this.applyFieldSelection(value, sel);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return filtered;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Format error message for client with correlation ID
|
|
164
|
+
*
|
|
165
|
+
* Why: Categorize errors as "safe" (4xx client errors) vs "unsafe" (5xx server errors)
|
|
166
|
+
* Safe errors show API message to help user fix the issue
|
|
167
|
+
* Unsafe errors show generic message to avoid leaking sensitive info
|
|
168
|
+
*/
|
|
169
|
+
formatErrorForClient(error, correlationId) {
|
|
170
|
+
// Authentication errors - safe to show (token expired, invalid credentials)
|
|
171
|
+
if (error instanceof AuthenticationError) {
|
|
172
|
+
return `Authentication failed: ${error.message} (correlation ID: ${correlationId})`;
|
|
173
|
+
}
|
|
174
|
+
// Authorization errors - safe to show (insufficient permissions)
|
|
175
|
+
if (error instanceof AuthorizationError) {
|
|
176
|
+
return `Authorization failed: ${error.message} (correlation ID: ${correlationId})`;
|
|
177
|
+
}
|
|
178
|
+
// Rate limit errors - safe to show (helps user understand backoff)
|
|
179
|
+
if (error instanceof RateLimitError) {
|
|
180
|
+
const retryInfo = error.details?.retryAfter
|
|
181
|
+
? ` Retry after ${error.details.retryAfter} seconds.`
|
|
182
|
+
: '';
|
|
183
|
+
return `Rate limit exceeded: ${error.message}${retryInfo} (correlation ID: ${correlationId})`;
|
|
184
|
+
}
|
|
185
|
+
// Network errors with 4xx status - safe to show (client errors)
|
|
186
|
+
if (error instanceof NetworkError && error.details?.statusCode) {
|
|
187
|
+
const statusCode = error.details.statusCode;
|
|
188
|
+
if (statusCode >= 400 && statusCode < 500) {
|
|
189
|
+
return `Request failed: ${error.message} (correlation ID: ${correlationId})`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Validation errors - safe to show (helps user fix input)
|
|
193
|
+
if (error instanceof ValidationError) {
|
|
194
|
+
return `Validation error: ${error.message} (correlation ID: ${correlationId})`;
|
|
195
|
+
}
|
|
196
|
+
// Operation not found - safe to show (configuration issue)
|
|
197
|
+
if (error instanceof OperationNotFoundError) {
|
|
198
|
+
return `Operation not found: ${error.message} (correlation ID: ${correlationId})`;
|
|
199
|
+
}
|
|
200
|
+
if (error instanceof ResourceNotFoundError) {
|
|
201
|
+
return `${error.message} (correlation ID: ${correlationId})`;
|
|
202
|
+
}
|
|
203
|
+
// Configuration errors - safe to show (helps admin fix setup)
|
|
204
|
+
if (error instanceof ConfigurationError) {
|
|
205
|
+
return `Configuration error: ${error.message} (correlation ID: ${correlationId})`;
|
|
206
|
+
}
|
|
207
|
+
// Generic/unknown errors - hide details, show only correlation ID
|
|
208
|
+
return `Internal error (correlation ID: ${correlationId})`;
|
|
209
|
+
}
|
|
210
|
+
constructor(logger) {
|
|
211
|
+
this.httpClientFactory = new HttpClientFactory();
|
|
212
|
+
this.httpTransport = null;
|
|
213
|
+
this.logger = logger || new ConsoleLogger();
|
|
214
|
+
this.schemaValidator = new SchemaValidator();
|
|
215
|
+
this.server = new Server({
|
|
216
|
+
name: 'mcp4openapi',
|
|
217
|
+
version: '0.1.0',
|
|
218
|
+
}, {
|
|
219
|
+
capabilities: {
|
|
220
|
+
tools: {},
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
this.parser = new OpenAPIParser();
|
|
224
|
+
this.toolGenerator = new ToolGenerator(this.parser);
|
|
225
|
+
this.setupHandlers();
|
|
226
|
+
}
|
|
227
|
+
async initialize(specPath, profilePath) {
|
|
228
|
+
// Load OpenAPI spec
|
|
229
|
+
await this.parser.load(specPath);
|
|
230
|
+
this.logger.info('Loaded OpenAPI spec', { specPath });
|
|
231
|
+
// Load or create MCP profile
|
|
232
|
+
if (profilePath) {
|
|
233
|
+
const loader = new ProfileLoader();
|
|
234
|
+
this.profile = await loader.load(profilePath);
|
|
235
|
+
this.logger.info('Loaded profile', {
|
|
236
|
+
profile: this.profile.profile_name,
|
|
237
|
+
toolCount: this.profile.tools.length,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
this.profile = ProfileLoader.createDefaultProfile('default', this.parser);
|
|
242
|
+
this.logger.info('Using auto-generated default profile', {
|
|
243
|
+
profile: this.profile.profile_name,
|
|
244
|
+
toolCount: this.profile.tools.length,
|
|
245
|
+
});
|
|
246
|
+
// Check if we should warn about long names
|
|
247
|
+
this.checkToolNameLengths();
|
|
248
|
+
}
|
|
249
|
+
this.applyGlobalToolFiltering();
|
|
250
|
+
// Re-create logger with auth config for token redaction
|
|
251
|
+
const authConfigs = this.getAuthConfigs();
|
|
252
|
+
if (authConfigs.length > 0) {
|
|
253
|
+
// Use first auth config for logger (primary)
|
|
254
|
+
this.logger = this.createLoggerWithAuth(authConfigs[0]);
|
|
255
|
+
this.logger.info('Logger re-configured with auth token redaction', {
|
|
256
|
+
authMethods: authConfigs.length,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
// Setup HTTP client with interceptors
|
|
260
|
+
// For stdio transport, create client with env token
|
|
261
|
+
// For HTTP transport, clients are created per-session with user's token
|
|
262
|
+
const baseUrl = this.getBaseUrl();
|
|
263
|
+
const envAuthConfig = this.getEnvBackedAuthConfig();
|
|
264
|
+
const envVarName = envAuthConfig?.value_from_env;
|
|
265
|
+
const envToken = envVarName ? process.env[envVarName] : undefined;
|
|
266
|
+
if (envAuthConfig && envToken) {
|
|
267
|
+
// Token available in env - create global client (stdio transport)
|
|
268
|
+
const httpClient = this.httpClientFactory.createGlobalClient({
|
|
269
|
+
profile: this.profile,
|
|
270
|
+
baseUrl,
|
|
271
|
+
});
|
|
272
|
+
this.compositeExecutor = new CompositeExecutor(this.parser, httpClient, this.profile.parameter_aliases);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
// No env token or no auth - will use per-session clients (HTTP transport)
|
|
276
|
+
this.compositeExecutor = new CompositeExecutor(this.parser, undefined, this.profile.parameter_aliases);
|
|
277
|
+
}
|
|
278
|
+
this.logger.info('MCP server initialized', {
|
|
279
|
+
baseUrl,
|
|
280
|
+
toolCount: this.profile.tools.length,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Create logger with auth configuration for token redaction
|
|
285
|
+
*
|
|
286
|
+
* Why: Prevents sensitive tokens from appearing in logs
|
|
287
|
+
*/
|
|
288
|
+
createLoggerWithAuth(authConfig) {
|
|
289
|
+
const logFormat = process.env.MCP4_LOG_FORMAT || 'console';
|
|
290
|
+
const logLevel = this.logger instanceof ConsoleLogger || this.logger instanceof JsonLogger
|
|
291
|
+
? this.logger.level
|
|
292
|
+
: undefined;
|
|
293
|
+
return logFormat === 'json'
|
|
294
|
+
? new JsonLogger(logLevel, authConfig)
|
|
295
|
+
: new ConsoleLogger(logLevel, authConfig);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Check tool name lengths and warn if needed
|
|
299
|
+
*/
|
|
300
|
+
checkToolNameLengths() {
|
|
301
|
+
const maxLength = parseInt(process.env.MCP4_TOOLNAME_MAX || '45', 10);
|
|
302
|
+
const strategy = (process.env.MCP4_TOOLNAME_STRATEGY || 'none').toLowerCase();
|
|
303
|
+
const warnOnly = (process.env.MCP4_TOOLNAME_WARN_ONLY || 'true').toLowerCase() === 'true';
|
|
304
|
+
// Only warn if strategy is 'none' or warn-only mode is enabled
|
|
305
|
+
if (strategy !== NamingStrategy.None && !warnOnly) {
|
|
306
|
+
return; // Names already shortened, no need to warn
|
|
307
|
+
}
|
|
308
|
+
// Get all operations as OperationForNaming
|
|
309
|
+
const operations = this.parser.getAllOperations();
|
|
310
|
+
const opsForNaming = operations.map(op => ({
|
|
311
|
+
operationId: op.operationId,
|
|
312
|
+
method: op.method,
|
|
313
|
+
path: op.path,
|
|
314
|
+
tags: op.tags,
|
|
315
|
+
}));
|
|
316
|
+
const warningOptions = {
|
|
317
|
+
maxLength,
|
|
318
|
+
similarTopN: parseInt(process.env.MCP4_TOOLNAME_SIMILAR_TOP || '3', 10),
|
|
319
|
+
similarityThreshold: parseFloat(process.env.MCP4_TOOLNAME_SIMILARITY_THRESHOLD || '0.75'),
|
|
320
|
+
minParts: parseInt(process.env.MCP4_TOOLNAME_MIN_PARTS || '3', 10),
|
|
321
|
+
minLength: parseInt(process.env.MCP4_TOOLNAME_MIN_LENGTH || '20', 10),
|
|
322
|
+
};
|
|
323
|
+
generateNameWarnings(opsForNaming, warningOptions, this.logger);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Get base URL from profile config or OpenAPI spec
|
|
327
|
+
*/
|
|
328
|
+
getBaseUrl() {
|
|
329
|
+
const baseUrlConfig = this.profile?.interceptors?.base_url;
|
|
330
|
+
if (baseUrlConfig) {
|
|
331
|
+
const envValue = process.env[baseUrlConfig.value_from_env];
|
|
332
|
+
if (envValue)
|
|
333
|
+
return envValue;
|
|
334
|
+
if (baseUrlConfig.default)
|
|
335
|
+
return baseUrlConfig.default;
|
|
336
|
+
}
|
|
337
|
+
return this.parser.getBaseUrl();
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Get auth configurations as array (supports single or multiple auth methods)
|
|
341
|
+
* Returns array sorted by priority (lower = higher priority)
|
|
342
|
+
*/
|
|
343
|
+
getAuthConfigs() {
|
|
344
|
+
const auth = this.profile?.interceptors?.auth;
|
|
345
|
+
if (!auth)
|
|
346
|
+
return [];
|
|
347
|
+
const configs = Array.isArray(auth) ? auth : [auth];
|
|
348
|
+
// Sort by priority (lower = higher priority)
|
|
349
|
+
return configs.sort((a, b) => (a.priority || 0) - (b.priority || 0));
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Get primary (highest priority) auth configuration
|
|
353
|
+
*/
|
|
354
|
+
getPrimaryAuthConfig() {
|
|
355
|
+
const configs = this.getAuthConfigs();
|
|
356
|
+
return configs[0];
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Get highest priority auth configuration that reads token from environment
|
|
360
|
+
*/
|
|
361
|
+
getEnvBackedAuthConfig() {
|
|
362
|
+
const configs = this.getAuthConfigs();
|
|
363
|
+
return configs.find(config => config.type !== 'oauth' && !!config.value_from_env);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Get OAuth configuration from auth configs (if any)
|
|
367
|
+
*/
|
|
368
|
+
getOAuthConfig() {
|
|
369
|
+
const configs = this.getAuthConfigs();
|
|
370
|
+
const oauthConfig = configs.find(c => c.type === 'oauth');
|
|
371
|
+
return oauthConfig?.oauth_config;
|
|
372
|
+
}
|
|
373
|
+
buildOAuthConfigWithAllowedRedirectHosts(oauthConfig) {
|
|
374
|
+
if (!oauthConfig) {
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
...oauthConfig,
|
|
379
|
+
allowed_redirect_hosts: oauthConfig.allowed_redirect_hosts
|
|
380
|
+
|| (process.env.MCP4_ALLOWED_ORIGINS
|
|
381
|
+
? this.extractHostsFromOrigins(process.env.MCP4_ALLOWED_ORIGINS)
|
|
382
|
+
: undefined),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
getProfileIdValue() {
|
|
386
|
+
if (!this.profile) {
|
|
387
|
+
throw new ConfigurationError('Profile not initialized. Call initialize() first.');
|
|
388
|
+
}
|
|
389
|
+
const profileId = this.profile.profile_id?.trim() || this.profile.profile_name;
|
|
390
|
+
if (!profileId) {
|
|
391
|
+
throw new ConfigurationError('Profile is missing profile_id and profile_name.');
|
|
392
|
+
}
|
|
393
|
+
return profileId;
|
|
394
|
+
}
|
|
395
|
+
getOAuthRateLimitConfig() {
|
|
396
|
+
const authConfigs = this.getAuthConfigs();
|
|
397
|
+
const oauthAuthConfig = authConfigs.find(c => c.type === 'oauth');
|
|
398
|
+
const oauthRateLimit = oauthAuthConfig?.oauth_rate_limit;
|
|
399
|
+
const max = oauthRateLimit?.max_requests
|
|
400
|
+
|| parseInt(process.env.MCP4_OAUTH_RATE_LIMIT_MAX || String(OAUTH_RATE_LIMIT.MAX_REQUESTS), 10);
|
|
401
|
+
const windowMs = oauthRateLimit?.window_ms
|
|
402
|
+
|| parseInt(process.env.MCP4_OAUTH_RATE_LIMIT_WINDOW_MS || String(OAUTH_RATE_LIMIT.WINDOW_MS), 10);
|
|
403
|
+
return { max, windowMs };
|
|
404
|
+
}
|
|
405
|
+
getHttpProfileContext() {
|
|
406
|
+
if (!this.profile) {
|
|
407
|
+
throw new ConfigurationError('Profile not initialized. Call initialize() first.');
|
|
408
|
+
}
|
|
409
|
+
const authConfigs = this.getAuthConfigs();
|
|
410
|
+
const baseUrl = this.getBaseUrl();
|
|
411
|
+
const oauthConfig = this.buildOAuthConfigWithAllowedRedirectHosts(this.getOAuthConfig());
|
|
412
|
+
const resourceMetadata = this.parser.getResourceMetadata();
|
|
413
|
+
const oauthRateLimit = this.getOAuthRateLimitConfig();
|
|
414
|
+
return {
|
|
415
|
+
profileId: this.getProfileIdValue(),
|
|
416
|
+
oauthConfig,
|
|
417
|
+
authConfigs,
|
|
418
|
+
baseUrl,
|
|
419
|
+
rateLimitOAuthMax: oauthRateLimit.max,
|
|
420
|
+
rateLimitOAuthWindowMs: oauthRateLimit.windowMs,
|
|
421
|
+
resourceName: this.profile.resource_name || resourceMetadata.name || 'MCP Server',
|
|
422
|
+
resourceDocumentation: this.profile.resource_documentation || resourceMetadata.documentation,
|
|
423
|
+
parser: this.parser,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Extract hostnames from origin patterns for OAuth redirect validation
|
|
428
|
+
* e.g., "http://localhost:*,https://app.example.com" -> ["localhost", "app.example.com"]
|
|
429
|
+
*
|
|
430
|
+
* Filters out CIDR blocks (e.g., "127.0.0.1/8") which are valid for origin validation
|
|
431
|
+
* but not for OAuth redirect URI validation
|
|
432
|
+
*/
|
|
433
|
+
extractHostsFromOrigins(origins) {
|
|
434
|
+
const hosts = [];
|
|
435
|
+
for (const origin of origins.split(',')) {
|
|
436
|
+
const trimmed = origin.trim();
|
|
437
|
+
try {
|
|
438
|
+
// Handle wildcard ports: http://localhost:* -> localhost
|
|
439
|
+
const normalized = trimmed.replace(/:\*$/, ':80');
|
|
440
|
+
const url = new URL(normalized);
|
|
441
|
+
// Preserve wildcards in hostname
|
|
442
|
+
if (trimmed.includes('*.')) {
|
|
443
|
+
const match = trimmed.match(/\*\.[^:/]+/);
|
|
444
|
+
if (match) {
|
|
445
|
+
hosts.push(match[0]);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
hosts.push(url.hostname);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// If not a URL, treat as hostname/pattern directly
|
|
454
|
+
// Skip CIDR blocks (e.g., 127.0.0.1/8, 10.0.0.0/8, 2a06:2140::/29)
|
|
455
|
+
if (trimmed && !trimmed.includes(' ') && !trimmed.includes('/')) {
|
|
456
|
+
hosts.push(trimmed);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return [...new Set(hosts)]; // Dedupe
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Get or create HTTP client for session
|
|
464
|
+
*/
|
|
465
|
+
async getHttpClientForSession(sessionId, profileId) {
|
|
466
|
+
if (!sessionId) {
|
|
467
|
+
// Fallback to global client for stdio transport
|
|
468
|
+
if (!this.httpClientFactory.hasGlobalClient()) {
|
|
469
|
+
const hasHttpTransport = !!this.httpTransport;
|
|
470
|
+
const transport = hasHttpTransport ? 'http' : 'stdio';
|
|
471
|
+
const envAuthConfig = this.getEnvBackedAuthConfig();
|
|
472
|
+
const envVarName = envAuthConfig?.value_from_env || 'MCP4_API_TOKEN';
|
|
473
|
+
const hasEnvToken = !!process.env[envVarName];
|
|
474
|
+
throw new ConfigurationError(`HTTP client not initialized. ` +
|
|
475
|
+
`Transport: ${transport}, ` +
|
|
476
|
+
`HasEnvToken(${envVarName}): ${hasEnvToken}, ` +
|
|
477
|
+
`Suggestion: ${hasHttpTransport
|
|
478
|
+
? 'Send token in Authorization header during initialization'
|
|
479
|
+
: `Set ${envVarName} environment variable`}`, { transport, hasEnvToken, envVarName, hasHttpTransport });
|
|
480
|
+
}
|
|
481
|
+
return this.httpClientFactory.getGlobalClient();
|
|
482
|
+
}
|
|
483
|
+
// Validate profile exists
|
|
484
|
+
if (!this.profile) {
|
|
485
|
+
throw new ConfigurationError('Profile not initialized. Call initialize() first.');
|
|
486
|
+
}
|
|
487
|
+
// Get auth token from session (ensures token is valid/refreshed)
|
|
488
|
+
const authToken = await this.getAuthTokenFromSession(sessionId, profileId);
|
|
489
|
+
// Create or get session client using factory
|
|
490
|
+
return this.httpClientFactory.getOrCreateSessionClient(sessionId, {
|
|
491
|
+
profile: this.profile,
|
|
492
|
+
baseUrl: this.getBaseUrl(),
|
|
493
|
+
sessionToken: authToken,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Get auth token from HTTP transport session
|
|
498
|
+
* Ensures token is valid (refreshes if expired) before returning
|
|
499
|
+
*/
|
|
500
|
+
async getAuthTokenFromSession(sessionId, profileId) {
|
|
501
|
+
// Early return if sessionId is missing/empty
|
|
502
|
+
// Prevents misleading warn logs with empty sessionId
|
|
503
|
+
if (!sessionId) {
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
if (!this.httpTransport) {
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
// Ensure token is valid (refresh if expired)
|
|
510
|
+
const effectiveProfileId = profileId || this.getProfileIdValue();
|
|
511
|
+
const isValid = await this.httpTransport.ensureValidSessionToken(effectiveProfileId, sessionId);
|
|
512
|
+
if (!isValid) {
|
|
513
|
+
this.logger.warn('Session token validation/refresh failed', { profileId: effectiveProfileId, sessionId });
|
|
514
|
+
// Still return token if available - let the API call fail with proper error
|
|
515
|
+
}
|
|
516
|
+
// Use public API instead of type casting
|
|
517
|
+
return this.httpTransport.getSessionToken(effectiveProfileId, sessionId);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Cleanup HTTP client for destroyed session
|
|
521
|
+
*
|
|
522
|
+
* Why: Prevent memory leak - sessions expire but cached clients stay forever
|
|
523
|
+
*/
|
|
524
|
+
cleanupSessionClient(profileId, sessionId) {
|
|
525
|
+
const removed = this.httpClientFactory.cleanupSessionClient(sessionId);
|
|
526
|
+
if (removed) {
|
|
527
|
+
this.logger.info('Cleaned up session HTTP client', { profileId, sessionId });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Setup MCP request handlers
|
|
532
|
+
*/
|
|
533
|
+
setupHandlers() {
|
|
534
|
+
// List available tools
|
|
535
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
536
|
+
try {
|
|
537
|
+
if (!this.profile) {
|
|
538
|
+
throw new ConfigurationError('Server not initialized. Call initialize() first.');
|
|
539
|
+
}
|
|
540
|
+
const tools = this.profile.tools.map(toolDef => this.toolGenerator.generateTool(toolDef));
|
|
541
|
+
return { tools };
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
// Generate correlation ID only on error (lazy)
|
|
545
|
+
const correlationId = generateCorrelationId();
|
|
546
|
+
this.logger.error('ListTools handler error', err, { correlationId });
|
|
547
|
+
// Always return generic error to clients
|
|
548
|
+
throw new Error(`Internal error (correlation ID: ${correlationId})`);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
// Execute tool
|
|
552
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
553
|
+
try {
|
|
554
|
+
if (!this.profile || !this.compositeExecutor) {
|
|
555
|
+
throw new ConfigurationError('Server not initialized. Call initialize() first.');
|
|
556
|
+
}
|
|
557
|
+
const toolDef = this.profile.tools.find(t => t.name === request.params.name);
|
|
558
|
+
if (!toolDef) {
|
|
559
|
+
throw new OperationNotFoundError(request.params.name);
|
|
560
|
+
}
|
|
561
|
+
const args = request.params.arguments || {};
|
|
562
|
+
// Validate arguments
|
|
563
|
+
this.toolGenerator.validateArguments(toolDef, args);
|
|
564
|
+
// Execute composite or simple tool
|
|
565
|
+
let result;
|
|
566
|
+
if (toolDef.composite && toolDef.steps) {
|
|
567
|
+
const compositeResult = await this.compositeExecutor.execute(toolDef.steps, args, toolDef.partial_results || false);
|
|
568
|
+
// Include metadata about completion
|
|
569
|
+
result = {
|
|
570
|
+
...compositeResult.data,
|
|
571
|
+
_metadata: {
|
|
572
|
+
completed_steps: compositeResult.completed_steps,
|
|
573
|
+
total_steps: compositeResult.total_steps,
|
|
574
|
+
success: compositeResult.completed_steps === compositeResult.total_steps,
|
|
575
|
+
errors: compositeResult.errors,
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
result = await this.executeSimpleTool(toolDef, args);
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
content: [
|
|
584
|
+
{
|
|
585
|
+
type: 'text',
|
|
586
|
+
text: JSON.stringify(result, null, 2),
|
|
587
|
+
},
|
|
588
|
+
],
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
// Generate correlation ID only on error (lazy)
|
|
593
|
+
const correlationId = generateCorrelationId();
|
|
594
|
+
this.logger.error('CallTool handler error', err, {
|
|
595
|
+
correlationId,
|
|
596
|
+
toolName: request.params.name,
|
|
597
|
+
action: request.params.arguments?.action
|
|
598
|
+
});
|
|
599
|
+
// Return user-friendly error message with correlation ID
|
|
600
|
+
const errorMessage = this.formatErrorForClient(err, correlationId);
|
|
601
|
+
throw new Error(errorMessage);
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Execute simple (non-composite) tool
|
|
607
|
+
*
|
|
608
|
+
* Why separate: Simple tools map directly to single OpenAPI operation.
|
|
609
|
+
* No result aggregation needed.
|
|
610
|
+
*/
|
|
611
|
+
async executeSimpleTool(toolDef, args, sessionId, profileId) {
|
|
612
|
+
const normalizedArgs = normalizeArguments(toolDef, args);
|
|
613
|
+
this.logger.debug('Executing simple tool', {
|
|
614
|
+
toolName: toolDef.name,
|
|
615
|
+
action: normalizedArgs['action'],
|
|
616
|
+
resourceType: normalizedArgs['resource_type'],
|
|
617
|
+
sessionId
|
|
618
|
+
});
|
|
619
|
+
// Get operation definition (can be string or ProxyDownloadOperation)
|
|
620
|
+
const operationDef = this.toolGenerator.getOperationDefinition(toolDef, normalizedArgs);
|
|
621
|
+
if (!operationDef) {
|
|
622
|
+
throw new ValidationError(`Could not map tool action to operation`, {
|
|
623
|
+
toolName: toolDef.name,
|
|
624
|
+
action: args['action'],
|
|
625
|
+
resourceType: args['resource_type'],
|
|
626
|
+
availableOperations: Object.keys(toolDef.operations || {})
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
// Check if this is a proxy download operation
|
|
630
|
+
if (typeof operationDef === 'object' && operationDef.type === 'proxy_download') {
|
|
631
|
+
return this.executeProxyDownload(operationDef, normalizedArgs, sessionId, profileId);
|
|
632
|
+
}
|
|
633
|
+
// Regular string operation
|
|
634
|
+
const operationId = operationDef;
|
|
635
|
+
const operation = this.parser.getOperation(operationId);
|
|
636
|
+
if (!operation) {
|
|
637
|
+
throw new OperationNotFoundError(operationId);
|
|
638
|
+
}
|
|
639
|
+
// Build request
|
|
640
|
+
const path = this.resolvePath(operation.path, normalizedArgs);
|
|
641
|
+
const queryParams = this.extractQueryParams(operation, normalizedArgs);
|
|
642
|
+
const body = this.extractBody(operation, normalizedArgs, toolDef);
|
|
643
|
+
this.logger.debug('Executing HTTP request', {
|
|
644
|
+
operationId,
|
|
645
|
+
method: operation.method,
|
|
646
|
+
path,
|
|
647
|
+
hasQueryParams: Object.keys(queryParams).length > 0,
|
|
648
|
+
hasBody: !!body
|
|
649
|
+
});
|
|
650
|
+
// Validate request body against schema
|
|
651
|
+
if (body && operation.requestBody) {
|
|
652
|
+
const validationResult = this.schemaValidator.validateRequestBody(operation, body);
|
|
653
|
+
if (!validationResult.valid && validationResult.errors) {
|
|
654
|
+
const errorDetails = validationResult.errors
|
|
655
|
+
.map(e => ` - ${e.path}: ${e.message}`)
|
|
656
|
+
.join('\n');
|
|
657
|
+
throw new ValidationError(`Request body validation failed:\n${errorDetails}`, { operationId, validationErrors: validationResult.errors });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Execute with session-specific client
|
|
661
|
+
const httpClient = await this.getHttpClientForSession(sessionId, profileId);
|
|
662
|
+
// Set fields parameter if response_fields are configured for this action AND enabled
|
|
663
|
+
const action = normalizedArgs.action;
|
|
664
|
+
if (toolDef.send_response_fields_as_param && toolDef.response_fields && action && toolDef.response_fields[action]) {
|
|
665
|
+
const fields = toolDef.response_fields[action];
|
|
666
|
+
queryParams.fields = fields.join(',');
|
|
667
|
+
}
|
|
668
|
+
const response = await httpClient.request(operation.method, path, {
|
|
669
|
+
params: queryParams,
|
|
670
|
+
body,
|
|
671
|
+
operationId: operationId,
|
|
672
|
+
});
|
|
673
|
+
// Apply response field filtering if configured
|
|
674
|
+
let result = response.body;
|
|
675
|
+
if (toolDef.response_fields) {
|
|
676
|
+
const action = normalizedArgs.action;
|
|
677
|
+
if (action && toolDef.response_fields[action]) {
|
|
678
|
+
const fields = toolDef.response_fields[action];
|
|
679
|
+
result = this.filterFields(result, fields);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return result;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Execute proxy download operation
|
|
686
|
+
*
|
|
687
|
+
* Why: Some APIs return authenticated URLs that LLMs cannot fetch directly.
|
|
688
|
+
* This proxies the download through the MCP server.
|
|
689
|
+
*/
|
|
690
|
+
async executeProxyDownload(operation, args, sessionId, profileId) {
|
|
691
|
+
this.logger.debug('Executing proxy download', {
|
|
692
|
+
metadataEndpoint: operation.metadata_endpoint,
|
|
693
|
+
urlField: operation.url_field,
|
|
694
|
+
sessionId
|
|
695
|
+
});
|
|
696
|
+
// Get the metadata operation to build the path
|
|
697
|
+
const metadataOp = this.parser.getOperation(operation.metadata_endpoint);
|
|
698
|
+
if (!metadataOp) {
|
|
699
|
+
throw new OperationNotFoundError(operation.metadata_endpoint);
|
|
700
|
+
}
|
|
701
|
+
// Build path for metadata endpoint
|
|
702
|
+
const metadataPath = this.resolvePath(metadataOp.path, args);
|
|
703
|
+
const metadataMethod = metadataOp.method;
|
|
704
|
+
let directDownloadRequest;
|
|
705
|
+
if (operation.download_endpoint) {
|
|
706
|
+
const downloadOp = this.parser.getOperation(operation.download_endpoint);
|
|
707
|
+
if (!downloadOp) {
|
|
708
|
+
throw new OperationNotFoundError(operation.download_endpoint);
|
|
709
|
+
}
|
|
710
|
+
directDownloadRequest = {
|
|
711
|
+
path: this.resolvePath(downloadOp.path, args),
|
|
712
|
+
method: downloadOp.method,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
// Get auth credentials for download
|
|
716
|
+
const httpClient = await this.getHttpClientForSession(sessionId, profileId);
|
|
717
|
+
const authCredentials = httpClient.getAuthCredentials();
|
|
718
|
+
// Execute proxy download
|
|
719
|
+
const proxyExecutor = new ProxyDownloadExecutor(httpClient, this.logger);
|
|
720
|
+
const result = await proxyExecutor.execute(operation, { path: metadataPath, method: metadataMethod }, authCredentials, directDownloadRequest);
|
|
721
|
+
this.logger.debug('Proxy download completed', {
|
|
722
|
+
fileName: result.fileName,
|
|
723
|
+
mimeType: result.mimeType,
|
|
724
|
+
size: result.size
|
|
725
|
+
});
|
|
726
|
+
return result;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Encode path segment if it contains special characters (like slashes)
|
|
730
|
+
*
|
|
731
|
+
* Why: GitLab and other APIs require path parameters (like project paths)
|
|
732
|
+
* to be URL-encoded when used in URL path.
|
|
733
|
+
*/
|
|
734
|
+
encodePathSegment(value) {
|
|
735
|
+
const val = String(value);
|
|
736
|
+
return val.includes('/') ? encodeURIComponent(val) : val;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Resolve path parameters using profile aliases
|
|
740
|
+
*
|
|
741
|
+
* Why aliases: Different tools may use different parameter names for same path param.
|
|
742
|
+
* Example: GitLab uses "resource_id", "project_id", "group_id" all mapping to "{id}"
|
|
743
|
+
*/
|
|
744
|
+
resolvePath(template, args) {
|
|
745
|
+
const aliases = this.profile?.parameter_aliases || {};
|
|
746
|
+
return template.replace(/\{(\w+)\}/g, (_, key) => {
|
|
747
|
+
// Try direct match first
|
|
748
|
+
if (args[key] !== undefined) {
|
|
749
|
+
return this.encodePathSegment(args[key]);
|
|
750
|
+
}
|
|
751
|
+
// Try aliases from profile
|
|
752
|
+
const possibleAliases = aliases[key] || [];
|
|
753
|
+
for (const alias of possibleAliases) {
|
|
754
|
+
if (args[alias] !== undefined) {
|
|
755
|
+
return this.encodePathSegment(args[alias]);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
throw new ValidationError(`Missing path parameter: ${key}` +
|
|
759
|
+
(possibleAliases.length > 0 ? `. Tried aliases: ${possibleAliases.join(', ')}` : ''), { paramName: key, possibleAliases });
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Extract query parameters from args
|
|
764
|
+
*
|
|
765
|
+
* Why: Separate query params from body params. Array handling is done by HttpClient
|
|
766
|
+
* based on profile's array_format setting.
|
|
767
|
+
*/
|
|
768
|
+
extractQueryParams(operation, args) {
|
|
769
|
+
const params = {};
|
|
770
|
+
const aliases = this.profile?.parameter_aliases || {};
|
|
771
|
+
for (const param of operation.parameters) {
|
|
772
|
+
if (param.in === 'query') {
|
|
773
|
+
let value = args[param.name];
|
|
774
|
+
// If not found by direct name, check aliases
|
|
775
|
+
if (value === undefined) {
|
|
776
|
+
const possibleAliases = aliases[param.name] || [];
|
|
777
|
+
for (const alias of possibleAliases) {
|
|
778
|
+
if (args[alias] !== undefined) {
|
|
779
|
+
value = args[alias];
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (value !== undefined) {
|
|
785
|
+
// Pass arrays as-is, HttpClient will serialize based on array_format
|
|
786
|
+
if (Array.isArray(value)) {
|
|
787
|
+
params[param.name] = value.map(String);
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
params[param.name] = String(value);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return params;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Extract request body from args
|
|
799
|
+
*
|
|
800
|
+
* Why: For create/update operations, collect non-metadata fields into body.
|
|
801
|
+
* Metadata (action, resource_type, etc.) are not sent to API.
|
|
802
|
+
* Path/query parameters are excluded from body UNLESS they're also in request body schema.
|
|
803
|
+
*
|
|
804
|
+
* Uses metadata_params from tool definition, defaults to ['action', 'resource_type']
|
|
805
|
+
*/
|
|
806
|
+
extractBody(operation, args, toolDef) {
|
|
807
|
+
// Metadata fields from tool definition (or defaults)
|
|
808
|
+
const metadataList = toolDef.metadata_params || ['action', 'resource_type'];
|
|
809
|
+
const metadata = new Set(metadataList);
|
|
810
|
+
// Collect parameter names that go in path or query
|
|
811
|
+
const pathOrQuery = new Set();
|
|
812
|
+
for (const param of operation.parameters) {
|
|
813
|
+
if (param.in === 'path' || param.in === 'query') {
|
|
814
|
+
pathOrQuery.add(param.name);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
// Get body schema properties to check if path/query params should also be in body
|
|
818
|
+
const bodySchemaProps = new Set();
|
|
819
|
+
if (operation.requestBody?.content) {
|
|
820
|
+
// Check all content types (typically application/json)
|
|
821
|
+
for (const mediaType of Object.values(operation.requestBody.content)) {
|
|
822
|
+
if (mediaType.schema?.properties) {
|
|
823
|
+
for (const propName of Object.keys(mediaType.schema.properties)) {
|
|
824
|
+
bodySchemaProps.add(propName);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
const body = {};
|
|
830
|
+
let hasBody = false;
|
|
831
|
+
for (const [key, value] of Object.entries(args)) {
|
|
832
|
+
// Include field if:
|
|
833
|
+
// - Not metadata
|
|
834
|
+
// - Not in path/query OR is in path/query but also required in body schema
|
|
835
|
+
// - Value is defined
|
|
836
|
+
const isPathOrQuery = pathOrQuery.has(key);
|
|
837
|
+
const isInBodySchema = bodySchemaProps.has(key);
|
|
838
|
+
if (!metadata.has(key) && (!isPathOrQuery || isInBodySchema) && value !== undefined) {
|
|
839
|
+
body[key] = value;
|
|
840
|
+
hasBody = true;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return hasBody ? body : undefined;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Start server with stdio transport
|
|
847
|
+
*/
|
|
848
|
+
async runStdio() {
|
|
849
|
+
const transport = new StdioServerTransport();
|
|
850
|
+
await this.server.connect(transport);
|
|
851
|
+
this.logger.info('MCP server running on stdio');
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Start server with HTTP transport
|
|
855
|
+
*
|
|
856
|
+
* Implements MCP Specification 2025-03-26 Streamable HTTP transport
|
|
857
|
+
*
|
|
858
|
+
* Why: Enables remote MCP server access with SSE streaming, session management,
|
|
859
|
+
* and resumability for reliable communication over HTTP.
|
|
860
|
+
*/
|
|
861
|
+
async runHttp(host, port) {
|
|
862
|
+
const { HttpTransport } = await import('./http-transport.js');
|
|
863
|
+
const profileContext = this.getHttpProfileContext();
|
|
864
|
+
if (profileContext.oauthConfig) {
|
|
865
|
+
this.logger.info('OAuth authentication enabled for HTTP transport');
|
|
866
|
+
}
|
|
867
|
+
const baseConfig = buildHttpTransportBaseConfig(host, port);
|
|
868
|
+
const config = {
|
|
869
|
+
...baseConfig,
|
|
870
|
+
profileRoutingEnabled: false,
|
|
871
|
+
defaultProfileId: profileContext.profileId,
|
|
872
|
+
// OAuth rate limiting (priority: profile > env vars > defaults)
|
|
873
|
+
rateLimitOAuthMax: profileContext.rateLimitOAuthMax,
|
|
874
|
+
rateLimitOAuthWindowMs: profileContext.rateLimitOAuthWindowMs,
|
|
875
|
+
// OAuth config already merged with allowed_redirect_hosts
|
|
876
|
+
oauthConfig: profileContext.oauthConfig,
|
|
877
|
+
baseUrl: profileContext.baseUrl,
|
|
878
|
+
authConfigs: profileContext.authConfigs,
|
|
879
|
+
resourceName: profileContext.resourceName,
|
|
880
|
+
resourceDocumentation: profileContext.resourceDocumentation,
|
|
881
|
+
parser: profileContext.parser,
|
|
882
|
+
};
|
|
883
|
+
// Warn if binding to non-localhost without explicit MCP4_ALLOWED_ORIGINS
|
|
884
|
+
const isLocalhost = host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
885
|
+
const hasAllowedOrigins = Array.isArray(config.allowedOrigins) && config.allowedOrigins.length > 0;
|
|
886
|
+
if (!isLocalhost && !hasAllowedOrigins) {
|
|
887
|
+
this.logger.warn('Binding to non-localhost with empty MCP4_ALLOWED_ORIGINS. Set MCP4_ALLOWED_ORIGINS or bind to localhost.');
|
|
888
|
+
}
|
|
889
|
+
this.httpTransport = new HttpTransport(config, this.logger);
|
|
890
|
+
this.recordGlobalToolFilterMetrics();
|
|
891
|
+
// Set message handler to process JSON-RPC messages
|
|
892
|
+
this.httpTransport.setMessageHandler(async (message, sessionId, profileId) => {
|
|
893
|
+
return await this.handleJsonRpcMessage(message, sessionId, profileId);
|
|
894
|
+
});
|
|
895
|
+
// Register cleanup listener for session destruction (memory leak prevention)
|
|
896
|
+
this.httpTransport.onSessionDestroyed((profileId, sessionId) => {
|
|
897
|
+
this.cleanupSessionClient(profileId, sessionId);
|
|
898
|
+
});
|
|
899
|
+
await this.httpTransport.start();
|
|
900
|
+
this.logger.info('MCP server running on HTTP', { host, port });
|
|
901
|
+
}
|
|
902
|
+
attachHttpTransport(transport) {
|
|
903
|
+
this.httpTransport = transport;
|
|
904
|
+
}
|
|
905
|
+
handleSessionDestroyed(profileId, sessionId) {
|
|
906
|
+
this.cleanupSessionClient(profileId, sessionId);
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Handle JSON-RPC message from HTTP transport
|
|
910
|
+
*
|
|
911
|
+
* Why: Unified message handling for both stdio and HTTP transports
|
|
912
|
+
*/
|
|
913
|
+
async handleJsonRpcMessage(message, sessionId, profileId) {
|
|
914
|
+
// Handle initialize
|
|
915
|
+
if (isInitializeRequest(message)) {
|
|
916
|
+
return this.handleInitialize(message, sessionId, profileId);
|
|
917
|
+
}
|
|
918
|
+
// Handle tool calls
|
|
919
|
+
if (isToolCallRequest(message)) {
|
|
920
|
+
return await this.handleToolCall(message, sessionId, profileId);
|
|
921
|
+
}
|
|
922
|
+
// Handle other JSON-RPC requests
|
|
923
|
+
// (tools/list, prompts/list, etc.)
|
|
924
|
+
return await this.handleOtherRequest(message, sessionId, profileId);
|
|
925
|
+
}
|
|
926
|
+
async handleHttpMessage(message, sessionId, profileId) {
|
|
927
|
+
return this.handleJsonRpcMessage(message, sessionId, profileId);
|
|
928
|
+
}
|
|
929
|
+
handleInitialize(message, sessionId, profileId) {
|
|
930
|
+
const req = message;
|
|
931
|
+
const params = req.params;
|
|
932
|
+
if (!this.httpTransport && params?.filtering !== undefined) {
|
|
933
|
+
if (typeof params.filtering !== 'string') {
|
|
934
|
+
throw new ValidationError('Invalid X-Mcp4-Params header. Expected comma-separated key=value pairs.');
|
|
935
|
+
}
|
|
936
|
+
const parsed = parseFilteringHeader(params.filtering);
|
|
937
|
+
this.stdioFiltering = parsed.filtering;
|
|
938
|
+
}
|
|
939
|
+
if (this.httpTransport && sessionId) {
|
|
940
|
+
this.applySessionToolFiltering(sessionId, profileId);
|
|
941
|
+
}
|
|
942
|
+
const result = {
|
|
943
|
+
protocolVersion: '2025-03-26',
|
|
944
|
+
serverInfo: {
|
|
945
|
+
name: 'mcp4openapi',
|
|
946
|
+
version: '0.1.0',
|
|
947
|
+
},
|
|
948
|
+
capabilities: {
|
|
949
|
+
tools: {},
|
|
950
|
+
},
|
|
951
|
+
};
|
|
952
|
+
// OAuth capability is communicated via 401 responses with WWW-Authenticate header
|
|
953
|
+
// as per MCP Authorization specification
|
|
954
|
+
// Include sessionId if available (for HTTP transport)
|
|
955
|
+
if (sessionId) {
|
|
956
|
+
result.sessionId = sessionId;
|
|
957
|
+
}
|
|
958
|
+
return {
|
|
959
|
+
jsonrpc: '2.0',
|
|
960
|
+
id: req.id,
|
|
961
|
+
result,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
async handleToolCall(message, sessionId, profileId) {
|
|
965
|
+
const req = message;
|
|
966
|
+
const params = req.params;
|
|
967
|
+
const toolName = params.name;
|
|
968
|
+
const args = params.arguments;
|
|
969
|
+
// Check OAuth authentication for tool operations
|
|
970
|
+
if (this.httpTransport && this.httpTransport.hasOAuthProvider(profileId)) {
|
|
971
|
+
const authToken = await this.getAuthTokenFromSession(sessionId || '', profileId);
|
|
972
|
+
if (!authToken) {
|
|
973
|
+
// Return OAuth required error with WWW-Authenticate header
|
|
974
|
+
// This should trigger the OAuth flow in the client
|
|
975
|
+
const errorResponse = {
|
|
976
|
+
jsonrpc: '2.0',
|
|
977
|
+
id: req.id,
|
|
978
|
+
error: {
|
|
979
|
+
code: -32001, // Application error
|
|
980
|
+
message: 'Authentication required. Please authorize via OAuth.',
|
|
981
|
+
data: {
|
|
982
|
+
oauth_required: true,
|
|
983
|
+
resource_metadata: this.httpTransport.getOAuthProtectedResourceUrl(profileId),
|
|
984
|
+
scope: 'api'
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
return errorResponse;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
// Find tool definition
|
|
993
|
+
const toolDef = this.profile?.tools.find(t => t.name === toolName);
|
|
994
|
+
if (!toolDef) {
|
|
995
|
+
throw new ResourceNotFoundError(toolName, 'Tool');
|
|
996
|
+
}
|
|
997
|
+
const toolFilter = this.getToolFilterForSession(sessionId, profileId);
|
|
998
|
+
if (toolFilter && !toolFilter.allowedToolNames.has(toolName)) {
|
|
999
|
+
this.recordToolFilterRejection(toolName, 'session');
|
|
1000
|
+
const reason = toolFilter.reasons.get(toolName)?.[0];
|
|
1001
|
+
const reasonSuffix = reason ? ` Blocked by: ${reason}.` : '';
|
|
1002
|
+
throw new AuthorizationError(`Tool '${toolName}' not allowed by X-Mcp4-Tools filter.${reasonSuffix}`);
|
|
1003
|
+
}
|
|
1004
|
+
const filtering = this.getFilteringForSession(sessionId, profileId);
|
|
1005
|
+
if (filtering) {
|
|
1006
|
+
const operation = this.getFilteringOperationInfo(toolDef, args);
|
|
1007
|
+
enforceFiltering({
|
|
1008
|
+
filtering,
|
|
1009
|
+
toolDef,
|
|
1010
|
+
args,
|
|
1011
|
+
parameterAliases: this.profile?.parameter_aliases,
|
|
1012
|
+
operation,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
// Execute tool (reuse existing execution logic)
|
|
1016
|
+
let result;
|
|
1017
|
+
if (toolDef.composite && toolDef.steps) {
|
|
1018
|
+
const httpClient = await this.getHttpClientForSession(sessionId, profileId);
|
|
1019
|
+
const compositeResult = await this.compositeExecutor.execute(toolDef.steps, args, toolDef.partial_results || false, httpClient);
|
|
1020
|
+
result = {
|
|
1021
|
+
data: compositeResult.data,
|
|
1022
|
+
completed_steps: compositeResult.completed_steps,
|
|
1023
|
+
total_steps: compositeResult.total_steps,
|
|
1024
|
+
success: compositeResult.completed_steps === compositeResult.total_steps,
|
|
1025
|
+
errors: compositeResult.errors,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
else {
|
|
1029
|
+
result = await this.executeSimpleTool(toolDef, args, sessionId, profileId);
|
|
1030
|
+
}
|
|
1031
|
+
return {
|
|
1032
|
+
jsonrpc: '2.0',
|
|
1033
|
+
id: req.id,
|
|
1034
|
+
result: {
|
|
1035
|
+
content: [
|
|
1036
|
+
{
|
|
1037
|
+
type: 'text',
|
|
1038
|
+
text: JSON.stringify(result, null, 2),
|
|
1039
|
+
},
|
|
1040
|
+
],
|
|
1041
|
+
},
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
catch (error) {
|
|
1045
|
+
// Generate correlation ID only on error (lazy)
|
|
1046
|
+
const correlationId = generateCorrelationId();
|
|
1047
|
+
// Log internal error details with correlation ID
|
|
1048
|
+
this.logger.error('Tool call error', error, {
|
|
1049
|
+
correlationId,
|
|
1050
|
+
toolName,
|
|
1051
|
+
action: args?.action,
|
|
1052
|
+
resourceType: args?.resource_type,
|
|
1053
|
+
sessionId
|
|
1054
|
+
});
|
|
1055
|
+
// Return user-friendly error message with correlation ID
|
|
1056
|
+
const errorMessage = this.formatErrorForClient(error, correlationId);
|
|
1057
|
+
// Map error type to JSON-RPC error code
|
|
1058
|
+
let errorCode = -32603; // Internal error (default)
|
|
1059
|
+
if (error instanceof AuthenticationError) {
|
|
1060
|
+
errorCode = -32001; // Authentication error
|
|
1061
|
+
}
|
|
1062
|
+
else if (error instanceof AuthorizationError) {
|
|
1063
|
+
errorCode = -32002; // Authorization error
|
|
1064
|
+
}
|
|
1065
|
+
else if (error instanceof ValidationError) {
|
|
1066
|
+
errorCode = -32602; // Invalid params
|
|
1067
|
+
}
|
|
1068
|
+
else if (error instanceof RateLimitError) {
|
|
1069
|
+
errorCode = -32003; // Rate limit error
|
|
1070
|
+
}
|
|
1071
|
+
else if (error instanceof OperationNotFoundError) {
|
|
1072
|
+
errorCode = -32601; // Method not found
|
|
1073
|
+
}
|
|
1074
|
+
else if (error instanceof ResourceNotFoundError) {
|
|
1075
|
+
errorCode = -32601; // Method not found
|
|
1076
|
+
}
|
|
1077
|
+
return {
|
|
1078
|
+
jsonrpc: '2.0',
|
|
1079
|
+
id: req.id,
|
|
1080
|
+
error: {
|
|
1081
|
+
code: errorCode,
|
|
1082
|
+
message: errorMessage,
|
|
1083
|
+
},
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
getFilteringForSession(sessionId, profileId) {
|
|
1088
|
+
if (this.httpTransport && sessionId) {
|
|
1089
|
+
const effectiveProfileId = profileId || this.getProfileIdValue();
|
|
1090
|
+
return this.httpTransport.getSessionFiltering(effectiveProfileId, sessionId);
|
|
1091
|
+
}
|
|
1092
|
+
return this.stdioFiltering;
|
|
1093
|
+
}
|
|
1094
|
+
getToolFilterForSession(sessionId, profileId) {
|
|
1095
|
+
if (this.httpTransport && sessionId && typeof this.httpTransport.getSessionToolFilter === 'function') {
|
|
1096
|
+
const effectiveProfileId = profileId || this.getProfileIdValue();
|
|
1097
|
+
return this.httpTransport.getSessionToolFilter(effectiveProfileId, sessionId);
|
|
1098
|
+
}
|
|
1099
|
+
return undefined;
|
|
1100
|
+
}
|
|
1101
|
+
getFilteringOperationInfo(toolDef, args) {
|
|
1102
|
+
if (toolDef.composite) {
|
|
1103
|
+
return undefined;
|
|
1104
|
+
}
|
|
1105
|
+
const operationId = this.toolGenerator.mapActionToOperation(toolDef, args);
|
|
1106
|
+
if (!operationId) {
|
|
1107
|
+
return undefined;
|
|
1108
|
+
}
|
|
1109
|
+
return this.parser.getOperation(operationId);
|
|
1110
|
+
}
|
|
1111
|
+
async handleOtherRequest(message, sessionId, profileId) {
|
|
1112
|
+
const req = message;
|
|
1113
|
+
// Check OAuth authentication for other operations (like tools/list)
|
|
1114
|
+
if (this.httpTransport && this.httpTransport.hasOAuthProvider(profileId)) {
|
|
1115
|
+
const authToken = await this.getAuthTokenFromSession(sessionId || '', profileId);
|
|
1116
|
+
if (!authToken) {
|
|
1117
|
+
// Return OAuth required error with WWW-Authenticate header
|
|
1118
|
+
// This should trigger the OAuth flow in the client
|
|
1119
|
+
const errorResponse = {
|
|
1120
|
+
jsonrpc: '2.0',
|
|
1121
|
+
id: req.id,
|
|
1122
|
+
error: {
|
|
1123
|
+
code: -32001, // Application error
|
|
1124
|
+
message: 'Authentication required. Please authorize via OAuth.',
|
|
1125
|
+
data: {
|
|
1126
|
+
oauth_required: true,
|
|
1127
|
+
resource_metadata: this.httpTransport.getOAuthProtectedResourceUrl(profileId),
|
|
1128
|
+
scope: 'api'
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
return errorResponse;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
// Handle tools/list
|
|
1136
|
+
if (req.method === 'tools/list') {
|
|
1137
|
+
const sessionFilter = this.getToolFilterForSession(sessionId, profileId);
|
|
1138
|
+
const allowedSet = sessionFilter?.allowedToolNames;
|
|
1139
|
+
const tools = this.profile?.tools
|
|
1140
|
+
.filter(toolDef => !allowedSet || allowedSet.has(toolDef.name))
|
|
1141
|
+
.map(toolDef => this.toolGenerator.generateTool(toolDef)) || [];
|
|
1142
|
+
return {
|
|
1143
|
+
jsonrpc: '2.0',
|
|
1144
|
+
id: req.id,
|
|
1145
|
+
result: {
|
|
1146
|
+
tools,
|
|
1147
|
+
},
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
// Unknown method
|
|
1151
|
+
return {
|
|
1152
|
+
jsonrpc: '2.0',
|
|
1153
|
+
id: req.id,
|
|
1154
|
+
error: {
|
|
1155
|
+
code: -32601,
|
|
1156
|
+
message: `Method not found: ${req.method}`,
|
|
1157
|
+
},
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
applyGlobalToolFiltering() {
|
|
1161
|
+
if (!this.profile) {
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
// Initialize ToolFilterService if not already done
|
|
1165
|
+
if (!this.toolFilterService) {
|
|
1166
|
+
const validator = new RegexValidator();
|
|
1167
|
+
const compiler = new RegexCompiler(validator);
|
|
1168
|
+
const envParser = new EnvConfigParser(compiler);
|
|
1169
|
+
const headerParser = new HeaderConfigParser(compiler);
|
|
1170
|
+
// Create OperationDetector for category filtering
|
|
1171
|
+
const classifier = new OperationClassifier();
|
|
1172
|
+
const resolver = new OpenAPIOperationResolver(this.parser);
|
|
1173
|
+
const detector = new OperationDetector(classifier, resolver);
|
|
1174
|
+
this.toolFilterService = new ToolFilterService(envParser, headerParser, this.logger, detector);
|
|
1175
|
+
}
|
|
1176
|
+
const originalTools = this.profile.tools;
|
|
1177
|
+
const originalCount = originalTools.length;
|
|
1178
|
+
// Apply filtering using new service
|
|
1179
|
+
const filteredTools = this.toolFilterService.applyGlobalFilter(originalTools, process.env);
|
|
1180
|
+
const allowedCount = filteredTools.length;
|
|
1181
|
+
const removedCount = originalCount - allowedCount;
|
|
1182
|
+
// Early return if no filtering config present (service returned same tools)
|
|
1183
|
+
if (filteredTools === originalTools) {
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
// Validation: check if filter has no effect
|
|
1187
|
+
if (originalCount > 0 && allowedCount === originalCount && removedCount === 0) {
|
|
1188
|
+
throw new ConfigurationError(`Tool filter configuration has no effect. Original tool count: ${originalCount}, filtered: ${allowedCount}. Check MCP4_TOOL_FILTER_* patterns.`);
|
|
1189
|
+
}
|
|
1190
|
+
// Validation: check if all tools filtered
|
|
1191
|
+
if (originalCount > 0 && allowedCount === 0) {
|
|
1192
|
+
throw new ConfigurationError(`All tools filtered out (original: ${originalCount}). Check MCP4_TOOL_FILTER_* settings.`);
|
|
1193
|
+
}
|
|
1194
|
+
// Validate composite tools against filtered operations
|
|
1195
|
+
const resolver = this.buildToolFilterResolver();
|
|
1196
|
+
this.validateCompositeToolsAgainstFilteredOperations(originalTools, filteredTools, resolver);
|
|
1197
|
+
// Update profile
|
|
1198
|
+
this.profile.tools = filteredTools;
|
|
1199
|
+
// Record summary for metrics
|
|
1200
|
+
this.globalToolFilterSummary = {
|
|
1201
|
+
originalCount,
|
|
1202
|
+
allowedCount,
|
|
1203
|
+
removedCount,
|
|
1204
|
+
patternCounts: {
|
|
1205
|
+
// Note: counts not available from new service, using simplified version
|
|
1206
|
+
filtered: removedCount
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
// Warn if high percentage filtered
|
|
1210
|
+
const warnThreshold = this.getToolFilterWarnThresholdPct();
|
|
1211
|
+
if (originalCount > 0) {
|
|
1212
|
+
const percentFiltered = (removedCount / originalCount) * 100;
|
|
1213
|
+
if (percentFiltered >= warnThreshold) {
|
|
1214
|
+
this.logger.warn('Tool filter removed high percentage of tools', {
|
|
1215
|
+
original: originalCount,
|
|
1216
|
+
surviving: allowedCount,
|
|
1217
|
+
threshold_pct: warnThreshold,
|
|
1218
|
+
removed_count: removedCount
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
if (this.httpTransport) {
|
|
1223
|
+
this.recordGlobalToolFilterMetrics();
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
applySessionToolFiltering(sessionId, profileId) {
|
|
1227
|
+
if (!this.httpTransport || !this.profile) {
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (typeof this.httpTransport.getSessionToolFilterRequest !== 'function') {
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
const effectiveProfileId = profileId || this.getProfileIdValue();
|
|
1234
|
+
const request = this.httpTransport.getSessionToolFilterRequest(effectiveProfileId, sessionId);
|
|
1235
|
+
if (!request) {
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const originalCount = this.profile.tools.length;
|
|
1239
|
+
const resolver = this.buildToolFilterResolver();
|
|
1240
|
+
const sessionFilter = applySessionToolFilter(this.profile.tools, request, resolver);
|
|
1241
|
+
const allowedCount = sessionFilter.allowedToolNames.size;
|
|
1242
|
+
if (allowedCount === originalCount) {
|
|
1243
|
+
throw new ValidationError(`X-Mcp4-Tools filter has no effect for this session. Available tools: ${originalCount}, after filter: ${allowedCount}. Check patterns.`);
|
|
1244
|
+
}
|
|
1245
|
+
if (originalCount > 0 && allowedCount === 0) {
|
|
1246
|
+
const sources = request.rawEntries.length > 0 ? request.rawEntries.join(', ') : 'none';
|
|
1247
|
+
throw new ValidationError(`X-Mcp4-Tools filtered out all tools (original: ${originalCount}). Removed by: ${sources}. Check session filter configuration.`);
|
|
1248
|
+
}
|
|
1249
|
+
this.httpTransport.setSessionToolFilter(effectiveProfileId, sessionId, sessionFilter);
|
|
1250
|
+
this.logger.info('Session tool filter applied', {
|
|
1251
|
+
sessionId,
|
|
1252
|
+
originalCount,
|
|
1253
|
+
allowedCount,
|
|
1254
|
+
patterns: request.rawEntries,
|
|
1255
|
+
});
|
|
1256
|
+
this.recordSessionToolFilterMetrics(sessionId, allowedCount, request);
|
|
1257
|
+
}
|
|
1258
|
+
buildToolFilterResolver() {
|
|
1259
|
+
return {
|
|
1260
|
+
getOperationById: (operationId) => this.parser.getOperation(operationId),
|
|
1261
|
+
getOperationForCall: (call) => {
|
|
1262
|
+
const [method, path] = call.split(' ');
|
|
1263
|
+
if (!method || !path) {
|
|
1264
|
+
return undefined;
|
|
1265
|
+
}
|
|
1266
|
+
const pathInfo = this.parser.getPath(path);
|
|
1267
|
+
return pathInfo?.operations[method.toLowerCase()];
|
|
1268
|
+
},
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
validateCompositeToolsAgainstFilteredOperations(originalTools, allowedTools, resolver) {
|
|
1272
|
+
const operationToTools = new Map();
|
|
1273
|
+
for (const tool of originalTools) {
|
|
1274
|
+
if (!tool.operations) {
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
for (const operationId of Object.values(tool.operations)) {
|
|
1278
|
+
if (typeof operationId !== 'string') {
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
const names = operationToTools.get(operationId) ?? [];
|
|
1282
|
+
names.push(tool.name);
|
|
1283
|
+
operationToTools.set(operationId, names);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
const allowedOperationIds = new Set();
|
|
1287
|
+
for (const tool of allowedTools) {
|
|
1288
|
+
if (!tool.operations) {
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
for (const operationId of Object.values(tool.operations)) {
|
|
1292
|
+
if (typeof operationId !== 'string') {
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
allowedOperationIds.add(operationId);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
for (const tool of allowedTools) {
|
|
1299
|
+
if (!tool.composite || !tool.steps) {
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
for (const step of tool.steps) {
|
|
1303
|
+
const operation = resolver.getOperationForCall(step.call);
|
|
1304
|
+
if (!operation) {
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
if (allowedOperationIds.has(operation.operationId)) {
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
const removedTools = operationToTools.get(operation.operationId);
|
|
1311
|
+
if (!removedTools || removedTools.length === 0) {
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
const removedList = removedTools.join(', ');
|
|
1315
|
+
throw new ConfigurationError(`Composite tool '${tool.name}' step '${step.call}' calls filtered tool '${removedList}'. ` +
|
|
1316
|
+
`Add '${removedList}' to filter or include _allow_list or _allow_read if it is a list or read operation.`);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
getToolFilterWarnThresholdPct() {
|
|
1321
|
+
const raw = process.env.MCP4_TOOL_FILTER_WARN_THRESHOLD_PCT;
|
|
1322
|
+
if (raw === undefined) {
|
|
1323
|
+
return 90;
|
|
1324
|
+
}
|
|
1325
|
+
const parsed = Number(raw);
|
|
1326
|
+
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
1327
|
+
throw new ConfigurationError(`Invalid MCP4_TOOL_FILTER_WARN_THRESHOLD_PCT: expected positive number, got '${raw}'.`);
|
|
1328
|
+
}
|
|
1329
|
+
return parsed;
|
|
1330
|
+
}
|
|
1331
|
+
recordGlobalToolFilterMetrics() {
|
|
1332
|
+
if (!this.httpTransport || !this.globalToolFilterSummary) {
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (typeof this.httpTransport.recordGlobalToolFilterMetrics !== 'function') {
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
this.httpTransport.recordGlobalToolFilterMetrics(this.globalToolFilterSummary);
|
|
1339
|
+
}
|
|
1340
|
+
recordSessionToolFilterMetrics(sessionId, allowedCount, request) {
|
|
1341
|
+
if (!this.httpTransport) {
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (typeof this.httpTransport.recordSessionToolFilterMetrics !== 'function') {
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
this.httpTransport.recordSessionToolFilterMetrics(sessionId, allowedCount, request);
|
|
1348
|
+
}
|
|
1349
|
+
recordToolFilterRejection(toolName, source) {
|
|
1350
|
+
if (!this.httpTransport) {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
if (typeof this.httpTransport.recordToolFilterRejection !== 'function') {
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
this.httpTransport.recordToolFilterRejection(toolName, source);
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Stop the MCP server gracefully
|
|
1360
|
+
*
|
|
1361
|
+
* Why: Cleanup resources, close connections, allow graceful shutdown
|
|
1362
|
+
*/
|
|
1363
|
+
async stop() {
|
|
1364
|
+
if (this.httpTransport) {
|
|
1365
|
+
await this.httpTransport.stop();
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
//# sourceMappingURL=mcp-server.js.map
|