wontopos-mcp 1.0.23 → 1.0.25
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/README.md +17 -1
- package/index.mjs +194 -64
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -59,11 +59,12 @@ Create a key in the [console](https://wontopos.com). Optional env:
|
|
|
59
59
|
| `WONTOPOS_VERIFY` | Default re-ask passes for `search`, 0-3. Default `0`. Each pass is another engine call, billed for what it delivers; the agent can raise it per call. |
|
|
60
60
|
| `WONTOPOS_MAX_IMAGES` | Default photo results per `search`, 0-5. Default `0` — see below. |
|
|
61
61
|
| `WONTOPOS_BASE_URL` | A different API host. Leave unset for the default. |
|
|
62
|
+
| `WONTOPOS_TIMEOUT_MS` | Per-request timeout in milliseconds. Default `20000`. |
|
|
62
63
|
| `WONTOPOS_HTTP_PORT` | Serve over HTTP instead of stdio, for hosts that cannot spawn a process. Needs `WONTOPOS_HTTP_TOKEN`. |
|
|
63
64
|
| `WONTOPOS_HTTP_TOKEN` | Bearer token every HTTP request must carry. The server refuses to start HTTP mode without one. |
|
|
64
65
|
| `WONTOPOS_HTTP_HOST` | Interface to bind in HTTP mode. Default `127.0.0.1` - this machine only. |
|
|
65
66
|
| `WONTOPOS_HTTP_ALLOWED_HOSTS` | Comma-separated `Host` values to accept in HTTP mode. A tunnel forwards its own name, and an undeclared one is refused 403. |
|
|
66
|
-
| `WONTOPOS_READ_ONLY` | `1` / `true` / `yes` / `on` / `y` / `t` = read tools only, see Security. A value that is none of those (and none of `0` / `false` / `no` / `off`) stops the server rather than being read as off. |
|
|
67
|
+
| `WONTOPOS_READ_ONLY` | `1` / `true` / `yes` / `on` / `y` / `t` = read tools only, see Security. A value that is none of those (and none of `0` / `false` / `no` / `off` / `n` / `f`) stops the server rather than being read as off. |
|
|
67
68
|
| `WONTOPOS_ALLOW_STORE_OVERRIDE` | `1` (same spellings) = multi-store; default = pinned, see Security. |
|
|
68
69
|
|
|
69
70
|
**Why photos default to none.** Not because the model cannot see them - `view_image`
|
|
@@ -224,6 +225,21 @@ advertise different limits.
|
|
|
224
225
|
|
|
225
226
|
## Changelog
|
|
226
227
|
|
|
228
|
+
- **1.0.25** - replies pass a host that checks them against their schemas. The output
|
|
229
|
+
schemas were closed to fields they do not name, so a validating client refused most
|
|
230
|
+
tools whenever the service added one. A reply over the byte cap now loses only what
|
|
231
|
+
the overflow needs, from its largest list; the model catalogue is read without regard
|
|
232
|
+
to case and asked again after a failure; an `event_date` with no year, or a day its
|
|
233
|
+
month does not have, is refused; a `store_turn` or `update` that times out says it
|
|
234
|
+
may have been applied; and a `WONTOPOS_HTTP_PORT` that is not a port stops the server. Underneath,
|
|
235
|
+
the `wontopos` SDK pin moves to **2.2.45**.
|
|
236
|
+
|
|
237
|
+
- **1.0.24** - a retired model is said plainly. At startup the server exits when its
|
|
238
|
+
model answers `410`, as it does on a rejected key, and names a model that is scheduled
|
|
239
|
+
to retire together with its date. A tool call answered `410` tells the model to ask
|
|
240
|
+
the person running the server to set `WONTOPOS_MODEL` to another model. Underneath,
|
|
241
|
+
the `wontopos` SDK pin moves to **2.2.44**.
|
|
242
|
+
|
|
227
243
|
- **1.0.23** - on a duplicate, `remember` now says how to find the memory the write
|
|
228
244
|
matched: in `duplicate_of` when the reply has it, otherwise with `search`, before
|
|
229
245
|
combining both facts or updating that memory. Underneath, the `wontopos` SDK pin moves
|
package/index.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { z } from "zod";
|
|
|
11
11
|
import {
|
|
12
12
|
Client,
|
|
13
13
|
WosError,
|
|
14
|
+
APIConnectionError,
|
|
14
15
|
SEARCH_LIMIT_MIN,
|
|
15
16
|
SEARCH_LIMIT_MAX,
|
|
16
17
|
CONTEXT_LIMIT_MIN,
|
|
@@ -120,6 +121,25 @@ function normalizeEventDate(raw) {
|
|
|
120
121
|
const s = String(raw).trim();
|
|
121
122
|
if (/^[+-]?\d+$/.test(s) && !/^\d{4}$/.test(s)) return null;
|
|
122
123
|
if (Number.isNaN(Date.parse(s))) return null;
|
|
124
|
+
// The parser files "March 14" as 2001-03-14 and rolls 2026-02-31 into March, so a date
|
|
125
|
+
// with no year, or an ISO day its month does not have, is refused rather than moved.
|
|
126
|
+
if (!/\d{4}/.test(s)) return null;
|
|
127
|
+
const ymd = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
|
|
128
|
+
if (ymd) {
|
|
129
|
+
const [y, mo, d] = ymd.slice(1).map(Number);
|
|
130
|
+
const t = new Date(Date.UTC(y, mo - 1, d));
|
|
131
|
+
if (t.getUTCMonth() !== mo - 1 || t.getUTCDate() !== d) return null;
|
|
132
|
+
} else {
|
|
133
|
+
// Any other spelling ("February 30, 2026", "2026/02/30") rolls over the same way: a
|
|
134
|
+
// written day from 29 to 31 has to come back as that day. The time is taken off first,
|
|
135
|
+
// so its minutes are not read as a day and its zone cannot move the date.
|
|
136
|
+
const datePart = s
|
|
137
|
+
.replace(/T?\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?\s*(?:[ap]\.?m\.?)?\s*(?:Z|(?:GMT|UTC)?\s*[+-]\d{2}:?\d{2}|GMT|UTC)?/gi, " ")
|
|
138
|
+
.trim();
|
|
139
|
+
const local = new Date(datePart);
|
|
140
|
+
for (const n of datePart.match(/\b\d{1,2}\b/g) ?? [])
|
|
141
|
+
if (Number(n) >= 29 && (Number.isNaN(local.getTime()) || local.getDate() !== Number(n))) return null;
|
|
142
|
+
}
|
|
123
143
|
if (DATE_ONLY_RE.test(s)) return `${s}T00:00:00Z`;
|
|
124
144
|
// `2026` and `2026-03` are ISO date-only forms too, and the spec parses those as
|
|
125
145
|
// UTC — so the local-parts branch below reads them back a day early on every host
|
|
@@ -242,16 +262,24 @@ function registerTool(name, spec, handler) {
|
|
|
242
262
|
throw new Error(`tool "${name}" does not declare annotations.readOnlyHint.`);
|
|
243
263
|
}
|
|
244
264
|
if (READ_ONLY && !readOnly) return;
|
|
245
|
-
|
|
265
|
+
// A write that is not safe to repeat is told, on a lost answer, that it may have landed.
|
|
266
|
+
const once = !readOnly && spec.annotations.idempotentHint === false;
|
|
267
|
+
TOOL_DEFS.push([name, spec, once ? (args, extra) => handler(args, extra, true) : handler]);
|
|
246
268
|
}
|
|
247
269
|
|
|
248
270
|
function registerPrompt(name, spec, handler) {
|
|
249
271
|
PROMPT_DEFS.push([name, spec, handler]);
|
|
250
272
|
}
|
|
251
273
|
|
|
274
|
+
// A raw shape is published with additionalProperties: false, and a client that checks
|
|
275
|
+
// structuredContent against it refused every reply carrying a field the shape does not name.
|
|
276
|
+
function openOutput(spec) {
|
|
277
|
+
return spec.outputSchema ? { ...spec, outputSchema: z.object(spec.outputSchema).passthrough() } : spec;
|
|
278
|
+
}
|
|
279
|
+
|
|
252
280
|
function createMcpServer() {
|
|
253
281
|
const s = new McpServer({ name: "wontopos", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
254
|
-
for (const [name, spec, handler] of TOOL_DEFS) s.registerTool(name, spec, handler);
|
|
282
|
+
for (const [name, spec, handler] of TOOL_DEFS) s.registerTool(name, openOutput(spec), handler);
|
|
255
283
|
for (const [name, spec, handler] of PROMPT_DEFS) s.registerPrompt(name, spec, handler);
|
|
256
284
|
return s;
|
|
257
285
|
}
|
|
@@ -276,7 +304,10 @@ const KEEP_TAIL = new Set(["turns"]);
|
|
|
276
304
|
// exchanges. The service holds thirty; the rest of its reply is dropped here.
|
|
277
305
|
const RECALL_TURNS = 20;
|
|
278
306
|
|
|
279
|
-
|
|
307
|
+
// Paging state, not content: cut, it resumes the next page in the wrong place.
|
|
308
|
+
const PAGE_KEYS = new Set(["next_cursor", "next_before", "next_skip_ids"]);
|
|
309
|
+
|
|
310
|
+
function droppableLists(root, minLength = 2) {
|
|
280
311
|
const out = [];
|
|
281
312
|
const stack = [["", root, null]];
|
|
282
313
|
let budget = 20000;
|
|
@@ -287,7 +318,10 @@ function droppableLists(root) {
|
|
|
287
318
|
// when the array is cut. Without it the trimmer handed a model 100 memories beside
|
|
288
319
|
// "count": 200 — and the schemas call that field authoritative, so the model reads
|
|
289
320
|
// the JSON as truncated mid-structure and either retries or reports the store wrong.
|
|
290
|
-
|
|
321
|
+
// A list of strings is cut too: recall's context rows are strings, and leaving them
|
|
322
|
+
// whole cut the memories and turns down to one each instead.
|
|
323
|
+
if (node.length >= minLength && !PAGE_KEYS.has(name) && node.some((e) => (e && typeof e === "object") || typeof e === "string"))
|
|
324
|
+
out.push([name || "items", node, owner]);
|
|
291
325
|
for (const v of node) if (v && typeof v === "object") stack.push([name, v, null]);
|
|
292
326
|
continue;
|
|
293
327
|
}
|
|
@@ -305,6 +339,31 @@ function syncCount(owner, arr, before) {
|
|
|
305
339
|
if (owner && typeof owner.count === "number" && owner.count === before) owner.count = arr.length;
|
|
306
340
|
}
|
|
307
341
|
|
|
342
|
+
/** The list holding the most bytes gives up entries from its end (the oldest, for a list
|
|
343
|
+
* kept by its tail) until about `over` bytes are gone; the other lists stay whole. Halving
|
|
344
|
+
* every list at once dropped half of each for an overflow one list could cover. */
|
|
345
|
+
function cutLargest(lists, over, dropped) {
|
|
346
|
+
let pick = null;
|
|
347
|
+
let most = 0;
|
|
348
|
+
for (const l of lists) {
|
|
349
|
+
if (l[1].length <= 1) continue;
|
|
350
|
+
const b = byteLen(JSON.stringify(l[1]));
|
|
351
|
+
if (b > most) [pick, most] = [l, b];
|
|
352
|
+
}
|
|
353
|
+
if (!pick) return false;
|
|
354
|
+
const [name, arr, owner] = pick;
|
|
355
|
+
const before = arr.length;
|
|
356
|
+
const tail = KEEP_TAIL.has(name);
|
|
357
|
+
let n = 0;
|
|
358
|
+
for (let freed = 0; n < before - 1 && freed < over; n++)
|
|
359
|
+
freed += byteLen(JSON.stringify(arr[tail ? n : before - 1 - n])) + 1;
|
|
360
|
+
if (tail) arr.splice(0, n);
|
|
361
|
+
else arr.length = before - n;
|
|
362
|
+
dropped[name || "items"] = (dropped[name || "items"] ?? 0) + n;
|
|
363
|
+
syncCount(owner, arr, before);
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
308
367
|
function truncationKey(obj) {
|
|
309
368
|
let k = "_truncated";
|
|
310
369
|
while (Object.prototype.hasOwnProperty.call(obj, k)) k = `_${k}`;
|
|
@@ -355,28 +414,15 @@ function fitToCap(result) {
|
|
|
355
414
|
const clone = structuredClone(result);
|
|
356
415
|
const dropped = {};
|
|
357
416
|
const NOTE_KEY = Array.isArray(clone) ? "_truncated" : truncationKey(clone);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
dropped[name || "items"] = (dropped[name || "items"] ?? 0) + (arr.length - keep);
|
|
368
|
-
if (KEEP_TAIL.has(name)) arr.splice(0, arr.length - keep);
|
|
369
|
-
else arr.length = keep;
|
|
370
|
-
syncCount(owner, arr, before);
|
|
371
|
-
cutAny = true;
|
|
372
|
-
}
|
|
373
|
-
if (!cutAny) break;
|
|
374
|
-
text = ser(Array.isArray(clone) ? clone : { ...clone, [NOTE_KEY]: dropped });
|
|
375
|
-
}
|
|
376
|
-
if (byteLen(text) <= MAX_RESULT_BYTES) {
|
|
377
|
-
const paged = ["next_cursor", "next_before", "next_skip_ids"].some(
|
|
378
|
-
(k) => !Array.isArray(clone) && clone[k] != null
|
|
379
|
-
);
|
|
417
|
+
if (droppableLists(clone).length) {
|
|
418
|
+
const paged = ["next_cursor", "next_before", "next_skip_ids"].some(
|
|
419
|
+
(k) => !Array.isArray(clone) && clone[k] != null
|
|
420
|
+
);
|
|
421
|
+
// The note is part of what has to fit, so each round is measured with it: measured
|
|
422
|
+
// without, a reply could pass the loop and then go over once the note was added.
|
|
423
|
+
const withNote = () => {
|
|
424
|
+
// Down to one entry a list is no longer cut, but it is still the page that was.
|
|
425
|
+
const lists = droppableLists(clone, 1);
|
|
380
426
|
// The page's own list, not the longest array anywhere in the reply: `kept` used
|
|
381
427
|
// to be a Math.max over every list, so the advice could name the length of a
|
|
382
428
|
// nested array and send the model back for a page that overflows again.
|
|
@@ -389,24 +435,33 @@ function fitToCap(result) {
|
|
|
389
435
|
`cursor below points PAST them — following it would skip what is missing. Ask for ` +
|
|
390
436
|
`this page again with limit=${kept} instead.`
|
|
391
437
|
: `${MAX_RESULT_BYTES}-byte cap` +
|
|
392
|
-
(
|
|
438
|
+
(Object.keys(dropped).some((n) => KEEP_TAIL.has(n))
|
|
393
439
|
? " — the OLDEST turns went, the most recent were kept."
|
|
394
|
-
: " — narrow the query
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
440
|
+
: " — narrow the query or ask for fewer results."));
|
|
441
|
+
return Array.isArray(clone) ? [...clone, { [NOTE_KEY]: note }] : { ...clone, [NOTE_KEY]: note };
|
|
442
|
+
};
|
|
443
|
+
let noted = withNote();
|
|
444
|
+
let guard = 64;
|
|
445
|
+
while (byteLen((text = ser(noted))) > MAX_RESULT_BYTES && guard-- > 0) {
|
|
446
|
+
if (!cutLargest(droppableLists(clone), byteLen(text) - MAX_RESULT_BYTES, dropped)) break;
|
|
447
|
+
noted = withNote();
|
|
398
448
|
}
|
|
449
|
+
if (Object.keys(dropped).length && byteLen(text) <= MAX_RESULT_BYTES) return { text, value: noted };
|
|
399
450
|
}
|
|
400
451
|
|
|
452
|
+
// Without the note the reply can already fit; then it is the note that does not.
|
|
401
453
|
const oversize = byteLen(ser(clone)) - MAX_RESULT_BYTES;
|
|
402
454
|
// Name whatever the loop above dropped. It may have dropped nothing — a reply with
|
|
403
455
|
// one oversized entry never enters that branch — so the count is conditional. What
|
|
404
456
|
// it must never do is claim nothing went when something did.
|
|
405
457
|
const cut = Object.entries(dropped).map(([k, n]) => `${n} ${k}`).join(", ");
|
|
406
458
|
const note =
|
|
407
|
-
(cut ? `dropped ${cut}, and after that
|
|
408
|
-
|
|
409
|
-
|
|
459
|
+
(cut ? `dropped ${cut}, and after that ` : "") +
|
|
460
|
+
(oversize > 0
|
|
461
|
+
? `${cut ? "still " : ""}${oversize} bytes over the ${MAX_RESULT_BYTES}-byte cap`
|
|
462
|
+
: `at the ${MAX_RESULT_BYTES}-byte cap`) +
|
|
463
|
+
", so the long text left in this reply was cut at the end and marked. Narrow the query or ask " +
|
|
464
|
+
"for fewer results to see it whole.";
|
|
410
465
|
const noted = Array.isArray(clone) ? [...clone, { [NOTE_KEY]: note }] : { ...clone, [NOTE_KEY]: note };
|
|
411
466
|
if (shrinkStrings(noted, ser)) return { text: ser(noted), value: noted };
|
|
412
467
|
}
|
|
@@ -414,7 +469,7 @@ function fitToCap(result) {
|
|
|
414
469
|
const value = {
|
|
415
470
|
_truncated:
|
|
416
471
|
`the reply was ${byteLen(text)} bytes, over the ${MAX_RESULT_BYTES}-byte cap, and could not be ` +
|
|
417
|
-
"shortened into a readable form. Narrow the query
|
|
472
|
+
"shortened into a readable form. Narrow the query or ask for fewer results.",
|
|
418
473
|
};
|
|
419
474
|
return { text: ser(value), value };
|
|
420
475
|
}
|
|
@@ -470,13 +525,43 @@ function run(fn) {
|
|
|
470
525
|
});
|
|
471
526
|
}
|
|
472
527
|
|
|
528
|
+
// Codes the SDK counts as the connection never opening, from connect or a DNS lookup.
|
|
529
|
+
// It retries a write on those, since nothing was sent, and throws only after the last try.
|
|
530
|
+
const CONNECT_ONLY_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);
|
|
531
|
+
const CONNECT_CODES = new Set([
|
|
532
|
+
...CONNECT_ONLY_CODES, "ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EADDRNOTAVAIL",
|
|
533
|
+
]);
|
|
534
|
+
|
|
535
|
+
/** Whether a write that failed without an answer may still have been applied: a timeout
|
|
536
|
+
* or a connection lost after sending, not one that never opened. */
|
|
537
|
+
function mayHaveLanded(e) {
|
|
538
|
+
if (!(e instanceof APIConnectionError)) return false;
|
|
539
|
+
const stack = [e.cause];
|
|
540
|
+
for (let steps = 0; stack.length && steps < 24; steps++) {
|
|
541
|
+
const cur = stack.pop();
|
|
542
|
+
if (!cur || typeof cur !== "object") continue;
|
|
543
|
+
const atConnect = ["connect", "getaddrinfo"].includes(cur.syscall);
|
|
544
|
+
if (typeof cur.code === "string" && (CONNECT_ONLY_CODES.has(cur.code) || (CONNECT_CODES.has(cur.code) && atConnect)))
|
|
545
|
+
return false;
|
|
546
|
+
if (cur.cause) stack.push(cur.cause);
|
|
547
|
+
if (Array.isArray(cur.errors)) stack.push(...cur.errors.slice(0, 8));
|
|
548
|
+
}
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
|
|
473
552
|
function runContent(fn) {
|
|
474
|
-
return async (args, extra) => {
|
|
553
|
+
return async (args, extra, notRepeatable = false) => {
|
|
475
554
|
const mem = extra?.signal ? base.withSignal(extra.signal) : base;
|
|
476
555
|
try {
|
|
477
556
|
return await fn(args ?? {}, mem);
|
|
478
557
|
} catch (e) {
|
|
479
558
|
let msg = e instanceof WosError ? e.message : String(e?.message ?? e);
|
|
559
|
+
if (e instanceof WosError && e.status === 410)
|
|
560
|
+
msg = `Model "${MODEL}" is retired, so every tool call is refused. Ask the person running this ` +
|
|
561
|
+
"server to set WONTOPOS_MODEL to another model.";
|
|
562
|
+
if (notRepeatable && mayHaveLanded(e))
|
|
563
|
+
msg = msg.replace(/[.\s]*$/, ".") + " The write may have been applied before the connection ended; " +
|
|
564
|
+
"check with search or list_memories before calling again, or it may be applied twice.";
|
|
480
565
|
if (byteLen(msg) > 2000) msg = truncateToBytes(msg, 2000) + "…[error truncated]";
|
|
481
566
|
return { content: [{ type: "text", text: msg }], isError: true };
|
|
482
567
|
}
|
|
@@ -520,7 +605,7 @@ const filtersArg = z
|
|
|
520
605
|
})
|
|
521
606
|
.strict()
|
|
522
607
|
.nullish()
|
|
523
|
-
.describe("Search a time range or categories: 'in June', 'last year', 'about work'.
|
|
608
|
+
.describe("Search a time range or categories: 'in June', 'last year', 'about work'.");
|
|
524
609
|
|
|
525
610
|
function cleanFilters(f) {
|
|
526
611
|
if (!f || typeof f !== "object") return undefined;
|
|
@@ -533,25 +618,40 @@ function cleanFilters(f) {
|
|
|
533
618
|
return Object.keys(out).length ? out : undefined;
|
|
534
619
|
}
|
|
535
620
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
621
|
+
// The catalogue's row for MODEL: { listed, caps, retiresAt }, or null while the catalogue
|
|
622
|
+
// does not answer. `caps` is null when the row carries none. After a failure the next call
|
|
623
|
+
// asks again; after two in a row, a minute later, so a catalogue that hangs does not hold
|
|
624
|
+
// every search for a full timeout. Three failures used to stick for the life of the
|
|
625
|
+
// process, and with them the install defaults for photos and re-ask.
|
|
626
|
+
let catalogue = null;
|
|
627
|
+
let catalogueFailures = 0;
|
|
628
|
+
let catalogueFailedAt = 0;
|
|
629
|
+
const CATALOGUE_RETRY_MS = 60_000;
|
|
630
|
+
function modelRow() {
|
|
631
|
+
if (!catalogue && (catalogueFailures < 2 || Date.now() - catalogueFailedAt >= CATALOGUE_RETRY_MS)) {
|
|
632
|
+
catalogue = base.listModels().then(
|
|
543
633
|
(models) => {
|
|
544
|
-
|
|
545
|
-
|
|
634
|
+
// The service reads the model name without regard to case, so this does too.
|
|
635
|
+
const want = MODEL.toLowerCase();
|
|
636
|
+
const m = models.find((x) => typeof x?.id === "string" && x.id.toLowerCase() === want);
|
|
637
|
+
const c = m?.capabilities;
|
|
638
|
+
return {
|
|
639
|
+
listed: Boolean(m),
|
|
640
|
+
caps: c ? { images: c.images === true, reAsk: c.re_ask === true } : null,
|
|
641
|
+
retiresAt: typeof m?.retires_at === "string" ? m.retires_at : undefined,
|
|
642
|
+
};
|
|
546
643
|
},
|
|
547
644
|
() => {
|
|
548
|
-
|
|
645
|
+
catalogue = null;
|
|
646
|
+
catalogueFailures++;
|
|
647
|
+
catalogueFailedAt = Date.now();
|
|
549
648
|
return null;
|
|
550
649
|
},
|
|
551
650
|
);
|
|
552
651
|
}
|
|
553
|
-
return
|
|
652
|
+
return catalogue ?? Promise.resolve(null);
|
|
554
653
|
}
|
|
654
|
+
const modelCaps = async () => (await modelRow())?.caps ?? null;
|
|
555
655
|
|
|
556
656
|
function checkQuery(q, verb) {
|
|
557
657
|
if (!q?.trim()) throw new Error(`query is blank — say what you want to ${verb}.`);
|
|
@@ -655,7 +755,9 @@ registerTool(
|
|
|
655
755
|
throw new Error("speaker must be 'me' (something the agent itself said) or omitted (the user's fact).");
|
|
656
756
|
const eventDate = event_date ? normalizeEventDate(event_date) : null;
|
|
657
757
|
if (event_date && !eventDate)
|
|
658
|
-
throw new Error(
|
|
758
|
+
throw new Error(
|
|
759
|
+
"event_date is not a calendar date with a year — use a date (2026-07-01) or RFC3339 (2026-07-01T09:00:00Z)."
|
|
760
|
+
);
|
|
659
761
|
const metadata = {};
|
|
660
762
|
if (speaker) metadata.speaker = speaker;
|
|
661
763
|
if (eventDate) metadata.event_date = eventDate;
|
|
@@ -709,7 +811,10 @@ registerTool(
|
|
|
709
811
|
inputSchema: {
|
|
710
812
|
query: z.string(),
|
|
711
813
|
limit: z.coerce.number().int().min(SEARCH_LIMIT_MIN).max(SEARCH_LIMIT_MAX).nullish()
|
|
712
|
-
.describe(
|
|
814
|
+
.describe(
|
|
815
|
+
`Memories to return, ${SEARCH_LIMIT_MIN}-${SEARCH_LIMIT_MAX} (default 10); each verify pass can add up to as ` +
|
|
816
|
+
"many more. Out of range is refused."
|
|
817
|
+
),
|
|
713
818
|
speaker: z.string().nullish().describe("Only this speaker's words: 'me' or a registered name; other names return 404."),
|
|
714
819
|
filters: filtersArg,
|
|
715
820
|
verify: z.coerce
|
|
@@ -1024,7 +1129,7 @@ registerTool(
|
|
|
1024
1129
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1025
1130
|
description:
|
|
1026
1131
|
"This key's spend and the account's remaining balance, in cents. Free; check it before a long run. " +
|
|
1027
|
-
"
|
|
1132
|
+
"When the balance runs out, calls are refused with 402: tell the user instead of retrying." +
|
|
1028
1133
|
(ALLOW_STORE_OVERRIDE
|
|
1029
1134
|
? " `stores` is at most 50 rows, so it need not add up to `workspace`; a row named 'other' is not a store."
|
|
1030
1135
|
: ""),
|
|
@@ -1108,7 +1213,16 @@ registerPrompt(
|
|
|
1108
1213
|
// URL, not a command. It is off unless a port is named, and it will not start without
|
|
1109
1214
|
// a token: the reason to run it is to put it behind a tunnel, and a tunnel makes an
|
|
1110
1215
|
// unauthenticated listener a public door to the memory store.
|
|
1111
|
-
|
|
1216
|
+
// Named but unusable stops the server: falling back to stdio left one that never
|
|
1217
|
+
// listened and, with stdin closed under a supervisor, exited 0 as if asked to.
|
|
1218
|
+
const HTTP_PORT = (() => {
|
|
1219
|
+
const raw = process.env.WONTOPOS_HTTP_PORT?.trim();
|
|
1220
|
+
if (!raw) return 0;
|
|
1221
|
+
const n = Number(raw);
|
|
1222
|
+
if (Number.isInteger(n) && n >= 1 && n <= 65535) return n;
|
|
1223
|
+
console.error(`wontopos-mcp: WONTOPOS_HTTP_PORT=${JSON.stringify(raw)} is not a port from 1 to 65535. Exiting.`);
|
|
1224
|
+
process.exit(1);
|
|
1225
|
+
})();
|
|
1112
1226
|
// An IPv6 literal may be written bracketed; it is bound without the brackets.
|
|
1113
1227
|
const HTTP_HOST = (process.env.WONTOPOS_HTTP_HOST?.trim() || "127.0.0.1").replace(/^\[(.*)\]$/, "$1");
|
|
1114
1228
|
const HTTP_TOKEN = process.env.WONTOPOS_HTTP_TOKEN?.trim() || "";
|
|
@@ -1134,7 +1248,9 @@ function canonicalHost(value) {
|
|
|
1134
1248
|
name = name.replace(/\.$/, "");
|
|
1135
1249
|
if (!name) return null;
|
|
1136
1250
|
}
|
|
1137
|
-
|
|
1251
|
+
// A client leaves out the port its scheme implies, so :80 and :443 compare as none.
|
|
1252
|
+
const port = m[2] === undefined ? null : Number(m[2]);
|
|
1253
|
+
return port === null || port === 80 || port === 443 ? name : `${name}:${port}`;
|
|
1138
1254
|
}
|
|
1139
1255
|
|
|
1140
1256
|
// The bind address as a client writes it in Host: an IPv6 literal goes in brackets.
|
|
@@ -1150,10 +1266,7 @@ const HTTP_ALLOWED_UNUSABLE = HTTP_ALLOWED_HOSTS.filter((h) => !canonicalHost(h)
|
|
|
1150
1266
|
const ALLOWED_HOSTS = [
|
|
1151
1267
|
...new Set(
|
|
1152
1268
|
[
|
|
1153
|
-
`${
|
|
1154
|
-
`localhost:${HTTP_PORT}`,
|
|
1155
|
-
`127.0.0.1:${HTTP_PORT}`,
|
|
1156
|
-
`[::1]:${HTTP_PORT}`,
|
|
1269
|
+
...[HTTP_HOST_NAME, "localhost", "127.0.0.1", "[::1]"].flatMap((h) => [`${h}:${HTTP_PORT}`, h]),
|
|
1157
1270
|
...HTTP_ALLOWED_HOSTS,
|
|
1158
1271
|
]
|
|
1159
1272
|
.map(canonicalHost)
|
|
@@ -1225,9 +1338,9 @@ function httpRefuse(req, res, status, code, message, { id = null, headers } = {}
|
|
|
1225
1338
|
}
|
|
1226
1339
|
|
|
1227
1340
|
/** 401 and 403 answer a plain `{ error }` body. */
|
|
1228
|
-
function httpDeny(req, res, status, error, why) {
|
|
1341
|
+
function httpDeny(req, res, status, error, why, headers = {}) {
|
|
1229
1342
|
console.error(`wontopos-mcp: HTTP ${status} to ${req.method} — ${logText(why)}`);
|
|
1230
|
-
sendRefusal(req, res, status,
|
|
1343
|
+
sendRefusal(req, res, status, headers, JSON.stringify({ error }));
|
|
1231
1344
|
}
|
|
1232
1345
|
|
|
1233
1346
|
/** The body as text, or null past HTTP_MAX_BODY_BYTES (the rest is read and dropped). */
|
|
@@ -1460,7 +1573,8 @@ async function serveRequests(s, req, res, body) {
|
|
|
1460
1573
|
}
|
|
1461
1574
|
|
|
1462
1575
|
async function routeHttp(req, res) {
|
|
1463
|
-
if (!tokenOk(req))
|
|
1576
|
+
if (!tokenOk(req))
|
|
1577
|
+
return httpDeny(req, res, 401, "unauthorized", "missing or wrong bearer token", { "WWW-Authenticate": "Bearer" });
|
|
1464
1578
|
const host = String(req.headers.host ?? "");
|
|
1465
1579
|
if (headerCount(req, "host") !== 1) {
|
|
1466
1580
|
return httpRefuse(req, res, 400, -32000, "Bad Request: send exactly one Host header");
|
|
@@ -1547,13 +1661,23 @@ if (HTTP_PORT) {
|
|
|
1547
1661
|
console.error(
|
|
1548
1662
|
`wontopos-mcp ${VERSION} ready (store: ${STORE_SHOWN}${STORE_FROM_ENV ? "" : " — FALLBACK: WONTOPOS_USER_ID is not set"}, model: ${MODEL}${READ_ONLY ? ", read-only" : ""})`
|
|
1549
1663
|
);
|
|
1550
|
-
|
|
1551
|
-
if (!caps) {
|
|
1664
|
+
modelRow().then((row) => {
|
|
1665
|
+
if (!row?.caps) {
|
|
1666
|
+
const why = !row
|
|
1667
|
+
? ": the model catalogue did not answer"
|
|
1668
|
+
: row.listed
|
|
1669
|
+
? " is in the catalogue without capabilities"
|
|
1670
|
+
: " is not in the catalogue";
|
|
1671
|
+
console.error(`wontopos-mcp: model "${MODEL}"${why} — photos and re-ask are sent only when asked for.`);
|
|
1672
|
+
}
|
|
1673
|
+
if (row?.retiresAt) {
|
|
1552
1674
|
console.error(
|
|
1553
|
-
`wontopos-mcp: model "${MODEL}"
|
|
1675
|
+
`wontopos-mcp: model "${MODEL}" retires at ${logText(row.retiresAt)}; from then on every tool ` +
|
|
1676
|
+
"call is refused. Set WONTOPOS_MODEL to another model before then."
|
|
1554
1677
|
);
|
|
1555
|
-
return;
|
|
1556
1678
|
}
|
|
1679
|
+
const caps = row?.caps;
|
|
1680
|
+
if (!caps) return;
|
|
1557
1681
|
const has = [caps.images ? "photos" : null, caps.reAsk ? "re-ask" : null].filter(Boolean);
|
|
1558
1682
|
console.error(
|
|
1559
1683
|
`wontopos-mcp: model "${MODEL}" serves ${has.length ? has.join(" + ") : "neither photos nor re-ask"}` +
|
|
@@ -1582,6 +1706,12 @@ base.stats().then(
|
|
|
1582
1706
|
if (status === 401) {
|
|
1583
1707
|
console.error("wontopos-mcp: the API key was rejected (401) — check WONTOPOS_API_KEY. Exiting.");
|
|
1584
1708
|
process.exit(1);
|
|
1709
|
+
} else if (status === 410) {
|
|
1710
|
+
console.error(
|
|
1711
|
+
`wontopos-mcp: model "${MODEL}" is retired (410), so every tool call would be refused — ` +
|
|
1712
|
+
"set WONTOPOS_MODEL to another model. Exiting."
|
|
1713
|
+
);
|
|
1714
|
+
process.exit(1);
|
|
1585
1715
|
} else if (status === 404) {
|
|
1586
1716
|
console.error(
|
|
1587
1717
|
`wontopos-mcp: store "${STORE_SHOWN}" does not exist — ` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wontopos-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.25",
|
|
4
4
|
"mcpName": "com.wontopos/mcp",
|
|
5
5
|
"description": "(beta) Wontopos (WOS) long-term memory as an MCP server — one memory across Claude Code, Claude Desktop, Cursor, ChatGPT, and your own agents.",
|
|
6
6
|
"keywords": [
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
37
|
-
"wontopos": "2.2.
|
|
37
|
+
"wontopos": "2.2.45",
|
|
38
38
|
"zod": "^3.23.0"
|
|
39
39
|
},
|
|
40
40
|
"overrides": {
|