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.
@@ -0,0 +1,248 @@
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
+ /** Levenshtein distance, iterative two-row. Only used on short key names. */
23
+ function editDistance(a, b) {
24
+ if (a === b)
25
+ return 0;
26
+ let prev = Int32Array.from({ length: b.length + 1 }, (_, i) => i);
27
+ let row = new Int32Array(b.length + 1);
28
+ // Every index below is in bounds by construction; `?? 0` is only there to
29
+ // satisfy noUncheckedIndexedAccess, which applies to typed arrays too.
30
+ for (let i = 1; i <= a.length; i++) {
31
+ row[0] = i;
32
+ for (let j = 1; j <= b.length; j++) {
33
+ const drop = (prev[j] ?? 0) + 1;
34
+ const add = (row[j - 1] ?? 0) + 1;
35
+ const swap = (prev[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1);
36
+ row[j] = Math.min(drop, add, swap);
37
+ }
38
+ // Swap the rows instead of reallocating; the old `prev` becomes scratch.
39
+ const done = row;
40
+ row = prev;
41
+ prev = done;
42
+ }
43
+ return prev[b.length] ?? 0;
44
+ }
45
+ /**
46
+ * The declared name `key` was probably meant to be, or undefined. Case-blind
47
+ * exact match first (`Expr` → `expr`), then near-misses, with the threshold
48
+ * scaled to length so short names don't match everything.
49
+ */
50
+ function didYouMean(key, candidates) {
51
+ const lower = key.toLowerCase();
52
+ const exact = candidates.find((c) => c.toLowerCase() === lower);
53
+ if (exact !== undefined)
54
+ return exact;
55
+ let best;
56
+ let bestDist = Infinity;
57
+ for (const c of candidates) {
58
+ const d = editDistance(lower, c.toLowerCase());
59
+ if (d < bestDist) {
60
+ bestDist = d;
61
+ best = c;
62
+ }
63
+ }
64
+ if (best === undefined)
65
+ return undefined;
66
+ const maxLen = Math.max(key.length, best.length);
67
+ const limit = Math.min(3, Math.max(1, Math.ceil(maxLen / 3)));
68
+ return bestDist <= limit ? best : undefined;
69
+ }
70
+ /** The JSON type name for a value, in the vocabulary schemas use. */
71
+ function jsonTypeOf(v) {
72
+ if (v === null)
73
+ return 'null';
74
+ if (Array.isArray(v))
75
+ return 'array';
76
+ if (typeof v === 'number')
77
+ return Number.isInteger(v) ? 'integer' : 'number';
78
+ if (typeof v === 'string')
79
+ return 'string';
80
+ if (typeof v === 'boolean')
81
+ return 'boolean';
82
+ return 'object';
83
+ }
84
+ /** Does `v` satisfy a single declared type? `integer` implies `number`. */
85
+ function matchesType(v, want) {
86
+ const got = jsonTypeOf(v);
87
+ if (got === want)
88
+ return true;
89
+ // An integer is a valid number; a whole-valued float is a valid integer.
90
+ if (want === 'number' && got === 'integer')
91
+ return true;
92
+ return false;
93
+ }
94
+ function describe(v) {
95
+ if (typeof v === 'string')
96
+ return JSON.stringify(v);
97
+ if (v === null || v === undefined)
98
+ return String(v);
99
+ if (Array.isArray(v))
100
+ return `an array of ${v.length}`;
101
+ if (typeof v === 'object')
102
+ return 'an object';
103
+ return String(v);
104
+ }
105
+ /** Human-readable form of a schema's declared type(s). */
106
+ function typeNames(t) {
107
+ return Array.isArray(t) ? t.join(' or ') : String(t);
108
+ }
109
+ /**
110
+ * Collect every problem with `value` against `schema`. `path` is the argument
111
+ * name as the agent wrote it (`changes[0].row_key`), so a message points at the
112
+ * exact spot in a nested payload.
113
+ */
114
+ function collect(value, schema, path, out) {
115
+ const label = path === '' ? 'argument' : `\`${path}\``;
116
+ if (schema.type !== undefined) {
117
+ const wanted = Array.isArray(schema.type) ? schema.type : [schema.type];
118
+ if (!wanted.some((w) => matchesType(value, w))) {
119
+ out.push(`${label} must be ${typeNames(schema.type)}, got ${describe(value)}`);
120
+ // The type is wrong, so every nested check below would just be
121
+ // noise about a value that has to be replaced wholesale.
122
+ return;
123
+ }
124
+ }
125
+ if (schema.enum !== undefined && Array.isArray(schema.enum)) {
126
+ if (!schema.enum.includes(value)) {
127
+ const allowed = schema.enum.map((e) => JSON.stringify(e)).join(', ');
128
+ const hint = typeof value === 'string'
129
+ ? didYouMean(value, schema.enum.filter((e) => typeof e === 'string'))
130
+ : undefined;
131
+ out.push(`${label} must be one of ${allowed}, got ${describe(value)}` +
132
+ (hint !== undefined ? ` — did you mean ${JSON.stringify(hint)}?` : ''));
133
+ }
134
+ }
135
+ if (typeof value === 'number') {
136
+ if (schema.minimum !== undefined && value < schema.minimum) {
137
+ out.push(`${label} must be >= ${schema.minimum}, got ${value}`);
138
+ }
139
+ if (schema.maximum !== undefined && value > schema.maximum) {
140
+ out.push(`${label} must be <= ${schema.maximum}, got ${value}`);
141
+ }
142
+ }
143
+ if (Array.isArray(value)) {
144
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
145
+ out.push(`${label} needs at least ${schema.minItems} item(s), got ${value.length}`);
146
+ }
147
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) {
148
+ out.push(`${label} allows at most ${schema.maxItems} item(s), got ${value.length}`);
149
+ }
150
+ if (schema.items !== undefined) {
151
+ for (let i = 0; i < value.length; i++) {
152
+ collect(value[i], schema.items, `${path}[${i}]`, out);
153
+ }
154
+ }
155
+ return;
156
+ }
157
+ // Objects: missing required keys, then recurse into declared properties.
158
+ if (value !== null &&
159
+ typeof value === 'object' &&
160
+ (schema.properties !== undefined || schema.required !== undefined)) {
161
+ const obj = value;
162
+ const props = schema.properties ?? {};
163
+ const declared = Object.keys(props);
164
+ const unknown = Object.keys(obj).filter((k) => !declared.includes(k));
165
+ const missing = (schema.required ?? []).filter((k) => obj[k] === undefined);
166
+ // Stray keys already blamed on a missing parameter; not repeated below.
167
+ const paired = new Set();
168
+ for (const key of missing) {
169
+ // The commonest agent mistake: the value IS there, under a name the
170
+ // agent invented. Point at the stray key rather than just the gap.
171
+ //
172
+ // Two ways to spot it. A lexical near-miss catches typos
173
+ // (`feild`/`field`). But the frequent case is a *synonym* —
174
+ // `formula` for `expr` — which shares no letters, so fall back to
175
+ // structure: exactly one parameter missing and exactly one key the
176
+ // schema doesn't know is almost certainly the same one renamed.
177
+ let stray = unknown.find((k) => !paired.has(k) && didYouMean(k, [key]) === key);
178
+ if (stray === undefined &&
179
+ missing.length === 1 &&
180
+ unknown.length === 1) {
181
+ const only = unknown[0];
182
+ // ...unless that key is plainly a typo of some OTHER declared
183
+ // parameter (`feild` for `field`). Blaming it on this one would
184
+ // send the agent to the wrong place; the unknown-key hint below
185
+ // names the right one.
186
+ const elsewhere = didYouMean(only, declared);
187
+ if (elsewhere === undefined || elsewhere === key)
188
+ stray = only;
189
+ }
190
+ if (stray !== undefined)
191
+ paired.add(stray);
192
+ const at = path === '' ? '' : ` on \`${path}\``;
193
+ out.push(stray !== undefined
194
+ ? `missing required parameter \`${key}\`${at} — you passed \`${stray}\`, did you mean \`${key}\`?`
195
+ : `missing required parameter \`${key}\`${at}`);
196
+ }
197
+ for (const [key, sub] of Object.entries(props)) {
198
+ const v = obj[key];
199
+ if (v === undefined)
200
+ continue; // absent optional; required handled above
201
+ collect(v, sub, path === '' ? key : `${path}.${key}`, out);
202
+ }
203
+ // Unknown keys are a hard error, not a hint.
204
+ //
205
+ // This started out the other way round — reported only alongside some
206
+ // other problem, on the theory that a handler might tolerate extras
207
+ // and that rejecting a call which would have worked is worse than
208
+ // staying quiet. Testing settled it: passing `after_key` to a tool
209
+ // that had no such parameter returned success, the rows went
210
+ // somewhere else entirely, and everything downstream reasoned from a
211
+ // false premise. A rejected call costs one retry; a silently ignored
212
+ // parameter costs the agent its model of what the workbook contains.
213
+ //
214
+ // Free-form objects are unaffected — this whole branch is only
215
+ // entered for schemas that actually declare `properties`/`required`,
216
+ // so a value-bag keyed by arbitrary field names never reaches here.
217
+ if (declared.length > 0) {
218
+ for (const key of unknown) {
219
+ if (paired.has(key))
220
+ continue;
221
+ const guess = didYouMean(key, declared);
222
+ out.push(guess !== undefined
223
+ ? `unknown parameter \`${key}\` — did you mean \`${guess}\`?`
224
+ : `unknown parameter \`${key}\``);
225
+ }
226
+ }
227
+ }
228
+ }
229
+ /**
230
+ * Validate a tool call's arguments. Returns an agent-facing message listing
231
+ * every problem, or undefined when the arguments are acceptable.
232
+ */
233
+ export function validateToolInput(tool, args) {
234
+ // A schema declaring nothing constrains nothing.
235
+ const schema = tool.inputSchema;
236
+ if (schema.properties === undefined && schema.required === undefined) {
237
+ return undefined;
238
+ }
239
+ if (args !== undefined && (typeof args !== 'object' || args === null || Array.isArray(args))) {
240
+ return `${tool.name}: arguments must be an object, got ${describe(args)}`;
241
+ }
242
+ const problems = [];
243
+ collect(args ?? {}, { ...schema, type: 'object' }, '', problems);
244
+ if (problems.length === 0)
245
+ return undefined;
246
+ const lines = problems.map((p) => ` - ${p}`).join('\n');
247
+ return `${tool.name}: invalid arguments\n${lines}`;
248
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "logisheets-mcp",
3
+ "version": "0.1.0",
4
+ "description": "An MCP server that gives an AI agent a real, Excel-compatible spreadsheet engine to compute in and remember in — deterministic formulas, structured block memory addressed by (block, key, field), and a real .xlsx out.",
5
+ "mcpName": "io.github.logisky/logisheets-mcp",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "logisheets-mcp": "./dist/cli.js"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run",
25
+ "link:local": "node scripts/link-local.mjs",
26
+ "prepack": "yarn build",
27
+ "release:deps": "node scripts/release-deps.mjs",
28
+ "demo": "node examples/revenue-model.mjs",
29
+ "pretest": "yarn build"
30
+ },
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "^1.30.0",
33
+ "logisheets-logician": "^1.12.0",
34
+ "logisheets-runtime": "^1.12.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22",
38
+ "typescript": "^5.7.0",
39
+ "vitest": "^3.2.6"
40
+ },
41
+ "files": [
42
+ "dist"
43
+ ],
44
+ "keywords": [
45
+ "mcp",
46
+ "model-context-protocol",
47
+ "claude",
48
+ "agent",
49
+ "llm",
50
+ "spreadsheet",
51
+ "xlsx",
52
+ "excel",
53
+ "formula",
54
+ "logisheets"
55
+ ],
56
+ "author": "Jeremy He",
57
+ "license": "MIT",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/logisky/logisheets-mcp.git"
61
+ },
62
+ "bugs": {
63
+ "url": "https://github.com/logisky/logisheets-mcp/issues"
64
+ },
65
+ "homepage": "https://github.com/logisky/logisheets-mcp#readme"
66
+ }