mcp4openapi 0.3.1 → 0.3.3

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.
Files changed (152) hide show
  1. package/README.md +7 -0
  2. package/dist/src/core/cli-config.d.ts.map +1 -1
  3. package/dist/src/core/cli-config.js +2 -0
  4. package/dist/src/core/cli-config.js.map +1 -1
  5. package/dist/src/core/index.d.ts.map +1 -1
  6. package/dist/src/core/index.js +18 -3
  7. package/dist/src/core/index.js.map +1 -1
  8. package/dist/src/index.js +0 -0
  9. package/dist/src/profile/profile-allowlist.d.ts +18 -0
  10. package/dist/src/profile/profile-allowlist.d.ts.map +1 -0
  11. package/dist/src/profile/profile-allowlist.js +68 -0
  12. package/dist/src/profile/profile-allowlist.js.map +1 -0
  13. package/dist/src/profile/profile-registry.d.ts +5 -0
  14. package/dist/src/profile/profile-registry.d.ts.map +1 -1
  15. package/dist/src/profile/profile-registry.js +38 -14
  16. package/dist/src/profile/profile-registry.js.map +1 -1
  17. package/package.json +2 -2
  18. package/profiles/gitlab/developer-profile-oauth.json +243 -41
  19. package/profiles/gitlab/developer-profile-oauth.test.json +1009 -5
  20. package/profiles/gitlab/openapi.yaml +1419 -164
  21. package/profiles/gitlab/profile-optimized-oauth.json +785 -0
  22. package/profiles/gitlab/profile-optimized-oauth.test.json +1566 -0
  23. package/profiles/grafana/openapi.json +28078 -0
  24. package/profiles/grafana/profile.json +1083 -0
  25. package/profiles/grafana/profile.test.json +235 -0
  26. package/profiles/mattermost/openapi.yaml +27434 -0
  27. package/profiles/mattermost/profile.json +463 -0
  28. package/profiles/mattermost/profile.test.json +607 -0
  29. package/profiles/n8n/profile-optimized.json +1002 -364
  30. package/profiles/n8n/profile-optimized.test.json +43 -43
  31. package/dist/src/argument-normalizer.d.ts +0 -5
  32. package/dist/src/argument-normalizer.d.ts.map +0 -1
  33. package/dist/src/argument-normalizer.js +0 -61
  34. package/dist/src/argument-normalizer.js.map +0 -1
  35. package/dist/src/cli-config.d.ts +0 -9
  36. package/dist/src/cli-config.d.ts.map +0 -1
  37. package/dist/src/cli-config.js +0 -111
  38. package/dist/src/cli-config.js.map +0 -1
  39. package/dist/src/composite-executor.d.ts +0 -77
  40. package/dist/src/composite-executor.d.ts.map +0 -1
  41. package/dist/src/composite-executor.js +0 -193
  42. package/dist/src/composite-executor.js.map +0 -1
  43. package/dist/src/constants.d.ts +0 -85
  44. package/dist/src/constants.d.ts.map +0 -1
  45. package/dist/src/constants.js +0 -85
  46. package/dist/src/constants.js.map +0 -1
  47. package/dist/src/dag-executor.d.ts +0 -49
  48. package/dist/src/dag-executor.d.ts.map +0 -1
  49. package/dist/src/dag-executor.js +0 -138
  50. package/dist/src/dag-executor.js.map +0 -1
  51. package/dist/src/errors.d.ts +0 -59
  52. package/dist/src/errors.d.ts.map +0 -1
  53. package/dist/src/errors.js +0 -119
  54. package/dist/src/errors.js.map +0 -1
  55. package/dist/src/filtering.d.ts +0 -19
  56. package/dist/src/filtering.d.ts.map +0 -1
  57. package/dist/src/filtering.js +0 -292
  58. package/dist/src/filtering.js.map +0 -1
  59. package/dist/src/http-client-factory.d.ts +0 -62
  60. package/dist/src/http-client-factory.d.ts.map +0 -1
  61. package/dist/src/http-client-factory.js +0 -133
  62. package/dist/src/http-client-factory.js.map +0 -1
  63. package/dist/src/http-transport-config.d.ts +0 -6
  64. package/dist/src/http-transport-config.d.ts.map +0 -1
  65. package/dist/src/http-transport-config.js +0 -47
  66. package/dist/src/http-transport-config.js.map +0 -1
  67. package/dist/src/http-transport.d.ts +0 -316
  68. package/dist/src/http-transport.d.ts.map +0 -1
  69. package/dist/src/http-transport.js +0 -2412
  70. package/dist/src/http-transport.js.map +0 -1
  71. package/dist/src/interceptors.d.ts +0 -116
  72. package/dist/src/interceptors.d.ts.map +0 -1
  73. package/dist/src/interceptors.js +0 -392
  74. package/dist/src/interceptors.js.map +0 -1
  75. package/dist/src/jsonrpc-validator.d.ts +0 -27
  76. package/dist/src/jsonrpc-validator.d.ts.map +0 -1
  77. package/dist/src/jsonrpc-validator.js +0 -58
  78. package/dist/src/jsonrpc-validator.js.map +0 -1
  79. package/dist/src/logger.d.ts +0 -59
  80. package/dist/src/logger.d.ts.map +0 -1
  81. package/dist/src/logger.js +0 -177
  82. package/dist/src/logger.js.map +0 -1
  83. package/dist/src/mcp-server-manager.d.ts +0 -20
  84. package/dist/src/mcp-server-manager.d.ts.map +0 -1
  85. package/dist/src/mcp-server-manager.js +0 -38
  86. package/dist/src/mcp-server-manager.js.map +0 -1
  87. package/dist/src/mcp-server.d.ts +0 -203
  88. package/dist/src/mcp-server.d.ts.map +0 -1
  89. package/dist/src/mcp-server.js +0 -1369
  90. package/dist/src/mcp-server.js.map +0 -1
  91. package/dist/src/metrics.d.ts +0 -97
  92. package/dist/src/metrics.d.ts.map +0 -1
  93. package/dist/src/metrics.js +0 -273
  94. package/dist/src/metrics.js.map +0 -1
  95. package/dist/src/naming-warnings.d.ts +0 -23
  96. package/dist/src/naming-warnings.d.ts.map +0 -1
  97. package/dist/src/naming-warnings.js +0 -83
  98. package/dist/src/naming-warnings.js.map +0 -1
  99. package/dist/src/naming.d.ts +0 -58
  100. package/dist/src/naming.d.ts.map +0 -1
  101. package/dist/src/naming.js +0 -510
  102. package/dist/src/naming.js.map +0 -1
  103. package/dist/src/oauth-provider.d.ts +0 -131
  104. package/dist/src/oauth-provider.d.ts.map +0 -1
  105. package/dist/src/oauth-provider.js +0 -836
  106. package/dist/src/oauth-provider.js.map +0 -1
  107. package/dist/src/openapi-parser.d.ts +0 -70
  108. package/dist/src/openapi-parser.d.ts.map +0 -1
  109. package/dist/src/openapi-parser.js +0 -436
  110. package/dist/src/openapi-parser.js.map +0 -1
  111. package/dist/src/profile-loader.d.ts +0 -78
  112. package/dist/src/profile-loader.d.ts.map +0 -1
  113. package/dist/src/profile-loader.js +0 -483
  114. package/dist/src/profile-loader.js.map +0 -1
  115. package/dist/src/profile-registry.d.ts +0 -18
  116. package/dist/src/profile-registry.d.ts.map +0 -1
  117. package/dist/src/profile-registry.js +0 -26
  118. package/dist/src/profile-registry.js.map +0 -1
  119. package/dist/src/profile-resolver.d.ts +0 -19
  120. package/dist/src/profile-resolver.d.ts.map +0 -1
  121. package/dist/src/profile-resolver.js +0 -167
  122. package/dist/src/profile-resolver.js.map +0 -1
  123. package/dist/src/proxy-executor.d.ts +0 -86
  124. package/dist/src/proxy-executor.d.ts.map +0 -1
  125. package/dist/src/proxy-executor.js +0 -497
  126. package/dist/src/proxy-executor.js.map +0 -1
  127. package/dist/src/schema-validator.d.ts +0 -30
  128. package/dist/src/schema-validator.d.ts.map +0 -1
  129. package/dist/src/schema-validator.js +0 -128
  130. package/dist/src/schema-validator.js.map +0 -1
  131. package/dist/src/startup-profile.d.ts +0 -17
  132. package/dist/src/startup-profile.d.ts.map +0 -1
  133. package/dist/src/startup-profile.js +0 -30
  134. package/dist/src/startup-profile.js.map +0 -1
  135. package/dist/src/startup-validation.d.ts +0 -11
  136. package/dist/src/startup-validation.d.ts.map +0 -1
  137. package/dist/src/startup-validation.js +0 -21
  138. package/dist/src/startup-validation.js.map +0 -1
  139. package/dist/src/tool-filter.d.ts +0 -65
  140. package/dist/src/tool-filter.d.ts.map +0 -1
  141. package/dist/src/tool-filter.js +0 -471
  142. package/dist/src/tool-filter.js.map +0 -1
  143. package/dist/src/tool-generator.d.ts +0 -67
  144. package/dist/src/tool-generator.d.ts.map +0 -1
  145. package/dist/src/tool-generator.js +0 -182
  146. package/dist/src/tool-generator.js.map +0 -1
  147. package/dist/src/validation-utils.d.ts +0 -49
  148. package/dist/src/validation-utils.d.ts.map +0 -1
  149. package/dist/src/validation-utils.js +0 -138
  150. package/dist/src/validation-utils.js.map +0 -1
  151. package/profiles/gitlab/developer-profile.json +0 -1508
  152. package/profiles/gitlab/developer-profile.test.json +0 -3432
@@ -1,1369 +0,0 @@
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