@brass-lantern/mcp 0.0.1-stage → 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/LICENSE +21 -0
- package/README.md +37 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +19 -0
- package/dist/docs/llms.txt +35 -0
- package/dist/docs/pages/guide/building-worlds/index.md +218 -0
- package/dist/docs/pages/guide/building-worlds/recipes.md +1406 -0
- package/dist/docs/pages/guide/building-worlds/two-rooms.md +339 -0
- package/dist/docs/pages/guide/coding-agents.md +124 -0
- package/dist/docs/pages/guide/deploying.md +56 -0
- package/dist/docs/pages/guide/getting-started.md +72 -0
- package/dist/docs/pages/guide/how-it-works.md +152 -0
- package/dist/docs/pages/guide/intent-server.md +92 -0
- package/dist/docs/pages/guide/porting-zork.md +115 -0
- package/dist/docs/pages/guide/testing.md +100 -0
- package/dist/docs/pages/guide/using-the-library.md +272 -0
- package/dist/docs/pages/guide/your-first-world.md +206 -0
- package/dist/docs/pages/guide/z-machine.md +96 -0
- package/dist/docs/pages/index.md +58 -0
- package/dist/docs/pages/reference/cartridges.md +47 -0
- package/dist/docs/pages/reference/commands.md +79 -0
- package/dist/docs/pages/reference/conditions-and-events.md +129 -0
- package/dist/docs/pages/reference/world-schema.md +592 -0
- package/dist/docs/pages.json +74 -0
- package/dist/docs.d.ts +21 -0
- package/dist/docs.js +89 -0
- package/dist/engine.d.ts +16 -0
- package/dist/engine.js +42 -0
- package/dist/job.d.ts +22 -0
- package/dist/job.js +29 -0
- package/dist/load.d.ts +22 -0
- package/dist/load.js +106 -0
- package/dist/run.d.ts +16 -0
- package/dist/run.js +57 -0
- package/dist/server.d.ts +47 -0
- package/dist/server.js +97 -0
- package/dist/stdio.d.ts +25 -0
- package/dist/stdio.js +75 -0
- package/dist/tools.d.ts +8 -0
- package/dist/tools.js +82 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +6 -0
- package/package.json +47 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"path": "index",
|
|
4
|
+
"title": "Home"
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"path": "guide/getting-started",
|
|
8
|
+
"title": "Getting started"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "guide/using-the-library",
|
|
12
|
+
"title": "Using the library"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "guide/coding-agents",
|
|
16
|
+
"title": "Coding agents"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "guide/z-machine",
|
|
20
|
+
"title": "Playing story files (Zork)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "guide/porting-zork",
|
|
24
|
+
"title": "Porting Zork"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "guide/how-it-works",
|
|
28
|
+
"title": "How it works"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "guide/intent-server",
|
|
32
|
+
"title": "The intent server"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"path": "guide/deploying",
|
|
36
|
+
"title": "Deploying"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"path": "guide/building-worlds/index",
|
|
40
|
+
"title": "Overview"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"path": "guide/building-worlds/two-rooms",
|
|
44
|
+
"title": "A two-room game"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"path": "guide/your-first-world",
|
|
48
|
+
"title": "The demo game: Snack Attack"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "guide/building-worlds/recipes",
|
|
52
|
+
"title": "Recipes"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "guide/testing",
|
|
56
|
+
"title": "Testing a world"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "reference/world-schema",
|
|
60
|
+
"title": "World schema"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "reference/conditions-and-events",
|
|
64
|
+
"title": "Conditions and events"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"path": "reference/commands",
|
|
68
|
+
"title": "Player commands"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"path": "reference/cartridges",
|
|
72
|
+
"title": "Cartridges and storage"
|
|
73
|
+
}
|
|
74
|
+
]
|
package/dist/docs.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface DocPage {
|
|
2
|
+
/** 'reference/world-schema', 'guide/building-worlds/index'. */
|
|
3
|
+
path: string;
|
|
4
|
+
title: string;
|
|
5
|
+
markdown: string;
|
|
6
|
+
}
|
|
7
|
+
export interface Docs {
|
|
8
|
+
index: string;
|
|
9
|
+
pages: DocPage[];
|
|
10
|
+
}
|
|
11
|
+
/** Reads a bundle written by scripts/bundle-docs.ts. */
|
|
12
|
+
export declare function readDocs(dir: string): Docs;
|
|
13
|
+
/** A page name as an agent might type it, as a bundle path. */
|
|
14
|
+
export declare function normalizePage(page: string): string;
|
|
15
|
+
export declare function docsReport(docs: Docs, args: {
|
|
16
|
+
page?: string;
|
|
17
|
+
query?: string;
|
|
18
|
+
}): {
|
|
19
|
+
text: string;
|
|
20
|
+
isError: boolean;
|
|
21
|
+
};
|
package/dist/docs.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const MAX_SECTIONS = 5;
|
|
4
|
+
const MAX_SECTION_CHARS = 4000;
|
|
5
|
+
/** Reads a bundle written by scripts/bundle-docs.ts. */
|
|
6
|
+
export function readDocs(dir) {
|
|
7
|
+
const list = JSON.parse(readFileSync(join(dir, 'pages.json'), 'utf8'));
|
|
8
|
+
return {
|
|
9
|
+
index: readFileSync(join(dir, 'llms.txt'), 'utf8'),
|
|
10
|
+
pages: list.map((p) => ({ ...p, markdown: readFileSync(join(dir, 'pages', `${p.path}.md`), 'utf8') })),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** A page name as an agent might type it, as a bundle path. */
|
|
14
|
+
export function normalizePage(page) {
|
|
15
|
+
const trimmed = page.trim().replace(/^\/+/, '').replace(/\.md$/, '');
|
|
16
|
+
if (trimmed === '' || trimmed.endsWith('/'))
|
|
17
|
+
return `${trimmed}index`;
|
|
18
|
+
return trimmed;
|
|
19
|
+
}
|
|
20
|
+
/** A page split at its headings: each heading with the text under it. */
|
|
21
|
+
function sections(page) {
|
|
22
|
+
const out = [];
|
|
23
|
+
let current = { heading: page.title, text: '' };
|
|
24
|
+
// The open fence, if any. A fence opens on ``` or ~~~ (with an optional info string) and closes
|
|
25
|
+
// only on a bare fence of the same character at least as long, so a ```json line shown inside a
|
|
26
|
+
// ````text block doesn't end it.
|
|
27
|
+
let fence;
|
|
28
|
+
for (const line of page.markdown.split('\n')) {
|
|
29
|
+
const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
30
|
+
if (marker) {
|
|
31
|
+
const run = marker[1];
|
|
32
|
+
if (!fence)
|
|
33
|
+
fence = { char: run[0], length: run.length };
|
|
34
|
+
else if (run[0] === fence.char && run.length >= fence.length && marker[2].trim() === '')
|
|
35
|
+
fence = undefined;
|
|
36
|
+
}
|
|
37
|
+
const heading = !fence && /^#{1,4}\s+(.+)$/.exec(line);
|
|
38
|
+
if (heading) {
|
|
39
|
+
if (current.text.trim())
|
|
40
|
+
out.push(current);
|
|
41
|
+
current = { heading: heading[1].trim(), text: `${line}\n` };
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
current.text += `${line}\n`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (current.text.trim())
|
|
48
|
+
out.push(current);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** Counts how often `term` appears in `text`, up to `cap`, without a regex (both are plain lowercase strings). */
|
|
52
|
+
function occurrences(text, term, cap) {
|
|
53
|
+
let n = 0;
|
|
54
|
+
for (let at = text.indexOf(term); at !== -1 && n < cap; at = text.indexOf(term, at + term.length))
|
|
55
|
+
n += 1;
|
|
56
|
+
return n;
|
|
57
|
+
}
|
|
58
|
+
export function docsReport(docs, args) {
|
|
59
|
+
if (args.page !== undefined) {
|
|
60
|
+
const wanted = normalizePage(args.page);
|
|
61
|
+
const page = docs.pages.find((p) => p.path === wanted);
|
|
62
|
+
if (!page)
|
|
63
|
+
return { isError: true, text: `No page “${args.page}”. Pages:\n${docs.pages.map((p) => `- ${p.path} (${p.title})`).join('\n')}` };
|
|
64
|
+
return { isError: false, text: page.markdown };
|
|
65
|
+
}
|
|
66
|
+
if (args.query !== undefined && args.query.trim() !== '') {
|
|
67
|
+
const terms = [...new Set(args.query.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 3))];
|
|
68
|
+
const scored = docs.pages
|
|
69
|
+
.flatMap((page) => sections(page).map((s) => ({ page, ...s })))
|
|
70
|
+
.map((s) => {
|
|
71
|
+
const heading = s.heading.toLowerCase();
|
|
72
|
+
const body = s.text.toLowerCase();
|
|
73
|
+
const score = terms.reduce((sum, t) => sum + (heading.includes(t) ? 3 : 0) + occurrences(body, t, 5), 0);
|
|
74
|
+
return { ...s, score };
|
|
75
|
+
})
|
|
76
|
+
.filter((s) => s.score > 0)
|
|
77
|
+
.sort((a, b) => b.score - a.score)
|
|
78
|
+
.slice(0, MAX_SECTIONS);
|
|
79
|
+
if (scored.length === 0)
|
|
80
|
+
return { isError: false, text: `No sections matched “${args.query}”. Try other words, or call docs with no arguments for the index.` };
|
|
81
|
+
return {
|
|
82
|
+
isError: false,
|
|
83
|
+
text: scored
|
|
84
|
+
.map((s) => `## ${s.page.title} › ${s.heading}\nSource: ${s.page.path}\n\n${s.text.length > MAX_SECTION_CHARS ? `${s.text.slice(0, MAX_SECTION_CHARS)}\n[… cut; ask for the page “${s.page.path}” for all of it]` : s.text.trim()}`)
|
|
85
|
+
.join('\n\n'),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { isError: false, text: `${docs.index}\n\nAsk for a page with “page” (e.g. reference/world-schema), or search with “query”.` };
|
|
89
|
+
}
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type * as EngineModule from '@brass-lantern/engine';
|
|
2
|
+
/** What the tools use of an engine. The parser helpers are optional: older engines may lack them. */
|
|
3
|
+
export type Engine = Pick<typeof EngineModule, 'createGame' | 'auditWorld' | 'ENGINE_VERSION'> & Partial<Pick<typeof EngineModule, 'fallbackParse' | 'splitCommands' | 'andNames' | 'currentScore'>>;
|
|
4
|
+
export interface ResolvedEngine {
|
|
5
|
+
engine: Engine;
|
|
6
|
+
version: string;
|
|
7
|
+
source: 'project' | 'bundled';
|
|
8
|
+
/** Why the project’s engine wasn’t used, when it was found but couldn’t be. */
|
|
9
|
+
note?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The engine the world’s own project resolves (so a 2.2 project is audited by 2.2’s rules),
|
|
13
|
+
* else `bundled`. A project engine that can’t be resolved or imported, or lacks the API, falls back
|
|
14
|
+
* with a note; only a project with no engine installed falls back silently.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveEngine(worldFile: string, bundled: Engine): Promise<ResolvedEngine>;
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
/**
|
|
12
|
+
* The engine the world’s own project resolves (so a 2.2 project is audited by 2.2’s rules),
|
|
13
|
+
* else `bundled`. A project engine that can’t be resolved or imported, or lacks the API, falls back
|
|
14
|
+
* with a note; only a project with no engine installed falls back silently.
|
|
15
|
+
*/
|
|
16
|
+
export async function resolveEngine(worldFile, bundled) {
|
|
17
|
+
const fallback = (note) => ({ engine: bundled, version: bundled.ENGINE_VERSION, source: 'bundled', ...(note ? { note } : {}) });
|
|
18
|
+
let entry;
|
|
19
|
+
try {
|
|
20
|
+
entry = createRequire(worldFile).resolve('@brass-lantern/engine');
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const { code, message } = error;
|
|
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'`))
|
|
29
|
+
return fallback();
|
|
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`);
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href)));
|
|
34
|
+
if (typeof mod.createGame !== 'function' || typeof mod.auditWorld !== 'function') {
|
|
35
|
+
return fallback(`the project’s @brass-lantern/engine doesn’t export createGame and auditWorld; used the bundled engine`);
|
|
36
|
+
}
|
|
37
|
+
return { engine: mod, version: typeof mod.ENGINE_VERSION === 'string' ? mod.ENGINE_VERSION : 'unknown', source: 'project' };
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return fallback(`couldn’t load the project’s @brass-lantern/engine (${error instanceof Error ? error.message : String(error)}); used the bundled engine`);
|
|
41
|
+
}
|
|
42
|
+
}
|
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.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { World } from '@brass-lantern/engine';
|
|
2
|
+
/** The module kinds a world can be written as. */
|
|
3
|
+
export declare const WORLD_EXTENSIONS: readonly string[];
|
|
4
|
+
/** A world file that couldn't be loaded: its message is the tool’s reply. */
|
|
5
|
+
export declare class WorldLoadError extends Error {
|
|
6
|
+
}
|
|
7
|
+
/** A world is an object with rooms, items, characters and a starting room. */
|
|
8
|
+
export declare function isWorldShaped(value: unknown): value is World;
|
|
9
|
+
export interface LoadedWorld {
|
|
10
|
+
world: World;
|
|
11
|
+
/** The absolute path loaded. */
|
|
12
|
+
file: string;
|
|
13
|
+
/** The export the world came from ('default' for a default export). */
|
|
14
|
+
exportName: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Loads a world module fresh (no module or transpile cache, so an edit is seen on the next call),
|
|
18
|
+
* resolving `path` against `cwd`. The export is the one named, else a world default export, else
|
|
19
|
+
* the only world-shaped named export. Every failure is a WorldLoadError whose message starts with
|
|
20
|
+
* the path as given.
|
|
21
|
+
*/
|
|
22
|
+
export declare function loadWorld(path: string, exportName: string | undefined, cwd: string): Promise<LoadedWorld>;
|
package/dist/load.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { register } from 'node:module';
|
|
3
|
+
import { extname, isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { createJiti } from 'jiti';
|
|
6
|
+
import { createJiti as createNativeJiti } from 'jiti/native';
|
|
7
|
+
/** The module kinds a world can be written as. */
|
|
8
|
+
export const WORLD_EXTENSIONS = ['.ts', '.mts', '.js', '.mjs'];
|
|
9
|
+
/** A world file that couldn't be loaded: its message is the tool’s reply. */
|
|
10
|
+
export class WorldLoadError extends Error {
|
|
11
|
+
}
|
|
12
|
+
/** A world is an object with rooms, items, characters and a starting room. */
|
|
13
|
+
export function isWorldShaped(value) {
|
|
14
|
+
if (typeof value !== 'object' || value === null)
|
|
15
|
+
return false;
|
|
16
|
+
const v = value;
|
|
17
|
+
return ['rooms', 'items', 'npcs'].every((key) => typeof v[key] === 'object' && v[key] !== null) && typeof v.startRoom === 'string';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* jiti hands native ES modules (.mjs, and .js in a "type": "module" package) to Node's loader,
|
|
21
|
+
* which caches by URL, so jiti's own moduleCache: false can't refresh them. Those files are
|
|
22
|
+
* imported natively under a unique ?bl= query instead, and this resolve hook carries the query
|
|
23
|
+
* onto every file they import by a relative or absolute specifier, so a helper edit is seen too.
|
|
24
|
+
* Bare specifiers (packages) are never touched, whatever their real path: a symlinked package
|
|
25
|
+
* (workspaces, npm link) has no /node_modules/ in its resolved URL, and re-instantiating it per
|
|
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 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.
|
|
30
|
+
*/
|
|
31
|
+
const FRESH_HOOK = `
|
|
32
|
+
export async function resolve(specifier, context, next) {
|
|
33
|
+
const result = await next(specifier, context);
|
|
34
|
+
const parent = context.parentURL;
|
|
35
|
+
const query = parent && /[?&]bl=\\d+/.exec(parent);
|
|
36
|
+
if (query && /^(\\.{1,2}\\/|\\/|file:)/.test(specifier) && result.url.startsWith('file:') && !result.url.includes('?')) {
|
|
37
|
+
return { ...result, url: result.url + '?' + query[0].replace(/^&/, '') };
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}`;
|
|
41
|
+
// jiti/native is a plain import() living in node_modules, so a bundler or test runner (Vitest's
|
|
42
|
+
// module runner rewrites an import() in our own source) can't route it around the loader hook.
|
|
43
|
+
const native = createNativeJiti(import.meta.url);
|
|
44
|
+
let hookRegistered = false;
|
|
45
|
+
let generation = 0;
|
|
46
|
+
async function importNativeFresh(file) {
|
|
47
|
+
if (!hookRegistered) {
|
|
48
|
+
register(`data:text/javascript,${encodeURIComponent(FRESH_HOOK)}`);
|
|
49
|
+
hookRegistered = true;
|
|
50
|
+
}
|
|
51
|
+
return (await native.import(`${pathToFileURL(file).href}?bl=${++generation}`));
|
|
52
|
+
}
|
|
53
|
+
function jitiImport(file) {
|
|
54
|
+
return createJiti(import.meta.url, { moduleCache: false, fsCache: false, tryNative: false }).import(file);
|
|
55
|
+
}
|
|
56
|
+
/** Native and fresh; ES syntax in a CommonJS-typed .js falls back to jiti. */
|
|
57
|
+
async function importModule(file) {
|
|
58
|
+
try {
|
|
59
|
+
return await importNativeFresh(file);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
// jiti only for what it exists for: ES syntax in a .js of a CommonJS-typed package.
|
|
63
|
+
// Anything else (a throw at top level, a real syntax error) is Node's error to report.
|
|
64
|
+
if (error instanceof SyntaxError && /import statement outside a module|Unexpected token 'export'|Cannot use 'import\.meta'/.test(error.message)) {
|
|
65
|
+
return jitiImport(file);
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Loads a world module fresh (no module or transpile cache, so an edit is seen on the next call),
|
|
72
|
+
* resolving `path` against `cwd`. The export is the one named, else a world default export, else
|
|
73
|
+
* the only world-shaped named export. Every failure is a WorldLoadError whose message starts with
|
|
74
|
+
* the path as given.
|
|
75
|
+
*/
|
|
76
|
+
export async function loadWorld(path, exportName, cwd) {
|
|
77
|
+
const file = isAbsolute(path) ? path : resolve(cwd, path);
|
|
78
|
+
if (!WORLD_EXTENSIONS.includes(extname(file)))
|
|
79
|
+
throw new WorldLoadError(`${path}: a world is a .ts, .mts, .js or .mjs module`);
|
|
80
|
+
if (!existsSync(file))
|
|
81
|
+
throw new WorldLoadError(`${path}: no such file (looked in ${cwd})`);
|
|
82
|
+
let mod;
|
|
83
|
+
try {
|
|
84
|
+
mod = (await (extname(file) === '.mjs' || extname(file) === '.js' ? importModule(file) : jitiImport(file)));
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new WorldLoadError(`${path}: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
const exportsList = () => Object.keys(mod).join(', ') || 'none';
|
|
90
|
+
if (exportName !== undefined) {
|
|
91
|
+
if (!(exportName in mod))
|
|
92
|
+
throw new WorldLoadError(`${path}: no export “${exportName}” (exports: ${exportsList()})`);
|
|
93
|
+
const value = mod[exportName];
|
|
94
|
+
if (!isWorldShaped(value))
|
|
95
|
+
throw new WorldLoadError(`${path}: export “${exportName}” isn’t a world (a world has rooms, items, npcs and startRoom)`);
|
|
96
|
+
return { world: value, file, exportName };
|
|
97
|
+
}
|
|
98
|
+
if (isWorldShaped(mod.default))
|
|
99
|
+
return { world: mod.default, file, exportName: 'default' };
|
|
100
|
+
const candidates = Object.keys(mod).filter((key) => key !== 'default' && isWorldShaped(mod[key]));
|
|
101
|
+
if (candidates.length === 1)
|
|
102
|
+
return { world: mod[candidates[0]], file, exportName: candidates[0] };
|
|
103
|
+
if (candidates.length === 0)
|
|
104
|
+
throw new WorldLoadError(`${path}: no world-shaped export (exports: ${exportsList()})`);
|
|
105
|
+
throw new WorldLoadError(`${path}: several worlds (${candidates.join(', ')}); name one with “export”`);
|
|
106
|
+
}
|
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
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { Engine } from './engine.ts';
|
|
3
|
+
import { type Job, type JobResult } from './job.ts';
|
|
4
|
+
export { quietStdout } from './stdio.ts';
|
|
5
|
+
export type { Job, JobResult } from './job.ts';
|
|
6
|
+
export interface ServerOptions {
|
|
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;
|
|
13
|
+
/** Where the docs bundle is. Default: the one shipped with this package (dist/docs). */
|
|
14
|
+
docsDir?: string;
|
|
15
|
+
/** Relative world paths resolve against this (the client’s launch directory). */
|
|
16
|
+
cwd: string;
|
|
17
|
+
/** Worlds must live inside one of these directories (symlinks resolved). Default: [cwd]. */
|
|
18
|
+
roots?: string[];
|
|
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>;
|
|
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>;
|
|
39
|
+
/**
|
|
40
|
+
* The brass-lantern MCP server, unconnected: pass it a transport. Stdout-based transports also
|
|
41
|
+
* want quietStdout(), so a world’s console output can’t corrupt the protocol.
|
|
42
|
+
*
|
|
43
|
+
* Loading a .mjs or .js world registers a process-wide module resolve hook (once). It only acts
|
|
44
|
+
* on imports made by modules this server loaded under its own ?bl= query, so the rest of the
|
|
45
|
+
* process resolves as before.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createServer(options: ServerOptions): McpServer;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { docsReport, readDocs } from "./docs.js";
|
|
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
|
+
export { quietStdout } from "./stdio.js";
|
|
11
|
+
const reply = (text, isError = false) => ({ content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) });
|
|
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
|
+
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;
|
|
22
|
+
/**
|
|
23
|
+
* The brass-lantern MCP server, unconnected: pass it a transport. Stdout-based transports also
|
|
24
|
+
* want quietStdout(), so a world’s console output can’t corrupt the protocol.
|
|
25
|
+
*
|
|
26
|
+
* Loading a .mjs or .js world registers a process-wide module resolve hook (once). It only acts
|
|
27
|
+
* on imports made by modules this server loaded under its own ?bl= query, so the rest of the
|
|
28
|
+
* process resolves as before.
|
|
29
|
+
*/
|
|
30
|
+
export function createServer(options) {
|
|
31
|
+
const docsDir = options.docsDir ?? fileURLToPath(new URL('./docs/', import.meta.url));
|
|
32
|
+
const server = new McpServer({ name: 'brass-lantern', version: options.version });
|
|
33
|
+
const real = (p) => {
|
|
34
|
+
try {
|
|
35
|
+
return realpathSync(p);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return p;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const roots = (options.roots ?? [options.cwd]).map((r) => real(resolve(options.cwd, r)));
|
|
42
|
+
const allowed = (path) => {
|
|
43
|
+
const file = real(resolve(options.cwd, path));
|
|
44
|
+
return roots.some((root) => {
|
|
45
|
+
const rel = relative(root, file);
|
|
46
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
47
|
+
});
|
|
48
|
+
};
|
|
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);
|
|
64
|
+
}
|
|
65
|
+
server.registerTool('audit_world', {
|
|
66
|
+
title: 'Audit a Brass Lantern world',
|
|
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.',
|
|
68
|
+
inputSchema: { path: PATH, export: EXPORT },
|
|
69
|
+
}, ({ path, export: exportName }) => withWorld({ op: 'audit', path, exportName, cwd: options.cwd }));
|
|
70
|
+
server.registerTool('play', {
|
|
71
|
+
title: 'Play commands in a Brass Lantern world',
|
|
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.`,
|
|
73
|
+
inputSchema: {
|
|
74
|
+
path: PATH,
|
|
75
|
+
export: EXPORT,
|
|
76
|
+
commands: z.array(z.string()).min(1).describe('Commands to type, in order (e.g. ["open drawer", "take key", "north"]).'),
|
|
77
|
+
seed: z.number().int().optional().describe('Random seed. Default 1.'),
|
|
78
|
+
},
|
|
79
|
+
}, ({ path, export: exportName, commands, seed }) => withWorld({ op: 'play', path, exportName, cwd: options.cwd, commands, seed }));
|
|
80
|
+
server.registerTool('docs', {
|
|
81
|
+
title: 'Brass Lantern documentation',
|
|
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.',
|
|
83
|
+
inputSchema: {
|
|
84
|
+
page: z.string().optional().describe('A page path from the index, e.g. reference/world-schema or guide/building-worlds/recipes.'),
|
|
85
|
+
query: z.string().optional().describe('Words to search the docs for, e.g. "timers fuses".'),
|
|
86
|
+
},
|
|
87
|
+
}, ({ page, query }) => {
|
|
88
|
+
try {
|
|
89
|
+
const r = docsReport(readDocs(docsDir), { page, query });
|
|
90
|
+
return reply(r.text, r.isError);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
return reply(`The docs bundle couldn’t be read (${error instanceof Error ? error.message : String(error)}).`, true);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return server;
|
|
97
|
+
}
|