@tokenlabai/mcp-server 0.2.2 → 0.4.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/README.md +80 -22
- package/contract/mcp-overlay.json +212 -0
- package/contract/openapi.json +12782 -0
- package/generated/tools.json +7230 -0
- package/llms-install.md +60 -0
- package/package.json +18 -3
- package/scripts/generate-contract.mjs +276 -0
- package/scripts/sync-contract.mjs +27 -0
- package/src/index.js +227 -273
- package/.github/workflows/publish-npm.yml +0 -29
- package/server.json +0 -36
- package/test/mcp-tools.test.js +0 -78
package/llms-install.md
ADDED
|
@@ -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,15 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenlabai/mcp-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for TokenLab model discovery,
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "MCP server for TokenLab model discovery, native LLM endpoints, multimodal generation, async tasks, embeddings, rerank, and translation.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18.17"
|
|
8
|
+
},
|
|
6
9
|
"mcpName": "io.github.hedging8563/tokenlab",
|
|
7
10
|
"bin": {
|
|
8
11
|
"tokenlab-mcp-server": "src/index.js"
|
|
9
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
|
+
],
|
|
10
22
|
"scripts": {
|
|
11
23
|
"start": "node ./src/index.js",
|
|
12
|
-
"
|
|
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"
|
|
13
28
|
},
|
|
14
29
|
"keywords": [
|
|
15
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}`);
|