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.
Files changed (3) hide show
  1. package/README.md +17 -1
  2. package/index.mjs +194 -64
  3. 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
- TOOL_DEFS.push([name, spec, handler]);
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
- function droppableLists(root) {
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
- if (node.length > 1 && node.some((e) => e && typeof e === "object")) out.push([name || "items", node, owner]);
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
- const lists = droppableLists(clone);
359
- if (lists.length) {
360
- let guard = 64;
361
- while (byteLen(text) > MAX_RESULT_BYTES && guard-- > 0) {
362
- let cutAny = false;
363
- for (const [name, arr, owner] of lists) {
364
- if (arr.length <= 1) continue;
365
- const before = arr.length;
366
- const keep = Math.max(1, Math.floor(arr.length / 2));
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
- (lists.some(([n]) => KEEP_TAIL.has(n) && dropped[n])
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, lower limit, or page with cursor."));
395
- const noted = Array.isArray(clone) ? [...clone, { [NOTE_KEY]: note }] : { ...clone, [NOTE_KEY]: note };
396
- const withNote = ser(noted);
397
- if (byteLen(withNote) <= MAX_RESULT_BYTES) return { text: withNote, value: noted };
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 still ` : "") +
408
- `${oversize} bytes over the ${MAX_RESULT_BYTES}-byte cap, so the long text left in this reply ` +
409
- "was cut at the end and marked. Narrow the query, lower limit, or page with cursor to see it whole.";
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, lower limit, or page with cursor.",
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'. Filters choose what is searched, not what is kept, so a narrow one still fills `limit`.");
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
- let capsPromise = null;
537
- let capsAttempts = 0;
538
- const CAPS_ATTEMPTS_MAX = 3;
539
- function modelCaps() {
540
- if (!capsPromise) {
541
- capsAttempts++;
542
- capsPromise = base.listModels().then(
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
- const c = models.find((m) => m?.id === MODEL)?.capabilities;
545
- return c ? { images: c.images === true, reAsk: c.re_ask === true } : null;
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
- if (capsAttempts < CAPS_ATTEMPTS_MAX) capsPromise = null;
645
+ catalogue = null;
646
+ catalogueFailures++;
647
+ catalogueFailedAt = Date.now();
549
648
  return null;
550
649
  },
551
650
  );
552
651
  }
553
- return capsPromise;
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("event_date is not a parseable date — use a date (2026-07-01) or RFC3339 (2026-07-01T09:00:00Z).");
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(`Memories to return, ${SEARCH_LIMIT_MIN}-${SEARCH_LIMIT_MAX} (default 10). Out of range is refused.`),
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
- "If the balance is negative, calls are refused: tell the user instead of retrying." +
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
- const HTTP_PORT = intEnv("WONTOPOS_HTTP_PORT", 0, 1, 65535);
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
- return m[2] === undefined ? name : `${name}:${Number(m[2])}`;
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
- `${HTTP_HOST_NAME}:${HTTP_PORT}`,
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, {}, JSON.stringify({ error }));
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)) return httpDeny(req, res, 401, "unauthorized", "missing or wrong bearer token");
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
- modelCaps().then((caps) => {
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}" is not in the catalogue — photos and re-ask are sent only when asked for.`
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.23",
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.43",
37
+ "wontopos": "2.2.45",
38
38
  "zod": "^3.23.0"
39
39
  },
40
40
  "overrides": {