Добавь HTTP-клиент, каталог моделей и шесть read-only инструментов MCP

Клиент — замороженный объект с семью методами 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>
This commit is contained in:
vasyanskandClaude Opus 5.5 committed 2026-10-24 10:59:06 +07:00
1 parent ade6eb82df
commit d7d02267e2
18 files changed
+1935

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
// Вызов инструментов через настоящий 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));
});