@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.
@@ -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
- - run: npm ci
28
- - run: npm test
29
- - run: npm publish
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 endpoint guidance, and optional inference helpers.
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": "node",
39
- "args": ["/absolute/path/to/tokenlab-mcp-server/src/index.js"],
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 helper tools to call paid TokenLab APIs. `create_chat_completion` supports OpenAI-compatible messages, multimodal content parts, function calling, and common generation controls. MCP tools return a normal JSON result, so streaming is intentionally disabled.
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.2.1`
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. Publish the next `@tokenlabai/mcp-server` version to npm.
69
- 2. Authenticate with `mcp-publisher login github`.
70
- 3. Update `server.json` and run `mcp-publisher publish`.
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.2.1",
4
- "description": "MCP server for TokenLab model discovery, pricing, OpenAI-compatible Chat Completions, native endpoint guidance, and optional inference helpers.",
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 TokenLab model discovery, pricing, OpenAI-compatible Chat Completions, native endpoints, and inference helpers.",
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.2.1",
10
+ "version": "0.3.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@tokenlabai/mcp-server",
15
- "version": "0.2.1",
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.2.1";
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
- throw new Error(`TokenLab request failed: ${response.status} ${response.statusText}\n${text}`);
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
- return JSON.parse(text);
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.string().min(1).describe("Responses API input text."),
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).max(8192).optional().describe("Optional output token cap.")
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 ({ model, input, instructions, max_output_tokens }) => {
275
+ async (input) => {
236
276
  return textResult(await fetchJson("/v1/responses", {
237
277
  method: "POST",
238
278
  auth: true,
239
- body: {
240
- model,
241
- input,
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
- prompt: z.string().min(1).describe("User prompt text."),
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).max(8192).default(512).describe("Maximum output tokens.")
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 ({ model, prompt, system, max_tokens }) => {
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
- model,
264
- max_tokens,
265
- ...(system ? { system } : {}),
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
- prompt: z.string().min(1).describe("User prompt text."),
283
- temperature: z.number().min(0).max(2).optional().describe("Optional Gemini generation temperature.")
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
- contents: [
291
- {
292
- role: "user",
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
- temperature
359
+ ...generationConfig,
360
+ ...(temperature === undefined ? {} : { temperature })
303
361
  }
304
- })
362
+ } : {})
305
363
  }
306
364
  }));
307
365
  }
@@ -1,72 +1,157 @@
1
1
  import assert from "node:assert/strict";
2
- import { spawn } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import { createServer } from "node:http";
3
4
  import test from "node:test";
4
5
 
5
- function startServer() {
6
- const child = spawn(process.execPath, ["src/index.js"], {
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
- stdio: ["pipe", "pipe", "pipe"]
41
+ env: { ...process.env, ...env },
42
+ stderr: "pipe"
9
43
  });
10
- let nextId = 1;
11
- let buffer = "";
12
- const pending = new Map();
13
-
14
- child.stdout.setEncoding("utf8");
15
- child.stdout.on("data", (chunk) => {
16
- buffer += chunk;
17
- const lines = buffer.split("\n");
18
- buffer = lines.pop();
19
-
20
- for (const line of lines) {
21
- if (!line.trim()) continue;
22
- const message = JSON.parse(line);
23
- const resolve = pending.get(message.id);
24
- if (resolve) {
25
- pending.delete(message.id);
26
- resolve(message);
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
- function request(method, params) {
32
- const id = nextId++;
33
- return new Promise((resolve, reject) => {
34
- const timeout = setTimeout(() => {
35
- pending.delete(id);
36
- reject(new Error(`Timed out waiting for ${method}`));
37
- }, 5_000);
38
- pending.set(id, (message) => {
39
- clearTimeout(timeout);
40
- resolve(message);
41
- });
42
- child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
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
- function notify(method, params) {
47
- child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
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
- return { child, notify, request };
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("advertises an OpenAI-compatible Chat Completions tool", async (t) => {
54
- const server = startServer();
55
- t.after(() => server.child.kill());
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
- const initialized = await server.request("initialize", {
58
- protocolVersion: "2025-11-25",
59
- capabilities: {},
60
- clientInfo: { name: "tokenlab-mcp-test", version: "0.0.0" }
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
- server.notify("notifications/initialized");
65
- const listed = await server.request("tools/list", {});
66
- const tool = listed.result.tools.find(({ name }) => name === "create_chat_completion");
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
- assert.ok(tool);
69
- assert.equal(tool.inputSchema.properties.model.type, "string");
70
- assert.equal(tool.inputSchema.properties.messages.type, "array");
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
  });