@tokenlabai/mcp-server 0.3.0 → 0.4.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.
@@ -0,0 +1,60 @@
1
+ # Install TokenLab MCP Server
2
+
3
+ Use the published npm package. Do not clone or build the repository unless the user explicitly asks for a source checkout.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 18.17 or newer
8
+ - An MCP client that supports stdio servers
9
+ - `TOKENLAB_API_KEY` only when the user wants to call credentialed API tools
10
+
11
+ ## MCP Configuration
12
+
13
+ Add this server entry to the client's MCP configuration:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "tokenlab": {
19
+ "command": "npx",
20
+ "args": ["-y", "@tokenlabai/mcp-server"],
21
+ "env": {
22
+ "TOKENLAB_API_BASE": "https://api.tokenlab.sh"
23
+ }
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ Public model catalog and pricing tools work without credentials. When inference is requested, add the user's TokenLab key without printing or committing it. Do not put API keys in MCP tool arguments:
30
+
31
+ ```json
32
+ {
33
+ "TOKENLAB_API_KEY": "<TOKENLAB_API_KEY>"
34
+ }
35
+ ```
36
+
37
+ ## Verification
38
+
39
+ Start or reload the MCP client, then confirm these public tools are available without an API key:
40
+
41
+ - `list_models`
42
+ - `get_model`
43
+ - `get_model_pricing`
44
+ - `compare_models`
45
+ - `get_api_overview`
46
+
47
+ The default `core` profile also exposes generated credentialed tools for:
48
+
49
+ - OpenAI Chat Completions and Responses
50
+ - Anthropic Messages and Gemini generateContent
51
+ - image generation/editing through JSON or local multipart files
52
+ - video, music, 3D, speech, transcription, and audio translation
53
+ - task polling/cancellation and file operations
54
+ - embeddings, multimodal embeddings, rerank, and text translation
55
+
56
+ Set `TOKENLAB_MCP_TOOL_PROFILE=full` to expose every allowlisted developer operation generated from the public OpenAPI contract. Realtime and streaming-only operations remain excluded.
57
+
58
+ Video, music, and 3D tools return async task summaries. Image tools may return a completed result or an async task summary. When `delivery.mode` is `async`, call `get_task_status` with `{ "id": delivery.task_id }` until `delivery.terminal` is `true`.
59
+
60
+ If startup fails, run `node --version` and confirm it is at least `18.17`, then run `npx -y @tokenlabai/mcp-server` once in a terminal to surface npm or network errors.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tokenlabai/mcp-server",
3
- "version": "0.3.0",
4
- "description": "MCP server for TokenLab model discovery, pricing, OpenAI-compatible Chat Completions, and native Responses, Anthropic Messages, and Gemini inference.",
3
+ "version": "0.4.1",
4
+ "description": "MCP server for TokenLab model discovery, native LLM endpoints, multimodal generation, async tasks, embeddings, rerank, and translation.",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=18.17"
@@ -10,9 +10,21 @@
10
10
  "bin": {
11
11
  "tokenlab-mcp-server": "src/index.js"
12
12
  },
13
+ "files": [
14
+ "contract/mcp-overlay.json",
15
+ "contract/openapi.json",
16
+ "generated/tools.json",
17
+ "llms-install.md",
18
+ "scripts/generate-contract.mjs",
19
+ "scripts/sync-contract.mjs",
20
+ "src/index.js"
21
+ ],
13
22
  "scripts": {
14
23
  "start": "node ./src/index.js",
15
- "test": "node --check ./src/index.js && node --test"
24
+ "contract:generate": "node ./scripts/generate-contract.mjs",
25
+ "contract:check": "node ./scripts/generate-contract.mjs --check",
26
+ "contract:sync": "node ./scripts/sync-contract.mjs",
27
+ "test": "node --check ./src/index.js && node --check ./scripts/generate-contract.mjs && npm run contract:check && node --test"
16
28
  },
