@tokenlabai/mcp-server 0.2.1 → 0.3.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/.github/workflows/publish-npm.yml +28 -4
- package/README.md +11 -8
- package/package.json +5 -2
- package/server.json +9 -3
- package/src/index.js +103 -45
- package/test/mcp-tools.test.js +141 -56
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
name: Publish npm package
|
|
1
|
+
name: Publish npm package and MCP Registry
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
5
|
tags:
|
|
6
6
|
- "v*"
|
|
7
|
+
workflow_dispatch:
|
|
7
8
|
|
|
8
9
|
permissions:
|
|
9
10
|
contents: read
|
|
@@ -16,14 +17,37 @@ jobs:
|
|
|
16
17
|
- uses: actions/checkout@v6
|
|
17
18
|
|
|
18
19
|
- uses: actions/setup-node@v6
|
|
20
|
+
if: github.event_name == 'push'
|
|
19
21
|
with:
|
|
20
22
|
node-version: "24"
|
|
21
23
|
registry-url: "https://registry.npmjs.org"
|
|
22
24
|
package-manager-cache: false
|
|
23
25
|
|
|
24
26
|
- name: Verify tag matches package version
|
|
27
|
+
if: github.event_name == 'push'
|
|
25
28
|
run: node --eval 'if (process.env.GITHUB_REF_NAME !== `v${require("./package.json").version}`) process.exit(1)'
|
|
26
29
|
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
- name: Install dependencies
|
|
31
|
+
if: github.event_name == 'push'
|
|
32
|
+
run: npm ci
|
|
33
|
+
|
|
34
|
+
- name: Test package
|
|
35
|
+
if: github.event_name == 'push'
|
|
36
|
+
run: npm test
|
|
37
|
+
|
|
38
|
+
- name: Publish npm package
|
|
39
|
+
if: github.event_name == 'push'
|
|
40
|
+
run: npm publish
|
|
41
|
+
|
|
42
|
+
- name: Install MCP Registry publisher
|
|
43
|
+
run: |
|
|
44
|
+
curl -fsSLo mcp-publisher.tar.gz \
|
|
45
|
+
https://github.com/modelcontextprotocol/registry/releases/download/v1.7.9/mcp-publisher_linux_amd64.tar.gz
|
|
46
|
+
echo "ab128162b0616090b47cf245afe0a23f3ef08936fdce19074f5ba0a4469281ac mcp-publisher.tar.gz" | sha256sum --check
|
|
47
|
+
tar -xzf mcp-publisher.tar.gz mcp-publisher
|
|
48
|
+
|
|
49
|
+
- name: Authenticate to MCP Registry
|
|
50
|
+
run: ./mcp-publisher login github-oidc
|
|
51
|
+
|
|
52
|
+
- name: Publish to MCP Registry
|
|
53
|
+
run: ./mcp-publisher publish
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# TokenLab MCP Server
|
|
2
2
|
|
|
3
|
-
Model Context Protocol server for TokenLab public model discovery, pricing, OpenAI-compatible Chat Completions, native
|
|
3
|
+
Model Context Protocol server for TokenLab public model discovery, pricing, OpenAI-compatible Chat Completions, and native Responses, Anthropic Messages, and Gemini inference.
|
|
4
4
|
|
|
5
5
|
It exposes public catalog tools for agents that need to choose models, inspect supported request formats, or compare pricing before calling TokenLab APIs. Optional inference tools require `TOKENLAB_API_KEY`.
|
|
6
6
|
|
|
@@ -35,8 +35,8 @@ Claude Desktop style config:
|
|
|
35
35
|
{
|
|
36
36
|
"mcpServers": {
|
|
37
37
|
"tokenlab-model-catalog": {
|
|
38
|
-
"command": "
|
|
39
|
-
"args": ["/
|
|
38
|
+
"command": "npx",
|
|
39
|
+
"args": ["-y", "@tokenlabai/mcp-server"],
|
|
40
40
|
"env": {
|
|
41
41
|
"TOKENLAB_API_BASE": "https://api.tokenlab.sh"
|
|
42
42
|
}
|
|
@@ -45,12 +45,13 @@ Claude Desktop style config:
|
|
|
45
45
|
}
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
No TokenLab API key is required for the public catalog tools. Set `TOKENLAB_API_KEY` only when you want the inference
|
|
48
|
+
No TokenLab API key is required for the public catalog tools. Set `TOKENLAB_API_KEY` only when you want the inference tools to call paid TokenLab APIs. `create_chat_completion` supports OpenAI-compatible messages, multimodal content parts, function calling, and common generation controls. The native tools preserve structured Responses input, Anthropic message blocks and tools, and Gemini contents, multimodal parts, tools, and generation config. The `prompt` shortcuts remain available for simple Anthropic and Gemini calls. MCP tools return a normal JSON result, so streaming is intentionally disabled.
|
|
49
49
|
|
|
50
50
|
## Environment
|
|
51
51
|
|
|
52
52
|
- `TOKENLAB_API_BASE`: optional, defaults to `https://api.tokenlab.sh`
|
|
53
53
|
- `TOKENLAB_API_KEY`: optional; required only for `create_chat_completion`, `create_response`, `create_anthropic_message`, and `create_gemini_content`
|
|
54
|
+
- `TOKENLAB_REQUEST_TIMEOUT_MS`: optional request timeout in milliseconds, defaults to `30000`
|
|
54
55
|
|
|
55
56
|
## MCP Registry Metadata
|
|
56
57
|
|
|
@@ -58,16 +59,18 @@ This repository includes `server.json` for the official MCP Registry.
|
|
|
58
59
|
|
|
59
60
|
Current publication:
|
|
60
61
|
|
|
61
|
-
- npm package: `@tokenlabai/mcp-server@0.
|
|
62
|
+
- npm package: `@tokenlabai/mcp-server@0.3.0`
|
|
62
63
|
- MCP registry name: `io.github.hedging8563/tokenlab`
|
|
63
64
|
- Official MCP Registry status: active
|
|
64
65
|
- `package.json.mcpName`: `io.github.hedging8563/tokenlab`
|
|
65
66
|
|
|
66
67
|
For a new release:
|
|
67
68
|
|
|
68
|
-
1.
|
|
69
|
-
2.
|
|
70
|
-
3.
|
|
69
|
+
1. Bump the matching versions in `package.json`, `package-lock.json`, and `server.json`.
|
|
70
|
+
2. Push a matching tag such as `v0.3.0`.
|
|
71
|
+
3. The publish workflow tests and publishes npm through trusted publishing, then publishes the MCP Registry entry through GitHub Actions OIDC.
|
|
72
|
+
|
|
73
|
+
The same workflow can be run manually from `main` to republish only the current MCP Registry metadata. No npm or MCP Registry token is stored in GitHub.
|
|
71
74
|
|
|
72
75
|
## Links
|
|
73
76
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenlabai/mcp-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for TokenLab model discovery, pricing, OpenAI-compatible Chat Completions, native
|
|
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.",
|
|
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"
|
package/server.json
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.hedging8563/tokenlab",
|
|
4
4
|
"title": "TokenLab",
|
|
5
|
-
"description": "MCP server for
|
|
5
|
+
"description": "TokenLab MCP server for model discovery, pricing, OpenAI-compatible chat, and native endpoints.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"url": "https://github.com/hedging8563/tokenlab-mcp-server",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.3.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@tokenlabai/mcp-server",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.3.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
"isRequired": false,
|
|
30
30
|
"format": "string",
|
|
31
31
|
"isSecret": true
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "TOKENLAB_REQUEST_TIMEOUT_MS",
|
|
35
|
+
"description": "Optional request timeout in milliseconds. Defaults to 30000.",
|
|
36
|
+
"isRequired": false,
|
|
37
|
+
"format": "number"
|
|
32
38
|
}
|
|
33
39
|
]
|
|
34
40
|
}
|
package/src/index.js
CHANGED
|
@@ -4,9 +4,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
|
|
7
|
-
const VERSION = "0.
|
|
8
|
-
const API_BASE = (process.env.TOKENLAB_API_BASE || "https://api.tokenlab.sh").replace(
|
|
7
|
+
const VERSION = "0.3.0";
|
|
8
|
+
const API_BASE = (process.env.TOKENLAB_API_BASE || "https://api.tokenlab.sh").replace(/\/+$/, "");
|
|
9
9
|
const API_KEY = process.env.TOKENLAB_API_KEY || "";
|
|
10
|
+
const configuredTimeoutMs = Number.parseInt(process.env.TOKENLAB_REQUEST_TIMEOUT_MS || "30000", 10);
|
|
11
|
+
const REQUEST_TIMEOUT_MS = Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs > 0
|
|
12
|
+
? configuredTimeoutMs
|
|
13
|
+
: 30_000;
|
|
10
14
|
|
|
11
15
|
const scenes = [
|
|
12
16
|
"image",
|
|
@@ -42,6 +46,21 @@ const chatCompletionToolSchema = z.object({
|
|
|
42
46
|
}).passthrough()
|
|
43
47
|
}).passthrough();
|
|
44
48
|
|
|
49
|
+
const openObjectSchema = z.object({}).passthrough();
|
|
50
|
+
|
|
51
|
+
const anthropicMessageSchema = z.object({
|
|
52
|
+
role: z.enum(["user", "assistant"]),
|
|
53
|
+
content: z.union([
|
|
54
|
+
z.string(),
|
|
55
|
+
z.array(openObjectSchema).min(1)
|
|
56
|
+
])
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const geminiContentSchema = z.object({
|
|
60
|
+
role: z.enum(["user", "model"]).optional(),
|
|
61
|
+
parts: z.array(openObjectSchema).min(1)
|
|
62
|
+
}).passthrough();
|
|
63
|
+
|
|
45
64
|
const server = new McpServer({
|
|
46
65
|
name: "tokenlab",
|
|
47
66
|
version: VERSION
|
|
@@ -65,16 +84,22 @@ async function fetchJson(path, options = {}) {
|
|
|
65
84
|
const response = await fetch(`${API_BASE}${path}`, {
|
|
66
85
|
method: options.method || "GET",
|
|
67
86
|
headers,
|
|
68
|
-
body: options.body ? JSON.stringify(options.body) : undefined
|
|
87
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
88
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
69
89
|
});
|
|
70
90
|
|
|
71
91
|
const text = await response.text();
|
|
72
92
|
|
|
73
93
|
if (!response.ok) {
|
|
74
|
-
|
|
94
|
+
const detail = text.length > 2_000 ? `${text.slice(0, 2_000)}...` : text;
|
|
95
|
+
throw new Error(`TokenLab request failed: ${response.status} ${response.statusText}\n${detail}`);
|
|
75
96
|
}
|
|
76
97
|
|
|
77
|
-
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(text);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error("TokenLab returned a successful response that was not valid JSON.");
|
|
102
|
+
}
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
function textResult(value) {
|
|
@@ -225,50 +250,73 @@ server.tool(
|
|
|
225
250
|
|
|
226
251
|
server.tool(
|
|
227
252
|
"create_response",
|
|
228
|
-
"Create a TokenLab Responses API call. Requires TOKENLAB_API_KEY.",
|
|
253
|
+
"Create a non-streaming TokenLab Responses API call with text or native structured input. Requires TOKENLAB_API_KEY.",
|
|
229
254
|
{
|
|
230
255
|
model: z.string().min(1).describe("Public TokenLab model ID."),
|
|
231
|
-
input: z.
|
|
256
|
+
input: z.union([
|
|
257
|
+
z.string().min(1),
|
|
258
|
+
z.array(openObjectSchema).min(1)
|
|
259
|
+
]).describe("Responses API input as text or native structured input items."),
|
|
232
260
|
instructions: z.string().optional().describe("Optional system/developer instructions."),
|
|
233
|
-
max_output_tokens: z.number().int().min(1).
|
|
261
|
+
max_output_tokens: z.number().int().min(1).optional().describe("Optional output token cap."),
|
|
262
|
+
temperature: z.number().optional().describe("Optional sampling temperature."),
|
|
263
|
+
tools: z.array(openObjectSchema).optional().describe("Native Responses API tool definitions."),
|
|
264
|
+
tool_choice: z.union([z.string(), openObjectSchema]).optional().describe("Tool choice policy or explicit tool selection."),
|
|
265
|
+
reasoning_effort: z.string().optional().describe("Reasoning-effort hint for compatible models."),
|
|
266
|
+
include: z.array(z.string()).optional().describe("Additional response sections to include."),
|
|
267
|
+
service_tier: z.string().nullable().optional().describe("Optional service-tier hint."),
|
|
268
|
+
truncation_strategy: z.string().optional().describe("Optional truncation strategy."),
|
|
269
|
+
seed: z.number().int().optional().describe("Optional deterministic seed."),
|
|
270
|
+
user: z.string().optional().describe("Optional end-user identifier."),
|
|
271
|
+
parallel_tool_calls: z.boolean().optional().describe("Whether the model may issue parallel tool calls."),
|
|
272
|
+
metadata: openObjectSchema.optional().describe("Optional request metadata."),
|
|
273
|
+
text: openObjectSchema.optional().describe("Optional native text formatting configuration.")
|
|
234
274
|
},
|
|
235
|
-
async (
|
|
275
|
+
async (input) => {
|
|
236
276
|
return textResult(await fetchJson("/v1/responses", {
|
|
237
277
|
method: "POST",
|
|
238
278
|
auth: true,
|
|
239
|
-
body:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
...(instructions ? { instructions } : {}),
|
|
243
|
-
...(max_output_tokens ? { max_output_tokens } : {})
|
|
244
|
-
}
|
|
279
|
+
body: Object.fromEntries(
|
|
280
|
+
Object.entries({ ...input, stream: false }).filter(([, value]) => value !== undefined)
|
|
281
|
+
)
|
|
245
282
|
}));
|
|
246
283
|
}
|
|
247
284
|
);
|
|
248
285
|
|
|
249
286
|
server.tool(
|
|
250
287
|
"create_anthropic_message",
|
|
251
|
-
"Create a TokenLab Anthropic Messages call. Requires TOKENLAB_API_KEY.",
|
|
288
|
+
"Create a non-streaming TokenLab Anthropic Messages call with native messages, multimodal blocks, and tools. A prompt shortcut remains available for simple calls. Requires TOKENLAB_API_KEY.",
|
|
252
289
|
{
|
|
253
290
|
model: z.string().min(1).describe("Public TokenLab Claude-compatible model ID."),
|
|
254
|
-
|
|
291
|
+
messages: z.array(anthropicMessageSchema).min(1).optional().describe("Native Anthropic conversation messages."),
|
|
292
|
+
prompt: z.string().min(1).optional().describe("Convenience shortcut for one user text message; do not combine with messages."),
|
|
255
293
|
system: z.string().optional().describe("Optional system prompt."),
|
|
256
|
-
max_tokens: z.number().int().min(1).
|
|
294
|
+
max_tokens: z.number().int().min(1).default(512).describe("Maximum output tokens."),
|
|
295
|
+
temperature: z.number().min(0).max(1).optional().describe("Optional sampling temperature."),
|
|
296
|
+
top_p: z.number().min(0).max(1).optional().describe("Optional nucleus sampling probability."),
|
|
297
|
+
top_k: z.number().int().min(1).optional().describe("Optional top-k sampling cutoff."),
|
|
298
|
+
stop_sequences: z.array(z.string()).optional().describe("Optional stop sequences."),
|
|
299
|
+
tools: z.array(openObjectSchema).optional().describe("Native Anthropic tool definitions."),
|
|
300
|
+
tool_choice: z.union([z.string(), openObjectSchema]).optional().describe("Tool choice policy or explicit tool selection."),
|
|
301
|
+
metadata: openObjectSchema.optional().describe("Optional request metadata."),
|
|
302
|
+
thinking: openObjectSchema.optional().describe("Thinking configuration for compatible models."),
|
|
303
|
+
service_tier: z.string().optional().describe("Optional service-tier hint.")
|
|
257
304
|
},
|
|
258
|
-
async ({
|
|
305
|
+
async ({ prompt, messages, ...input }) => {
|
|
306
|
+
if (prompt && messages) {
|
|
307
|
+
throw new Error("Provide either prompt or messages, not both.");
|
|
308
|
+
}
|
|
309
|
+
if (!prompt && !messages) {
|
|
310
|
+
throw new Error("Provide prompt or at least one native Anthropic message.");
|
|
311
|
+
}
|
|
312
|
+
|
|
259
313
|
return textResult(await fetchJson("/v1/messages", {
|
|
260
314
|
method: "POST",
|
|
261
315
|
auth: true,
|
|
262
316
|
body: {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
messages: [
|
|
267
|
-
{
|
|
268
|
-
role: "user",
|
|
269
|
-
content: prompt
|
|
270
|
-
}
|
|
271
|
-
]
|
|
317
|
+
...input,
|
|
318
|
+
messages: messages || [{ role: "user", content: prompt }],
|
|
319
|
+
stream: false
|
|
272
320
|
}
|
|
273
321
|
}));
|
|
274
322
|
}
|
|
@@ -276,32 +324,42 @@ server.tool(
|
|
|
276
324
|
|
|
277
325
|
server.tool(
|
|
278
326
|
"create_gemini_content",
|
|
279
|
-
"Create a TokenLab Gemini generateContent call. Requires TOKENLAB_API_KEY.",
|
|
327
|
+
"Create a TokenLab Gemini generateContent call with native contents, multimodal parts, generation config, and tools. A prompt shortcut remains available for simple calls. Requires TOKENLAB_API_KEY.",
|
|
280
328
|
{
|
|
281
329
|
model: z.string().min(1).describe("Public TokenLab Gemini-compatible model ID."),
|
|
282
|
-
|
|
283
|
-
|
|
330
|
+
contents: z.array(geminiContentSchema).min(1).optional().describe("Native Gemini conversation contents."),
|
|
331
|
+
prompt: z.string().min(1).optional().describe("Convenience shortcut for one user text part; do not combine with contents."),
|
|
332
|
+
temperature: z.number().min(0).optional().describe("Convenience temperature setting; do not combine with generationConfig.temperature."),
|
|
333
|
+
systemInstruction: openObjectSchema.optional().describe("Native Gemini system instruction."),
|
|
334
|
+
generationConfig: openObjectSchema.optional().describe("Native Gemini generation configuration."),
|
|
335
|
+
safetySettings: z.array(openObjectSchema).optional().describe("Native Gemini safety settings."),
|
|
336
|
+
tools: z.array(openObjectSchema).optional().describe("Native Gemini tools."),
|
|
337
|
+
toolConfig: openObjectSchema.optional().describe("Native Gemini tool configuration."),
|
|
338
|
+
cachedContent: z.string().optional().describe("Optional cached content resource name.")
|
|
284
339
|
},
|
|
285
|
-
async ({ model, prompt, temperature }) => {
|
|
340
|
+
async ({ model, prompt, contents, temperature, generationConfig, ...input }) => {
|
|
341
|
+
if (prompt && contents) {
|
|
342
|
+
throw new Error("Provide either prompt or contents, not both.");
|
|
343
|
+
}
|
|
344
|
+
if (!prompt && !contents) {
|
|
345
|
+
throw new Error("Provide prompt or at least one native Gemini content item.");
|
|
346
|
+
}
|
|
347
|
+
if (temperature !== undefined && generationConfig?.temperature !== undefined) {
|
|
348
|
+
throw new Error("Set temperature either directly or in generationConfig, not both.");
|
|
349
|
+
}
|
|
350
|
+
|
|
286
351
|
return textResult(await fetchJson(`/v1beta/models/${encodeURIComponent(model)}:generateContent`, {
|
|
287
352
|
method: "POST",
|
|
288
353
|
auth: true,
|
|
289
354
|
body: {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
parts: [
|
|
294
|
-
{
|
|
295
|
-
text: prompt
|
|
296
|
-
}
|
|
297
|
-
]
|
|
298
|
-
}
|
|
299
|
-
],
|
|
300
|
-
...(temperature === undefined ? {} : {
|
|
355
|
+
...input,
|
|
356
|
+
contents: contents || [{ role: "user", parts: [{ text: prompt }] }],
|
|
357
|
+
...(generationConfig || temperature !== undefined ? {
|
|
301
358
|
generationConfig: {
|
|
302
|
-
|
|
359
|
+
...generationConfig,
|
|
360
|
+
...(temperature === undefined ? {} : { temperature })
|
|
303
361
|
}
|
|
304
|
-
})
|
|
362
|
+
} : {})
|
|
305
363
|
}
|
|
306
364
|
}));
|
|
307
365
|
}
|
package/test/mcp-tools.test.js
CHANGED
|
@@ -1,72 +1,157 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "node:http";
|
|
3
4
|
import test from "node:test";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
7
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
8
|
+
|
|
9
|
+
async function startMockApi(t) {
|
|
10
|
+
const requests = [];
|
|
11
|
+
const server = createServer(async (request, response) => {
|
|
12
|
+
let body = "";
|
|
13
|
+
for await (const chunk of request) body += chunk;
|
|
14
|
+
|
|
15
|
+
requests.push({
|
|
16
|
+
method: request.method,
|
|
17
|
+
url: request.url,
|
|
18
|
+
authorization: request.headers.authorization,
|
|
19
|
+
body: body ? JSON.parse(body) : undefined
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
response.writeHead(200, { "Content-Type": "application/json" });
|
|
23
|
+
response.end(JSON.stringify({ ok: true }));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
27
|
+
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
28
|
+
|
|
29
|
+
const address = server.address();
|
|
30
|
+
return {
|
|
31
|
+
baseUrl: `http://127.0.0.1:${address.port}`,
|
|
32
|
+
requests
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function startMcpClient(t, env = {}) {
|
|
37
|
+
const transport = new StdioClientTransport({
|
|
38
|
+
command: process.execPath,
|
|
39
|
+
args: ["src/index.js"],
|
|
7
40
|
cwd: process.cwd(),
|
|
8
|
-
|
|
41
|
+
env: { ...process.env, ...env },
|
|
42
|
+
stderr: "pipe"
|
|
9
43
|
});
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
const client = new Client({ name: "tokenlab-mcp-test", version: "0.0.0" });
|
|
45
|
+
|
|
46
|
+
await client.connect(transport);
|
|
47
|
+
t.after(() => client.close());
|
|
48
|
+
return client;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("advertises all catalog, OpenAI-compatible, and native endpoint tools", async (t) => {
|
|
52
|
+
const client = await startMcpClient(t);
|
|
53
|
+
const { tools } = await client.listTools();
|
|
54
|
+
const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
|
|
55
|
+
|
|
56
|
+
assert.deepEqual(Object.keys(byName).sort(), [
|
|
57
|
+
"compare_models",
|
|
58
|
+
"create_anthropic_message",
|
|
59
|
+
"create_chat_completion",
|
|
60
|
+
"create_gemini_content",
|
|
61
|
+
"create_response",
|
|
62
|
+
"get_api_overview",
|
|
63
|
+
"get_model",
|
|
64
|
+
"get_model_pricing",
|
|
65
|
+
"list_models"
|
|
66
|
+
]);
|
|
67
|
+
assert.equal(byName.create_chat_completion.inputSchema.properties.messages.type, "array");
|
|
68
|
+
assert.equal(byName.create_chat_completion.inputSchema.properties.stream, undefined);
|
|
69
|
+
assert.ok(byName.create_response.inputSchema.properties.input.anyOf);
|
|
70
|
+
assert.equal(byName.create_anthropic_message.inputSchema.properties.messages.type, "array");
|
|
71
|
+
assert.equal(byName.create_gemini_content.inputSchema.properties.contents.type, "array");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("forwards native endpoint payloads without flattening their semantics", async (t) => {
|
|
75
|
+
const api = await startMockApi(t);
|
|
76
|
+
const client = await startMcpClient(t, {
|
|
77
|
+
TOKENLAB_API_BASE: api.baseUrl,
|
|
78
|
+
TOKENLAB_API_KEY: "test-key"
|
|
29
79
|
});
|
|
30
80
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
81
|
+
await client.callTool({
|
|
82
|
+
name: "create_response",
|
|
83
|
+
arguments: {
|
|
84
|
+
model: "gpt-5.5",
|
|
85
|
+
input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }],
|
|
86
|
+
tools: [{ type: "function", name: "lookup" }]
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
await client.callTool({
|
|
90
|
+
name: "create_anthropic_message",
|
|
91
|
+
arguments: {
|
|
92
|
+
model: "claude-sonnet-5",
|
|
93
|
+
max_tokens: 1024,
|
|
94
|
+
messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
|
95
|
+
tools: [{ name: "lookup", input_schema: { type: "object" } }]
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
await client.callTool({
|
|
99
|
+
name: "create_gemini_content",
|
|
100
|
+
arguments: {
|
|
101
|
+
model: "gemini-3.5-flash",
|
|
102
|
+
contents: [{ role: "user", parts: [{ text: "Hello" }] }],
|
|
103
|
+
generationConfig: { responseMimeType: "application/json" },
|
|
104
|
+
tools: [{ functionDeclarations: [{ name: "lookup" }] }]
|
|
105
|
+
}
|
|
106
|
+
});
|
|
45
107
|
|
|
46
|
-
|
|
47
|
-
|
|
108
|
+
assert.equal(api.requests.length, 3);
|
|
109
|
+
for (const request of api.requests) {
|
|
110
|
+
assert.equal(request.method, "POST");
|
|
111
|
+
assert.equal(request.authorization, "Bearer test-key");
|
|
48
112
|
}
|
|
49
113
|
|
|
50
|
-
|
|
51
|
-
|
|
114
|
+
assert.equal(api.requests[0].url, "/v1/responses");
|
|
115
|
+
assert.equal(api.requests[0].body.stream, false);
|
|
116
|
+
assert.deepEqual(api.requests[0].body.input[0].content[0], { type: "input_text", text: "Hello" });
|
|
117
|
+
assert.equal(api.requests[1].url, "/v1/messages");
|
|
118
|
+
assert.equal(api.requests[1].body.stream, false);
|
|
119
|
+
assert.equal(api.requests[1].body.messages[0].content[0].type, "text");
|
|
120
|
+
assert.equal(api.requests[2].url, "/v1beta/models/gemini-3.5-flash:generateContent");
|
|
121
|
+
assert.equal(api.requests[2].body.generationConfig.responseMimeType, "application/json");
|
|
122
|
+
assert.equal(api.requests[2].body.tools[0].functionDeclarations[0].name, "lookup");
|
|
123
|
+
});
|
|
52
124
|
|
|
53
|
-
test("
|
|
54
|
-
const
|
|
55
|
-
|
|
125
|
+
test("keeps simple prompt shortcuts and requires auth for inference", async (t) => {
|
|
126
|
+
const api = await startMockApi(t);
|
|
127
|
+
const authenticated = await startMcpClient(t, {
|
|
128
|
+
TOKENLAB_API_BASE: api.baseUrl,
|
|
129
|
+
TOKENLAB_API_KEY: "test-key"
|
|
130
|
+
});
|
|
56
131
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
132
|
+
await authenticated.callTool({
|
|
133
|
+
name: "create_anthropic_message",
|
|
134
|
+
arguments: { model: "claude-sonnet-5", prompt: "Hello", max_tokens: 32 }
|
|
135
|
+
});
|
|
136
|
+
await authenticated.callTool({
|
|
137
|
+
name: "create_gemini_content",
|
|
138
|
+
arguments: { model: "gemini-3.5-flash", prompt: "Hello", temperature: 0.2 }
|
|
61
139
|
});
|
|
62
|
-
assert.equal(initialized.error, undefined);
|
|
63
140
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
141
|
+
assert.equal(api.requests[0].body.messages[0].content, "Hello");
|
|
142
|
+
assert.equal(api.requests[1].body.contents[0].parts[0].text, "Hello");
|
|
143
|
+
assert.equal(api.requests[1].body.generationConfig.temperature, 0.2);
|
|
144
|
+
|
|
145
|
+
const unauthenticated = await startMcpClient(t, { TOKENLAB_API_BASE: api.baseUrl });
|
|
146
|
+
const result = await unauthenticated.callTool({
|
|
147
|
+
name: "create_response",
|
|
148
|
+
arguments: { model: "gpt-5.5", input: "Hello" }
|
|
149
|
+
});
|
|
150
|
+
assert.equal(result.isError, true);
|
|
151
|
+
assert.match(result.content[0].text, /TOKENLAB_API_KEY is required/);
|
|
152
|
+
});
|
|
67
153
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
assert.
|
|
71
|
-
assert.equal(tool.inputSchema.properties.stream, undefined);
|
|
154
|
+
test("ships an executable npm binary", async () => {
|
|
155
|
+
const { mode } = await stat(new URL("../src/index.js", import.meta.url));
|
|
156
|
+
assert.notEqual(mode & 0o111, 0);
|
|
72
157
|
});
|