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/LICENSE +21 -0
- package/README.md +311 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +43 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/lifecycle.d.ts +42 -0
- package/dist/lifecycle.js +156 -0
- package/dist/server.d.ts +42 -0
- package/dist/server.js +205 -0
- package/dist/session.d.ts +99 -0
- package/dist/session.js +164 -0
- package/dist/surface.d.ts +24 -0
- package/dist/surface.js +147 -0
- package/dist/validate.d.ts +27 -0
- package/dist/validate.js +248 -0
- package/package.json +66 -0
package/dist/validate.js
ADDED
|
@@ -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
|
+
}
|