logisheets-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/dist/server.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The MCP shell: logician tools in, MCP protocol out.
3
+ *
4
+ * We use the SDK's low-level `Server` rather than `McpServer` on purpose.
5
+ * `McpServer.registerTool` wants Zod schemas, while logician tools already
6
+ * carry hand-written JSON Schema — which is what MCP puts on the wire anyway.
7
+ * Going low-level passes those straight through instead of round-tripping them
8
+ * through a Zod translation layer.
9
+ */
10
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
+ import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
12
+ import { WorkbookSession } from './session.js';
13
+ import { selectTools, toolModeFromEnv } from './surface.js';
14
+ import { validateToolInput } from './validate.js';
15
+ import { TOOLS_YIELDING_WORKBOOK, WORKBOOK_URI, XLSX_MIME, } from './lifecycle.js';
16
+ export const SERVER_NAME = 'logisheets';
17
+ export const SERVER_VERSION = '0.1.0';
18
+ /**
19
+ * How the agent should approach this server. Sent as MCP `instructions`, so a
20
+ * host can put it in front of the model before it starts guessing.
21
+ */
22
+ export const INSTRUCTIONS = [
23
+ 'A real, Excel-compatible spreadsheet engine you can compute in and remember in.',
24
+ '',
25
+ 'Two things it is for:',
26
+ ' 1. Arithmetic you should not do yourself. Write a formula and let the engine evaluate it — `eval_formula` for a one-off, or store the formula in a cell so it keeps recalculating.',
27
+ ' 2. Structured memory that survives the whole task. A *block* is a named table; you address its cells by (block name, row key, field name), never by A1 coordinates. Inserting rows never breaks a reference, so you can keep building without tracking where anything sits.',
28
+ '',
29
+ 'The loop: `list_blocks` to see what you have, `create_block` to open a structured workspace, `add_block_rows` / `set_block_cells` to fill it, formulas for the math, `describe_block` to read results back, `save_workbook` to hand the human a real .xlsx.',
30
+ '',
31
+ 'Prefer blocks over raw cells. `get_cells` / `set_cells` exist for data that genuinely has no structure.',
32
+ ].join('\n');
33
+ /** Translate a logician tool into its MCP wire description. */
34
+ function toMcpTool(t) {
35
+ // logician declares `required` readonly; the wire type wants it mutable.
36
+ const { required, ...schema } = t.inputSchema;
37
+ return {
38
+ // logician namespaces tools (`build__create_block`) to keep crafts from
39
+ // colliding. MCP hosts already prefix by server, so the namespace would
40
+ // just be noise in the model's context; names are asserted unique below.
41
+ name: t.name,
42
+ description: t.description,
43
+ // Spread first: a tool's own `type` must not shadow 'object', which is
44
+ // what MCP requires at the top level of a tool's input schema.
45
+ inputSchema: {
46
+ ...schema,
47
+ type: 'object',
48
+ ...(required !== undefined ? { required: [...required] } : {}),
49
+ },
50
+ annotations: {
51
+ readOnlyHint: !t.mutates,
52
+ // logician's 'destructive' policy marks the tools that discard data
53
+ // (clear a block, delete rows/sheets) rather than merely write.
54
+ destructiveHint: t.confirmation === 'destructive',
55
+ },
56
+ };
57
+ }
58
+ /**
59
+ * Build the server. Nothing is started — hand the result to a transport.
60
+ */
61
+ export function createServer(opts = {}) {
62
+ const session = opts.session ?? new WorkbookSession();
63
+ const mode = opts.mode ?? toolModeFromEnv();
64
+ const log = opts.log ?? ((msg) => process.stderr.write(`${msg}\n`));
65
+ const tools = new Map();
66
+ for (const t of selectTools(session, mode)) {
67
+ if (tools.has(t.name)) {
68
+ throw new Error(`two tools share the MCP name "${t.name}" — namespaces differ but names must be unique`);
69
+ }
70
+ tools.set(t.name, t);
71
+ }
72
+ const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, {
73
+ capabilities: { tools: {}, resources: {} },
74
+ instructions: INSTRUCTIONS,
75
+ });
76
+ // The workbook as a resource. This is how the finished file reaches the
77
+ // human: `save_workbook` returns a link, and a host that wants the bytes
78
+ // reads them here — outside the model's context, so a 200 KB workbook costs
79
+ // the conversation nothing.
80
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
81
+ if (!session.isOpen)
82
+ return { resources: [] };
83
+ return {
84
+ resources: [
85
+ {
86
+ uri: WORKBOOK_URI,
87
+ name: session.path ?? 'workbook.xlsx',
88
+ title: 'The active workbook',
89
+ description: 'The workbook this session is working in, serialized as a real .xlsx.',
90
+ mimeType: XLSX_MIME,
91
+ // Marked for the human: it is a file to open, not text to
92
+ // reason over.
93
+ annotations: { audience: ['user'] },
94
+ },
95
+ ],
96
+ };
97
+ });
98
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
99
+ if (request.params.uri !== WORKBOOK_URI) {
100
+ throw new Error(`unknown resource: ${request.params.uri}`);
101
+ }
102
+ // Serialize through the same lane as the tools, so a read can't observe
103
+ // a half-applied transaction.
104
+ const { base64, bytes } = await session.run(async () => session.exportBase64());
105
+ return {
106
+ contents: [
107
+ {
108
+ uri: WORKBOOK_URI,
109
+ mimeType: XLSX_MIME,
110
+ blob: base64,
111
+ _meta: { bytes },
112
+ },
113
+ ],
114
+ };
115
+ });
116
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
117
+ tools: [...tools.values()].map(toMcpTool),
118
+ }));
119
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
120
+ const tool = tools.get(request.params.name);
121
+ if (tool === undefined) {
122
+ return {
123
+ content: [
124
+ { type: 'text', text: `unknown tool: ${request.params.name}` },
125
+ ],
126
+ isError: true,
127
+ };
128
+ }
129
+ // Check arguments against the tool's declared schema first. Nothing
130
+ // else does — the SDK passes `arguments` through untouched — so a
131
+ // wrong parameter name would otherwise surface as whatever
132
+ // TypeError the handler happens to throw, naming neither the tool
133
+ // nor the parameter the agent got wrong.
134
+ const invalid = validateToolInput(tool, request.params.arguments);
135
+ if (invalid !== undefined) {
136
+ return {
137
+ content: [{ type: 'text', text: invalid }],
138
+ isError: true,
139
+ };
140
+ }
141
+ const ctx = {
142
+ workbook: session.client,
143
+ signal: extra.signal,
144
+ // The MCP host owns approval: it decided to dispatch this call,
145
+ // and a server-side prompt has nobody to ask. Mutating tools
146
+ // are flagged via annotations so the host can gate them there.
147
+ confirm: async () => true,
148
+ log,
149
+ };
150
+ try {
151
+ // One workbook, one lane. Handlers read state and then write it
152
+ // across an await, and the host may have several calls in
153
+ // flight, so without this they interleave and lose. See
154
+ // WorkbookSession.run.
155
+ const result = await session.run(() => tool.handler(request.params.arguments ?? {}, ctx));
156
+ if (result.canceled === true) {
157
+ return {
158
+ content: [{ type: 'text', text: 'canceled' }],
159
+ isError: true,
160
+ };
161
+ }
162
+ const content = [];
163
+ if (result.display !== undefined && result.display !== '') {
164
+ content.push({ type: 'text', text: result.display });
165
+ }
166
+ if (result.data !== undefined) {
167
+ content.push({
168
+ type: 'text',
169
+ text: JSON.stringify(result.data),
170
+ });
171
+ }
172
+ if (content.length === 0) {
173
+ content.push({ type: 'text', text: 'ok' });
174
+ }
175
+ // Hand back a reference to the file, not the file. The host can
176
+ // turn this into a download for the human; the model just sees a
177
+ // short link.
178
+ if (TOOLS_YIELDING_WORKBOOK.has(tool.name)) {
179
+ content.push({
180
+ type: 'resource_link',
181
+ uri: WORKBOOK_URI,
182
+ name: session.path ?? 'workbook.xlsx',
183
+ mimeType: XLSX_MIME,
184
+ description: 'The saved workbook. Read this resource to get the .xlsx bytes.',
185
+ annotations: { audience: ['user'] },
186
+ });
187
+ }
188
+ return { content };
189
+ }
190
+ catch (err) {
191
+ // Tool failures are results, not protocol errors: the agent
192
+ // should see the message and get a chance to correct itself.
193
+ return {
194
+ content: [
195
+ {
196
+ type: 'text',
197
+ text: err instanceof Error ? err.message : String(err),
198
+ },
199
+ ],
200
+ isError: true,
201
+ };
202
+ }
203
+ });
204
+ return { server, session, tools };
205
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The session's workbook — the thing that makes this server *memory* rather
3
+ * than a calculator.
4
+ *
5
+ * One MCP session owns one active workbook, held here and persistent across
6
+ * tool calls. Every tool operates on it, so state the agent built in step 3 is
7
+ * still there in step 30. (Multiple named workbooks per session is a later
8
+ * feature; a single active one keeps the tool surface small and the agent's
9
+ * mental model simple.)
10
+ */
11
+ import { Workbook } from 'logisheets-runtime';
12
+ /** The async engine client, as logician's tool handlers consume it. */
13
+ export type WorkbookClient = Workbook['client'];
14
+ export interface OpenResult {
15
+ /** Where the workbook came from. */
16
+ source: 'new' | 'file' | 'bytes';
17
+ /** Absolute path, when opened from (or destined for) a file. */
18
+ path?: string;
19
+ /** Sheet names in order. */
20
+ sheets: string[];
21
+ }
22
+ export interface SaveResult {
23
+ path: string;
24
+ bytes: number;
25
+ }
26
+ export declare class WorkbookSession {
27
+ private readonly runtime;
28
+ private active;
29
+ /** Tail of the serialization chain — see {@link run}. */
30
+ private lane;
31
+ /** Where {@link saveTo} last wrote, so the workbook resource can be named
32
+ * after the file the human is actually being handed. */
33
+ private lastSaved;
34
+ /**
35
+ * Run `fn` after everything already queued on this session, and before
36
+ * anything queued later. One workbook, one lane.
37
+ *
38
+ * Tool handlers are read-then-write against shared state: `create_block`
39
+ * asks whether its sheet exists and creates it if not; it asks the engine
40
+ * for a free block id and then claims it. Those are two awaits with a gap
41
+ * in between, and MCP does not promise to serialize requests — JSON-RPC
42
+ * allows pipelining and the SDK dispatches concurrently. Three
43
+ * `create_block` calls in flight at once therefore each saw the sheet
44
+ * missing, each tried to create it, and two failed.
45
+ *
46
+ * Serializing costs nothing here. The engine is a single synchronous WASM
47
+ * instance, so concurrent handlers never bought throughput — they only
48
+ * interleaved. Reads are queued too: a read overlapping a half-applied
49
+ * transaction would report state that never existed.
50
+ *
51
+ * Not reentrant. Wrap once, at the dispatch boundary; a handler that called
52
+ * back into `run` would wait on itself forever.
53
+ */
54
+ run<T>(fn: () => Promise<T>): Promise<T>;
55
+ /**
56
+ * The active workbook, created empty on first touch.
57
+ *
58
+ * Lazy creation is deliberate: an agent that dives straight into
59
+ * `create_block` shouldn't fail because it skipped `open_workbook`. The
60
+ * scratchpad simply exists as soon as anything reaches for it.
61
+ */
62
+ get workbook(): Workbook;
63
+ get client(): WorkbookClient;
64
+ /**
65
+ * The file this workbook belongs to: where it was last saved, else where it
66
+ * was opened from. Undefined for a scratch workbook that has never been
67
+ * written.
68
+ */
69
+ get path(): string | undefined;
70
+ /** True once a workbook exists — i.e. anything has touched the session. */
71
+ get isOpen(): boolean;
72
+ /**
73
+ * Replace the active workbook.
74
+ *
75
+ * `path` loads a real `.xlsx` from disk; `xlsxBase64` loads one from bytes
76
+ * (for hosts with no shared filesystem); neither starts an empty workbook.
77
+ */
78
+ open(opts: {
79
+ path?: string;
80
+ xlsxBase64?: string;
81
+ name?: string;
82
+ }): Promise<OpenResult>;
83
+ /** Sheet names of the active workbook, in order. */
84
+ sheetNames(): Promise<string[]>;
85
+ /**
86
+ * Write the workbook to `path`, defaulting to where it was opened from.
87
+ * Returns the absolute path actually written and the file size.
88
+ */
89
+ saveTo(path?: string, opts?: {
90
+ resolveBlockRefs?: boolean;
91
+ }): Promise<SaveResult>;
92
+ /** Serialize the workbook to base64 `.xlsx` for transports with no shared disk. */
93
+ exportBase64(resolveBlockRefs?: boolean): {
94
+ base64: string;
95
+ bytes: number;
96
+ };
97
+ /** Release every engine resource this session holds. */
98
+ close(): void;
99
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The session's workbook — the thing that makes this server *memory* rather
3
+ * than a calculator.
4
+ *
5
+ * One MCP session owns one active workbook, held here and persistent across
6
+ * tool calls. Every tool operates on it, so state the agent built in step 3 is
7
+ * still there in step 30. (Multiple named workbooks per session is a later
8
+ * feature; a single active one keeps the tool surface small and the agent's
9
+ * mental model simple.)
10
+ */
11
+ import { resolve } from 'node:path';
12
+ import { stat } from 'node:fs/promises';
13
+ import { SpreadsheetRuntime } from 'logisheets-runtime';
14
+ export class WorkbookSession {
15
+ runtime = new SpreadsheetRuntime();
16
+ active;
17
+ /** Tail of the serialization chain — see {@link run}. */
18
+ lane = Promise.resolve();
19
+ /** Where {@link saveTo} last wrote, so the workbook resource can be named
20
+ * after the file the human is actually being handed. */
21
+ lastSaved;
22
+ /**
23
+ * Run `fn` after everything already queued on this session, and before
24
+ * anything queued later. One workbook, one lane.
25
+ *
26
+ * Tool handlers are read-then-write against shared state: `create_block`
27
+ * asks whether its sheet exists and creates it if not; it asks the engine
28
+ * for a free block id and then claims it. Those are two awaits with a gap
29
+ * in between, and MCP does not promise to serialize requests — JSON-RPC
30
+ * allows pipelining and the SDK dispatches concurrently. Three
31
+ * `create_block` calls in flight at once therefore each saw the sheet
32
+ * missing, each tried to create it, and two failed.
33
+ *
34
+ * Serializing costs nothing here. The engine is a single synchronous WASM
35
+ * instance, so concurrent handlers never bought throughput — they only
36
+ * interleaved. Reads are queued too: a read overlapping a half-applied
37
+ * transaction would report state that never existed.
38
+ *
39
+ * Not reentrant. Wrap once, at the dispatch boundary; a handler that called
40
+ * back into `run` would wait on itself forever.
41
+ */
42
+ run(fn) {
43
+ // Run `fn` whether or not its predecessor settled cleanly, then keep the
44
+ // lane resolved so one failed tool call can't poison the queue.
45
+ const result = this.lane.then(fn, fn);
46
+ this.lane = result.then(() => undefined, () => undefined);
47
+ return result;
48
+ }
49
+ /**
50
+ * The active workbook, created empty on first touch.
51
+ *
52
+ * Lazy creation is deliberate: an agent that dives straight into
53
+ * `create_block` shouldn't fail because it skipped `open_workbook`. The
54
+ * scratchpad simply exists as soon as anything reaches for it.
55
+ */
56
+ get workbook() {
57
+ if (this.active === undefined) {
58
+ this.active = this.runtime.createWorkbook();
59
+ }
60
+ return this.active;
61
+ }
62
+ get client() {
63
+ return this.workbook.client;
64
+ }
65
+ /**
66
+ * The file this workbook belongs to: where it was last saved, else where it
67
+ * was opened from. Undefined for a scratch workbook that has never been
68
+ * written.
69
+ */
70
+ get path() {
71
+ return this.lastSaved ?? this.workbook.path;
72
+ }
73
+ /** True once a workbook exists — i.e. anything has touched the session. */
74
+ get isOpen() {
75
+ return this.active !== undefined;
76
+ }
77
+ /**
78
+ * Replace the active workbook.
79
+ *
80
+ * `path` loads a real `.xlsx` from disk; `xlsxBase64` loads one from bytes
81
+ * (for hosts with no shared filesystem); neither starts an empty workbook.
82
+ */
83
+ async open(opts) {
84
+ if (opts.path !== undefined && opts.xlsxBase64 !== undefined) {
85
+ throw new Error('pass either path or xlsx_base64, not both');
86
+ }
87
+ let next;
88
+ let source;
89
+ if (opts.path !== undefined) {
90
+ next = await this.runtime.loadWorkbook(opts.path);
91
+ source = 'file';
92
+ }
93
+ else if (opts.xlsxBase64 !== undefined) {
94
+ const bytes = Buffer.from(opts.xlsxBase64, 'base64');
95
+ if (bytes.length === 0) {
96
+ throw new Error('xlsx_base64 decoded to zero bytes');
97
+ }
98
+ next = this.runtime.loadWorkbookFromBytes(bytes, opts.name ?? 'workbook.xlsx');
99
+ source = 'bytes';
100
+ }
101
+ else {
102
+ next = this.runtime.createWorkbook();
103
+ source = 'new';
104
+ }
105
+ // Release the workbook being displaced. The runtime dedups loads by
106
+ // path, so re-opening the current file hands back the same handle —
107
+ // closing it then would release the workbook we just "opened".
108
+ const previous = this.active;
109
+ this.active = next;
110
+ this.lastSaved = undefined;
111
+ if (previous !== undefined && previous !== next) {
112
+ this.runtime.close(previous);
113
+ }
114
+ return { source, path: next.path, sheets: await this.sheetNames() };
115
+ }
116
+ /** Sheet names of the active workbook, in order. */
117
+ async sheetNames() {
118
+ const infos = await this.workbook.client.getAllSheetInfo();
119
+ if (isErrorMessage(infos)) {
120
+ throw new Error(`getAllSheetInfo failed: ${infos.msg}`);
121
+ }
122
+ return infos.map((s) => s.name);
123
+ }
124
+ /**
125
+ * Write the workbook to `path`, defaulting to where it was opened from.
126
+ * Returns the absolute path actually written and the file size.
127
+ */
128
+ async saveTo(path, opts = {}) {
129
+ // `this.path`, not `workbook.path`: a second bare save should go back to
130
+ // wherever the last one went, even for a workbook that started empty.
131
+ const target = path ?? this.path;
132
+ if (target === undefined) {
133
+ throw new Error('no path given, and this workbook was not opened from a file — ' +
134
+ 'pass an explicit path');
135
+ }
136
+ const absolute = resolve(target);
137
+ await this.workbook.saveAs(absolute, '', opts.resolveBlockRefs ?? false);
138
+ this.lastSaved = absolute;
139
+ // Size from disk rather than a second `save()` — serializing a whole
140
+ // workbook twice just to report a number is not worth it.
141
+ return { path: absolute, bytes: (await stat(absolute)).size };
142
+ }
143
+ /** Serialize the workbook to base64 `.xlsx` for transports with no shared disk. */
144
+ exportBase64(resolveBlockRefs = false) {
145
+ const data = this.workbook.save('', resolveBlockRefs);
146
+ return {
147
+ base64: Buffer.from(data).toString('base64'),
148
+ bytes: data.length,
149
+ };
150
+ }
151
+ /** Release every engine resource this session holds. */
152
+ close() {
153
+ this.runtime.closeAll();
154
+ this.active = undefined;
155
+ this.lastSaved = undefined;
156
+ }
157
+ }
158
+ /** Local copy of the engine's error-shape guard (avoids a direct engine dep). */
159
+ function isErrorMessage(v) {
160
+ return (typeof v === 'object' &&
161
+ v !== null &&
162
+ 'msg' in v &&
163
+ typeof v.msg === 'string');
164
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Which tools this server exposes.
3
+ *
4
+ * logician ships ~55 tools, built for an in-app assistant with a UI. Handing an
5
+ * agent all of them is a real cost: tool-selection accuracy falls as the list
6
+ * grows, and every description is context the agent pays for on every turn. So
7
+ * the default is a deliberate core — the loop from the design doc and nothing
8
+ * else — with the rest available behind an env flag.
9
+ *
10
+ * LOGISHEETS_MCP_TOOLS=core (default) the 19 below
11
+ * LOGISHEETS_MCP_TOOLS=full everything except the browser-only tools
12
+ */
13
+ import type { Tool } from 'logisheets-logician';
14
+ import type { WorkbookSession } from './session.js';
15
+ export type ToolMode = 'core' | 'full';
16
+ /** Read the mode from the environment, defaulting to `core`. */
17
+ export declare function toolModeFromEnv(env?: Record<string, string | undefined>): ToolMode;
18
+ /**
19
+ * Resolve the tool list for a session.
20
+ *
21
+ * Core tools come first and in the declared order, so a host that renders the
22
+ * list in order shows the agent the loop rather than an alphabet soup.
23
+ */
24
+ export declare function selectTools(session: WorkbookSession, mode: ToolMode): Tool[];
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Which tools this server exposes.
3
+ *
4
+ * logician ships ~55 tools, built for an in-app assistant with a UI. Handing an
5
+ * agent all of them is a real cost: tool-selection accuracy falls as the list
6
+ * grows, and every description is context the agent pays for on every turn. So
7
+ * the default is a deliberate core — the loop from the design doc and nothing
8
+ * else — with the rest available behind an env flag.
9
+ *
10
+ * LOGISHEETS_MCP_TOOLS=core (default) the 19 below
11
+ * LOGISHEETS_MCP_TOOLS=full everything except the browser-only tools
12
+ */
13
+ import { BLOCK_OPS_TOOLS, BUILDER_TOOLS, CELL_TOOLS, COMMENT_TOOLS, EDIT_TOOLS, FORMAT_TOOLS, HISTORY_TOOLS, INSPECT_TOOLS, LINK_TOOLS, STRUCTURE_TOOLS, toolId, } from 'logisheets-logician';
14
+ import { createLifecycleTools } from './lifecycle.js';
15
+ /**
16
+ * The core surface, in the order an agent meets it: orient, build structured
17
+ * memory, compute, read back, hand over.
18
+ *
19
+ * Everything here is addressed semantically — `(block, row_key, field)` rather
20
+ * than `C7` — because positional addressing is exactly what agents get wrong.
21
+ * The raw-cell pair is the escape hatch, kept last on purpose.
22
+ */
23
+ const CORE_IDS = [
24
+ // Lifecycle
25
+ 'workbook__open_workbook',
26
+ 'workbook__save_workbook',
27
+ 'workbook__export_xlsx',
28
+ // Orient
29
+ 'build__list_blocks',
30
+ 'build__describe_block',
31
+ // Compute
32
+ 'build__eval_formula',
33
+ // Structured memory
34
+ 'build__create_block',
35
+ // The counterpart for a workbook someone hands you: `create_block` refuses
36
+ // to write over existing data, and this takes data that is already there and
37
+ // makes it addressable in place. Without it an agent given a legacy file has
38
+ // only the destructive half of the pair.
39
+ 'build__convert_to_block',
40
+ 'build__add_block_rows',
41
+ 'build__delete_block_rows',
42
+ // Row order is presentation, not model — but the presentation is part of
43
+ // the deliverable. A person can drag rows around in the app; without this
44
+ // an agent handed the same file cannot, and cannot put a table into the
45
+ // order someone asked for.
46
+ 'build__move_block_row',
47
+ 'edit__set_block_cells',
48
+ 'build__set_field_rule',
49
+ // Sheets
50
+ 'build__create_sheet',
51
+ // Raw-cell escape hatch
52
+ 'cell__get_cells',
53
+ 'cell__set_cells',
54
+ // `set_field_rule` can attach a validation rule, and this is the only way
55
+ // to see what breaks it — without it, validation is write-only and the
56
+ // agent has no way to check its own work.
57
+ 'inspect__list_violations',
58
+ // Answering "what would happen if…" without changing anything. Read-only:
59
+ // it runs the edits on the engine's temp branch, reports the whole cascade
60
+ // and discards them. Without it the only way to explore is to mutate and
61
+ // put back, which walks the model somewhere else if anything goes wrong
62
+ // mid-scan — and a sensitivity scan is dozens of probes.
63
+ 'edit__preview_changes',
64
+ // Auditing a number and predicting the blast radius of an edit, from the
65
+ // engine's own dependency graph. The alternative is reading every formula
66
+ // in the workbook and parsing it — and that still cannot answer the reverse
67
+ // direction, which is the one you want before changing an assumption.
68
+ 'inspect__trace',
69
+ // Reverse the model: what input lands the answer on a given number. Runs the
70
+ // whole search on the temp branch inside one call — as a conversation it is
71
+ // one round trip per bisection step, and it changes nothing either way.
72
+ 'edit__goal_seek',
73
+ ];
74
+ /**
75
+ * Tools that cannot work here, excluded from `full` as well as `core`.
76
+ *
77
+ * The craft-interaction tools register cell widgets in the browser app; they
78
+ * detect a headless host and return "not available", so exposing them would
79
+ * only spend context to advertise failures. `get_active_selection` reads what
80
+ * the user has selected on a canvas that doesn't exist in an MCP session.
81
+ */
82
+ const NEVER_IDS = new Set([
83
+ 'craft__register_radio_group',
84
+ 'craft__register_multi_select_group',
85
+ 'craft__register_point_allocator',
86
+ 'craft__register_percent_allocator',
87
+ 'craft__register_number_slider',
88
+ 'craft__clear_interaction',
89
+ 'craft__read_selection',
90
+ 'inspect__get_active_selection',
91
+ ]);
92
+ /** Every logician tool this server is willing to expose, core first. */
93
+ function allEngineTools() {
94
+ return [
95
+ ...BUILDER_TOOLS,
96
+ ...EDIT_TOOLS,
97
+ ...CELL_TOOLS,
98
+ ...INSPECT_TOOLS,
99
+ ...STRUCTURE_TOOLS,
100
+ ...FORMAT_TOOLS,
101
+ ...HISTORY_TOOLS,
102
+ ...COMMENT_TOOLS,
103
+ ...BLOCK_OPS_TOOLS,
104
+ ...LINK_TOOLS,
105
+ ];
106
+ }
107
+ /** Read the mode from the environment, defaulting to `core`. */
108
+ export function toolModeFromEnv(env = process.env) {
109
+ const raw = env.LOGISHEETS_MCP_TOOLS?.trim().toLowerCase();
110
+ if (raw === undefined || raw === '')
111
+ return 'core';
112
+ if (raw === 'core' || raw === 'full')
113
+ return raw;
114
+ throw new Error(`LOGISHEETS_MCP_TOOLS must be "core" or "full", got "${raw}"`);
115
+ }
116
+ /**
117
+ * Resolve the tool list for a session.
118
+ *
119
+ * Core tools come first and in the declared order, so a host that renders the
120
+ * list in order shows the agent the loop rather than an alphabet soup.
121
+ */
122
+ export function selectTools(session, mode) {
123
+ const available = new Map();
124
+ for (const t of [...createLifecycleTools(session), ...allEngineTools()]) {
125
+ const id = toolId(t);
126
+ if (available.has(id)) {
127
+ throw new Error(`duplicate tool id from logician: ${id}`);
128
+ }
129
+ available.set(id, t);
130
+ }
131
+ const core = CORE_IDS.map((id) => {
132
+ const t = available.get(id);
133
+ if (t === undefined) {
134
+ // A rename upstream must fail loudly at startup rather than
135
+ // silently shrink the agent's surface.
136
+ throw new Error(`core tool "${id}" not found — logisheets-logician may have renamed it`);
137
+ }
138
+ return t;
139
+ });
140
+ if (mode === 'core')
141
+ return core;
142
+ const coreIds = new Set(CORE_IDS);
143
+ const rest = [...available.entries()]
144
+ .filter(([id]) => !coreIds.has(id) && !NEVER_IDS.has(id))
145
+ .map(([, t]) => t);
146
+ return [...core, ...rest];
147
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Check tool arguments against the tool's own declared JSON Schema, before the
3
+ * handler ever sees them.
4
+ *
5
+ * MCP puts each tool's `inputSchema` on the wire, but nothing enforces it: the
6
+ * SDK hands `params.arguments` straight through. Handlers then read fields that
7
+ * aren't there, and the agent gets whatever TypeError falls out —
8
+ * `Cannot read properties of undefined (reading 'startsWith')` for a missing
9
+ * `expr`. That names neither the tool nor the parameter, so the agent has no
10
+ * way to correct itself and burns turns guessing.
11
+ *
12
+ * Agents mostly get arguments wrong in a few predictable ways: they omit a
13
+ * required parameter, invent a plausible synonym for its name (`formula` for
14
+ * `expr`), pass a string where a number belongs, or guess an enum variant. So
15
+ * the messages here name the parameter, say what was expected, and — the part
16
+ * that actually saves a turn — suggest the declared name a stray key looks like.
17
+ *
18
+ * Deliberately a subset of draft-07: the keywords logician's schemas actually
19
+ * use (type incl. unions, required, properties, items, enum, bounds). No $ref,
20
+ * no anyOf/allOf. An unrecognized keyword is ignored rather than guessed at.
21
+ */
22
+ import type { Tool } from 'logisheets-logician';
23
+ /**
24
+ * Validate a tool call's arguments. Returns an agent-facing message listing
25
+ * every problem, or undefined when the arguments are acceptable.
26
+ */
27
+ export declare function validateToolInput(tool: Pick<Tool, 'name' | 'inputSchema'>, args: unknown): string | undefined;