@centia-io/mcp-server 1.0.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/README.md +131 -0
- package/centia-api.json +3122 -0
- package/dist/index.js +410 -0
- package/package.json +32 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import axios from "axios";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const apiSpecPath = path.join(__dirname, "..", "centia-api.json");
|
|
11
|
+
const apiSpec = JSON.parse(fs.readFileSync(apiSpecPath, "utf-8"));
|
|
12
|
+
const API_BASE_URL = process.env.API_BASE_URL || "https://api.centia.io";
|
|
13
|
+
const API_TOKEN = process.env.API_TOKEN;
|
|
14
|
+
const server = new Server({
|
|
15
|
+
name: "centia-io-mcp-server",
|
|
16
|
+
version: "1.0.0",
|
|
17
|
+
}, {
|
|
18
|
+
capabilities: {
|
|
19
|
+
tools: {},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
function resolveSchema(schema) {
|
|
23
|
+
if (!schema)
|
|
24
|
+
return { type: "string" };
|
|
25
|
+
if (schema.$ref) {
|
|
26
|
+
const refPath = schema.$ref.replace("#/", "").split("/");
|
|
27
|
+
let current = apiSpec;
|
|
28
|
+
for (const segment of refPath) {
|
|
29
|
+
if (!current[segment]) {
|
|
30
|
+
console.error(`Could not resolve ref: ${schema.$ref}`);
|
|
31
|
+
return { type: "string" };
|
|
32
|
+
}
|
|
33
|
+
current = current[segment];
|
|
34
|
+
}
|
|
35
|
+
return resolveSchema(current);
|
|
36
|
+
}
|
|
37
|
+
if (schema.allOf) {
|
|
38
|
+
let merged = { type: "object", properties: {}, required: [] };
|
|
39
|
+
for (const s of schema.allOf) {
|
|
40
|
+
const resolved = resolveSchema(s);
|
|
41
|
+
if (resolved.properties) {
|
|
42
|
+
merged.properties = { ...merged.properties, ...resolved.properties };
|
|
43
|
+
}
|
|
44
|
+
if (resolved.required) {
|
|
45
|
+
merged.required = [...new Set([...merged.required, ...resolved.required])];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return merged;
|
|
49
|
+
}
|
|
50
|
+
if (schema.type === "object" && schema.properties) {
|
|
51
|
+
const properties = {};
|
|
52
|
+
for (const [key, value] of Object.entries(schema.properties)) {
|
|
53
|
+
properties[key] = resolveSchema(value);
|
|
54
|
+
}
|
|
55
|
+
return { ...schema, properties };
|
|
56
|
+
}
|
|
57
|
+
if (schema.type === "array" && schema.items) {
|
|
58
|
+
return { ...schema, items: resolveSchema(schema.items) };
|
|
59
|
+
}
|
|
60
|
+
return schema;
|
|
61
|
+
}
|
|
62
|
+
// Sanitize JSON Schemas for MCP tool input to avoid oversized numeric literals
|
|
63
|
+
function sanitizeSchemaForMCP(schema) {
|
|
64
|
+
if (schema == null)
|
|
65
|
+
return schema;
|
|
66
|
+
if (Array.isArray(schema)) {
|
|
67
|
+
return schema.map((s) => sanitizeSchemaForMCP(s));
|
|
68
|
+
}
|
|
69
|
+
if (typeof schema !== "object")
|
|
70
|
+
return schema;
|
|
71
|
+
const clone = { ...schema };
|
|
72
|
+
const numericKeys = [
|
|
73
|
+
"example",
|
|
74
|
+
"default",
|
|
75
|
+
"maximum",
|
|
76
|
+
"minimum",
|
|
77
|
+
"exclusiveMaximum",
|
|
78
|
+
"exclusiveMinimum",
|
|
79
|
+
"multipleOf",
|
|
80
|
+
"const",
|
|
81
|
+
];
|
|
82
|
+
for (const key of numericKeys) {
|
|
83
|
+
if (clone[key] !== undefined && typeof clone[key] === "number") {
|
|
84
|
+
if (!Number.isFinite(clone[key]) || Math.abs(clone[key]) > Number.MAX_SAFE_INTEGER) {
|
|
85
|
+
delete clone[key];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Drop non‑standard/annotation keywords that some validators reject
|
|
90
|
+
for (const k of ["example", "examples", "deprecated", "readOnly", "writeOnly", "xml", "style", "explode", "nullable"]) {
|
|
91
|
+
if (k in clone)
|
|
92
|
+
delete clone[k];
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(clone.enum)) {
|
|
95
|
+
clone.enum = clone.enum.filter((v) => !(typeof v === "number" && (!Number.isFinite(v) || Math.abs(v) > Number.MAX_SAFE_INTEGER)));
|
|
96
|
+
if (clone.enum.length === 0)
|
|
97
|
+
delete clone.enum;
|
|
98
|
+
}
|
|
99
|
+
if (clone.properties && typeof clone.properties === "object") {
|
|
100
|
+
const newProps = {};
|
|
101
|
+
for (const [k, v] of Object.entries(clone.properties)) {
|
|
102
|
+
newProps[k] = sanitizeSchemaForMCP(v);
|
|
103
|
+
}
|
|
104
|
+
clone.properties = newProps;
|
|
105
|
+
}
|
|
106
|
+
if (clone.items) {
|
|
107
|
+
clone.items = sanitizeSchemaForMCP(clone.items);
|
|
108
|
+
}
|
|
109
|
+
for (const k of ["allOf", "anyOf", "oneOf"]) {
|
|
110
|
+
if (Array.isArray(clone[k])) {
|
|
111
|
+
clone[k] = clone[k].map((s) => sanitizeSchemaForMCP(s));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return clone;
|
|
115
|
+
}
|
|
116
|
+
// Normalize a schema to a conservative JSON Schema 2020-12 subset accepted by most MCP clients
|
|
117
|
+
function normalizeJsonSchemaForMCP(schema) {
|
|
118
|
+
if (schema == null)
|
|
119
|
+
return undefined;
|
|
120
|
+
if (Array.isArray(schema))
|
|
121
|
+
return schema.map((s) => normalizeJsonSchemaForMCP(s));
|
|
122
|
+
if (typeof schema !== "object")
|
|
123
|
+
return schema;
|
|
124
|
+
const allowedKeys = new Set([
|
|
125
|
+
"type",
|
|
126
|
+
"properties",
|
|
127
|
+
"required",
|
|
128
|
+
"description",
|
|
129
|
+
"enum",
|
|
130
|
+
"items",
|
|
131
|
+
"anyOf",
|
|
132
|
+
"oneOf",
|
|
133
|
+
"allOf",
|
|
134
|
+
"const",
|
|
135
|
+
"default",
|
|
136
|
+
"minimum",
|
|
137
|
+
"maximum",
|
|
138
|
+
"exclusiveMinimum",
|
|
139
|
+
"exclusiveMaximum",
|
|
140
|
+
"multipleOf",
|
|
141
|
+
"minLength",
|
|
142
|
+
"maxLength",
|
|
143
|
+
"pattern",
|
|
144
|
+
"format",
|
|
145
|
+
"minItems",
|
|
146
|
+
"maxItems",
|
|
147
|
+
"uniqueItems",
|
|
148
|
+
"contains",
|
|
149
|
+
"additionalProperties",
|
|
150
|
+
"patternProperties",
|
|
151
|
+
"title",
|
|
152
|
+
]);
|
|
153
|
+
const formatWhitelist = new Set([
|
|
154
|
+
"email",
|
|
155
|
+
"uri",
|
|
156
|
+
"uuid",
|
|
157
|
+
"ipv4",
|
|
158
|
+
"ipv6",
|
|
159
|
+
"date-time",
|
|
160
|
+
"date",
|
|
161
|
+
"time",
|
|
162
|
+
]);
|
|
163
|
+
const result = {};
|
|
164
|
+
for (const [k, v] of Object.entries(schema)) {
|
|
165
|
+
if (!allowedKeys.has(k))
|
|
166
|
+
continue; // drop unknown keys
|
|
167
|
+
result[k] = v;
|
|
168
|
+
}
|
|
169
|
+
// Ensure type validity or provide a safe default
|
|
170
|
+
const validTypes = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]);
|
|
171
|
+
if (result.type !== undefined) {
|
|
172
|
+
if (Array.isArray(result.type)) {
|
|
173
|
+
const filtered = result.type.filter((t) => validTypes.has(t));
|
|
174
|
+
if (filtered.length > 0)
|
|
175
|
+
result.type = filtered;
|
|
176
|
+
else
|
|
177
|
+
delete result.type;
|
|
178
|
+
}
|
|
179
|
+
else if (!validTypes.has(result.type)) {
|
|
180
|
+
delete result.type;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Prune/normalize format
|
|
184
|
+
if (typeof result.format === "string" && !formatWhitelist.has(result.format)) {
|
|
185
|
+
delete result.format; // remove non-standard formats like 'url' or 'binary'
|
|
186
|
+
}
|
|
187
|
+
// Normalize required
|
|
188
|
+
if (result.required) {
|
|
189
|
+
if (!Array.isArray(result.required))
|
|
190
|
+
delete result.required;
|
|
191
|
+
else {
|
|
192
|
+
const onlyStrings = result.required.filter((r) => typeof r === "string");
|
|
193
|
+
if (onlyStrings.length > 0)
|
|
194
|
+
result.required = Array.from(new Set(onlyStrings));
|
|
195
|
+
else
|
|
196
|
+
delete result.required;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Recurse
|
|
200
|
+
if (result.properties && typeof result.properties === "object") {
|
|
201
|
+
const newProps = {};
|
|
202
|
+
for (const [k, v] of Object.entries(result.properties)) {
|
|
203
|
+
const norm = normalizeJsonSchemaForMCP(sanitizeSchemaForMCP(v));
|
|
204
|
+
if (norm)
|
|
205
|
+
newProps[k] = norm;
|
|
206
|
+
}
|
|
207
|
+
result.properties = newProps;
|
|
208
|
+
if (Object.keys(result.properties).length === 0)
|
|
209
|
+
delete result.properties;
|
|
210
|
+
}
|
|
211
|
+
if (result.items) {
|
|
212
|
+
result.items = normalizeJsonSchemaForMCP(sanitizeSchemaForMCP(result.items));
|
|
213
|
+
if (result.items === undefined)
|
|
214
|
+
delete result.items;
|
|
215
|
+
}
|
|
216
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
217
|
+
if (Array.isArray(result[key])) {
|
|
218
|
+
result[key] = result[key]
|
|
219
|
+
.map((s) => normalizeJsonSchemaForMCP(sanitizeSchemaForMCP(s)))
|
|
220
|
+
.filter(Boolean);
|
|
221
|
+
if (result[key].length === 0)
|
|
222
|
+
delete result[key];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (result.enum && Array.isArray(result.enum)) {
|
|
226
|
+
// Ensure enums are unique and non-empty
|
|
227
|
+
const uniq = Array.from(new Set(result.enum));
|
|
228
|
+
if (uniq.length > 0)
|
|
229
|
+
result.enum = uniq;
|
|
230
|
+
else
|
|
231
|
+
delete result.enum;
|
|
232
|
+
}
|
|
233
|
+
// If nothing constrains the schema, default to a string to keep it simple
|
|
234
|
+
const hasConstraints = result.type !== undefined ||
|
|
235
|
+
result.enum !== undefined ||
|
|
236
|
+
result.const !== undefined ||
|
|
237
|
+
result.anyOf !== undefined ||
|
|
238
|
+
result.oneOf !== undefined ||
|
|
239
|
+
result.allOf !== undefined ||
|
|
240
|
+
result.properties !== undefined ||
|
|
241
|
+
result.items !== undefined;
|
|
242
|
+
if (!hasConstraints) {
|
|
243
|
+
result.type = "string";
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
const tools = [];
|
|
248
|
+
const operationMap = new Map();
|
|
249
|
+
for (const [pathStr, pathItem] of Object.entries(apiSpec.paths)) {
|
|
250
|
+
for (const [method, operation] of Object.entries(pathItem)) {
|
|
251
|
+
if (method === "parameters")
|
|
252
|
+
continue;
|
|
253
|
+
const op = operation;
|
|
254
|
+
const operationId = op.operationId || `${method}${pathStr.replace(/[\/\W]/g, '_')}`;
|
|
255
|
+
const parameters = [...(pathItem.parameters || []), ...(op.parameters || [])];
|
|
256
|
+
const requestBody = op.requestBody;
|
|
257
|
+
const properties = {};
|
|
258
|
+
const required = [];
|
|
259
|
+
const toolMeta = {
|
|
260
|
+
method,
|
|
261
|
+
path: pathStr,
|
|
262
|
+
pathParams: [],
|
|
263
|
+
queryParams: [],
|
|
264
|
+
headerParams: [],
|
|
265
|
+
bodyParams: [],
|
|
266
|
+
isBodyFlattened: false,
|
|
267
|
+
};
|
|
268
|
+
// Handle path/query/header parameters
|
|
269
|
+
for (const param of parameters) {
|
|
270
|
+
const resolvedParam = param.$ref ? resolveSchema(param) : param;
|
|
271
|
+
const paramSchema = resolveSchema(resolvedParam.schema);
|
|
272
|
+
const sanitizedParamSchema = sanitizeSchemaForMCP(paramSchema);
|
|
273
|
+
const normalizedParamSchema = normalizeJsonSchemaForMCP(sanitizedParamSchema);
|
|
274
|
+
properties[resolvedParam.name] = {
|
|
275
|
+
...normalizedParamSchema,
|
|
276
|
+
description: resolvedParam.description || normalizedParamSchema?.description,
|
|
277
|
+
};
|
|
278
|
+
if (resolvedParam.required) {
|
|
279
|
+
required.push(resolvedParam.name);
|
|
280
|
+
}
|
|
281
|
+
if (resolvedParam.in === "path")
|
|
282
|
+
toolMeta.pathParams.push(resolvedParam.name);
|
|
283
|
+
if (resolvedParam.in === "query")
|
|
284
|
+
toolMeta.queryParams.push(resolvedParam.name);
|
|
285
|
+
if (resolvedParam.in === "header")
|
|
286
|
+
toolMeta.headerParams.push(resolvedParam.name);
|
|
287
|
+
}
|
|
288
|
+
// Handle request body
|
|
289
|
+
if (requestBody?.content?.["application/json"]?.schema) {
|
|
290
|
+
const bodySchema = resolveSchema(requestBody.content["application/json"].schema);
|
|
291
|
+
if (bodySchema.type === "object" && bodySchema.properties) {
|
|
292
|
+
toolMeta.isBodyFlattened = true;
|
|
293
|
+
for (const [key, value] of Object.entries(bodySchema.properties)) {
|
|
294
|
+
properties[key] = normalizeJsonSchemaForMCP(sanitizeSchemaForMCP(value));
|
|
295
|
+
toolMeta.bodyParams.push(key);
|
|
296
|
+
}
|
|
297
|
+
if (bodySchema.required) {
|
|
298
|
+
required.push(...bodySchema.required);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
properties.requestBody = normalizeJsonSchemaForMCP(sanitizeSchemaForMCP(bodySchema));
|
|
303
|
+
toolMeta.bodyParams.push("requestBody");
|
|
304
|
+
if (requestBody.required) {
|
|
305
|
+
required.push("requestBody");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
tools.push({
|
|
310
|
+
name: operationId,
|
|
311
|
+
description: op.description || op.summary || `Execute ${method.toUpperCase()} ${pathStr}`,
|
|
312
|
+
inputSchema: {
|
|
313
|
+
type: "object",
|
|
314
|
+
properties,
|
|
315
|
+
...(required.length > 0 ? { required: [...new Set(required)] } : {}),
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
operationMap.set(operationId, toolMeta);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
322
|
+
tools,
|
|
323
|
+
}));
|
|
324
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
325
|
+
const { name, arguments: args } = request.params;
|
|
326
|
+
const toolMeta = operationMap.get(name);
|
|
327
|
+
if (!toolMeta) {
|
|
328
|
+
throw new Error(`Tool not found: ${name}`);
|
|
329
|
+
}
|
|
330
|
+
let url = `${API_BASE_URL}${toolMeta.path}`;
|
|
331
|
+
const config = {
|
|
332
|
+
method: toolMeta.method,
|
|
333
|
+
headers: {},
|
|
334
|
+
params: {},
|
|
335
|
+
};
|
|
336
|
+
if (API_TOKEN) {
|
|
337
|
+
config.headers["Authorization"] = `Bearer ${API_TOKEN}`;
|
|
338
|
+
}
|
|
339
|
+
const safeArgs = args || {};
|
|
340
|
+
// Path parameters
|
|
341
|
+
for (const paramName of toolMeta.pathParams) {
|
|
342
|
+
if (safeArgs[paramName] !== undefined) {
|
|
343
|
+
url = url.replace(`{${paramName}}`, encodeURIComponent(String(safeArgs[paramName])));
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
url = url.replace(`/{${paramName}}`, "");
|
|
347
|
+
url = url.replace(`{${paramName}}`, "");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// Query parameters
|
|
351
|
+
for (const paramName of toolMeta.queryParams) {
|
|
352
|
+
if (safeArgs[paramName] !== undefined) {
|
|
353
|
+
config.params[paramName] = safeArgs[paramName];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Header parameters
|
|
357
|
+
for (const paramName of toolMeta.headerParams) {
|
|
358
|
+
if (safeArgs[paramName] !== undefined) {
|
|
359
|
+
config.headers[paramName] = String(safeArgs[paramName]);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Body
|
|
363
|
+
if (toolMeta.bodyParams.length > 0) {
|
|
364
|
+
if (toolMeta.isBodyFlattened) {
|
|
365
|
+
const body = {};
|
|
366
|
+
for (const paramName of toolMeta.bodyParams) {
|
|
367
|
+
if (safeArgs[paramName] !== undefined) {
|
|
368
|
+
body[paramName] = safeArgs[paramName];
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
config.data = body;
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
config.data = safeArgs.requestBody;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const response = await axios({ ...config, url });
|
|
379
|
+
return {
|
|
380
|
+
content: [
|
|
381
|
+
{
|
|
382
|
+
type: "text",
|
|
383
|
+
text: JSON.stringify(response.data, null, 2),
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
return {
|
|
390
|
+
isError: true,
|
|
391
|
+
content: [
|
|
392
|
+
{
|
|
393
|
+
type: "text",
|
|
394
|
+
text: error.response?.data
|
|
395
|
+
? JSON.stringify(error.response.data, null, 2)
|
|
396
|
+
: error.message,
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
async function main() {
|
|
403
|
+
const transport = new StdioServerTransport();
|
|
404
|
+
await server.connect(transport);
|
|
405
|
+
console.error("Centia MCP Server running on stdio");
|
|
406
|
+
}
|
|
407
|
+
main().catch((error) => {
|
|
408
|
+
console.error("Fatal error in main():", error);
|
|
409
|
+
process.exit(1);
|
|
410
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@centia-io/mcp-server",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"description": "Centia MCP Server",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-server": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"centia-api.json"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"dev": "tsx src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [],
|
|
20
|
+
"author": "",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
24
|
+
"axios": "^1.13.5",
|
|
25
|
+
"zod": "^4.3.6"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^25.2.2",
|
|
29
|
+
"tsx": "^4.21.0",
|
|
30
|
+
"typescript": "^5.9.3"
|
|
31
|
+
}
|
|
32
|
+
}
|