@ttoss/http-server-mcp-openapi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,434 @@
1
+ /** Powered by @ttoss/config. https://ttoss.dev/docs/modules/packages/config/ */
2
+ import { registerToolFromSchema } from "@ttoss/http-server-mcp";
3
+
4
+ //#region src/schema.ts
5
+ var getAlternativeSchemas = schema => {
6
+ if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return schema.oneOf;
7
+ if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return schema.anyOf;
8
+ };
9
+ var mergeResolvedSchemas = resolvedAlternatives => {
10
+ const mergedProperties = Object.assign({}, ...resolvedAlternatives.map(candidate => {
11
+ return candidate.properties ?? {};
12
+ }));
13
+ const requiredIntersection = resolvedAlternatives.reduce((current, candidate) => {
14
+ const required = candidate.required ?? [];
15
+ if (current === void 0) return [...required];
16
+ return current.filter(field => {
17
+ return required.includes(field);
18
+ });
19
+ }, void 0);
20
+ return {
21
+ type: "object",
22
+ properties: Object.keys(mergedProperties).length > 0 ? mergedProperties : void 0,
23
+ required: requiredIntersection && requiredIntersection.length > 0 ? requiredIntersection : void 0
24
+ };
25
+ };
26
+ var dereferenceValue = args => {
27
+ const {
28
+ value,
29
+ spec,
30
+ seenRefs
31
+ } = args;
32
+ if (Array.isArray(value)) return value.map(item => {
33
+ return dereferenceValue({
34
+ value: item,
35
+ spec,
36
+ seenRefs
37
+ });
38
+ });
39
+ if (value && typeof value === "object") {
40
+ const obj = value;
41
+ if (typeof obj.$ref === "string") {
42
+ const refName = obj.$ref.replace("#/components/schemas/", "");
43
+ if (seenRefs.has(refName)) return {};
44
+ const resolved = spec.components?.schemas?.[refName];
45
+ return dereferenceValue({
46
+ value: resolved,
47
+ spec,
48
+ seenRefs: new Set(seenRefs).add(refName)
49
+ });
50
+ }
51
+ const result = {};
52
+ for (const [key, entryValue] of Object.entries(obj)) result[key] = dereferenceValue({
53
+ value: entryValue,
54
+ spec,
55
+ seenRefs
56
+ });
57
+ return result;
58
+ }
59
+ return value;
60
+ };
61
+ /**
62
+ * Recursively inlines every `$ref` in a schema (including refs nested inside
63
+ * `properties`, `items`, `oneOf`, `anyOf`, etc.), producing a self-contained
64
+ * schema safe to hand to an MCP client or LLM provider as a tool definition —
65
+ * provider tool schemas have no `components` section to resolve refs against.
66
+ */
67
+ var dereferenceSchema = (schema, spec) => {
68
+ if (!schema) return schema;
69
+ return dereferenceValue({
70
+ value: schema,
71
+ spec,
72
+ seenRefs: /* @__PURE__ */new Set()
73
+ });
74
+ };
75
+ /**
76
+ * Resolves a schema down to a single object shape: follows a top-level `$ref`
77
+ * and merges `oneOf` / `anyOf` alternatives (union of properties, intersection
78
+ * of `required`) so the caller sees one flat property set.
79
+ */
80
+ var resolveSchema = (schema, spec) => {
81
+ if (!schema) return {};
82
+ if (typeof schema.$ref === "string") {
83
+ const refName = schema.$ref.replace("#/components/schemas/", "");
84
+ const resolved = spec.components?.schemas?.[refName];
85
+ return resolveSchema(resolved, spec);
86
+ }
87
+ const alternatives = getAlternativeSchemas(schema);
88
+ if (alternatives) return mergeResolvedSchemas(alternatives.map(candidate => {
89
+ return resolveSchema(candidate, spec);
90
+ }));
91
+ return schema;
92
+ };
93
+ /** Follows a parameter `$ref` into `components.parameters`, if present. */
94
+ var resolveParameter = (param, spec) => {
95
+ if (!param) return {};
96
+ if (typeof param.$ref === "string") {
97
+ const refName = param.$ref.replace("#/components/parameters/", "");
98
+ return spec.components?.parameters?.[refName] || {};
99
+ }
100
+ return param;
101
+ };
102
+ /** Builds a function that substitutes path params into the path template. */
103
+ var buildPathFn = (pathTemplate, pathParams) => {
104
+ return args => {
105
+ let result = pathTemplate;
106
+ for (const {
107
+ name,
108
+ camelName
109
+ } of pathParams) {
110
+ const value = args[camelName];
111
+ if (value !== void 0) result = result.replace(`{${name}}`, encodeURIComponent(String(value)));
112
+ }
113
+ return result;
114
+ };
115
+ };
116
+ /**
117
+ * Builds a function that serialises query params into a query string
118
+ * (including the leading `?`). Returns `undefined` when the op has no query
119
+ * params. Array values are appended once per element.
120
+ */
121
+ var buildQueryFn = queryParams => {
122
+ if (queryParams.length === 0) return void 0;
123
+ return args => {
124
+ const search = new URLSearchParams();
125
+ for (const {
126
+ name,
127
+ camelName
128
+ } of queryParams) {
129
+ const value = args[camelName];
130
+ if (value === void 0 || value === null) continue;
131
+ if (Array.isArray(value)) for (const item of value) search.append(name, String(item));else search.append(name, String(value));
132
+ }
133
+ const qs = search.toString();
134
+ return qs ? `?${qs}` : "";
135
+ };
136
+ };
137
+ /**
138
+ * Builds a function that maps camelCase args back to a snake_case request
139
+ * body, skipping `undefined` args. Returns `undefined` when the op has no body.
140
+ */
141
+ var buildBodyFn = bodyProps => {
142
+ if (bodyProps.length === 0) return void 0;
143
+ return args => {
144
+ const body = {};
145
+ for (const {
146
+ snakeName,
147
+ camelName
148
+ } of bodyProps) if (args[camelName] !== void 0) body[snakeName] = args[camelName];
149
+ return body;
150
+ };
151
+ };
152
+
153
+ //#endregion
154
+ //#region src/types.ts
155
+ var DEFAULT_EXCLUDE_EXTENSION = "x-mcp-exclude";
156
+ var DEFAULT_SERVER_MANAGED_EXTENSION = "x-mcp-server-managed";
157
+
158
+ //#endregion
159
+ //#region src/toolDefinitions.ts
160
+ /**
161
+ * Folds `_` and `-` separators into camelCase. OpenAPI operation and parameter
162
+ * names may be snake_case (`agent_id`) or kebab-case (`list-tools`), and MCP
163
+ * tool inputs are camelCase by convention, so both are folded here.
164
+ */
165
+ var snakeToCamel = str => {
166
+ return str.replace(/[_-]([a-z])/g, (_, letter) => {
167
+ return letter.toUpperCase();
168
+ });
169
+ };
170
+ /** Converts a camelCase `operationId` to a kebab-case tool name. */
171
+ var operationIdToToolName = operationId => {
172
+ return operationId.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "");
173
+ };
174
+ var getJsonSchemaType = schemaType => {
175
+ if (schemaType === "integer" || schemaType === "number") return "number";
176
+ if (schemaType === "boolean") return "boolean";
177
+ if (schemaType === "array") return "array";
178
+ if (schemaType === "object") return "object";
179
+ return "string";
180
+ };
181
+ var sanitizeDescription = description => {
182
+ return (description || "").replace(/'/g, "\\'").replace(/\n/g, " ").trim();
183
+ };
184
+ var buildInputSchema = (pathParams, queryParams, bodyProps) => {
185
+ const allParams = [...pathParams, ...queryParams, ...bodyProps];
186
+ if (allParams.length === 0) return {
187
+ type: "object"
188
+ };
189
+ const requiredFields = [...pathParams.map(p => {
190
+ return p.camelName;
191
+ }), ...queryParams.filter(p => {
192
+ return p.required;
193
+ }).map(p => {
194
+ return p.camelName;
195
+ }), ...bodyProps.filter(p => {
196
+ return p.required;
197
+ }).map(p => {
198
+ return p.camelName;
199
+ })];
200
+ const properties = {};
201
+ for (const param of allParams) if ("type" in param) {
202
+ const jsonType = getJsonSchemaType(param.type);
203
+ const description = sanitizeDescription(param.description);
204
+ if (param.type === "array") {
205
+ const itemsSchema = "items" in param && param.items ? param.items : {
206
+ type: "string"
207
+ };
208
+ properties[param.camelName] = {
209
+ type: "array",
210
+ items: itemsSchema,
211
+ description
212
+ };
213
+ } else properties[param.camelName] = {
214
+ type: jsonType,
215
+ description
216
+ };
217
+ } else properties[param.camelName] = {
218
+ type: "string",
219
+ description: ""
220
+ };
221
+ return {
222
+ type: "object",
223
+ properties,
224
+ required: requiredFields.length > 0 ? requiredFields : void 0
225
+ };
226
+ };
227
+ var extractPathParams = args => {
228
+ return (args.parameters || []).map(p => {
229
+ return resolveParameter(p, args.spec);
230
+ }).filter(p => {
231
+ return p.in === "path";
232
+ }).map(p => {
233
+ return {
234
+ name: p.name || "",
235
+ camelName: snakeToCamel(p.name || "")
236
+ };
237
+ });
238
+ };
239
+ var extractQueryParams = args => {
240
+ return (args.parameters || []).map(p => {
241
+ return resolveParameter(p, args.spec);
242
+ }).filter(p => {
243
+ return p.in === "query";
244
+ }).map(p => {
245
+ return {
246
+ name: p.name || "",
247
+ camelName: snakeToCamel(p.name || ""),
248
+ description: p.description || "",
249
+ required: p.required || false,
250
+ type: p.schema?.type || "string"
251
+ };
252
+ });
253
+ };
254
+ var resolveBodySchema = args => {
255
+ const rawBodySchema = args.requestBody?.content?.["application/json"]?.schema;
256
+ return resolveSchema(dereferenceSchema(rawBodySchema, args.spec), args.spec);
257
+ };
258
+ /**
259
+ * snake_case names of every top-level property an operation's request schema
260
+ * declares, including server-managed ones.
261
+ */
262
+ var extractAcceptedBodyFields = args => {
263
+ const bodySchema = resolveBodySchema(args);
264
+ return Object.keys(bodySchema?.properties ?? {});
265
+ };
266
+ var extractBodyProps = args => {
267
+ const bodySchema = resolveBodySchema(args);
268
+ if (!bodySchema?.properties) return [];
269
+ return Object.entries(bodySchema.properties).filter(([, value]) => {
270
+ return !value[args.serverManagedExtension];
271
+ }).map(([key, value]) => {
272
+ const val = value;
273
+ return {
274
+ snakeName: key,
275
+ camelName: snakeToCamel(key),
276
+ description: typeof val.description === "string" ? val.description : "",
277
+ required: (bodySchema.required || []).includes(key),
278
+ type: typeof val.type === "string" ? val.type : "string",
279
+ items: val.items
280
+ };
281
+ });
282
+ };
283
+ /** Collects every `x-` prefixed extension declared on the operation. */
284
+ var extractExtensions = operation => {
285
+ const extensions = {};
286
+ for (const [key, value] of Object.entries(operation)) if (key.startsWith("x-")) extensions[key] = value;
287
+ return extensions;
288
+ };
289
+ var processOperation = args => {
290
+ const httpMethod = args.method.toUpperCase();
291
+ if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(httpMethod)) return null;
292
+ if (!args.operation.operationId) return null;
293
+ if (args.operation[args.options.excludeExtension]) return null;
294
+ const toolName = operationIdToToolName(args.operation.operationId);
295
+ const pathParams = extractPathParams({
296
+ parameters: args.operation.parameters || [],
297
+ spec: args.spec
298
+ });
299
+ const queryParams = extractQueryParams({
300
+ parameters: args.operation.parameters || [],
301
+ spec: args.spec
302
+ });
303
+ const bodyProps = extractBodyProps({
304
+ requestBody: args.operation.requestBody,
305
+ spec: args.spec,
306
+ serverManagedExtension: args.options.serverManagedExtension
307
+ });
308
+ const inputSchema = buildInputSchema(pathParams, queryParams, bodyProps);
309
+ const acceptedBodyFields = extractAcceptedBodyFields({
310
+ requestBody: args.operation.requestBody,
311
+ spec: args.spec
312
+ });
313
+ return {
314
+ name: toolName,
315
+ description: sanitizeDescription(args.operation.description),
316
+ inputSchema,
317
+ method: httpMethod,
318
+ pathTemplate: args.pathTemplate,
319
+ operationId: args.operation.operationId,
320
+ path: buildPathFn(args.pathTemplate, pathParams),
321
+ query: buildQueryFn(queryParams),
322
+ body: buildBodyFn(bodyProps),
323
+ acceptedBodyFields,
324
+ extensions: extractExtensions(args.operation)
325
+ };
326
+ };
327
+ var processPath = args => {
328
+ const tools = [];
329
+ for (const [method, operation] of Object.entries(args.pathItem)) {
330
+ const tool = processOperation({
331
+ pathTemplate: args.pathTemplate,
332
+ method,
333
+ operation,
334
+ spec: args.spec,
335
+ options: args.options
336
+ });
337
+ if (tool) tools.push(tool);
338
+ }
339
+ return tools;
340
+ };
341
+ /**
342
+ * Translates one or more OpenAPI documents into REST-backed MCP tool
343
+ * definitions. Each translatable operation (has an `operationId`, a supported
344
+ * HTTP method, and is not excluded) becomes one {@link ToolDefinition}.
345
+ *
346
+ * @example
347
+ * ```typescript
348
+ * import { openApiToToolDefinitions } from '@ttoss/http-server-mcp-openapi';
349
+ *
350
+ * const tools = openApiToToolDefinitions({ spec: myOpenApiDocument });
351
+ * ```
352
+ */
353
+ var openApiToToolDefinitions = args => {
354
+ const options = {
355
+ excludeExtension: args.options?.excludeExtension ?? "x-mcp-exclude",
356
+ serverManagedExtension: args.options?.serverManagedExtension ?? "x-mcp-server-managed"
357
+ };
358
+ const specs = Array.isArray(args.spec) ? args.spec : [args.spec];
359
+ const tools = [];
360
+ for (const spec of specs) {
361
+ const paths = spec.paths || {};
362
+ for (const [pathTemplate, pathItem] of Object.entries(paths)) tools.push(...processPath({
363
+ pathTemplate,
364
+ pathItem,
365
+ spec,
366
+ options
367
+ }));
368
+ }
369
+ return tools;
370
+ };
371
+
372
+ //#endregion
373
+ //#region src/registerOpenApiTools.ts
374
+ var defaultToText = data => {
375
+ return typeof data === "string" ? data : JSON.stringify(data, null, 2);
376
+ };
377
+ /**
378
+ * Derives MCP tools from OpenAPI document(s) and registers each on the given
379
+ * MCP server. Every tool's handler resolves the incoming camelCase args into a
380
+ * concrete HTTP request and delegates execution to `callApi`.
381
+ *
382
+ * @returns The list of {@link ToolDefinition} that were registered.
383
+ *
384
+ * @example
385
+ * ```typescript
386
+ * import { McpServer } from '@ttoss/http-server-mcp';
387
+ * import { registerOpenApiTools } from '@ttoss/http-server-mcp-openapi';
388
+ *
389
+ * const server = new McpServer({ name: 'my-api', version: '1.0.0' });
390
+ *
391
+ * registerOpenApiTools({
392
+ * server,
393
+ * spec: myOpenApiDocument,
394
+ * callApi: async ({ method, url, body }) => {
395
+ * const res = await fetch(`https://api.example.com${url}`, {
396
+ * method,
397
+ * headers: { 'Content-Type': 'application/json' },
398
+ * body: body ? JSON.stringify(body) : undefined,
399
+ * });
400
+ * return res.json();
401
+ * },
402
+ * });
403
+ * ```
404
+ */
405
+ var registerOpenApiTools = args => {
406
+ const toText = args.toText ?? defaultToText;
407
+ const tools = openApiToToolDefinitions({
408
+ spec: args.spec,
409
+ options: args.options
410
+ });
411
+ for (const tool of tools) registerToolFromSchema(args.server, {
412
+ name: tool.name,
413
+ description: tool.description,
414
+ inputSchema: tool.inputSchema,
415
+ handler: async handlerArgs => {
416
+ const url = tool.path(handlerArgs) + (tool.query ? tool.query(handlerArgs) : "");
417
+ return {
418
+ content: [{
419
+ type: "text",
420
+ text: toText(await args.callApi({
421
+ method: tool.method,
422
+ url,
423
+ body: tool.body ? tool.body(handlerArgs) : void 0,
424
+ tool
425
+ }))
426
+ }]
427
+ };
428
+ }
429
+ });
430
+ return tools;
431
+ };
432
+
433
+ //#endregion
434
+ export { DEFAULT_EXCLUDE_EXTENSION, DEFAULT_SERVER_MANAGED_EXTENSION, buildBodyFn, buildInputSchema, buildPathFn, buildQueryFn, dereferenceSchema, extractAcceptedBodyFields, extractBodyProps, extractPathParams, extractQueryParams, getJsonSchemaType, openApiToToolDefinitions, operationIdToToolName, processOperation, processPath, registerOpenApiTools, resolveParameter, resolveSchema, snakeToCamel };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@ttoss/http-server-mcp-openapi",
3
+ "version": "0.1.0",
4
+ "description": "Generate Model Context Protocol (MCP) tools from an OpenAPI specification for @ttoss/http-server-mcp",
5
+ "keywords": [
6
+ "ai",
7
+ "koa",
8
+ "llm",
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "openapi",
12
+ "rest",
13
+ "server"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/ttoss/ttoss.git",
18
+ "directory": "packages/http-server-mcp-openapi"
19
+ },
20
+ "license": "MIT",
21
+ "author": "ttoss",
22
+ "contributors": [
23
+ "Pedro Arantes <pedro@arantespp.com> (https://arantespp.com)"
24
+ ],
25
+ "sideEffects": false,
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "import": "./dist/index.mjs",
30
+ "require": "./dist/index.cjs",
31
+ "types": "./dist/index.d.mts"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "dependencies": {
38
+ "@ttoss/http-server-mcp": "^0.22.0"
39
+ },
40
+ "devDependencies": {
41
+ "jest": "^30.4.2",
42
+ "supertest": "^7.2.2",
43
+ "tsdown": "^0.22.2",
44
+ "@ttoss/config": "^1.37.17",
45
+ "@ttoss/http-server": "^0.7.1"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public",
49
+ "provenance": true
50
+ },
51
+ "scripts": {
52
+ "build": "tsdown",
53
+ "test": "jest --projects tests/unit"
54
+ }
55
+ }