@microtoll/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/docs.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The docs, as the package ships them: generated/docs.json is written by
3
+ * the repository's docs build (docs/build.mjs) from the same sources as
4
+ * microtoll.dev, so search here and the site never drift. No network: the
5
+ * pages are inside the package.
6
+ */
7
+ import fs from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ let cache = null;
11
+ export function loadDocs(file = new URL('../generated/docs.json', import.meta.url)) {
12
+ if (!cache) cache = JSON.parse(fs.readFileSync(fileURLToPath(file), 'utf8'));
13
+ return cache;
14
+ }
15
+
16
+ const tokenize = (s) => String(s || '').toLowerCase().match(/[a-z0-9][a-z0-9-]*/g) || [];
17
+ const stem = (t) => t.replace(/(ings?|ed|es|s)$/, '');
18
+
19
+ /** The page a `path` names: "packages/access.html", "packages/access", or the full URL. */
20
+ export function readDoc(path, docs = loadDocs()) {
21
+ if (typeof path !== 'string' || !path) return null;
22
+ let p = path.trim().replace(/^https?:\/\/microtoll\.dev\//, '').replace(/^\//, '');
23
+ if (!/\.html$/.test(p)) p += '.html';
24
+ const page = docs.find((d) => d.path === p);
25
+ return page ? { path: page.path, url: page.url, title: page.title, description: page.description, markdown: page.markdown } : null;
26
+ }
27
+
28
+ /** The guides and package pages outrank the project records (the decisions log, the policies), which mention everything. */
29
+ const SECTION_WEIGHT = { Start: 1.3, Packages: 1.4, 'Examples and deployment': 1.2, 'The honest part': 1.1, 'For agents': 1.1, Project: 0.5 };
30
+
31
+ /**
32
+ * Full-text search over every page's sections: a term in a heading counts
33
+ * five, in the page title three, each occurrence in the text one (capped),
34
+ * the whole phrase four more; every query term must appear somewhere in the
35
+ * page for it to count; the page's part of the site weights the result.
36
+ */
37
+ export function searchDocs(query, { limit = 8, docs = loadDocs() } = {}) {
38
+ const phrase = String(query || '').toLowerCase().trim();
39
+ const terms = [...new Set(tokenize(query).map(stem))].filter((t) => t.length > 1);
40
+ if (terms.length === 0) return [];
41
+ const hits = [];
42
+ for (const page of docs) {
43
+ const pageText = page.markdown.toLowerCase();
44
+ if (!terms.every((t) => pageText.includes(t))) continue;
45
+ const titleTokens = tokenize(page.title).map(stem);
46
+ const weight = SECTION_WEIGHT[page.section] || 1;
47
+ for (const section of page.sections) {
48
+ const heading = section.heading || page.title;
49
+ const headingTokens = tokenize(heading).map(stem);
50
+ const text = section.text.toLowerCase();
51
+ let score = 0;
52
+ for (const t of terms) {
53
+ if (headingTokens.some((h) => h.startsWith(t))) score += 5;
54
+ if (titleTokens.some((h) => h.startsWith(t))) score += 3;
55
+ const n = text.split(t).length - 1;
56
+ score += Math.min(n, 4);
57
+ }
58
+ if (terms.length > 1 && (text.includes(phrase) || heading.toLowerCase().includes(phrase))) score += 4;
59
+ if (score === 0) continue;
60
+ hits.push({ path: page.path, url: page.url, title: page.title, heading, score: Math.round(score * weight * 10) / 10, excerpt: excerpt(section.text, terms) });
61
+ }
62
+ }
63
+ hits.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
64
+ return hits.slice(0, limit);
65
+ }
66
+
67
+ function excerpt(text, terms) {
68
+ const lines = text.split('\n').filter((l) => l.trim() && !/^```/.test(l));
69
+ const line = lines.find((l) => terms.some((t) => l.toLowerCase().includes(t))) || lines[0] || '';
70
+ const clean = line.replace(/[#*`>]/g, '').trim();
71
+ return clean.length > 240 ? `${clean.slice(0, 237)}…` : clean;
72
+ }
73
+
74
+ /** The search result as the model reads it. */
75
+ export function formatSearch(query, hits) {
76
+ if (hits.length === 0) return `No page mentions "${query}". Try other words, or read https://microtoll.dev/llms.txt for the index.`;
77
+ return hits.map((h, i) => `${i + 1}. ${h.title} — ${h.heading}\n ${h.url} (path: ${h.path})\n ${h.excerpt}`).join('\n\n');
78
+ }
package/src/index.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @microtoll/mcp — public entry point (D-39). `createMicrotollServer`
3
+ * builds the three tools on the protocol in protocol.js; bin/microtoll-mcp.mjs
4
+ * starts it on stdio.
5
+ */
6
+ import { createServer, PROTOCOL_VERSION } from './protocol.js';
7
+ import { loadDocs, searchDocs, readDoc, formatSearch } from './docs.js';
8
+ import { scaffold, renderScaffold } from './scaffold.js';
9
+
10
+ export { createServer, PROTOCOL_VERSION, loadDocs, searchDocs, readDoc, formatSearch, scaffold, renderScaffold };
11
+
12
+ export const VERSION = '0.0.0';
13
+
14
+ export const INSTRUCTIONS = [
15
+ 'Microtoll Engine: sign-in, key handling, access control and revocation for end-to-end-encrypted apps, as packages.',
16
+ 'Search the docs before writing security code; never add cryptography beyond what the packages provide; keep the server storing only what it cannot read.',
17
+ 'microtoll_scaffold writes the notes starter into an empty directory and runs nothing.',
18
+ ].join(' ');
19
+
20
+ export function tools() {
21
+ return [
22
+ {
23
+ name: 'microtoll_search_docs',
24
+ description: 'Full-text search over the Microtoll Engine documentation (the same pages as microtoll.dev, shipped inside this package). Returns pages, sections and excerpts; read a page with microtoll_read_doc.',
25
+ inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'What to look for, in words.' }, limit: { type: 'integer', minimum: 1, maximum: 20, default: 8 } }, required: ['query'] },
26
+ handler: ({ query, limit }) => formatSearch(String(query || ''), searchDocs(String(query || ''), { limit: Number.isInteger(limit) ? Math.min(Math.max(limit, 1), 20) : 8 })),
27
+ },
28
+ {
29
+ name: 'microtoll_read_doc',
30
+ description: 'One documentation page as Markdown: the `path` from a search result (for example "packages/access.html") or from https://microtoll.dev/llms.txt.',
31
+ inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
32
+ handler: ({ path }) => {
33
+ const page = readDoc(String(path || ''));
34
+ if (!page) return { text: `No page at "${path}". The index is https://microtoll.dev/llms.txt; paths look like "packages/access.html".`, isError: true };
35
+ return `# ${page.title}\n${page.url}\n\n${page.markdown}`;
36
+ },
37
+ },
38
+ {
39
+ name: 'microtoll_scaffold',
40
+ description: 'Writes the notes starter (an end-to-end-encrypted notes app on Microtoll Engine: client, page, Compose file, nginx, README) into an EMPTY directory, with the namespace and origin filled in. Writes files and nothing else: no commands run, no network. The next steps (npm install, docker compose up) are returned for the person to run.',
41
+ inputSchema: {
42
+ type: 'object',
43
+ properties: {
44
+ directory: { type: 'string', description: 'Where to write the app: an empty or not-yet-existing directory.' },
45
+ namespace: { type: 'string', description: 'The app\'s crypto namespace (a-z, 0-9, "-"); used by the client and the server alike. Default "myapp".' },
46
+ origin: { type: 'string', description: 'The web origin the app runs at, for example http://localhost:8088. Default http://localhost:8088.' },
47
+ name: { type: 'string', description: 'The app\'s name (a-z, 0-9, "-"); default: the namespace.' },
48
+ },
49
+ required: ['directory'],
50
+ },
51
+ handler: (args) => {
52
+ const r = scaffold({ directory: args.directory, namespace: args.namespace || 'myapp', origin: args.origin || 'http://localhost:8088', name: args.name || null });
53
+ return `Wrote ${r.files.length} files into ${r.directory}:\n${r.files.map((f) => ` ${f}`).join('\n')}\n\nNamespace ${r.namespace}, origin ${r.origin}.\n\nNext, for the person to run:\n${r.next.map((s) => ` ${s}`).join('\n')}`;
54
+ },
55
+ },
56
+ ];
57
+ }
58
+
59
+ export function createMicrotollServer() {
60
+ return createServer({ name: 'microtoll', version: VERSION, instructions: INSTRUCTIONS, tools: tools() });
61
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The Model Context Protocol over standard input and output, the subset a
3
+ * tools-only server needs (D-39): JSON-RPC 2.0 messages, one per line, on
4
+ * stdin and stdout; `initialize`, `ping`, `tools/list`, `tools/call`;
5
+ * notifications acknowledged by silence; errors as JSON-RPC errors; logs on
6
+ * stderr, never stdout (stdout is the protocol). Written in rather than
7
+ * taken from the SDK so the whole package can be read in one sitting and
8
+ * carries no dependency tree.
9
+ *
10
+ * Protocol version 2025-06-18. A host offers its version in `initialize`;
11
+ * this server answers with the one it speaks, and a host that cannot speak
12
+ * it disconnects -- the negotiation the specification describes.
13
+ */
14
+ export const PROTOCOL_VERSION = '2025-06-18';
15
+
16
+ const PARSE_ERROR = -32700;
17
+ const INVALID_REQUEST = -32600;
18
+ const METHOD_NOT_FOUND = -32601;
19
+ const INVALID_PARAMS = -32602;
20
+
21
+ /**
22
+ * createServer({ name, version, instructions, tools })
23
+ * tools: [{ name, description, inputSchema, handler(args) -> string | { text, isError } }]
24
+ * Returns { handle(message) -> Promise<reply | null>, listen({ input, output, log }) }.
25
+ * `handle` is the protocol; `listen` wires it to streams.
26
+ */
27
+ export function createServer({ name, version, instructions = '', tools = [] }) {
28
+ for (const t of tools) {
29
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(t.name)) throw new Error(`tool name not allowed: ${t.name}`);
30
+ if (typeof t.handler !== 'function') throw new Error(`tool ${t.name} needs a handler`);
31
+ }
32
+ const byName = new Map(tools.map((t) => [t.name, t]));
33
+ const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
34
+ const error = (id, code, message, data) => ({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } });
35
+
36
+ /** One message in, one reply out (or null for a notification). Never throws. */
37
+ async function handle(msg) {
38
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return error(null, INVALID_REQUEST, 'a JSON-RPC request object was expected');
39
+ const { id, method, params } = msg;
40
+ const isNotification = id === undefined || id === null;
41
+ if (msg.jsonrpc !== '2.0' || typeof method !== 'string') return isNotification ? null : error(id, INVALID_REQUEST, 'invalid request');
42
+ if (method.startsWith('notifications/')) return null; // initialized, cancelled, progress: nothing to do
43
+ if (isNotification) return null; // any other notification is ignored, as the specification allows
44
+ switch (method) {
45
+ case 'initialize':
46
+ return ok(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: { listChanged: false } }, serverInfo: { name, version }, ...(instructions ? { instructions } : {}) });
47
+ case 'ping':
48
+ return ok(id, {});
49
+ case 'tools/list':
50
+ return ok(id, { tools: tools.map(({ name: n, description, inputSchema }) => ({ name: n, description, inputSchema })) });
51
+ case 'tools/call': {
52
+ const wanted = params && typeof params === 'object' ? params.name : undefined;
53
+ const tool = byName.get(wanted);
54
+ if (!tool) return error(id, INVALID_PARAMS, `unknown tool: ${String(wanted)}`);
55
+ const args = params && params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
56
+ try {
57
+ const r = await tool.handler(args);
58
+ const text = typeof r === 'string' ? r : String(r && r.text);
59
+ return ok(id, { content: [{ type: 'text', text }], isError: Boolean(r && typeof r === 'object' && r.isError) });
60
+ } catch (e) {
61
+ // A tool's failure is a tool result, not a protocol error: the host shows it to the model.
62
+ return ok(id, { content: [{ type: 'text', text: e.message }], isError: true });
63
+ }
64
+ }
65
+ default:
66
+ return error(id, METHOD_NOT_FOUND, `method not found: ${method}`);
67
+ }
68
+ }
69
+
70
+ /** Reads newline-delimited JSON from `input`, answers on `output`, in order. */
71
+ function listen({ input = process.stdin, output = process.stdout, log = (line) => process.stderr.write(`${line}\n`) } = {}) {
72
+ let buffer = '';
73
+ let queue = Promise.resolve();
74
+ const write = (reply) => { if (reply) output.write(`${JSON.stringify(reply)}\n`); };
75
+ input.setEncoding('utf8');
76
+ input.on('data', (chunk) => {
77
+ buffer += chunk;
78
+ let nl;
79
+ while ((nl = buffer.indexOf('\n')) >= 0) {
80
+ const line = buffer.slice(0, nl).trim();
81
+ buffer = buffer.slice(nl + 1);
82
+ if (!line) continue;
83
+ let msg;
84
+ try { msg = JSON.parse(line); } catch { write(error(null, PARSE_ERROR, 'parse error')); continue; }
85
+ queue = queue.then(() => handle(msg)).then(write).catch((e) => log(`microtoll-mcp: ${e.message}`));
86
+ }
87
+ });
88
+ input.on('end', () => { queue.then(() => { if (typeof output.end === 'function' && output !== process.stdout) output.end(); }); });
89
+ return { stop: () => input.removeAllListeners('data') };
90
+ }
91
+
92
+ return { handle, listen, tools };
93
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The scaffold: writes the notes starter (the engine's notes example with
3
+ * the namespace and origin filled in) into an EMPTY directory the host
4
+ * names, and does nothing else -- no command runs, nothing is fetched, no
5
+ * telemetry. The templates are generated/scaffold/notes/, produced by the
6
+ * repository's docs build from examples/notes-app and docs/scaffold/notes.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const NAMESPACE_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
13
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
14
+ export const TEMPLATES_DIR = fileURLToPath(new URL('../generated/scaffold/notes/', import.meta.url));
15
+
16
+ /** Everything the scaffold would write, without writing it: [{ name, text }]. */
17
+ export function renderScaffold({ namespace = 'myapp', origin = 'http://localhost:8088', name = null } = {}, templatesDir = TEMPLATES_DIR) {
18
+ if (!NAMESPACE_RE.test(namespace)) throw new Error('namespace: 1-64 characters of a-z, 0-9 and "-", starting with a letter or digit (the same string createCryptoCore takes)');
19
+ let url;
20
+ try { url = new URL(origin); } catch { throw new Error('origin: a web origin such as http://localhost:8088 or https://app.example'); }
21
+ if (!/^https?:$/.test(url.protocol) || url.pathname !== '/' || url.search || url.hash) throw new Error('origin: scheme and host only, no path');
22
+ const appName = name || namespace;
23
+ if (!NAME_RE.test(appName)) throw new Error('name: 1-64 characters of a-z, 0-9 and "-"');
24
+ const port = url.port || (url.protocol === 'https:' ? '443' : '80');
25
+ const wsOrigin = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
26
+ const fill = (text) => text
27
+ .replace(/__NAMESPACE__/g, namespace).replace(/__ORIGIN__/g, url.origin).replace(/__WS_ORIGIN__/g, wsOrigin)
28
+ .replace(/__PORT__/g, port).replace(/__NAME__/g, appName);
29
+ const out = [];
30
+ for (const file of fs.readdirSync(templatesDir).sort()) {
31
+ let text = fs.readFileSync(path.join(templatesDir, file), 'utf8');
32
+ if (file === 'page.js') {
33
+ // The example's own namespace and passkey name become the app's.
34
+ text = text.replace("namespace: 'notes-example'", `namespace: '${namespace}'`).replace("rpName: 'Microtoll notes'", `rpName: '${appName}'`);
35
+ }
36
+ out.push({ name: file, text: fill(text) });
37
+ }
38
+ return { files: out, namespace, origin: url.origin, name: appName };
39
+ }
40
+
41
+ /** Writes the scaffold. Refuses a directory that exists and is not empty. Returns { directory, files, next }. */
42
+ export function scaffold(options = {}) {
43
+ const directory = options.directory;
44
+ if (typeof directory !== 'string' || !directory.trim()) throw new Error('directory: where to write the app (an absolute path, or one relative to the host\'s working directory)');
45
+ const target = path.resolve(directory);
46
+ if (fs.existsSync(target)) {
47
+ if (!fs.statSync(target).isDirectory()) throw new Error(`${target} exists and is not a directory`);
48
+ if (fs.readdirSync(target).length > 0) throw new Error(`${target} is not empty: the scaffold writes only into an empty directory, and never overwrites`);
49
+ }
50
+ const rendered = renderScaffold(options);
51
+ fs.mkdirSync(target, { recursive: true });
52
+ for (const f of rendered.files) fs.writeFileSync(path.join(target, f.name), f.text, { flag: 'wx' });
53
+ return {
54
+ directory: target,
55
+ files: rendered.files.map((f) => f.name),
56
+ namespace: rendered.namespace,
57
+ origin: rendered.origin,
58
+ next: [`cd ${target}`, 'npm install', 'docker compose up', `open ${rendered.origin}`, 'read notes.js, then README.md'],
59
+ };
60
+ }
@@ -0,0 +1,35 @@
1
+ // Hand-written declarations for @microtoll/mcp (M5).
2
+ export const PROTOCOL_VERSION: '2025-06-18';
3
+ export const VERSION: string;
4
+ export const INSTRUCTIONS: string;
5
+
6
+ export interface ToolResult { text: string; isError?: boolean; }
7
+ export interface Tool {
8
+ name: string;
9
+ description: string;
10
+ inputSchema: object;
11
+ handler(args: Record<string, unknown>): string | ToolResult | Promise<string | ToolResult>;
12
+ }
13
+ export interface JsonRpcMessage { jsonrpc?: string; id?: string | number | null; method?: string; params?: unknown; }
14
+ export interface JsonRpcReply { jsonrpc: '2.0'; id: string | number | null; result?: unknown; error?: { code: number; message: string; data?: unknown }; }
15
+ export interface ReadableLike { setEncoding(enc: string): unknown; on(event: string, listener: (...args: any[]) => void): unknown; removeAllListeners(event: string): unknown; }
16
+ export interface WritableLike { write(text: string): unknown; end?(): unknown; }
17
+ export interface McpServer {
18
+ handle(message: unknown): Promise<JsonRpcReply | null>;
19
+ listen(options?: { input?: ReadableLike; output?: WritableLike; log?: (line: string) => void }): { stop(): void };
20
+ tools: Tool[];
21
+ }
22
+ export function createServer(options: { name: string; version: string; instructions?: string; tools?: Tool[] }): McpServer;
23
+ export function createMicrotollServer(): McpServer;
24
+ export function tools(): Tool[];
25
+
26
+ export interface DocPage { path: string; url: string; title: string; description: string; section: string; markdown: string; sections: Array<{ heading: string | null; level: number; text: string }>; }
27
+ export function loadDocs(file?: URL): DocPage[];
28
+ export function readDoc(path: string, docs?: DocPage[]): { path: string; url: string; title: string; description: string; markdown: string } | null;
29
+ export interface SearchHit { path: string; url: string; title: string; heading: string; score: number; excerpt: string; }
30
+ export function searchDocs(query: string, options?: { limit?: number; docs?: DocPage[] }): SearchHit[];
31
+ export function formatSearch(query: string, hits: SearchHit[]): string;
32
+
33
+ export interface ScaffoldOptions { directory: string; namespace?: string; origin?: string; name?: string | null; }
34
+ export function renderScaffold(options?: Omit<ScaffoldOptions, 'directory'>, templatesDir?: string): { files: Array<{ name: string; text: string }>; namespace: string; origin: string; name: string };
35
+ export function scaffold(options: ScaffoldOptions): { directory: string; files: string[]; namespace: string; origin: string; next: string[] };