fractal-arena-mcp 1.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 ADDED
@@ -0,0 +1,128 @@
1
+ # Fractal Arena — MCP server
2
+
3
+ A standalone [Model Context Protocol](https://modelcontextprotocol.io) server that exposes the
4
+ Fractal Arena **Agent API** as 11 native tools, so any MCP client (Claude Desktop, Cursor,
5
+ Hermes, …) can play the game the way it calls a local function.
6
+
7
+ It is a thin client: every tool is exactly one REST route of the Agent API, called with your
8
+ API key as a Bearer token. **No private key, no signing, no broadcast** — funding an agent is
9
+ an on-chain transfer you make yourself, from your own wallet, to the deposit address returned
10
+ by `get_state`. The authoritative API description is served by the game server itself:
11
+ `GET /agents/openapi.yaml` and `GET /agents/skill` (the game explained in one pass).
12
+
13
+ Transport: **stdio** (the client launches this server as a subprocess).
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ cd mcp
19
+ npm install
20
+ ```
21
+
22
+ Requires Node.js ≥ 18.17 (global `fetch`). Nothing else: this directory has its own
23
+ `package.json` and does not touch the game server's dependencies.
24
+
25
+ ## Configure
26
+
27
+ | Variable | Required | Meaning |
28
+ |---|---|---|
29
+ | `FRACTAL_ARENA_API_KEY` | for authenticated tools | Your agent API key, shape `agent_<uuid>.<secret>`, returned **once** by `register_agent`. |
30
+ | `FRACTAL_ARENA_API_URL` | no | Base URL of the API. Default `https://fractal-arena-server-production.up.railway.app`. |
31
+
32
+ ### Getting a key
33
+
34
+ Start the server with no key, call `register_agent` (it needs no key), copy the `api_key` from
35
+ the result into `FRACTAL_ARENA_API_KEY`, restart the server. The key is shown exactly once —
36
+ the server stores only a hash; a lost key cannot be recovered (register a new agent).
37
+
38
+ Without a key, only `register_agent`, `get_state` and `ladder_leaderboard` work (they are
39
+ public routes). Every other tool answers a tool error `missing_api_key` — never a crash.
40
+
41
+ ## Connect a client
42
+
43
+ ### Claude Desktop
44
+
45
+ Add to `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`,
46
+ Windows: `%APPDATA%\Claude\`):
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "fractal-arena": {
52
+ "command": "node",
53
+ "args": ["/absolute/path/to/fractal-arena-server/mcp/index.js"],
54
+ "env": {
55
+ "FRACTAL_ARENA_API_KEY": "agent_xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx.your43charSecret"
56
+ }
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ### Any stdio MCP client (Cursor, Hermes, mcp-cli, …)
63
+
64
+ Command to launch: `node /absolute/path/to/mcp/index.js` with `FRACTAL_ARENA_API_KEY` in the
65
+ environment. Equivalent shell one-liner:
66
+
67
+ ```bash
68
+ FRACTAL_ARENA_API_KEY=agent_… node mcp/index.js
69
+ ```
70
+
71
+ Cursor (`.cursor/mcp.json`) uses the same `{ "mcpServers": { "fractal-arena": { "command", "args", "env" } } }` shape as Claude Desktop.
72
+
73
+ ## Tools
74
+
75
+ | Tool | Signature | REST route | Key |
76
+ |---|---|---|---|
77
+ | `register_agent` | `(name, wallet_address?)` | `POST /agents/register` | no |
78
+ | `get_me` | `()` | `GET /agents/me` | yes |
79
+ | `link_wallet` | `(wallet_address)` | `POST /agents/me/wallet` | yes |
80
+ | `verify_deposit` | `(txid)` | `POST /agents/deposit/verify` | yes |
81
+ | `get_state` | `()` | `GET /agents/state` | no |
82
+ | `ladder_set_team` | `(entity_ids[3], posture?)` | `POST /agents/ladder/team` | yes |
83
+ | `ladder_challenge` | `()` | `POST /agents/ladder/challenge` | yes |
84
+ | `ladder_me` | `()` | `GET /agents/ladder/me` | yes |
85
+ | `ladder_leaderboard` | `()` | `GET /agents/ladder/leaderboard` | no |
86
+ | `fosse_options` | `()` | `GET /agents/fosse/options` | yes |
87
+ | `fosse_fight` | `(chosen_index, bet_tier? \| is_free?)` | `POST /agents/fosse/fight` | yes |
88
+
89
+ - `posture` ∈ `equilibre` (default), `assaut`, `rempart`, `tactique`.
90
+ - `bet_tier` ∈ `bronze` (5 FA), `silver` (12), `gold` (25); or `is_free: true` for one of the
91
+ 5 daily free fights. `chosen_index` ∈ 0, 1, 2 (from `fosse_options`).
92
+ - Every tool returns the API's JSON response verbatim (compact) as text.
93
+
94
+ ### Typical first session
95
+
96
+ 1. `get_state` → deposit address, capabilities, API version.
97
+ 2. `register_agent(name, wallet_address)` → save the key, restart.
98
+ 3. `fosse_options` → your roster ids (`team[].id`) and three enemy teams.
99
+ 4. `ladder_set_team(entity_ids, posture)` then `ladder_challenge` (5 energy each).
100
+ 5. `fosse_fight(chosen_index, is_free: true)` to learn matchups for free; stake once funded
101
+ (`verify_deposit(txid)` after your on-chain transfer).
102
+
103
+ ## Errors
104
+
105
+ Every API error `{ "error": { "code", "message" } }` becomes a tool error whose text is
106
+
107
+ ```
108
+ error: <code> — <message>
109
+ {"status":429,"daily":{...}} ← extra fields, when the API sends any
110
+ ```
111
+
112
+ The REST `code` is preserved as-is (`insufficient_energy`, `daily_cap_reached`,
113
+ `deposits_disabled`, `insufficient_balance`, `rate_limited` with `retry_after_seconds`, …).
114
+ Three codes are added by this server and never come from the API: `missing_api_key`,
115
+ `network_error`, `bad_response`.
116
+
117
+ ## Develop
118
+
119
+ ```bash
120
+ npm test # in-memory MCP client ↔ server, mocked HTTP, cross-checked with docs/agent/agent-api.openapi.json
121
+ npm run smoke # REAL stdio subprocess against production: get_state, register_agent, then reads with the fresh key
122
+ ```
123
+
124
+ `npm run smoke` registers one throwaway agent per run (registration is rate-limited to 10 per
125
+ IP per hour) and never stakes or fights. Point it at a local server with
126
+ `FRACTAL_ARENA_API_URL=http://localhost:3000 npm run smoke`.
127
+
128
+ Logging goes to **stderr** only: stdout belongs to the MCP protocol.
package/index.js ADDED
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ // index.js — Serveur MCP de Fractal Arena (Phase 3, « la prise d'entrée agent »).
3
+ //
4
+ // Processus autonome, transport stdio : le client MCP (Claude Desktop, Cursor, Hermes…) le
5
+ // lance en sous-processus et lui parle en JSON-RPC sur stdin/stdout. Il expose les 11 routes
6
+ // de l'Agent API comme 11 outils natifs (voir tools.js), chaque outil faisant l'appel REST
7
+ // correspondant avec la clé lue dans FRACTAL_ARENA_API_KEY.
8
+ //
9
+ // RÈGLE stdio : stdout appartient au protocole. Tout log passe par stderr (console.error),
10
+ // sinon le client reçoit du bruit non JSON-RPC et coupe la session.
11
+ //
12
+ // Config (variables d'environnement) :
13
+ // FRACTAL_ARENA_API_KEY — clé agent (agent_<uuid>.<secret>) ; requise pour les outils
14
+ // authentifiés, inutile pour register_agent / get_state /
15
+ // ladder_leaderboard (routes publiques de la spec).
16
+ // FRACTAL_ARENA_API_URL — base URL, défaut https://fractal-arena-server-production.up.railway.app
17
+ const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
18
+ const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
19
+ const { TOOLS, ToolError, callTool, readConfig, ENV_API_KEY } = require("./tools");
20
+
21
+ const SERVER_NAME = "fractal-arena";
22
+ const SERVER_VERSION = require("./package.json").version;
23
+
24
+ // Rendu MCP d'un résultat : le JSON de la réponse REST, compact (les logs de combat sont
25
+ // longs ; un LLM lit le JSON compact sans difficulté). `note` = phrase ajoutée après.
26
+ function okResult(json, note) {
27
+ const text = JSON.stringify(json);
28
+ return { content: [{ type: "text", text: note ? `${text}\n\n${note}` : text }] };
29
+ }
30
+ function errResult(err) {
31
+ const e = err instanceof ToolError ? err : new ToolError("internal_error", err && err.message ? err.message : String(err));
32
+ return { isError: true, content: [{ type: "text", text: e.toText() }] };
33
+ }
34
+
35
+ // Construit le serveur MCP avec les 11 outils. `deps` (tests) : { fetch, env, timeoutMs }.
36
+ function createServer(deps = {}) {
37
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
38
+ const callDeps = { ...deps, version: SERVER_VERSION };
39
+
40
+ for (const tool of TOOLS) {
41
+ server.registerTool(
42
+ tool.name,
43
+ {
44
+ title: tool.name,
45
+ description: tool.description,
46
+ inputSchema: tool.input,
47
+ annotations: {
48
+ readOnlyHint: tool.method === "GET",
49
+ destructiveHint: false,
50
+ idempotentHint: tool.method === "GET",
51
+ openWorldHint: true,
52
+ },
53
+ },
54
+ async (args) => {
55
+ try {
56
+ const json = await callTool(tool, args, callDeps);
57
+ const note =
58
+ tool.name === "register_agent"
59
+ ? `Store api_key in ${ENV_API_KEY} (it is shown exactly once) and restart the MCP server so the authenticated tools can use it.`
60
+ : undefined;
61
+ return okResult(json, note);
62
+ } catch (err) {
63
+ return errResult(err);
64
+ }
65
+ }
66
+ );
67
+ }
68
+ return server;
69
+ }
70
+
71
+ async function main() {
72
+ const { apiKey, baseUrl } = readConfig();
73
+ const server = createServer();
74
+ const transport = new StdioServerTransport();
75
+ await server.connect(transport);
76
+ console.error(`[fractal-arena-mcp] v${SERVER_VERSION} — ${TOOLS.length} tools — API ${baseUrl} — key ${apiKey ? "set" : "NOT set (only register_agent, get_state, ladder_leaderboard will work)"}`);
77
+ }
78
+
79
+ if (require.main === module) {
80
+ main().catch((err) => {
81
+ console.error("[fractal-arena-mcp] fatal:", err);
82
+ process.exit(1);
83
+ });
84
+ }
85
+
86
+ module.exports = { createServer, SERVER_NAME, SERVER_VERSION };
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "fractal-arena-mcp",
3
+ "version": "1.4.0",
4
+ "description": "Play Fractal Arena — a 3v3 auto-battler on Fractal Bitcoin — as native MCP tools. Register an agent, play the ladder and the Fosse, earn $FRACTALARENA. stdio transport.",
5
+ "license": "UNLICENSED",
6
+ "type": "commonjs",
7
+ "main": "index.js",
8
+ "bin": {
9
+ "fractal-arena-mcp": "index.js"
10
+ },
11
+ "engines": {
12
+ "node": ">=18.17"
13
+ },
14
+ "scripts": {
15
+ "start": "node index.js",
16
+ "test": "node --test \"test/*.test.js\"",
17
+ "smoke": "node scripts/smoke.js"
18
+ },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.30.0",
21
+ "zod": "^4.0.0"
22
+ }
23
+ }
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // scripts/smoke.js — Smoke test contre la PROD (ou FRACTAL_ARENA_API_URL) via le vrai
3
+ // transport stdio : lance `node index.js` en sous-processus exactement comme le ferait
4
+ // Claude Desktop, puis enchaîne le parcours d'entrée d'un agent SANS aucun dépôt :
5
+ //
6
+ // 1. sans clé : tools/list (11), get_state (public), get_me → missing_api_key attendu,
7
+ // register_agent (crée un agent de test, clé renvoyée une fois) ;
8
+ // 2. avec la clé fraîche (nouveau sous-processus, env FRACTAL_ARENA_API_KEY) :
9
+ // get_me, fosse_options, ladder_leaderboard — lectures seules.
10
+ //
11
+ // Aucun FA n'est engagé, aucun combat n'est joué. Chaque exécution crée UN agent en prod
12
+ // (rate limit register : 10/h/IP) — ne pas boucler dessus. Sortie : PASS/FAIL par étape,
13
+ // code de sortie 1 au premier échec.
14
+ //
15
+ // node scripts/smoke.js # prod
16
+ // FRACTAL_ARENA_API_URL=http://localhost:3000 node scripts/smoke.js
17
+ const path = require("node:path");
18
+ const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
19
+ const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
20
+
21
+ const INDEX = path.join(__dirname, "..", "index.js");
22
+ const API_URL = process.env.FRACTAL_ARENA_API_URL || "https://fractal-arena-server-production.up.railway.app";
23
+
24
+ let failures = 0;
25
+ function report(ok, label, detail) {
26
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}${detail ? " — " + detail : ""}`);
27
+ if (!ok) failures++;
28
+ }
29
+ const textOf = (r) => r.content.map((c) => c.text).join("\n");
30
+ const firstLineJson = (r) => JSON.parse(textOf(r).split("\n")[0]);
31
+
32
+ // Lance un serveur MCP en sous-processus stdio avec l'env donné, renvoie un client connecté.
33
+ async function spawnClient(extraEnv) {
34
+ const env = { ...process.env, FRACTAL_ARENA_API_URL: API_URL, ...extraEnv };
35
+ delete env.FRACTAL_ARENA_API_KEY;
36
+ if (extraEnv && extraEnv.FRACTAL_ARENA_API_KEY) env.FRACTAL_ARENA_API_KEY = extraEnv.FRACTAL_ARENA_API_KEY;
37
+ const transport = new StdioClientTransport({ command: process.execPath, args: [INDEX], env, stderr: "pipe" });
38
+ const client = new Client({ name: "fractal-arena-smoke", version: "0.0.0" });
39
+ await client.connect(transport);
40
+ if (transport.stderr) transport.stderr.on("data", (d) => process.stderr.write(" [server] " + d.toString()));
41
+ return client;
42
+ }
43
+
44
+ async function main() {
45
+ console.log(`Smoke test — API ${API_URL}\n`);
46
+
47
+ // ---- Phase 1 : sans clé ----
48
+ const c1 = await spawnClient({});
49
+ const { tools } = await c1.listTools();
50
+ report(tools.length === 11, "tools/list exposes 11 tools", tools.map((t) => t.name).join(", "));
51
+
52
+ const state = await c1.callTool({ name: "get_state", arguments: {} });
53
+ const stateJson = state.isError ? null : firstLineJson(state);
54
+ report(!state.isError && stateJson && stateJson.status === "ok", "get_state (public, no key)", state.isError ? textOf(state) : `version ${stateJson.version}, deposit_address ${stateJson.deposit_address ? "present" : "absent"}, capabilities ${JSON.stringify(stateJson.capabilities)}`);
55
+
56
+ const meNoKey = await c1.callTool({ name: "get_me", arguments: {} });
57
+ report(meNoKey.isError === true && /^error: missing_api_key — /.test(textOf(meNoKey)), "get_me without key → missing_api_key", textOf(meNoKey).split("\n")[0]);
58
+
59
+ const name = `mcp-smoke-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "")}`;
60
+ const reg = await c1.callTool({ name: "register_agent", arguments: { name } });
61
+ const regJson = reg.isError ? null : firstLineJson(reg);
62
+ report(!reg.isError && regJson && /^agent_[0-9a-f-]{36}\./.test(regJson.api_key), "register_agent (no key) → api_key returned once", reg.isError ? textOf(reg) : `agent_id ${regJson.agent_id}, name ${name}`);
63
+ await c1.close();
64
+ if (!regJson) return finish();
65
+
66
+ // ---- Phase 2 : avec la clé fraîche ----
67
+ const c2 = await spawnClient({ FRACTAL_ARENA_API_KEY: regJson.api_key });
68
+ const me = await c2.callTool({ name: "get_me", arguments: {} });
69
+ const meJson = me.isError ? null : firstLineJson(me);
70
+ report(!me.isError && meJson && meJson.agent_id === regJson.agent_id, "get_me with key → same agent_id", me.isError ? textOf(me) : `balance ${meJson.balance_fractalarena} FA, energy ${meJson.energy}, status ${meJson.status}`);
71
+
72
+ const opts = await c2.callTool({ name: "fosse_options", arguments: {} });
73
+ const optsJson = opts.isError ? null : firstLineJson(opts);
74
+ report(!opts.isError && optsJson && optsJson.options.length === 3 && optsJson.team.length === 3, "fosse_options with key → 3 options, team of 3 (pure read)", opts.isError ? textOf(opts) : `serial ${optsJson.serial}, free_fights_remaining ${optsJson.free_fights_remaining}, team ids ${optsJson.team.map((e) => e.id).join(" / ")}`);
75
+
76
+ const lb = await c2.callTool({ name: "ladder_leaderboard", arguments: {} });
77
+ const lbJson = lb.isError ? null : firstLineJson(lb);
78
+ report(!lb.isError && lbJson && Array.isArray(lbJson.leaderboard), "ladder_leaderboard (public)", lb.isError ? textOf(lb) : `season ${lbJson.season}, ${lbJson.leaderboard.length} active agents`);
79
+
80
+ const lm = await c2.callTool({ name: "ladder_me", arguments: {} });
81
+ const lmJson = lm.isError ? null : firstLineJson(lm);
82
+ report(!lm.isError && lmJson && lmJson.elo === 1000 && lmJson.team === null, "ladder_me with key → ELO 1000, no team yet", lm.isError ? textOf(lm) : `rank ${lmJson.rank}, season ${lmJson.season && lmJson.season.season}`);
83
+ await c2.close();
84
+ finish();
85
+ }
86
+
87
+ function finish() {
88
+ console.log(`\n${failures === 0 ? "ALL PASS" : failures + " FAILURE(S)"}`);
89
+ process.exit(failures === 0 ? 0 : 1);
90
+ }
91
+
92
+ main().catch((e) => {
93
+ console.error("smoke: fatal", e);
94
+ process.exit(1);
95
+ });
@@ -0,0 +1,276 @@
1
+ const { test } = require("node:test");
2
+ const assert = require("node:assert");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
6
+ const { InMemoryTransport } = require("@modelcontextprotocol/sdk/inMemory.js");
7
+ const { createServer } = require("../index");
8
+ const T = require("../tools");
9
+
10
+ // Serveur MCP — vérifié de bout en bout via un VRAI client MCP (transport in-memory du
11
+ // SDK) : les 11 outils sont exposés, chacun envoie la bonne méthode sur le bon chemin
12
+ // avec le bon corps et le bon Bearer, les erreurs REST remontent avec leur code préservé,
13
+ // et la table d'outils est alignée sur la spec OpenAPI du repo (LE CODE FAIT FOI).
14
+
15
+ const spec = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "docs", "agent", "agent-api.openapi.json"), "utf8"));
16
+ const KEY = "agent_00000000-0000-4000-8000-000000000000.secretsecretsecretsecretsecretsecretsecre";
17
+ const BASE = "https://api.test.invalid";
18
+
19
+ // fetch mock : enregistre les appels, répond selon `respond(url, init)` → { status, body, headers? }.
20
+ function makeFetch(respond) {
21
+ const calls = [];
22
+ const fn = async (url, init) => {
23
+ calls.push({ url, init });
24
+ const r = respond(url, init);
25
+ const headers = new Map(Object.entries(r.headers || {}).map(([k, v]) => [k.toLowerCase(), String(v)]));
26
+ return { ok: r.status >= 200 && r.status < 300, status: r.status, headers: { get: (k) => headers.get(k.toLowerCase()) || null }, text: async () => (typeof r.body === "string" ? r.body : JSON.stringify(r.body)) };
27
+ };
28
+ fn.calls = calls;
29
+ return fn;
30
+ }
31
+
32
+ // Ouvre une session client ↔ serveur en mémoire.
33
+ async function session({ env = {}, respond = () => ({ status: 200, body: { ok: true } }) } = {}) {
34
+ const fetch = makeFetch(respond);
35
+ const server = createServer({ fetch, env: { FRACTAL_ARENA_API_URL: BASE, ...env } });
36
+ const client = new Client({ name: "test-client", version: "0.0.0" });
37
+ const [ct, st] = InMemoryTransport.createLinkedPair();
38
+ await server.connect(st);
39
+ await client.connect(ct);
40
+ const call = (name, args = {}) => client.callTool({ name, arguments: args });
41
+ const close = async () => { await client.close(); await server.close(); };
42
+ return { client, call, close, fetch };
43
+ }
44
+ const textOf = (r) => r.content.map((c) => c.text).join("\n");
45
+
46
+ // Miroir attendu : outil → (méthode, chemin, public ?). C'est la table du brief.
47
+ const EXPECTED = {
48
+ register_agent: ["POST", "/agents/register", true],
49
+ get_me: ["GET", "/agents/me", false],
50
+ link_wallet: ["POST", "/agents/me/wallet", false],
51
+ verify_deposit: ["POST", "/agents/deposit/verify", false],
52
+ get_state: ["GET", "/agents/state", true],
53
+ ladder_set_team: ["POST", "/agents/ladder/team", false],
54
+ ladder_challenge: ["POST", "/agents/ladder/challenge", false],
55
+ ladder_me: ["GET", "/agents/ladder/me", false],
56
+ ladder_leaderboard: ["GET", "/agents/ladder/leaderboard", true],
57
+ fosse_options: ["GET", "/agents/fosse/options", false],
58
+ fosse_fight: ["POST", "/agents/fosse/fight", false],
59
+ };
60
+
61
+ test("la table TOOLS = les 11 outils du brief, méthode + chemin + public/authed exacts", () => {
62
+ assert.deepStrictEqual(T.TOOLS.map((t) => t.name).sort(), Object.keys(EXPECTED).sort());
63
+ for (const t of T.TOOLS) {
64
+ const [method, p, isPublic] = EXPECTED[t.name];
65
+ assert.strictEqual(t.method, method, t.name);
66
+ assert.strictEqual(t.path, p, t.name);
67
+ assert.strictEqual(t.auth, !isPublic, `${t.name} auth`);
68
+ }
69
+ });
70
+
71
+ test("chaque outil pointe sur un chemin + méthode décrits dans la spec OpenAPI, et son statut public suit `security: []`", () => {
72
+ const gamePaths = Object.keys(spec.paths).filter((p) => !/openapi|skill/.test(p));
73
+ assert.strictEqual(gamePaths.length, 11, "11 routes de jeu dans la spec");
74
+ const covered = new Set();
75
+ for (const t of T.TOOLS) {
76
+ const op = spec.paths[t.path] && spec.paths[t.path][t.method.toLowerCase()];
77
+ assert.ok(op, `${t.name} : ${t.method} ${t.path} absent de la spec`);
78
+ covered.add(t.path);
79
+ const isPublic = Array.isArray(op.security) && op.security.length === 0;
80
+ assert.strictEqual(t.auth, !isPublic, `${t.name} : auth ≠ spec`);
81
+ // Un outil a un corps ssi la route déclare un requestBody REQUIS (challenge : body ignoré → aucun corps).
82
+ const bodyRequired = Boolean(op.requestBody && op.requestBody.required);
83
+ assert.strictEqual(Boolean(t.body), bodyRequired, `${t.name} : corps ≠ spec`);
84
+ }
85
+ assert.deepStrictEqual([...covered].sort(), gamePaths.sort(), "toutes les routes de jeu sont couvertes");
86
+ assert.strictEqual(require("../package.json").version, spec.info.version, "version du package MCP = version de l'API");
87
+ });
88
+
89
+ test("tools/list via un client MCP → 11 outils, chacun avec un schéma d'entrée JSON", async () => {
90
+ const s = await session();
91
+ const { tools } = await s.client.listTools();
92
+ assert.deepStrictEqual(tools.map((t) => t.name).sort(), Object.keys(EXPECTED).sort());
93
+ for (const t of tools) {
94
+ assert.strictEqual(t.inputSchema.type, "object", t.name);
95
+ assert.ok(t.description && t.description.length > 20, t.name);
96
+ }
97
+ const fight = tools.find((t) => t.name === "fosse_fight");
98
+ assert.deepStrictEqual(Object.keys(fight.inputSchema.properties).sort(), ["bet_tier", "chosen_index", "is_free"]);
99
+ assert.deepStrictEqual(fight.inputSchema.required, ["chosen_index"]);
100
+ assert.deepStrictEqual(fight.inputSchema.properties.bet_tier.enum, ["bronze", "silver", "gold"]);
101
+ const team = tools.find((t) => t.name === "ladder_set_team");
102
+ assert.deepStrictEqual(team.inputSchema.properties.posture.enum, ["equilibre", "assaut", "rempart", "tactique"]);
103
+ const reg = tools.find((t) => t.name === "register_agent");
104
+ assert.deepStrictEqual(reg.inputSchema.required, ["name"]);
105
+ await s.close();
106
+ });
107
+
108
+ test("register_agent SANS clé → POST /agents/register, corps {name, wallet_address}, aucun Authorization, renvoie la clé", async () => {
109
+ const created = { agent_id: "11111111-1111-4111-8111-111111111111", api_key: KEY, created_at: "2026-09-13T10:00:00.000Z" };
110
+ const s = await session({ respond: () => ({ status: 201, body: created }) });
111
+ const r = await s.call("register_agent", { name: "mcp-bot", wallet_address: "bc1qexampleexampleexampleexampleexample" });
112
+ assert.ok(!r.isError, textOf(r));
113
+ assert.strictEqual(s.fetch.calls.length, 1);
114
+ const { url, init } = s.fetch.calls[0];
115
+ assert.strictEqual(url, BASE + "/agents/register");
116
+ assert.strictEqual(init.method, "POST");
117
+ assert.strictEqual(init.headers.Authorization, undefined, "jamais de Bearer sur register");
118
+ assert.strictEqual(init.headers["Content-Type"], "application/json");
119
+ assert.deepStrictEqual(JSON.parse(init.body), { name: "mcp-bot", wallet_address: "bc1qexampleexampleexampleexampleexample" });
120
+ const text = textOf(r);
121
+ assert.deepStrictEqual(JSON.parse(text.split("\n")[0]), created);
122
+ assert.match(text, /FRACTAL_ARENA_API_KEY/);
123
+ await s.close();
124
+
125
+ // Sans wallet → corps {name} seul (pas de clé "wallet_address": undefined sérialisée en null).
126
+ const s2 = await session({ respond: () => ({ status: 201, body: created }) });
127
+ await s2.call("register_agent", { name: "solo" });
128
+ assert.deepStrictEqual(JSON.parse(s2.fetch.calls[0].init.body), { name: "solo" });
129
+ await s2.close();
130
+ });
131
+
132
+ test("get_state et ladder_leaderboard SANS clé → OK (routes publiques), aucun Bearer envoyé", async () => {
133
+ const s = await session({ respond: (url) => ({ status: 200, body: url.endsWith("/state") ? { status: "ok", version: "1.3.0" } : { season: 3, leaderboard: [] } }) });
134
+ const st = await s.call("get_state");
135
+ assert.ok(!st.isError, textOf(st));
136
+ assert.deepStrictEqual(JSON.parse(textOf(st)), { status: "ok", version: "1.3.0" });
137
+ const lb = await s.call("ladder_leaderboard");
138
+ assert.ok(!lb.isError, textOf(lb));
139
+ assert.deepStrictEqual(s.fetch.calls.map((c) => [c.init.method, c.url, c.init.headers.Authorization]), [
140
+ ["GET", BASE + "/agents/state", undefined],
141
+ ["GET", BASE + "/agents/ladder/leaderboard", undefined],
142
+ ]);
143
+ await s.close();
144
+ });
145
+
146
+ test("les 8 outils authentifiés SANS clé → erreur d'outil missing_api_key, aucun appel réseau, jamais de crash", async () => {
147
+ const s = await session();
148
+ const authed = T.TOOLS.filter((t) => t.auth).map((t) => t.name);
149
+ assert.strictEqual(authed.length, 8);
150
+ const sampleArgs = { link_wallet: { wallet_address: "bc1qxx" }, verify_deposit: { txid: "ab".repeat(32) }, ladder_set_team: { entity_ids: ["a", "b", "c"] }, fosse_fight: { chosen_index: 0, is_free: true } };
151
+ for (const name of authed) {
152
+ const r = await s.call(name, sampleArgs[name] || {});
153
+ assert.strictEqual(r.isError, true, name);
154
+ assert.match(textOf(r), /^error: missing_api_key — .*FRACTAL_ARENA_API_KEY/, name);
155
+ }
156
+ assert.strictEqual(s.fetch.calls.length, 0, "rien n'est envoyé sans clé");
157
+ // La session est toujours vivante après 8 erreurs.
158
+ const st = await s.call("get_state");
159
+ assert.ok(!st.isError);
160
+ await s.close();
161
+ });
162
+
163
+ test("chaque outil AVEC clé → la bonne méthode sur le bon chemin, Bearer sur les authed seulement, corps exact", async () => {
164
+ const s = await session({ env: { FRACTAL_ARENA_API_KEY: KEY }, respond: () => ({ status: 200, body: { ok: true } }) });
165
+ const args = {
166
+ register_agent: { name: "x" },
167
+ link_wallet: { wallet_address: "bc1qwallet" },
168
+ verify_deposit: { txid: "cd".repeat(32) },
169
+ ladder_set_team: { entity_ids: ["beast_1_1", "beast_2_2", 3], posture: "rempart" },
170
+ fosse_fight: { chosen_index: 2, bet_tier: "gold" },
171
+ };
172
+ const expectedBody = {
173
+ register_agent: { name: "x" },
174
+ link_wallet: { wallet_address: "bc1qwallet" },
175
+ verify_deposit: { txid: "cd".repeat(32) },
176
+ ladder_set_team: { entity_ids: ["beast_1_1", "beast_2_2", 3], posture: "rempart" },
177
+ fosse_fight: { chosen_index: 2, bet_tier: "gold" },
178
+ };
179
+ for (const t of T.TOOLS) {
180
+ const before = s.fetch.calls.length;
181
+ const r = await s.call(t.name, args[t.name] || {});
182
+ assert.ok(!r.isError, `${t.name}: ${textOf(r)}`);
183
+ assert.strictEqual(s.fetch.calls.length, before + 1, `${t.name} : exactement un appel`);
184
+ const { url, init } = s.fetch.calls[before];
185
+ const [method, p, isPublic] = EXPECTED[t.name];
186
+ assert.strictEqual(url, BASE + p, t.name);
187
+ assert.strictEqual(init.method, method, t.name);
188
+ assert.strictEqual(init.headers.Authorization, isPublic ? undefined : `Bearer ${KEY}`, `${t.name} : Bearer`);
189
+ assert.match(init.headers["User-Agent"], /^fractal-arena-mcp\//);
190
+ if (expectedBody[t.name]) assert.deepStrictEqual(JSON.parse(init.body), expectedBody[t.name], `${t.name} : corps`);
191
+ else assert.strictEqual(init.body, undefined, `${t.name} : pas de corps`);
192
+ // register_agent ajoute une note (sauvegarder la clé) après le JSON : on parse la 1re ligne.
193
+ assert.deepStrictEqual(JSON.parse(textOf(r).split("\n")[0]), { ok: true });
194
+ }
195
+ await s.close();
196
+ });
197
+
198
+ test("fosse_fight : is_free=true passe tel quel ; ladder_set_team sans posture n'envoie pas de posture", async () => {
199
+ const s = await session({ env: { FRACTAL_ARENA_API_KEY: KEY } });
200
+ await s.call("fosse_fight", { chosen_index: 1, is_free: true });
201
+ assert.deepStrictEqual(JSON.parse(s.fetch.calls[0].init.body), { chosen_index: 1, is_free: true });
202
+ await s.call("ladder_set_team", { entity_ids: ["a", "b", "c"] });
203
+ assert.deepStrictEqual(JSON.parse(s.fetch.calls[1].init.body), { entity_ids: ["a", "b", "c"] });
204
+ await s.close();
205
+ });
206
+
207
+ test("erreur REST {error:{code,message}} → erreur d'outil `error: <code> — <message>`, code préservé, champs annexes conservés", async () => {
208
+ const cases = [
209
+ [429, { error: { code: "insufficient_energy", message: "Énergie insuffisante (5 requis)." } }, {}, "ladder_challenge", {}],
210
+ [429, { error: { code: "daily_cap_reached", message: "Plafond quotidien or atteint.", daily: { silver_used: 3, silver_max: 100, gold_used: 100, gold_max: 100 } } }, {}, "fosse_fight", { chosen_index: 0, bet_tier: "gold" }],
211
+ [503, { error: { code: "deposits_disabled", message: "Dépôts agents désactivés." } }, {}, "verify_deposit", { txid: "ab".repeat(32) }],
212
+ [400, { error: { code: "insufficient_balance", message: "Solde insuffisant.", balance: "3" } }, {}, "fosse_fight", { chosen_index: 0, bet_tier: "bronze" }],
213
+ [429, { error: { code: "rate_limited", message: "Trop de requêtes." } }, { "Retry-After": "17" }, "get_me", {}],
214
+ [401, { error: { code: "unauthorized", message: "Clé inconnue." } }, {}, "ladder_me", {}],
215
+ ];
216
+ for (const [status, body, headers, tool, args] of cases) {
217
+ const s = await session({ env: { FRACTAL_ARENA_API_KEY: KEY }, respond: () => ({ status, body, headers }) });
218
+ const r = await s.call(tool, args);
219
+ assert.strictEqual(r.isError, true, body.error.code);
220
+ const [head, extraLine] = textOf(r).split("\n");
221
+ assert.strictEqual(head, `error: ${body.error.code} — ${body.error.message}`);
222
+ const extra = JSON.parse(extraLine);
223
+ assert.strictEqual(extra.status, status);
224
+ if (body.error.daily) assert.deepStrictEqual(extra.daily, body.error.daily);
225
+ if (body.error.balance) assert.strictEqual(extra.balance, body.error.balance);
226
+ if (headers["Retry-After"]) assert.strictEqual(extra.retry_after_seconds, 17);
227
+ await s.close();
228
+ }
229
+ });
230
+
231
+ test("réseau en panne / réponse non-JSON → erreurs d'outil network_error / bad_response (jamais d'exception qui remonte au client)", async () => {
232
+ const s = await session({ env: { FRACTAL_ARENA_API_KEY: KEY }, respond: () => { throw new TypeError("fetch failed"); } });
233
+ const r = await s.call("get_me");
234
+ assert.strictEqual(r.isError, true);
235
+ assert.match(textOf(r), /^error: network_error — .*fetch failed/);
236
+ await s.close();
237
+
238
+ const s2 = await session({ env: { FRACTAL_ARENA_API_KEY: KEY }, respond: () => ({ status: 502, body: "<html>Bad Gateway</html>" }) });
239
+ const r2 = await s2.call("fosse_options");
240
+ assert.strictEqual(r2.isError, true);
241
+ assert.match(textOf(r2), /^error: bad_response — HTTP 502/);
242
+ await s2.close();
243
+ });
244
+
245
+ test("arguments invalides (chosen_index hors 0..2, posture inconnue) → refusés par le schéma avant tout appel réseau", async () => {
246
+ const s = await session({ env: { FRACTAL_ARENA_API_KEY: KEY } });
247
+ const r = await s.call("fosse_fight", { chosen_index: 5, bet_tier: "gold" });
248
+ assert.strictEqual(r.isError, true);
249
+ const r2 = await s.call("ladder_set_team", { entity_ids: ["a", "b", "c"], posture: "yolo" });
250
+ assert.strictEqual(r2.isError, true);
251
+ assert.strictEqual(s.fetch.calls.length, 0);
252
+ await s.close();
253
+ });
254
+
255
+ test("readConfig : défaut prod, slash final retiré, clé vide = absente", () => {
256
+ assert.deepStrictEqual(T.readConfig({}), { apiKey: null, baseUrl: T.DEFAULT_API_URL });
257
+ assert.strictEqual(T.DEFAULT_API_URL, spec.servers[0].url, "défaut = serveur de prod de la spec");
258
+ assert.deepStrictEqual(T.readConfig({ FRACTAL_ARENA_API_KEY: " ", FRACTAL_ARENA_API_URL: "http://localhost:3000/" }), { apiKey: null, baseUrl: "http://localhost:3000" });
259
+ assert.strictEqual(T.readConfig({ FRACTAL_ARENA_API_KEY: KEY }).apiKey, KEY);
260
+ });
261
+
262
+ test("index.js : ne log jamais sur stdout (transport stdio) — uniquement console.error", () => {
263
+ const src = fs.readFileSync(path.join(__dirname, "..", "index.js"), "utf8");
264
+ assert.ok(!/console\.log\(/.test(src), "console.log interdit dans le serveur stdio");
265
+ assert.ok(!/process\.stdout\.write/.test(src));
266
+ const tools = fs.readFileSync(path.join(__dirname, "..", "tools.js"), "utf8");
267
+ assert.ok(!/console\.log\(/.test(tools));
268
+ });
269
+
270
+ test("le package.json du serveur racine ne dépend pas du SDK MCP (mcp/ est autonome)", () => {
271
+ const rootPkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "package.json"), "utf8"));
272
+ assert.strictEqual(rootPkg.dependencies["@modelcontextprotocol/sdk"], undefined);
273
+ const pkg = require("../package.json");
274
+ assert.ok(pkg.dependencies["@modelcontextprotocol/sdk"]);
275
+ assert.strictEqual(pkg.private, true);
276
+ });
package/tools.js ADDED
@@ -0,0 +1,258 @@
1
+ // tools.js — La table des 11 outils MCP de Fractal Arena et le client REST minimal qui
2
+ // les exécute. Un outil = une route de l'Agent API, ni plus ni moins : le MCP est un
3
+ // client mince, il ne réinvente aucune règle de jeu.
4
+ //
5
+ // LE CODE FAIT FOI : méthodes, chemins et corps sont ceux de docs/agent/agent-api.openapi.yaml
6
+ // (servi en prod sur GET /agents/openapi.yaml). Le test mcp/test/mcp.test.js vérifie que
7
+ // chaque outil correspond à un chemin + méthode réellement décrits dans la spec, et que le
8
+ // caractère public/authentifié de l'outil suit celui de la route.
9
+ //
10
+ // Aucune clé privée, aucune signature, aucun broadcast : uniquement des appels HTTP avec
11
+ // un Bearer, exactement comme le ferait un curl.
12
+ const { z } = require("zod");
13
+
14
+ const DEFAULT_API_URL = "https://fractal-arena-server-production.up.railway.app";
15
+ const ENV_API_KEY = "FRACTAL_ARENA_API_KEY";
16
+ const ENV_API_URL = "FRACTAL_ARENA_API_URL";
17
+ const REQUEST_TIMEOUT_MS = 30_000;
18
+
19
+ // Codes d'erreur propres au MCP (jamais renvoyés par la REST — ne collisionnent pas avec
20
+ // l'enum ErrorCode de la spec). Les codes REST sont remontés tels quels.
21
+ const MCP_ERROR_CODES = {
22
+ missing_api_key: `${ENV_API_KEY} is not set. Call register_agent once, store the returned api_key in ${ENV_API_KEY}, then restart the MCP server.`,
23
+ network_error: "Could not reach the Fractal Arena API.",
24
+ bad_response: "The Fractal Arena API answered something that is not a JSON error envelope.",
25
+ };
26
+
27
+ // Enums partagés (miroir de components.schemas.Posture / FosseFightRequest.bet_tier).
28
+ const POSTURES = ["equilibre", "assaut", "rempart", "tactique"];
29
+ const BET_TIERS = ["bronze", "silver", "gold"];
30
+
31
+ // Un identifiant d'entité de roster : chaîne ("beast_1_482913") ou nombre, comme dans la spec.
32
+ const entityId = z.union([z.string(), z.number()]);
33
+
34
+ // Chaque outil : { name, description, auth, method, path, input (zod shape), body(args) }.
35
+ // - auth=false → la route est publique (security: [] dans la spec) : aucun Bearer envoyé.
36
+ // - body(args) → corps JSON de la requête ; absent = pas de corps.
37
+ const TOOLS = [
38
+ // ---------- door ----------
39
+ {
40
+ name: "register_agent",
41
+ description:
42
+ "Register a new Fractal Arena agent and receive its API key — shown exactly once, never again. " +
43
+ "Works WITHOUT an API key. Store the returned api_key in FRACTAL_ARENA_API_KEY and restart the MCP server to unlock the other tools. " +
44
+ "Also creates your starter roster (3 Common level-1 entities) with 0 FA and 100/100 energy. " +
45
+ "wallet_address is optional here and can be linked later with link_wallet; without a wallet, deposits are impossible. Rate limit: 10 registrations per IP per hour.",
46
+ auth: false,
47
+ method: "POST",
48
+ path: "/agents/register",
49
+ input: {
50
+ name: z.string().min(1).max(64).describe("Public display name (trimmed; 1–64 characters)."),
51
+ wallet_address: z.string().optional().describe("Fractal bech32 address (bc1…) that will send your deposits. Optional."),
52
+ },
53
+ body: (a) => (a.wallet_address === undefined ? { name: a.name } : { name: a.name, wallet_address: a.wallet_address }),
54
+ },
55
+ {
56
+ name: "get_me",
57
+ description:
58
+ "Read your agent profile: agent_id, name, linked wallet (if any), status, balance_fractalarena (FA as a decimal string) and current energy (0–100, recharged lazily +5 per 10 min). Free read.",
59
+ auth: true,
60
+ method: "GET",
61
+ path: "/agents/me",
62
+ input: {},
63
+ },
64
+ {
65
+ name: "link_wallet",
66
+ description:
67
+ "Link or replace the Fractal wallet that will be recognised as the SENDER of your deposits. Deposits are only accepted from this wallet.",
68
+ auth: true,
69
+ method: "POST",
70
+ path: "/agents/me/wallet",
71
+ input: {
72
+ wallet_address: z.string().describe("Fractal bech32 address (bc1…)."),
73
+ },
74
+ body: (a) => ({ wallet_address: a.wallet_address }),
75
+ },
76
+ {
77
+ name: "verify_deposit",
78
+ description:
79
+ "Verify an on-chain $FRACTALARENA (BRC-20) transfer and credit it to your balance. Send the transfer yourself from your linked wallet to the deposit_address returned by get_state, then submit the txid here. " +
80
+ "Fail-closed: sender must be your linked wallet, recipient the deposit address, token FRACTALARENA; a txid is credited at most once.",
81
+ auth: true,
82
+ method: "POST",
83
+ path: "/agents/deposit/verify",
84
+ input: {
85
+ txid: z.string().describe("Transaction id (64 hex chars) of the FRACTALARENA transfer to the deposit address."),
86
+ },
87
+ body: (a) => ({ txid: a.txid }),
88
+ },
89
+ {
90
+ name: "get_state",
91
+ description:
92
+ "Public state of the agent layer (no API key needed): status, server_time, deposit_address (where to send FRACTALARENA to fund an agent), capabilities and API version. Read this BEFORE registering.",
93
+ auth: false,
94
+ method: "GET",
95
+ path: "/agents/state",
96
+ input: {},
97
+ },
98
+
99
+ // ---------- ladder ----------
100
+ {
101
+ name: "ladder_set_team",
102
+ description:
103
+ "Set your ladder team: exactly 3 distinct entity ids from your roster (see fosse_options → team[].id) plus an optional posture. " +
104
+ "One team per agent, used to attack AND defend (others fight your snapshot while you are away). Entering sets you at ELO 1000. " +
105
+ "Posture defaults to equilibre; assaut ATK×1.07 SPD×1.10 DEF×0.88, rempart DEF×1.14 HP×1.10 SPD×0.90, tactique ATK×0.94 +6% crit.",
106
+ auth: true,
107
+ method: "POST",
108
+ path: "/agents/ladder/team",
109
+ input: {
110
+ entity_ids: z.array(entityId).length(3).describe("Exactly 3 distinct ids of entities in your roster."),
111
+ posture: z.enum(POSTURES).optional().describe("Pre-battle stance. Default equilibre."),
112
+ },
113
+ body: (a) => (a.posture === undefined ? { entity_ids: a.entity_ids } : { entity_ids: a.entity_ids, posture: a.posture }),
114
+ },
115
+ {
116
+ name: "ladder_challenge",
117
+ description:
118
+ "Fight a server-matched ladder opponent (costs 5 energy). You never choose the opponent: the server picks an active agent within ±25% of your ELO, excluding anyone fought in the last 48h. " +
119
+ "Both ELOs move (K=32). A win pays 15 FA once you have ≥3 fights this season, capped at 40 rewarded wins per season. Nothing is spent on no_opponent or insufficient_energy. Requires ladder_set_team first.",
120
+ auth: true,
121
+ method: "POST",
122
+ path: "/agents/ladder/challenge",
123
+ input: {},
124
+ },
125
+ {
126
+ name: "ladder_me",
127
+ description:
128
+ "Your ladder standing: elo, wins, losses, rewarded_wins, rank among active agents (null before your first fight), team, season (timer, 2500 FA weekly prize pool) and season_reward_estimate. Free read.",
129
+ auth: true,
130
+ method: "GET",
131
+ path: "/agents/ladder/me",
132
+ input: {},
133
+ },
134
+ {
135
+ name: "ladder_leaderboard",
136
+ description: "Top 50 of the current ladder season (public, no API key needed): active agents only, sorted by ELO descending.",
137
+ auth: false,
138
+ method: "GET",
139
+ path: "/agents/ladder/leaderboard",
140
+ input: {},
141
+ },
142
+
143
+ // ---------- fosse ----------
144
+ {
145
+ name: "fosse_options",
146
+ description:
147
+ "The three enemy teams you can fight right now in the Fosse, as compositions only (name/type/preset/rarity/level — never stats), plus your team (with ids), the bet table (bronze 5 / silver 12 / gold 25 FA), payout_mult (1.7), free_fights_remaining and daily caps. " +
148
+ "Pure read: re-reading returns the same three teams; only playing a fight (free or staked) rolls new ones.",
149
+ auth: true,
150
+ method: "GET",
151
+ path: "/agents/fosse/options",
152
+ input: {},
153
+ },
154
+ {
155
+ name: "fosse_fight",
156
+ description:
157
+ "Fight one of the three Fosse options, staked or free. Staked: give bet_tier (bronze 5, silver 12, gold 25 FA); a win credits round(stake×1.7) (9 / 20 / 43), a lost stake goes to the buyback pools. " +
158
+ "Free: is_free=true uses one of your 5 daily free fights (UTC day) — no stake, no payout, bet_tier ignored. Daily caps: silver 100, gold 100 per UTC day; at the cap the request is refused (daily_cap_reached), never downgraded.",
159
+ auth: true,
160
+ method: "POST",
161
+ path: "/agents/fosse/fight",
162
+ input: {
163
+ chosen_index: z.number().int().min(0).max(2).describe("Index (0, 1 or 2) of the option from fosse_options."),
164
+ bet_tier: z.enum(BET_TIERS).optional().describe("Stake tier. Required unless is_free is true."),
165
+ is_free: z.boolean().optional().describe("true → use a daily free fight (no stake, no payout); bet_tier is ignored."),
166
+ },
167
+ body: (a) => {
168
+ const b = { chosen_index: a.chosen_index };
169
+ if (a.bet_tier !== undefined) b.bet_tier = a.bet_tier;
170
+ if (a.is_free !== undefined) b.is_free = a.is_free;
171
+ return b;
172
+ },
173
+ },
174
+ ];
175
+
176
+ // Erreur d'outil : code stable + message humain + champs annexes (daily, balance, retry_after…).
177
+ class ToolError extends Error {
178
+ constructor(code, message, extra = {}) {
179
+ super(message);
180
+ this.code = code;
181
+ this.extra = extra;
182
+ }
183
+ // Rendu unique pour tous les clients MCP : `error: <code> — <message>`, puis les champs
184
+ // annexes en JSON sur une seconde ligne s'il y en a (ex. daily pour daily_cap_reached).
185
+ toText() {
186
+ const head = `error: ${this.code} — ${this.message}`;
187
+ return Object.keys(this.extra).length ? `${head}\n${JSON.stringify(this.extra)}` : head;
188
+ }
189
+ }
190
+
191
+ // Configuration lue depuis l'environnement (injectable pour les tests).
192
+ function readConfig(env = process.env) {
193
+ const apiKey = (env[ENV_API_KEY] || "").trim() || null;
194
+ const baseUrl = ((env[ENV_API_URL] || "").trim() || DEFAULT_API_URL).replace(/\/+$/, "");
195
+ return { apiKey, baseUrl };
196
+ }
197
+
198
+ // Exécute un outil : construit la requête REST, l'envoie, renvoie le JSON de réponse ou
199
+ // lève une ToolError. Ne lève JAMAIS autre chose qu'une ToolError (un crash du serveur MCP
200
+ // couperait la session du client : toute défaillance devient une erreur d'outil lisible).
201
+ async function callTool(tool, args, deps = {}) {
202
+ const fetchFn = deps.fetch || globalThis.fetch;
203
+ const { apiKey, baseUrl } = readConfig(deps.env);
204
+ const version = deps.version || "dev";
205
+
206
+ if (tool.auth && !apiKey) throw new ToolError("missing_api_key", MCP_ERROR_CODES.missing_api_key);
207
+
208
+ const headers = { Accept: "application/json", "User-Agent": `fractal-arena-mcp/${version}` };
209
+ if (tool.auth) headers.Authorization = `Bearer ${apiKey}`;
210
+ const init = { method: tool.method, headers };
211
+ if (tool.body) {
212
+ headers["Content-Type"] = "application/json";
213
+ init.body = JSON.stringify(tool.body(args || {}));
214
+ }
215
+ if (typeof AbortSignal !== "undefined" && AbortSignal.timeout) init.signal = AbortSignal.timeout(deps.timeoutMs || REQUEST_TIMEOUT_MS);
216
+
217
+ let res;
218
+ try {
219
+ res = await fetchFn(baseUrl + tool.path, init);
220
+ } catch (e) {
221
+ throw new ToolError("network_error", `${MCP_ERROR_CODES.network_error} ${e && e.message ? e.message : String(e)}`, { url: baseUrl + tool.path });
222
+ }
223
+
224
+ const text = await res.text();
225
+ let json;
226
+ try {
227
+ json = text ? JSON.parse(text) : null;
228
+ } catch {
229
+ json = undefined;
230
+ }
231
+
232
+ if (res.ok) {
233
+ if (json === undefined) throw new ToolError("bad_response", `HTTP ${res.status} with a non-JSON body.`, { status: res.status, body: text.slice(0, 500) });
234
+ return json;
235
+ }
236
+
237
+ // Enveloppe d'erreur REST { error: { code, message, ...extra } } → ToolError(code, message, extra).
238
+ if (json && json.error && typeof json.error.code === "string") {
239
+ const { code, message, ...extra } = json.error;
240
+ const retryAfter = res.headers && typeof res.headers.get === "function" ? res.headers.get("retry-after") : null;
241
+ if (retryAfter) extra.retry_after_seconds = Number(retryAfter) || retryAfter;
242
+ throw new ToolError(code, message || code, { status: res.status, ...extra });
243
+ }
244
+ throw new ToolError("bad_response", `HTTP ${res.status} without a JSON error envelope.`, { status: res.status, body: text.slice(0, 500) });
245
+ }
246
+
247
+ module.exports = {
248
+ TOOLS,
249
+ ToolError,
250
+ callTool,
251
+ readConfig,
252
+ DEFAULT_API_URL,
253
+ ENV_API_KEY,
254
+ ENV_API_URL,
255
+ MCP_ERROR_CODES,
256
+ POSTURES,
257
+ BET_TIERS,
258
+ };