@brass-lantern/mcp 0.0.0-stage → 2.3.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/LICENSE +21 -0
- package/README.md +36 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +16 -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 +120 -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 +40 -0
- package/dist/load.d.ts +22 -0
- package/dist/load.js +105 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +96 -0
- package/dist/stdio.d.ts +19 -0
- package/dist/stdio.js +49 -0
- package/dist/tools.d.ts +8 -0
- package/dist/tools.js +82 -0
- package/package.json +45 -4
|
@@ -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,40 @@
|
|
|
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”; anything else (a broken exports map, a malformed
|
|
25
|
+
// package.json) means the project has an engine we couldn’t use, which the agent should know.
|
|
26
|
+
if (code === 'MODULE_NOT_FOUND' && typeof message === 'string' && message.includes('@brass-lantern/engine'))
|
|
27
|
+
return fallback();
|
|
28
|
+
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
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href)));
|
|
32
|
+
if (typeof mod.createGame !== 'function' || typeof mod.auditWorld !== 'function') {
|
|
33
|
+
return fallback(`the project’s @brass-lantern/engine doesn’t export createGame and auditWorld; used the bundled engine`);
|
|
34
|
+
}
|
|
35
|
+
return { engine: mod, version: typeof mod.ENGINE_VERSION === 'string' ? mod.ENGINE_VERSION : 'unknown', source: 'project' };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return fallback(`couldn’t load the project’s @brass-lantern/engine (${error instanceof Error ? error.message : String(error)}); used the bundled engine`);
|
|
39
|
+
}
|
|
40
|
+
}
|
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,105 @@
|
|
|
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 the life of the process. The hook only
|
|
28
|
+
* touches modules whose importer carries the query; nothing else in the process is affected.
|
|
29
|
+
*/
|
|
30
|
+
const FRESH_HOOK = `
|
|
31
|
+
export async function resolve(specifier, context, next) {
|
|
32
|
+
const result = await next(specifier, context);
|
|
33
|
+
const parent = context.parentURL;
|
|
34
|
+
const query = parent && /[?&]bl=\\d+/.exec(parent);
|
|
35
|
+
if (query && /^(\\.{1,2}\\/|\\/|file:)/.test(specifier) && result.url.startsWith('file:') && !result.url.includes('?')) {
|
|
36
|
+
return { ...result, url: result.url + '?' + query[0].replace(/^&/, '') };
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}`;
|
|
40
|
+
// jiti/native is a plain import() living in node_modules, so a bundler or test runner (Vitest's
|
|
41
|
+
// module runner rewrites an import() in our own source) can't route it around the loader hook.
|
|
42
|
+
const native = createNativeJiti(import.meta.url);
|
|
43
|
+
let hookRegistered = false;
|
|
44
|
+
let generation = 0;
|
|
45
|
+
async function importNativeFresh(file) {
|
|
46
|
+
if (!hookRegistered) {
|
|
47
|
+
register(`data:text/javascript,${encodeURIComponent(FRESH_HOOK)}`);
|
|
48
|
+
hookRegistered = true;
|
|
49
|
+
}
|
|
50
|
+
return (await native.import(`${pathToFileURL(file).href}?bl=${++generation}`));
|
|
51
|
+
}
|
|
52
|
+
function jitiImport(file) {
|
|
53
|
+
return createJiti(import.meta.url, { moduleCache: false, fsCache: false, tryNative: false }).import(file);
|
|
54
|
+
}
|
|
55
|
+
/** Native and fresh; ES syntax in a CommonJS-typed .js falls back to jiti. */
|
|
56
|
+
async function importModule(file) {
|
|
57
|
+
try {
|
|
58
|
+
return await importNativeFresh(file);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
// jiti only for what it exists for: ES syntax in a .js of a CommonJS-typed package.
|
|
62
|
+
// Anything else (a throw at top level, a real syntax error) is Node's error to report.
|
|
63
|
+
if (error instanceof SyntaxError && /import statement outside a module|Unexpected token 'export'|Cannot use 'import\.meta'/.test(error.message)) {
|
|
64
|
+
return jitiImport(file);
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Loads a world module fresh (no module or transpile cache, so an edit is seen on the next call),
|
|
71
|
+
* resolving `path` against `cwd`. The export is the one named, else a world default export, else
|
|
72
|
+
* the only world-shaped named export. Every failure is a WorldLoadError whose message starts with
|
|
73
|
+
* the path as given.
|
|
74
|
+
*/
|
|
75
|
+
export async function loadWorld(path, exportName, cwd) {
|
|
76
|
+
const file = isAbsolute(path) ? path : resolve(cwd, path);
|
|
77
|
+
if (!WORLD_EXTENSIONS.includes(extname(file)))
|
|
78
|
+
throw new WorldLoadError(`${path}: a world is a .ts, .mts, .js or .mjs module`);
|
|
79
|
+
if (!existsSync(file))
|
|
80
|
+
throw new WorldLoadError(`${path}: no such file (looked in ${cwd})`);
|
|
81
|
+
let mod;
|
|
82
|
+
try {
|
|
83
|
+
mod = (await (extname(file) === '.mjs' || extname(file) === '.js' ? importModule(file) : jitiImport(file)));
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
throw new WorldLoadError(`${path}: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`);
|
|
87
|
+
}
|
|
88
|
+
const exportsList = () => Object.keys(mod).join(', ') || 'none';
|
|
89
|
+
if (exportName !== undefined) {
|
|
90
|
+
if (!(exportName in mod))
|
|
91
|
+
throw new WorldLoadError(`${path}: no export “${exportName}” (exports: ${exportsList()})`);
|
|
92
|
+
const value = mod[exportName];
|
|
93
|
+
if (!isWorldShaped(value))
|
|
94
|
+
throw new WorldLoadError(`${path}: export “${exportName}” isn’t a world (a world has rooms, items, npcs and startRoom)`);
|
|
95
|
+
return { world: value, file, exportName };
|
|
96
|
+
}
|
|
97
|
+
if (isWorldShaped(mod.default))
|
|
98
|
+
return { world: mod.default, file, exportName: 'default' };
|
|
99
|
+
const candidates = Object.keys(mod).filter((key) => key !== 'default' && isWorldShaped(mod[key]));
|
|
100
|
+
if (candidates.length === 1)
|
|
101
|
+
return { world: mod[candidates[0]], file, exportName: candidates[0] };
|
|
102
|
+
if (candidates.length === 0)
|
|
103
|
+
throw new WorldLoadError(`${path}: no world-shaped export (exports: ${exportsList()})`);
|
|
104
|
+
throw new WorldLoadError(`${path}: several worlds (${candidates.join(', ')}); name one with “export”`);
|
|
105
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { type Engine } from './engine.ts';
|
|
3
|
+
export { quietStdout } from './stdio.ts';
|
|
4
|
+
export interface ServerOptions {
|
|
5
|
+
/** The engine to use when a world’s project has none of its own. */
|
|
6
|
+
bundled: Engine;
|
|
7
|
+
/** Where the docs bundle is. Default: the one shipped with this package (dist/docs). */
|
|
8
|
+
docsDir?: string;
|
|
9
|
+
/** Relative world paths resolve against this (the client’s launch directory). */
|
|
10
|
+
cwd: string;
|
|
11
|
+
/** Worlds must live inside one of these directories (symlinks resolved). Default: [cwd]. */
|
|
12
|
+
roots?: string[];
|
|
13
|
+
version: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The brass-lantern MCP server, unconnected: pass it a transport. Stdout-based transports also
|
|
17
|
+
* want quietStdout(), so a world’s console output can’t corrupt the protocol.
|
|
18
|
+
*
|
|
19
|
+
* Loading a .mjs or .js world registers a process-wide module resolve hook (once). It only acts
|
|
20
|
+
* on imports made by modules this server loaded under its own ?bl= query, so the rest of the
|
|
21
|
+
* process resolves as before.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createServer(options: ServerOptions): McpServer;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
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 { resolveEngine } from "./engine.js";
|
|
8
|
+
import { loadWorld, WorldLoadError } from "./load.js";
|
|
9
|
+
import { auditReport, header, MAX_COMMANDS, playReport } 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
|
+
* The brass-lantern MCP server, unconnected: pass it a transport. Stdout-based transports also
|
|
16
|
+
* want quietStdout(), so a world’s console output can’t corrupt the protocol.
|
|
17
|
+
*
|
|
18
|
+
* Loading a .mjs or .js world registers a process-wide module resolve hook (once). It only acts
|
|
19
|
+
* on imports made by modules this server loaded under its own ?bl= query, so the rest of the
|
|
20
|
+
* process resolves as before.
|
|
21
|
+
*/
|
|
22
|
+
export function createServer(options) {
|
|
23
|
+
const docsDir = options.docsDir ?? fileURLToPath(new URL('./docs/', import.meta.url));
|
|
24
|
+
const server = new McpServer({ name: 'brass-lantern', version: options.version });
|
|
25
|
+
const real = (p) => {
|
|
26
|
+
try {
|
|
27
|
+
return realpathSync(p);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const roots = (options.roots ?? [options.cwd]).map((r) => real(resolve(options.cwd, r)));
|
|
34
|
+
const allowed = (path) => {
|
|
35
|
+
const file = real(resolve(options.cwd, path));
|
|
36
|
+
return roots.some((root) => {
|
|
37
|
+
const rel = relative(root, file);
|
|
38
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
/** Loads the world and its engine, runs `report`, and turns any failure into an error result. */
|
|
42
|
+
async function withWorld(path, exportName, report) {
|
|
43
|
+
if (!allowed(path))
|
|
44
|
+
return reply(`${path}: outside the allowed directories (${roots.join(', ')}). Start the server with --root <dir> to allow another.`, true);
|
|
45
|
+
let loaded;
|
|
46
|
+
try {
|
|
47
|
+
loaded = await loadWorld(path, exportName, options.cwd);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error instanceof WorldLoadError)
|
|
51
|
+
return reply(error.message, true);
|
|
52
|
+
return reply(`The world couldn’t be loaded: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
53
|
+
}
|
|
54
|
+
let resolved;
|
|
55
|
+
try {
|
|
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
|
+
}
|
|
63
|
+
}
|
|
64
|
+
server.registerTool('audit_world', {
|
|
65
|
+
title: 'Audit a Brass Lantern world',
|
|
66
|
+
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
|
+
inputSchema: { path: PATH, export: EXPORT },
|
|
68
|
+
}, ({ path, export: exportName }) => withWorld(path, exportName, auditReport));
|
|
69
|
+
server.registerTool('play', {
|
|
70
|
+
title: 'Play commands in a Brass Lantern world',
|
|
71
|
+
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.`,
|
|
72
|
+
inputSchema: {
|
|
73
|
+
path: PATH,
|
|
74
|
+
export: EXPORT,
|
|
75
|
+
commands: z.array(z.string()).min(1).describe('Commands to type, in order (e.g. ["open drawer", "take key", "north"]).'),
|
|
76
|
+
seed: z.number().int().optional().describe('Random seed. Default 1.'),
|
|
77
|
+
},
|
|
78
|
+
}, ({ path, export: exportName, commands, seed }) => withWorld(path, exportName, (loaded, resolved) => playReport(loaded, resolved, commands, seed)));
|
|
79
|
+
server.registerTool('docs', {
|
|
80
|
+
title: 'Brass Lantern documentation',
|
|
81
|
+
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.',
|
|
82
|
+
inputSchema: {
|
|
83
|
+
page: z.string().optional().describe('A page path from the index, e.g. reference/world-schema or guide/building-worlds/recipes.'),
|
|
84
|
+
query: z.string().optional().describe('Words to search the docs for, e.g. "timers fuses".'),
|
|
85
|
+
},
|
|
86
|
+
}, ({ page, query }) => {
|
|
87
|
+
try {
|
|
88
|
+
const r = docsReport(readDocs(docsDir), { page, query });
|
|
89
|
+
return reply(r.text, r.isError);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
return reply(`The docs bundle couldn’t be read (${error instanceof Error ? error.message : String(error)}).`, true);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
return server;
|
|
96
|
+
}
|
package/dist/stdio.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdout carries the MCP protocol, so a world's console output (at load or while a script runs)
|
|
3
|
+
* would corrupt it. Swaps the global console for one whose stdout and stderr are both
|
|
4
|
+
* process.stderr, so every method (log, dir, table, trace, …) lands there; the SDK transport
|
|
5
|
+
* writes through process.stdout.write directly, so it is unaffected. Returns a function that
|
|
6
|
+
* puts the original console back.
|
|
7
|
+
*/
|
|
8
|
+
export declare function quietStdout(): () => void;
|
|
9
|
+
/**
|
|
10
|
+
* The directories worlds may load from: `cwd`, plus each `--root <dir>` (repeatable), resolved
|
|
11
|
+
* against `cwd`. A trailing `--root` with no directory is ignored, with a warning through `warn`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseRoots(args: readonly string[], cwd: string, warn?: (message: string) => void): string[];
|
|
14
|
+
/**
|
|
15
|
+
* A world can throw after its tool call has returned (a timer, a stray promise). Node's default
|
|
16
|
+
* would end the server mid-session; instead, log the error to stderr and keep serving. Returns a
|
|
17
|
+
* function that removes the handlers.
|
|
18
|
+
*/
|
|
19
|
+
export declare function keepRunningOnCrash(log?: (...args: unknown[]) => void): () => void;
|
package/dist/stdio.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Console } from 'node:console';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Stdout carries the MCP protocol, so a world's console output (at load or while a script runs)
|
|
5
|
+
* would corrupt it. Swaps the global console for one whose stdout and stderr are both
|
|
6
|
+
* process.stderr, so every method (log, dir, table, trace, …) lands there; the SDK transport
|
|
7
|
+
* writes through process.stdout.write directly, so it is unaffected. Returns a function that
|
|
8
|
+
* puts the original console back.
|
|
9
|
+
*/
|
|
10
|
+
export function quietStdout() {
|
|
11
|
+
const saved = globalThis.console;
|
|
12
|
+
globalThis.console = new Console({ stdout: process.stderr, stderr: process.stderr });
|
|
13
|
+
return () => {
|
|
14
|
+
globalThis.console = saved;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The directories worlds may load from: `cwd`, plus each `--root <dir>` (repeatable), resolved
|
|
19
|
+
* against `cwd`. A trailing `--root` with no directory is ignored, with a warning through `warn`.
|
|
20
|
+
*/
|
|
21
|
+
export function parseRoots(args, cwd, warn = (m) => process.stderr.write(`${m}\n`)) {
|
|
22
|
+
const roots = [cwd];
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
if (args[i] !== '--root')
|
|
25
|
+
continue;
|
|
26
|
+
const dir = args[i + 1];
|
|
27
|
+
if (dir === undefined)
|
|
28
|
+
warn('brass-lantern-mcp: --root needs a directory; ignored');
|
|
29
|
+
else
|
|
30
|
+
roots.push(resolve(cwd, dir));
|
|
31
|
+
i += 1;
|
|
32
|
+
}
|
|
33
|
+
return roots;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A world can throw after its tool call has returned (a timer, a stray promise). Node's default
|
|
37
|
+
* would end the server mid-session; instead, log the error to stderr and keep serving. Returns a
|
|
38
|
+
* function that removes the handlers.
|
|
39
|
+
*/
|
|
40
|
+
export function keepRunningOnCrash(log = (...args) => console.error(...args)) {
|
|
41
|
+
const onException = (error) => log('brass-lantern-mcp: uncaught exception:', error);
|
|
42
|
+
const onRejection = (reason) => log('brass-lantern-mcp: unhandled rejection:', reason);
|
|
43
|
+
process.on('uncaughtException', onException);
|
|
44
|
+
process.on('unhandledRejection', onRejection);
|
|
45
|
+
return () => {
|
|
46
|
+
process.off('uncaughtException', onException);
|
|
47
|
+
process.off('unhandledRejection', onRejection);
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ResolvedEngine } from './engine.ts';
|
|
2
|
+
import type { LoadedWorld } from './load.ts';
|
|
3
|
+
export declare const MAX_COMMANDS = 500;
|
|
4
|
+
export declare const MAX_LINES = 2000;
|
|
5
|
+
/** The lines every engine tool opens with: which world, which engine. */
|
|
6
|
+
export declare function header(loaded: LoadedWorld, resolved: ResolvedEngine): string[];
|
|
7
|
+
export declare function auditReport(loaded: LoadedWorld, resolved: ResolvedEngine): string;
|
|
8
|
+
export declare function playReport(loaded: LoadedWorld, resolved: ResolvedEngine, commands: string[], seed?: number): string;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export const MAX_COMMANDS = 500;
|
|
2
|
+
export const MAX_LINES = 2000;
|
|
3
|
+
/** How many of a long transcript’s last lines survive the cut. */
|
|
4
|
+
const TAIL_LINES = 200;
|
|
5
|
+
const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
6
|
+
/** The lines every engine tool opens with: which world, which engine. */
|
|
7
|
+
export function header(loaded, resolved) {
|
|
8
|
+
const whose = resolved.source === 'project' ? 'the project’s' : 'bundled with the MCP server';
|
|
9
|
+
return [
|
|
10
|
+
`World: ${loaded.exportName} in ${loaded.file}`,
|
|
11
|
+
`Engine: @brass-lantern/engine ${resolved.version} (${whose})`,
|
|
12
|
+
...(resolved.note ? [`Note: ${resolved.note}`] : []),
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
export function auditReport(loaded, resolved) {
|
|
16
|
+
const w = loaded.world;
|
|
17
|
+
const problems = resolved.engine.auditWorld(w);
|
|
18
|
+
const size = [
|
|
19
|
+
count(Object.keys(w.rooms).length, 'room', 'rooms'),
|
|
20
|
+
count(Object.keys(w.items).length, 'item', 'items'),
|
|
21
|
+
count(Object.keys(w.npcs).length, 'character', 'characters'),
|
|
22
|
+
count(Object.keys(w.events ?? {}).length, 'event', 'events'),
|
|
23
|
+
].join(', ');
|
|
24
|
+
return [
|
|
25
|
+
...header(loaded, resolved),
|
|
26
|
+
`Size: ${size}`,
|
|
27
|
+
'',
|
|
28
|
+
problems.length === 0 ? 'No problems.' : `${count(problems.length, 'problem', 'problems')}:`,
|
|
29
|
+
...problems.map((p) => `- ${p}`),
|
|
30
|
+
].join('\n');
|
|
31
|
+
}
|
|
32
|
+
/** The pieces of a typed line the regex parser can’t read (what the intent server would be asked about). */
|
|
33
|
+
function unread(resolved, world, line) {
|
|
34
|
+
const { splitCommands, fallbackParse, andNames } = resolved.engine;
|
|
35
|
+
if (!splitCommands || !fallbackParse)
|
|
36
|
+
return [];
|
|
37
|
+
return splitCommands(line, world.verbs, andNames?.(world)).filter((piece) => !fallbackParse(piece, world.verbs));
|
|
38
|
+
}
|
|
39
|
+
export function playReport(loaded, resolved, commands, seed = 1) {
|
|
40
|
+
const world = loaded.world;
|
|
41
|
+
const game = resolved.engine.createGame(world, { seed });
|
|
42
|
+
const run = commands.slice(0, MAX_COMMANDS);
|
|
43
|
+
const transcript = [...game.opening];
|
|
44
|
+
let awaiting = false;
|
|
45
|
+
let ran = 0;
|
|
46
|
+
let over = game.state.gameOver;
|
|
47
|
+
for (const command of run) {
|
|
48
|
+
if (over)
|
|
49
|
+
break;
|
|
50
|
+
transcript.push(`> ${command}`);
|
|
51
|
+
// An answer to “Which do you mean?” isn’t a command, so it isn’t checked as one.
|
|
52
|
+
const missed = awaiting ? [] : unread(resolved, world, command);
|
|
53
|
+
if (missed.length > 0) {
|
|
54
|
+
transcript.push(`[not understood by the parser: ${missed.map((p) => `“${p}”`).join(', ')}. With the intent server running, an LLM would get a chance to read it.]`);
|
|
55
|
+
}
|
|
56
|
+
const reply = game.send(command);
|
|
57
|
+
transcript.push(...reply.lines);
|
|
58
|
+
awaiting = reply.awaiting;
|
|
59
|
+
over = reply.gameOver;
|
|
60
|
+
ran += 1;
|
|
61
|
+
}
|
|
62
|
+
// Over the cap, keep the opening and the ending (where the run finished), and cut the middle.
|
|
63
|
+
const shown = transcript.length > MAX_LINES
|
|
64
|
+
? [...transcript.slice(0, MAX_LINES - TAIL_LINES), `[… ${transcript.length - MAX_LINES} lines cut (over ${MAX_LINES}) …]`, ...transcript.slice(-TAIL_LINES)]
|
|
65
|
+
: transcript;
|
|
66
|
+
const s = game.state;
|
|
67
|
+
const state = {
|
|
68
|
+
room: `${world.rooms[s.currentRoom]?.name ?? s.currentRoom} (${s.currentRoom})`,
|
|
69
|
+
inventory: Object.entries(s.locations ?? {})
|
|
70
|
+
.filter(([, place]) => place === 'player')
|
|
71
|
+
.map(([id]) => world.items[id]?.name ?? id),
|
|
72
|
+
flags: Object.keys(s.flags).filter((f) => s.flags[f]).sort(),
|
|
73
|
+
score: resolved.engine.currentScore?.(world, s) ?? s.vars?.score ?? null,
|
|
74
|
+
moves: s.moveCount,
|
|
75
|
+
gameOver: s.gameOver,
|
|
76
|
+
};
|
|
77
|
+
const notes = [
|
|
78
|
+
...(commands.length > MAX_COMMANDS ? [`Only the first ${MAX_COMMANDS} commands ran.`] : []),
|
|
79
|
+
...(over && ran < run.length ? [`Game over after ${ran} of ${run.length} commands.`] : []),
|
|
80
|
+
];
|
|
81
|
+
return [...header(loaded, resolved), `Seed: ${seed}`, '', ...shown, '', ...notes, 'Final state:', '```json', JSON.stringify(state, null, 2), '```'].join('\n');
|
|
82
|
+
}
|