champollion-mcp-server 0.1.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.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * forge_* — drive nmt-forge (the monorepo forge/ training suite) from an
3
+ * agent, one guarded step at a time.
4
+ *
5
+ * get_training_guardrails answers "what are the rules?"; these tools answer
6
+ * "where am I and what do I run next?" against a real workspace. Every tool
7
+ * shells out to the forge CLI with `--json` and relays the structured result;
8
+ * forge's refusals (what/why/fix) pass through verbatim, because a refusal is
9
+ * the product — it is the guard doing its job.
10
+ *
11
+ * The long-running GPU job (`nmt-forge run`) is deliberately NOT a tool: it
12
+ * would blow past any MCP client timeout. forge_status hands back the exact
13
+ * `run` command and the watcher guidance instead; forge_evaluate closes the
14
+ * loop after the run completes (decode+score+diagnose — CPU-cheap).
15
+ *
16
+ * Resolution: the forge package is invoked as `python3 -m nmt_forge.cli` with
17
+ * PYTHONPATH pointed at the forge/ directory (monorepo sibling of mcp-server,
18
+ * overridable via CHAMPOLLION_FORGE_DIR). An installed console script can be
19
+ * forced with NMT_FORGE_BIN. The Python interpreter is PYTHON_BIN or python3.
20
+ */
21
+
22
+ import { spawn } from 'node:child_process';
23
+ import { resolve, dirname } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const HERE = dirname(fileURLToPath(import.meta.url));
27
+ // src/tools/forge.js → mcp-server root is two levels up
28
+ const MCP_ROOT = resolve(HERE, '../..');
29
+
30
+ /** Absolute path to the forge/ package directory. */
31
+ export function forgeDir() {
32
+ return process.env.CHAMPOLLION_FORGE_DIR || resolve(MCP_ROOT, '../forge');
33
+ }
34
+
35
+ /**
36
+ * Build the argv + env to invoke a forge subcommand — PURE, unit-testable.
37
+ *
38
+ * The global `--workspace` flag precedes the subcommand (argparse layout).
39
+ * Callers pass already-validated positionals/flags in `subArgs`.
40
+ *
41
+ * @param {string[]} subArgs subcommand + its args, e.g. ['status', '--json']
42
+ * @param {{workspace?: string}} [opts]
43
+ * @returns {{cmd: string, args: string[], env: object, cwd: string}}
44
+ */
45
+ export function buildForgeInvocation(subArgs, { workspace } = {}) {
46
+ const ws = workspace || process.env.CHAMPOLLION_FORGE_WORKSPACE || '.forge';
47
+ const globals = ['--workspace', ws];
48
+ const dir = forgeDir();
49
+ if (process.env.NMT_FORGE_BIN) {
50
+ return {
51
+ cmd: process.env.NMT_FORGE_BIN,
52
+ args: [...globals, ...subArgs],
53
+ env: { ...process.env },
54
+ cwd: process.cwd(),
55
+ };
56
+ }
57
+ return {
58
+ cmd: process.env.PYTHON_BIN || 'python3',
59
+ args: ['-m', 'nmt_forge.cli', ...globals, ...subArgs],
60
+ env: {
61
+ ...process.env,
62
+ PYTHONPATH: process.env.PYTHONPATH
63
+ ? `${dir}:${process.env.PYTHONPATH}`
64
+ : dir,
65
+ },
66
+ cwd: process.cwd(),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Spawn forge, capture output. No shell. stdin ignored (MCP stdio parent's
72
+ * stdin is the JSON-RPC stream — a child must never read it).
73
+ *
74
+ * @param {string[]} subArgs
75
+ * @param {{workspace?: string, timeout?: number}} [opts]
76
+ * @returns {Promise<{code: number, stdout: string, stderr: string}>}
77
+ */
78
+ export function runForge(subArgs, { workspace, timeout = 120_000 } = {}) {
79
+ const { cmd, args, env, cwd } = buildForgeInvocation(subArgs, { workspace });
80
+ return new Promise((resolvePromise, reject) => {
81
+ const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], env, cwd, timeout });
82
+ let stdout = '';
83
+ let stderr = '';
84
+ proc.stdout.on('data', (d) => { stdout += d; });
85
+ proc.stderr.on('data', (d) => { stderr += d; });
86
+ proc.on('close', (code) => {
87
+ // The most common cold-start failure is python not finding the forge
88
+ // package at all — translate the raw traceback into the fix.
89
+ if ((code ?? 1) !== 0 && /No module named '?nmt_forge'?/.test(stderr)) {
90
+ stderr += '\n[forge] nmt_forge is not importable. forge is part of the '
91
+ + 'Champollion monorepo (not on PyPI): clone '
92
+ + 'https://github.com/gamedaysuits/Champollion and point '
93
+ + 'CHAMPOLLION_FORGE_DIR at its forge/ directory. Scoring also needs '
94
+ + 'the eval harness: pip install mt-eval';
95
+ }
96
+ resolvePromise({ code: code ?? 1, stdout, stderr });
97
+ });
98
+ proc.on('error', (err) => reject(new Error(
99
+ `could not launch forge (${cmd}): ${err.message}. Is forge/ present `
100
+ + `(set CHAMPOLLION_FORGE_DIR) and python3 available?`)));
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Run a forge subcommand and shape the MCP result. Parses stdout as JSON when
106
+ * the subcommand emits JSON (most do with --json / _print); otherwise relays
107
+ * text. A nonzero exit is surfaced as isError with the guard's message.
108
+ *
109
+ * @param {string[]} subArgs
110
+ * @param {{workspace?: string, parseJson?: boolean, nextHint?: string}} [opts]
111
+ * @returns {Promise<{content: Array, isError: boolean}>}
112
+ */
113
+ export async function forgeTool(subArgs, { workspace, parseJson = true, nextHint } = {}) {
114
+ let res;
115
+ try {
116
+ res = await runForge(subArgs, { workspace });
117
+ } catch (err) {
118
+ return { content: [{ type: 'text', text: err.message }], isError: true };
119
+ }
120
+ if (res.code !== 0) {
121
+ // forge writes what/why/fix to stderr on a ForgeError (exit 2)
122
+ const msg = (res.stderr || res.stdout || 'forge exited nonzero').trim();
123
+ return {
124
+ content: [{ type: 'text', text: `forge refused (exit ${res.code}):\n${msg}` }],
125
+ isError: true,
126
+ };
127
+ }
128
+ let payload = res.stdout.trim();
129
+ if (parseJson) {
130
+ try {
131
+ const parsed = JSON.parse(payload);
132
+ const out = { result: parsed };
133
+ if (nextHint) out.next = nextHint;
134
+ payload = JSON.stringify(out, null, 2);
135
+ } catch {
136
+ // not JSON (some commands print human text) — relay as-is
137
+ }
138
+ }
139
+ return { content: [{ type: 'text', text: payload }], isError: false };
140
+ }