Клиент — замороженный объект с семью методами GET, без универсального запроса и тела; денежные поля проверяются на строковый тип (fail-closed). Каталог джойнит /api/models и /v1/models: модальность и рубли из первого, USD и доступность ключу из второго. Инструменты: list_models, pick_model, estimate_cost, get_balance, get_usage, list_api_keys. Точка входа stdio уводит console.log/info в stderr первым импортом. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
101 lines
4.8 KiB
TypeScript
101 lines
4.8 KiB
TypeScript
// Вызов инструментов через настоящий MCP-протокол (in-memory транспорт) поверх
|
||
// клиента с фейковым fetch на фикстурах: проверяет связку zod-схем, дефолтов,
|
||
// кэша каталога и формы результатов.
|
||
|
||
import { test } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js";
|
||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import { createTtlCache } from "../src/cache.js";
|
||
import { CATALOG_TTL_MS, buildCatalog } from "../src/catalog.js";
|
||
import { createClient } from "../src/client.js";
|
||
import { registerAllTools } from "../src/tools/index.js";
|
||
import { fixture } from "./helpers.js";
|
||
|
||
const KEY = "orr_live_Ab3dEfSECRETSECRETSECRETSECRET";
|
||
|
||
const FIXTURES: Record<string, string> = {
|
||
"/v1/models": "v1-models",
|
||
"/api/models": "api-models",
|
||
"/api/balance": "api-balance",
|
||
"/api/usage": "api-usage",
|
||
"/api/keys": "api-keys",
|
||
};
|
||
|
||
async function setup(status = 200) {
|
||
const counts: Record<string, number> = {};
|
||
const fetchImpl = (async (input: string | URL | Request) => {
|
||
const path = new URL(String(input)).pathname;
|
||
counts[path] = (counts[path] ?? 0) + 1;
|
||
if (status !== 200) return new Response(JSON.stringify({ error: { code: "invalid_key" } }), { status });
|
||
return new Response(JSON.stringify(fixture(FIXTURES[path] ?? "missing")), { status: 200 });
|
||
}) as typeof fetch;
|
||
const client = createClient({ apiKey: KEY, baseUrl: "https://example.test" }, fetchImpl);
|
||
const server = new McpServer({ name: "openrusrouter", version: "0.0.0" });
|
||
registerAllTools(server, {
|
||
client,
|
||
catalog: createTtlCache(CATALOG_TTL_MS, () => buildCatalog(client)),
|
||
apiKey: KEY,
|
||
});
|
||
const [a, b] = InMemoryTransport.createLinkedPair();
|
||
const mcp = new McpClient({ name: "t", version: "0" });
|
||
await Promise.all([server.connect(a), mcp.connect(b)]);
|
||
return { mcp, counts };
|
||
}
|
||
|
||
type Result = { isError?: boolean; structuredContent?: Record<string, unknown>; content: { text: string }[] };
|
||
|
||
async function call(mcp: McpClient, name: string, args: Record<string, unknown> = {}): Promise<Result> {
|
||
return (await mcp.callTool({ name, arguments: args })) as unknown as Result;
|
||
}
|
||
|
||
test("list_models с дефолтами: только text, по цене, каталог кэшируется", async () => {
|
||
const { mcp, counts } = await setup();
|
||
const r = await call(mcp, "list_models");
|
||
assert.notEqual(r.isError, true);
|
||
const sc = r.structuredContent as { total: number; items: { id: string; modality: string }[] };
|
||
assert.equal(sc.total, 6);
|
||
assert.ok(sc.items.every((m) => m.modality === "text"));
|
||
assert.equal(sc.items[0]?.id, "nvidia/nemotron-3.5-super-vl:free");
|
||
assert.deepEqual(JSON.parse(r.content[0]?.text ?? ""), sc);
|
||
await call(mcp, "list_models", { modality: "image", limit: 1, offset: 1 });
|
||
assert.equal(counts["/api/models"], 1);
|
||
assert.equal(counts["/v1/models"], 1);
|
||
});
|
||
|
||
test("pick_model и estimate_cost", async () => {
|
||
const { mcp } = await setup();
|
||
const p = await call(mcp, "pick_model", { max_prompt_rub_1m: "9", limit: 1 });
|
||
const items = (p.structuredContent as { items: { id: string; why: string }[] }).items;
|
||
assert.equal(items.length, 1);
|
||
assert.equal(items[0]?.id, "nvidia/nemotron-3.5-super-vl:free");
|
||
|
||
const e = await call(mcp, "estimate_cost", { model_id: "openai/whisper-1", prompt_tokens: 10 });
|
||
assert.equal(e.isError, true);
|
||
const missing = await call(mcp, "estimate_cost", { model_id: "no/such", prompt_tokens: 10 });
|
||
assert.equal(missing.isError, true);
|
||
assert.match(missing.content[0]?.text ?? "", /не найдена в каталоге/);
|
||
});
|
||
|
||
test("get_balance, get_usage, list_api_keys", async () => {
|
||
const { mcp } = await setup();
|
||
const b = (await call(mcp, "get_balance")).structuredContent ?? {};
|
||
assert.equal(b.balance_rub, "1234.56789012");
|
||
assert.equal(b.referral_balance_rub, "0");
|
||
const u = (await call(mcp, "get_usage", { days: 7 })).structuredContent ?? {};
|
||
assert.equal(u.total_rub, "1.29459022");
|
||
const k = await call(mcp, "list_api_keys");
|
||
assert.ok(!(k.content[0]?.text ?? "").includes(KEY));
|
||
const keys = (k.structuredContent as { keys: { is_current_key: boolean }[] }).keys;
|
||
assert.deepEqual(keys.map((x) => x.is_current_key), [true, false, false]);
|
||
});
|
||
|
||
test("ошибка API превращается в isError без утечки ключа", async () => {
|
||
const { mcp } = await setup(401);
|
||
const r = await call(mcp, "get_balance");
|
||
assert.equal(r.isError, true);
|
||
assert.match(r.content[0]?.text ?? "", /ключ недействителен/);
|
||
assert.ok(!(r.content[0]?.text ?? "").includes(KEY));
|
||
});
|