17
29
  "keywords": [
18
30
  "mcp",
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import { dirname, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
+ const argv = process.argv.slice(2);
10
+ const args = new Map();
11
+ for (let index = 0; index < argv.length; index += 1) {
12
+ if (!argv[index].startsWith("--") || argv[index] === "--check") continue;
13
+ args.set(argv[index], argv[index + 1]);
14
+ index += 1;
15
+ }
16
+
17
+ const sourcePath = resolve(root, args.get("--source") || "contract/openapi.json");
18
+ const overlayPath = resolve(root, args.get("--overlay") || "contract/mcp-overlay.json");
19
+ const outputPath = resolve(root, args.get("--output") || "generated/tools.json");
20
+ const check = argv.includes("--check");
21
+
22
+ const [sourceText, overlayText] = await Promise.all([
23
+ readFile(sourcePath, "utf8"),
24
+ readFile(overlayPath, "utf8")
25
+ ]);
26
+ const spec = JSON.parse(sourceText);
27
+ const overlay = JSON.parse(overlayText);
28
+
29
+ function snakeCase(value) {
30
+ return value
31
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
32
+ .replace(/([A-Za-z])(\d+)/g, "$1_$2")
33
+ .replace(/[^A-Za-z0-9]+/g, "_")
34
+ .replace(/^_+|_+$/g, "")
35
+ .toLowerCase();
36
+ }
37
+
38
+ function getByPointer(pointer) {
39
+ return pointer
40
+ .replace(/^#\//, "")
41
+ .split("/")
42
+ .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
43
+ .reduce((value, key) => value?.[key], spec);
44
+ }
45
+
46
+ function normalizeSchema(value, stack = []) {
47
+ if (Array.isArray(value)) return value.map((entry) => normalizeSchema(entry, stack));
48
+ if (!value || typeof value !== "object") return value;
49
+
50
+ if (value.$ref) {
51
+ if (stack.includes(value.$ref)) {
52
+ return { type: "object", additionalProperties: true };
53
+ }
54
+ const target = getByPointer(value.$ref);
55
+ if (!target) throw new Error(`Unresolved OpenAPI reference: ${value.$ref}`);
56
+ const { $ref, ...rest } = value;
57
+ return {
58
+ ...normalizeSchema(target, [...stack, value.$ref]),
59
+ ...normalizeSchema(rest, stack)
60
+ };
61
+ }
62
+
63
+ const normalized = {};
64
+ for (const [key, entry] of Object.entries(value)) {
65
+ if (["example", "examples", "xml", "discriminator", "deprecated", "readOnly", "writeOnly"].includes(key)) continue;
66
+ normalized[key] = normalizeSchema(entry, stack);
67
+ }
68
+
69
+ if (normalized.nullable === true) {
70
+ delete normalized.nullable;
71
+ return { anyOf: [normalized, { type: "null" }] };
72
+ }
73
+
74
+ return normalized;
75
+ }
76
+
77
+ function operationIndex() {
78
+ const operations = new Map();
79
+ for (const [path, pathItem] of Object.entries(spec.paths || {})) {
80
+ for (const [method, operation] of Object.entries(pathItem)) {
81
+ if (!["get", "post", "put", "patch", "delete"].includes(method)) continue;
82
+ if (!operation.operationId) throw new Error(`${method.toUpperCase()} ${path} has no operationId`);
83
+ if (operations.has(operation.operationId)) throw new Error(`Duplicate operationId: ${operation.operationId}`);
84
+ operations.set(operation.operationId, { path, pathItem, method: method.toUpperCase(), operation });
85
+ }
86
+ }
87
+ return operations;
88
+ }
89
+
90
+ const operations = operationIndex();
91
+ const coreOperations = new Set(overlay.profiles.core.operations);
92
+ const fullTags = new Set(overlay.profiles.full.include_tags);
93
+ const fullExclusions = new Set(overlay.profiles.full.exclude_operations || []);
94
+ const publicAuth = new Set(overlay.public_auth_operations || []);
95
+
96
+ for (const operationId of coreOperations) {
97
+ if (!operations.has(operationId)) throw new Error(`Core MCP operation is missing from OpenAPI: ${operationId}`);
98
+ }
99
+
100
+ function chooseContentTypes(operationId, operation, override) {
101
+ const available = Object.keys(operation.requestBody?.content || {});
102
+ if (available.length === 0) return [null];
103
+ if (override.all_content_types) return available;
104
+ if (override.content_type) {
105
+ if (!available.includes(override.content_type)) {
106
+ throw new Error(`${operationId} does not declare ${override.content_type}`);
107
+ }
108
+ return [override.content_type];
109
+ }
110
+ return [available.includes("application/json") ? "application/json" : available[0]];
111
+ }
112
+
113
+ function buildInputSchema(pathItem, operation, contentType) {
114
+ const properties = {};
115
+ const required = [];
116
+ const bindings = { path: [], query: [], header: [], body: [], files: [] };
117
+ const parameters = [...(pathItem.parameters || []), ...(operation.parameters || [])]
118
+ .map((parameter) => normalizeSchema(parameter));
119
+
120
+ for (const parameter of parameters) {
121
+ if (!["path", "query", "header"].includes(parameter.in)) continue;
122
+ if (properties[parameter.name]) throw new Error(`Duplicate MCP argument: ${parameter.name}`);
123
+ properties[parameter.name] = {
124
+ ...normalizeSchema(parameter.schema || { type: "string" }),
125
+ ...(parameter.description ? { description: parameter.description } : {})
126
+ };
127
+ bindings[parameter.in].push(parameter.name);
128
+ if (parameter.required) required.push(parameter.name);
129
+ }
130
+
131
+ if (contentType) {
132
+ const media = operation.requestBody.content[contentType];
133
+ const bodySchema = normalizeSchema(media.schema || { type: "object", additionalProperties: true });
134
+ if (bodySchema.type === "object" || bodySchema.properties) {
135
+ for (const [name, schema] of Object.entries(bodySchema.properties || {})) {
136
+ if (properties[name]) throw new Error(`MCP argument collision for ${operation.operationId}: ${name}`);
137
+ const property = structuredClone(schema);
138
+ const binaryNodes = [];
139
+ const visit = (value) => {
140
+ if (Array.isArray(value)) {
141
+ value.forEach(visit);
142
+ return;
143
+ }
144
+ if (!value || typeof value !== "object") return;
145
+ if (value.format === "binary") {
146
+ delete value.format;
147
+ value.type = "string";
148
+ binaryNodes.push(value);
149
+ }
150
+ Object.values(value).forEach(visit);
151
+ };
152
+ visit(property);
153
+ if (binaryNodes.length > 0) {
154
+ property.description = `${property.description ? `${property.description} ` : ""}Pass local file path${binaryNodes.length > 1 || property.type === "array" ? "s" : ""}.`;
155
+ bindings.files.push(name);
156
+ }
157
+ properties[name] = property;
158
+ bindings.body.push(name);
159
+ }
160
+ for (const name of bodySchema.required || []) required.push(name);
161
+ } else {
162
+ properties.body = bodySchema;
163
+ bindings.body.push("body");
164
+ if (operation.requestBody.required) required.push("body");
165
+ }
166
+ }
167
+
168
+ return {
169
+ schema: {
170
+ type: "object",
171
+ properties,
172
+ ...(required.length > 0 ? { required: [...new Set(required)] } : {}),
173
+ additionalProperties: false
174
+ },
175
+ bindings
176
+ };
177
+ }
178
+
179
+ function applyInputOverrides(schema, bindings, override) {
180
+ for (const name of override.omit_arguments || []) {
181
+ if (!schema.properties[name]) throw new Error(`Cannot omit unknown MCP argument: ${name}`);
182
+ delete schema.properties[name];
183
+ if (schema.required) schema.required = schema.required.filter((entry) => entry !== name);
184
+ for (const binding of Object.values(bindings)) {
185
+ const index = binding.indexOf(name);
186
+ if (index >= 0) binding.splice(index, 1);
187
+ }
188
+ }
189
+ for (const [name, patch] of Object.entries(override.input_property_overrides || {})) {
190
+ if (!schema.properties[name]) throw new Error(`Cannot override unknown MCP argument: ${name}`);
191
+ schema.properties[name] = { ...schema.properties[name], ...patch };
192
+ }
193
+ }
194
+
195
+ function annotations(method) {
196
+ return {
197
+ readOnlyHint: method === "GET",
198
+ destructiveHint: method === "DELETE",
199
+ idempotentHint: ["GET", "PUT", "DELETE"].includes(method),
200
+ openWorldHint: true
201
+ };
202
+ }
203
+
204
+ const tools = [];
205
+ for (const [operationId, indexed] of operations) {
206
+ const tags = indexed.operation.tags || [];
207
+ const inCore = coreOperations.has(operationId);
208
+ const inFull = !fullExclusions.has(operationId) && tags.some((tag) => fullTags.has(tag));
209
+ if (!inCore && !inFull) continue;
210
+
211
+ const override = overlay.operation_overrides[operationId] || {};
212
+ const contentTypes = chooseContentTypes(operationId, indexed.operation, override);
213
+ for (const contentType of contentTypes) {
214
+ const suffix = contentTypes.length > 1 && contentType !== "application/json"
215
+ ? `_${contentType.split("/").at(-1).replace(/[^A-Za-z0-9]+/g, "_")}`
216
+ : "";
217
+ const toolName = override.tool_names_by_content_type?.[contentType]
218
+ || `${override.tool_name || snakeCase(operationId)}${suffix}`;
219
+ const { schema, bindings } = buildInputSchema(indexed.pathItem, indexed.operation, contentType);
220
+ applyInputOverrides(schema, bindings, override);
221
+ const description = [indexed.operation.summary, indexed.operation.description]
222
+ .filter(Boolean)
223
+ .join(" ")
224
+ .replace(/\s+/g, " ")
225
+ .trim();
226
+
227
+ tools.push({
228
+ name: toolName,
229
+ operation_id: operationId,
230
+ method: indexed.method,
231
+ path: indexed.path,
232
+ content_type: contentType,
233
+ auth: publicAuth.has(operationId) ? "optional" : "required",
234
+ tags,
235
+ profiles: [...(inCore ? ["core"] : []), ...(inFull ? ["full"] : [])],
236
+ description,
237
+ input_schema: schema,
238
+ bindings,
239
+ annotations: annotations(indexed.method),
240
+ ...(override.task ? { task: override.task } : {})
241
+ });
242
+ }
243
+ }
244
+
245
+ tools.sort((left, right) => left.name.localeCompare(right.name));
246
+ const duplicateNames = tools.filter((tool, index) => tools.findIndex((candidate) => candidate.name === tool.name) !== index);
247
+ if (duplicateNames.length > 0) throw new Error(`Duplicate MCP tool names: ${duplicateNames.map((tool) => tool.name).join(", ")}`);
248
+
249
+ const manifest = {
250
+ schema_version: 1,
251
+ generated_at: null,
252
+ source: {
253
+ url: overlay.openapi_url,
254
+ openapi: spec.openapi,
255
+ title: spec.info?.title,
256
+ version: spec.info?.version,
257
+ sha256: createHash("sha256").update(sourceText).digest("hex")
258
+ },
259
+ default_profile: overlay.default_profile,
260
+ profiles: Object.keys(overlay.profiles),
261
+ tool_count: tools.length,
262
+ tools
263
+ };
264
+ const output = `${JSON.stringify(manifest, null, 2)}\n`;
265
+
266
+ if (check) {
267
+ const existing = await readFile(outputPath, "utf8");
268
+ if (existing !== output) {
269
+ console.error("Generated MCP contract is stale. Run npm run contract:generate.");
270
+ process.exit(1);
271
+ }
272
+ console.log(`[contract] PASS (${tools.length} generated tools)`);
273
+ } else {
274
+ await writeFile(outputPath, output);
275
+ console.log(`[contract] generated ${tools.length} tools at ${outputPath}`);
276
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import { dirname, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
+ const overlay = JSON.parse(await readFile(resolve(root, "contract/mcp-overlay.json"), "utf8"));
10
+ const response = await fetch(overlay.openapi_url, {
11
+ headers: { Accept: "application/json", "User-Agent": "tokenlab-mcp-contract-sync" }
12
+ });
13
+ if (!response.ok) throw new Error(`OpenAPI fetch failed: ${response.status} ${response.statusText}`);
14
+
15
+ const source = `${JSON.stringify(await response.json(), null, 2)}\n`;
16
+ await writeFile(resolve(root, "contract/openapi.json"), source);
17
+
18
+ await new Promise((resolvePromise, reject) => {
19
+ const child = spawn(process.execPath, ["scripts/generate-contract.mjs"], {
20
+ cwd: root,
21
+ stdio: "inherit"
22
+ });
23
+ child.once("error", reject);
24
+ child.once("exit", (code) => code === 0 ? resolvePromise() : reject(new Error(`Generator exited ${code}`)));
25
+ });
26
+
27
+ console.log(`[contract] synchronized ${overlay.openapi_url}`);