@brass-lantern/mcp 2.3.0 → 2.3.1
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 +1 -1
- package/dist/cli.js +6 -3
- package/dist/docs/pages/guide/coding-agents.md +7 -3
- package/dist/engine.js +5 -3
- package/dist/job.d.ts +22 -0
- package/dist/job.js +29 -0
- package/dist/load.js +3 -2
- package/dist/run.d.ts +16 -0
- package/dist/run.js +57 -0
- package/dist/server.d.ts +27 -3
- package/dist/server.js +28 -27
- package/dist/stdio.d.ts +6 -0
- package/dist/stdio.js +26 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Or in any client’s JSON config:
|
|
|
18
18
|
{ "mcpServers": { "brass-lantern": { "command": "npx", "args": ["-y", "@brass-lantern/mcp"] } } }
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Node 24 or later. World paths are relative to where the client starts the server. Worlds must be inside that directory or one you allow with `--root <dir>` (repeatable).
|
|
21
|
+
Node 24 or later. World paths are relative to where the client starts the server. Worlds must be inside that directory or one you allow with `--root <dir>` (repeatable). Each `audit_world` and `play` call runs in its own worker thread and is stopped after 10 seconds; `--timeout <seconds>` changes that.
|
|
22
22
|
|
|
23
23
|
## Tools
|
|
24
24
|
|
package/dist/cli.js
CHANGED
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
// The stdio entry point an MCP client launches (`npx -y @brass-lantern/mcp`). Nothing may be
|
|
3
3
|
// written to stdout except through the transport: it carries the protocol.
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
|
-
import * as bundled from '@brass-lantern/engine';
|
|
6
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
6
|
import { createServer } from "./server.js";
|
|
8
|
-
import { keepRunningOnCrash, parseRoots, quietStdout } from "./stdio.js";
|
|
7
|
+
import { keepRunningOnCrash, parseRoots, parseTimeout, quietStdout } from "./stdio.js";
|
|
9
8
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
|
+
// Each audit_world and play runs in its own worker (stdout piped to stderr); these two are a
|
|
10
|
+
// second layer, for anything that still runs on the main thread.
|
|
10
11
|
quietStdout();
|
|
11
12
|
// A world's late throw (a timer, a stray promise) is logged to stderr instead of ending the server.
|
|
12
13
|
keepRunningOnCrash();
|
|
13
14
|
// --root <dir> (repeatable) allows worlds from another directory besides the launch directory.
|
|
14
15
|
const roots = parseRoots(process.argv.slice(2), process.cwd());
|
|
15
|
-
|
|
16
|
+
// --timeout <seconds> bounds one audit_world or play call (default 10).
|
|
17
|
+
const timeoutMs = parseTimeout(process.argv.slice(2));
|
|
18
|
+
const server = createServer({ roots, cwd: process.cwd(), timeoutMs, timeoutHint: 'Raise the limit with --timeout <seconds>.', version: pkg.version });
|
|
16
19
|
await server.connect(new StdioServerTransport());
|
|
@@ -109,12 +109,16 @@ The “not understood by the parser” note marks input the regex parser can’t
|
|
|
109
109
|
|
|
110
110
|
The server audits and plays a world with the engine the world’s own project uses, so a project on 2.2 is checked by 2.2’s rules. It looks for `@brass-lantern/engine` from the world file. If the project has none, the server uses the engine bundled with it, and the reply says “bundled with the MCP server” without further comment. If the project’s engine is found but can’t be used (it can’t be resolved or fails to import, or lacks `createGame` or `auditWorld`), the server falls back to the bundled one and adds a note saying why. Every reply states the engine version and whose it is, so you always know what you were checked against.
|
|
111
111
|
|
|
112
|
-
A world file is re-read on every call, so an edit shows on the next one
|
|
112
|
+
A world file is re-read on every call, so an edit shows on the next one: each `audit_world` and `play` call runs in a fresh worker thread, which loads the world, the project files it imports and the packages it uses from scratch.
|
|
113
113
|
|
|
114
114
|
### Safety and limits
|
|
115
115
|
|
|
116
116
|
The server **runs the world module it is pointed at**, with your permissions, the same way your test runner runs your tests. Point it at code you trust. It speaks only over local stdio and makes no network calls of its own. A world file is only loaded when it is inside the directory the server started in or one you passed with `--root`, compared after resolving symlinks; anything else is refused with a message naming `--root`. That check confines which world file is loaded, not what the module then imports or does: any module inside an allowed directory can be run. It is not a sandbox.
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
Each `audit_world` and `play` call runs in its own worker thread, with a deadline:
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
- A world that takes longer than 10 seconds (a script that loops forever, say) is stopped, and the call returns an error saying so. Start the server with `--timeout <seconds>` to allow longer.
|
|
121
|
+
- A world that calls `process.exit()` ends only its worker; the call returns an error, and the server keeps answering. So does a world that crashes.
|
|
122
|
+
- Whatever a world writes to stdout (`console.log`, `process.stdout.write`, its own `Console`) goes to the server’s stderr, so it can’t break the protocol.
|
|
123
|
+
|
|
124
|
+
The worker is not a sandbox either: it runs with the server’s permissions, and a world that writes straight to file descriptor 1 (`fs.writeSync(1, …)`) still reaches the protocol stream. In a worker `process.chdir()` and `process.abort()` throw, but `process.kill(process.pid)` still ends the whole server, and there is no memory limit per world.
|
package/dist/engine.js
CHANGED
|
@@ -21,9 +21,11 @@ export async function resolveEngine(worldFile, bundled) {
|
|
|
21
21
|
}
|
|
22
22
|
catch (error) {
|
|
23
23
|
const { code, message } = error;
|
|
24
|
-
// Silent only for a genuine “not installed
|
|
25
|
-
//
|
|
26
|
-
|
|
24
|
+
// Silent only for a genuine “not installed”, whose message quotes the bare specifier. Anything
|
|
25
|
+
// else (a broken exports map, a malformed package.json, an exports target that’s missing, whose
|
|
26
|
+
// message names a path inside the package) means the project has an engine we couldn’t use,
|
|
27
|
+
// which the agent should know.
|
|
28
|
+
if (code === 'MODULE_NOT_FOUND' && typeof message === 'string' && message.includes(`'@brass-lantern/engine'`))
|
|
27
29
|
return fallback();
|
|
28
30
|
return fallback(`couldn’t resolve the project’s @brass-lantern/engine (${typeof message === 'string' ? message.split('\n')[0] : String(error)}); used the bundled engine`);
|
|
29
31
|
}
|
package/dist/job.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Engine } from './engine.ts';
|
|
2
|
+
/** One audit_world or play call: everything a worker needs, all structured-cloneable. */
|
|
3
|
+
export interface Job {
|
|
4
|
+
op: 'audit' | 'play';
|
|
5
|
+
/** The world path as the agent gave it (already checked against the roots). */
|
|
6
|
+
path: string;
|
|
7
|
+
exportName?: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
commands?: string[];
|
|
10
|
+
seed?: number;
|
|
11
|
+
}
|
|
12
|
+
/** A tool reply in plain form. */
|
|
13
|
+
export interface JobResult {
|
|
14
|
+
text: string;
|
|
15
|
+
isError: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Loads the world and its engine and runs the report; any failure becomes an error result. The
|
|
19
|
+
* worker calls this with the engine it imported; it runs the world's code in whatever thread
|
|
20
|
+
* calls it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function runJob(job: Job, bundled: Engine): Promise<JobResult>;
|
package/dist/job.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { resolveEngine } from "./engine.js";
|
|
2
|
+
import { loadWorld, WorldLoadError } from "./load.js";
|
|
3
|
+
import { auditReport, header, playReport } from "./tools.js";
|
|
4
|
+
/**
|
|
5
|
+
* Loads the world and its engine and runs the report; any failure becomes an error result. The
|
|
6
|
+
* worker calls this with the engine it imported; it runs the world's code in whatever thread
|
|
7
|
+
* calls it.
|
|
8
|
+
*/
|
|
9
|
+
export async function runJob(job, bundled) {
|
|
10
|
+
let loaded;
|
|
11
|
+
try {
|
|
12
|
+
loaded = await loadWorld(job.path, job.exportName, job.cwd);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error instanceof WorldLoadError)
|
|
16
|
+
return { text: error.message, isError: true };
|
|
17
|
+
return { text: `The world couldn’t be loaded: ${error instanceof Error ? error.message : String(error)}`, isError: true };
|
|
18
|
+
}
|
|
19
|
+
let resolved;
|
|
20
|
+
try {
|
|
21
|
+
resolved = await resolveEngine(loaded.file, bundled);
|
|
22
|
+
const text = job.op === 'audit' ? auditReport(loaded, resolved) : playReport(loaded, resolved, job.commands ?? [], job.seed);
|
|
23
|
+
return { text, isError: false };
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
const lines = resolved ? [...header(loaded, resolved), ''] : [];
|
|
27
|
+
return { text: `${lines.join('\n')}${lines.length ? '\n' : ''}The engine threw: ${error instanceof Error ? error.message : String(error)}`, isError: true };
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/load.js
CHANGED
|
@@ -24,8 +24,9 @@ export function isWorldShaped(value) {
|
|
|
24
24
|
* Bare specifiers (packages) are never touched, whatever their real path: a symlinked package
|
|
25
25
|
* (workspaces, npm link) has no /node_modules/ in its resolved URL, and re-instantiating it per
|
|
26
26
|
* load would leak its whole graph. So packages stay cached and are not reloaded; each call keeps
|
|
27
|
-
* one generation of the world's own project files for
|
|
28
|
-
*
|
|
27
|
+
* one generation of the world's own project files for as long as the loader lives. The server
|
|
28
|
+
* loads each world in a fresh worker thread, so there the hook and those modules last one call.
|
|
29
|
+
* The hook only touches modules whose importer carries the query; nothing else is affected.
|
|
29
30
|
*/
|
|
30
31
|
const FRESH_HOOK = `
|
|
31
32
|
export async function resolve(specifier, context, next) {
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Job, JobResult } from './job.ts';
|
|
2
|
+
/** How long a world gets by default: 10 s. */
|
|
3
|
+
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
4
|
+
/** The longest delay setTimeout honors (2^31 − 1 ms, about 24.8 days); past it, Node fires after 1 ms. */
|
|
5
|
+
export declare const MAX_TIMEOUT_MS = 2147483647;
|
|
6
|
+
/**
|
|
7
|
+
* Runs a job in a fresh worker thread, so a world that loops forever, calls process.exit(),
|
|
8
|
+
* crashes or writes to stdout can't freeze, end or corrupt the server. The worker's stdout and
|
|
9
|
+
* stderr go to this process's stderr. Never rejects: every outcome (a worker that can't even
|
|
10
|
+
* start included) is a result. `timeoutHint` ends the timeout message; `workerUrl` is for tests.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runInWorker(job: Job, { timeoutMs: requested, timeoutHint, workerUrl }?: {
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
timeoutHint?: string;
|
|
15
|
+
workerUrl?: URL;
|
|
16
|
+
}): Promise<JobResult>;
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
/** How long a world gets by default: 10 s. */
|
|
3
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
4
|
+
/** The longest delay setTimeout honors (2^31 − 1 ms, about 24.8 days); past it, Node fires after 1 ms. */
|
|
5
|
+
export const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
6
|
+
// From source (tests, the repo) the worker is TypeScript and the engine resolves to its source;
|
|
7
|
+
// published, both are plain JavaScript in dist/.
|
|
8
|
+
const fromSource = import.meta.url.endsWith('.ts');
|
|
9
|
+
const WORKER_URL = new URL(fromSource ? './worker.ts' : './worker.js', import.meta.url);
|
|
10
|
+
// Published, the worker deliberately gets no flags: an empty execArgv stops it inheriting the
|
|
11
|
+
// server's own (process.execArgv), which a world's worker has no business running under.
|
|
12
|
+
const EXEC_ARGV = fromSource ? ['--import', 'tsx', '--conditions=@brass-lantern/source'] : [];
|
|
13
|
+
/** How long a worker that has answered may linger before it is stopped. */
|
|
14
|
+
const GRACE_MS = 500;
|
|
15
|
+
const seconds = (ms) => String(Number((ms / 1000).toFixed(3)));
|
|
16
|
+
/**
|
|
17
|
+
* Runs a job in a fresh worker thread, so a world that loops forever, calls process.exit(),
|
|
18
|
+
* crashes or writes to stdout can't freeze, end or corrupt the server. The worker's stdout and
|
|
19
|
+
* stderr go to this process's stderr. Never rejects: every outcome (a worker that can't even
|
|
20
|
+
* start included) is a result. `timeoutHint` ends the timeout message; `workerUrl` is for tests.
|
|
21
|
+
*/
|
|
22
|
+
export function runInWorker(job, { timeoutMs: requested = DEFAULT_TIMEOUT_MS, timeoutHint = 'Raise the server’s timeout.', workerUrl = WORKER_URL } = {}) {
|
|
23
|
+
// NaN means the default; anything past setTimeout's limit (Infinity included) is capped.
|
|
24
|
+
const timeoutMs = Number.isNaN(requested) ? DEFAULT_TIMEOUT_MS : Math.min(Math.max(requested, 1), MAX_TIMEOUT_MS);
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
let worker;
|
|
27
|
+
try {
|
|
28
|
+
worker = new Worker(workerUrl, { workerData: job, stdout: true, stderr: true, execArgv: EXEC_ARGV });
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
resolve({ isError: true, text: `The world runner couldn’t start: ${error instanceof Error ? error.message : String(error)}` });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Forwarded chunk by chunk rather than piped, so concurrent calls add no listeners to stderr.
|
|
35
|
+
const forward = (chunk) => process.stderr.write(chunk);
|
|
36
|
+
worker.stdout.on('data', forward);
|
|
37
|
+
worker.stderr.on('data', forward);
|
|
38
|
+
let settled = false;
|
|
39
|
+
const settle = (result, grace = GRACE_MS) => {
|
|
40
|
+
if (settled)
|
|
41
|
+
return;
|
|
42
|
+
settled = true;
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
resolve(result);
|
|
45
|
+
// A world may leave timers running: once the answer is in, the worker gets a moment to exit
|
|
46
|
+
// on its own (flushing its output to stderr), then goes.
|
|
47
|
+
if (grace === 0)
|
|
48
|
+
void worker.terminate();
|
|
49
|
+
else
|
|
50
|
+
setTimeout(() => void worker.terminate(), grace).unref();
|
|
51
|
+
};
|
|
52
|
+
const timer = setTimeout(() => settle({ isError: true, text: `The world didn’t finish within ${seconds(timeoutMs)} s (a script may loop forever). ${timeoutHint}` }, 0), timeoutMs);
|
|
53
|
+
worker.on('message', (result) => settle(typeof result?.text === 'string' ? { text: result.text, isError: result.isError === true } : { isError: true, text: 'The world crashed: its worker sent something other than a result.' }));
|
|
54
|
+
worker.on('error', (error) => settle({ isError: true, text: `The world crashed: ${error instanceof Error ? error.message : String(error)}` }));
|
|
55
|
+
worker.on('exit', (code) => settle({ isError: true, text: `The world ended before answering (exit code ${code}); a world shouldn’t call process.exit() or leave a top-level await pending.` }));
|
|
56
|
+
});
|
|
57
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import {
|
|
2
|
+
import type { Engine } from './engine.ts';
|
|
3
|
+
import { type Job, type JobResult } from './job.ts';
|
|
3
4
|
export { quietStdout } from './stdio.ts';
|
|
5
|
+
export type { Job, JobResult } from './job.ts';
|
|
4
6
|
export interface ServerOptions {
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* @deprecated Only used by a custom runJob; pass `runJob: inProcessRunner(engine)` to run
|
|
9
|
+
* in-process with your engine (without the timeout). The default runner’s worker imports this
|
|
10
|
+
* package’s own @brass-lantern/engine (an engine can’t cross threads).
|
|
11
|
+
*/
|
|
12
|
+
bundled?: Engine;
|
|
7
13
|
/** Where the docs bundle is. Default: the one shipped with this package (dist/docs). */
|
|
8
14
|
docsDir?: string;
|
|
9
15
|
/** Relative world paths resolve against this (the client’s launch directory). */
|
|
@@ -11,7 +17,25 @@ export interface ServerOptions {
|
|
|
11
17
|
/** Worlds must live inside one of these directories (symlinks resolved). Default: [cwd]. */
|
|
12
18
|
roots?: string[];
|
|
13
19
|
version: string;
|
|
20
|
+
/**
|
|
21
|
+
* How long one audit_world or play call may run before its worker is stopped, in milliseconds.
|
|
22
|
+
* Default 10 s. Must be a positive number (createServer throws a RangeError otherwise); past
|
|
23
|
+
* 2147483647 (about 24.8 days, setTimeout’s limit) it is capped.
|
|
24
|
+
*/
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
/** The last sentence of the timeout message, saying how to raise the limit. Default: “Raise the server’s timeout.” */
|
|
27
|
+
timeoutHint?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Runs one audit_world or play call. Default: a fresh worker thread per call with the deadline
|
|
30
|
+
* above, so a world can’t freeze, end or write over the server.
|
|
31
|
+
*/
|
|
32
|
+
runJob?: (job: Job) => Promise<JobResult>;
|
|
14
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* A runJob that runs each call in this thread with `engine` as the fallback engine: the 2.3.0
|
|
36
|
+
* behavior. No timeout and no isolation, so a world that loops forever freezes the server.
|
|
37
|
+
*/
|
|
38
|
+
export declare function inProcessRunner(engine: Engine): (job: Job) => Promise<JobResult>;
|
|
15
39
|
/**
|
|
16
40
|
* The brass-lantern MCP server, unconnected: pass it a transport. Stdout-based transports also
|
|
17
41
|
* want quietStdout(), so a world’s console output can’t corrupt the protocol.
|
package/dist/server.js
CHANGED
|
@@ -4,13 +4,21 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { docsReport, readDocs } from "./docs.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { runJob as runJobWith } from "./job.js";
|
|
8
|
+
import { DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS, runInWorker } from "./run.js";
|
|
9
|
+
import { MAX_COMMANDS } from "./tools.js";
|
|
10
10
|
export { quietStdout } from "./stdio.js";
|
|
11
11
|
const reply = (text, isError = false) => ({ content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) });
|
|
12
12
|
const PATH = z.string().min(1).describe('The world module: a .ts, .mts, .js or .mjs file, relative to the directory the server was started in, or absolute.');
|
|
13
13
|
const EXPORT = z.string().min(1).optional().describe('Which export is the world. Default: the default export, else the only world-shaped export.');
|
|
14
|
+
/**
|
|
15
|
+
* A runJob that runs each call in this thread with `engine` as the fallback engine: the 2.3.0
|
|
16
|
+
* behavior. No timeout and no isolation, so a world that loops forever freezes the server.
|
|
17
|
+
*/
|
|
18
|
+
export function inProcessRunner(engine) {
|
|
19
|
+
return (job) => runJobWith(job, engine);
|
|
20
|
+
}
|
|
21
|
+
let bundledNoted = false;
|
|
14
22
|
/**
|
|
15
23
|
* The brass-lantern MCP server, unconnected: pass it a transport. Stdout-based transports also
|
|
16
24
|
* want quietStdout(), so a world’s console output can’t corrupt the protocol.
|
|
@@ -38,34 +46,27 @@ export function createServer(options) {
|
|
|
38
46
|
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
39
47
|
});
|
|
40
48
|
};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
resolved = await resolveEngine(loaded.file, options.bundled);
|
|
57
|
-
return reply(report(loaded, resolved));
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
const lines = resolved ? [...header(loaded, resolved), ''] : [];
|
|
61
|
-
return reply(`${lines.join('\n')}${lines.length ? '\n' : ''}The engine threw: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
62
|
-
}
|
|
49
|
+
if (options.bundled && !options.runJob && !bundledNoted) {
|
|
50
|
+
bundledNoted = true;
|
|
51
|
+
process.stderr.write('brass-lantern-mcp: the bundled option is ignored (each call runs in a worker with this package’s engine); pass runJob: inProcessRunner(engine) to use your engine in-process.\n');
|
|
52
|
+
}
|
|
53
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
54
|
+
if (!(timeoutMs > 0))
|
|
55
|
+
throw new RangeError(`timeoutMs must be a positive number of milliseconds (it is capped at ${MAX_TIMEOUT_MS})`);
|
|
56
|
+
const { timeoutHint } = options;
|
|
57
|
+
const runJob = options.runJob ?? ((job) => runInWorker(job, { timeoutMs, timeoutHint }));
|
|
58
|
+
/** Checks the roots here (an outside path never reaches the runner), then runs the job. */
|
|
59
|
+
async function withWorld(job) {
|
|
60
|
+
if (!allowed(job.path))
|
|
61
|
+
return reply(`${job.path}: outside the allowed directories (${roots.join(', ')}). Start the server with --root <dir> to allow another.`, true);
|
|
62
|
+
const result = await runJob(job);
|
|
63
|
+
return reply(result.text, result.isError);
|
|
63
64
|
}
|
|
64
65
|
server.registerTool('audit_world', {
|
|
65
66
|
title: 'Audit a Brass Lantern world',
|
|
66
67
|
description: 'Checks a world file the way the engine’s auditWorld does: exits that go nowhere, conditions that don’t parse, effects naming things that don’t exist, unmapped flag labels and more. Reports the engine version used.',
|
|
67
68
|
inputSchema: { path: PATH, export: EXPORT },
|
|
68
|
-
}, ({ path, export: exportName }) => withWorld(path, exportName,
|
|
69
|
+
}, ({ path, export: exportName }) => withWorld({ op: 'audit', path, exportName, cwd: options.cwd }));
|
|
69
70
|
server.registerTool('play', {
|
|
70
71
|
title: 'Play commands in a Brass Lantern world',
|
|
71
72
|
description: `Starts a fresh game (seeded, so runs repeat exactly) and types each command, as a player would, with no LLM. Returns the transcript and the final state (room, inventory, flags, score, moves, game over). Each call starts from the beginning; at most ${MAX_COMMANDS} commands.`,
|
|
@@ -75,7 +76,7 @@ export function createServer(options) {
|
|
|
75
76
|
commands: z.array(z.string()).min(1).describe('Commands to type, in order (e.g. ["open drawer", "take key", "north"]).'),
|
|
76
77
|
seed: z.number().int().optional().describe('Random seed. Default 1.'),
|
|
77
78
|
},
|
|
78
|
-
}, ({ path, export: exportName, commands, seed }) => withWorld(path, exportName,
|
|
79
|
+
}, ({ path, export: exportName, commands, seed }) => withWorld({ op: 'play', path, exportName, cwd: options.cwd, commands, seed }));
|
|
79
80
|
server.registerTool('docs', {
|
|
80
81
|
title: 'Brass Lantern documentation',
|
|
81
82
|
description: 'The docs for this version: with no arguments, the index of pages; with “page”, one page’s markdown (e.g. reference/world-schema); with “query”, the best-matching sections. If both are given, “page” wins.',
|
package/dist/stdio.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export declare function quietStdout(): () => void;
|
|
|
11
11
|
* against `cwd`. A trailing `--root` with no directory is ignored, with a warning through `warn`.
|
|
12
12
|
*/
|
|
13
13
|
export declare function parseRoots(args: readonly string[], cwd: string, warn?: (message: string) => void): string[];
|
|
14
|
+
/**
|
|
15
|
+
* How long one audit_world or play call may run, in milliseconds: `--timeout <seconds>` (the last
|
|
16
|
+
* one wins), else 10 s. A missing, non-numeric or non-positive value is ignored with a warning;
|
|
17
|
+
* one past setTimeout's limit (2147483 s, about 24.8 days) is capped at it, with a warning.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseTimeout(args: readonly string[], warn?: (message: string) => void): number;
|
|
14
20
|
/**
|
|
15
21
|
* A world can throw after its tool call has returned (a timer, a stray promise). Node's default
|
|
16
22
|
* would end the server mid-session; instead, log the error to stderr and keep serving. Returns a
|
package/dist/stdio.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Console } from 'node:console';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
+
import { DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS } from "./run.js";
|
|
3
4
|
/**
|
|
4
5
|
* Stdout carries the MCP protocol, so a world's console output (at load or while a script runs)
|
|
5
6
|
* would corrupt it. Swaps the global console for one whose stdout and stderr are both
|
|
@@ -32,6 +33,31 @@ export function parseRoots(args, cwd, warn = (m) => process.stderr.write(`${m}\n
|
|
|
32
33
|
}
|
|
33
34
|
return roots;
|
|
34
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* How long one audit_world or play call may run, in milliseconds: `--timeout <seconds>` (the last
|
|
38
|
+
* one wins), else 10 s. A missing, non-numeric or non-positive value is ignored with a warning;
|
|
39
|
+
* one past setTimeout's limit (2147483 s, about 24.8 days) is capped at it, with a warning.
|
|
40
|
+
*/
|
|
41
|
+
export function parseTimeout(args, warn = (m) => process.stderr.write(`${m}\n`)) {
|
|
42
|
+
const DEFAULT = DEFAULT_TIMEOUT_MS;
|
|
43
|
+
const MAX_MS = MAX_TIMEOUT_MS;
|
|
44
|
+
let ms = DEFAULT;
|
|
45
|
+
for (let i = 0; i < args.length; i++) {
|
|
46
|
+
if (args[i] !== '--timeout')
|
|
47
|
+
continue;
|
|
48
|
+
const value = args[i + 1];
|
|
49
|
+
const seconds = value === undefined || value.trim() === '' ? NaN : Number(value);
|
|
50
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
51
|
+
ms = Math.min(seconds * 1000, MAX_MS);
|
|
52
|
+
if (seconds * 1000 > MAX_MS)
|
|
53
|
+
warn(`brass-lantern-mcp: --timeout is capped at ${Math.floor(MAX_MS / 1000)} seconds`);
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
warn(`brass-lantern-mcp: --timeout needs a positive number of seconds; using ${DEFAULT / 1000}`);
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
return ms;
|
|
60
|
+
}
|
|
35
61
|
/**
|
|
36
62
|
* A world can throw after its tool call has returned (a timer, a stray promise). Node's default
|
|
37
63
|
* would end the server mid-session; instead, log the error to stderr and keep serving. Returns a
|
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The worker entry: runs one audit_world or play job (workerData) and posts the result back.
|
|
2
|
+
// Its stdout and stderr are piped to the server's stderr, and run.ts terminates it on the deadline.
|
|
3
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
4
|
+
import * as bundled from '@brass-lantern/engine';
|
|
5
|
+
import { runJob } from "./job.js";
|
|
6
|
+
parentPort?.postMessage(await runJob(workerData, bundled));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brass-lantern/mcp",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"author": "Todd Greco <mrballistic@gmail.com>",
|
|
5
5
|
"description": "A Model Context Protocol server for brass-lantern: lets a coding agent audit, play and read the docs for the text-adventure world it is writing.",
|
|
6
6
|
"license": "MIT",
|