@mnemoverse/mcp-memory-server 0.11.0 → 0.12.1

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/tools.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * or the environment.
13
13
  */
14
14
  import { z } from "zod";
15
- import { CURSOR_RE, formatReadItem, formatRecentPage, safeInline, structuredItem, } from "./render.js";
15
+ import { CURSOR_RE, formatReadItem, formatRecentPage, rawAuthorName, safeInline, structuredItem, } from "./render.js";
16
16
  // `NO_MATCH_MESSAGE` is no longer imported here: this file used to pick between
17
17
  // it and buildReadEmptyResponse by testing a note string for truthiness, which
18
18
  // is how "we could not check" came to be spelled like "the store is there".
@@ -20,14 +20,43 @@ import { CURSOR_RE, formatReadItem, formatRecentPage, safeInline, structuredItem
20
20
  import { buildReadEmptyResponse } from "./teaching.js";
21
21
  import { classifyRooms, futureSinceNote, probeReadScope, readScopeNote, scopeLabel, } from "./scope.js";
22
22
  import { readRequestBody, recentRequestBody, writeRequestBody, searchedScope, } from "./requests.js";
23
- import { exactLiteral, formatDomainList, roomNamePhrase, structuredText, withDomainEscapeLegend, } from "./names.js";
24
- import { ApiError } from "./errors.js";
23
+ import { exactLiteral, formatDomainList, MAX_DOMAIN_LITERAL, MAX_DOMAIN_TAG_LITERAL, roomNamePhrase, structuredText, withDomainEscapeLegend, withEscapeLegendAt, } from "./names.js";
24
+ import { ApiError, rewordFailure } from "./errors.js";
25
25
  // Field limits, generated from core's contract (src/limits.ts, ADR-025).
26
26
  import { CORE_LIMITS } from "./limits.js";
27
+ // For the invite's `expires_at` (S8, structured-output plan): the same
28
+ // UTC-instant reader src/render.ts uses for a memory's `created_at`.
29
+ import { utcInstant } from "./time.js";
30
+ /**
31
+ * `deps.apiFetch` with `deps.wording` applied to what it throws (STEP4-2):
32
+ * every rejection passes through {@link rewordFailure}, so an `ApiError`,
33
+ * `NetworkError` or `UnreadableBodyError` the consumer built with no
34
+ * `wording` (or with a different one) reaches the model explained under the
35
+ * wording this registration was given. Results and every other rejection
36
+ * pass through untouched. Installed only when `wording` is supplied at all:
37
+ * the stdio server supplies none, and its `apiFetch` is used exactly as
38
+ * before.
39
+ */
40
+ export function wordedApiFetch(inner, wording) {
41
+ return async (path, options) => {
42
+ try {
43
+ return await inner(path, options);
44
+ }
45
+ catch (e) {
46
+ throw rewordFailure(e, wording);
47
+ }
48
+ };
49
+ }
27
50
  // Hard cap on tool result size — required by Claude Connectors Directory
28
51
  // (https://support.claude.com/en/articles/12922490-remote-mcp-server-submission-guide).
29
52
  // Approximate token count = chars / 4. Cap at 24,000 tokens to leave headroom under the 25K limit.
30
- const MAX_RESULT_CHARS = 24_000 * 4;
53
+ //
54
+ // Exported (re-exported from ./shared, ADR-025) so a future editor does not
55
+ // move or rename it without checking there: it is now the one cap both this
56
+ // server and, from step 4 of the structured-output plan, the hosted connector
57
+ // apply to a tool result's text. `structuredContent` is NOT capped (OD-11,
58
+ // 2026-09-23); this bound is text-only.
59
+ export const MAX_RESULT_CHARS = 24_000 * 4;
31
60
  /**
32
61
  * Truncate a result string to MAX_RESULT_CHARS, appending a notice if truncated.
33
62
  * Required by Claude Connectors Directory submission policy.
@@ -36,8 +65,12 @@ const MAX_RESULT_CHARS = 24_000 * 4;
36
65
  * before the cut point is a high surrogate (U+D800–U+DBFF), drop it so the
37
66
  * result stays well-formed. Otherwise an emoji or non-BMP character at the
38
67
  * boundary can produce a lone surrogate and corrupt downstream JSON encoding.
68
+ *
69
+ * Exported (re-exported from ./shared) so a consumer applying MAX_RESULT_CHARS
70
+ * to its own tool results does not have to re-implement this code-point-safe
71
+ * truncation as a plain `slice`, which can cut a surrogate pair.
39
72
  */
40
- function capResult(text,
73
+ export function capResult(text,
41
74
  // The default recommends ONLY the control that works. A previous draft also
42
75
  // said "or smaller top_k" — but top_k is not a hard cap (association
43
76
  // expansion can return more, the relevance floor fewer; the same query at
@@ -46,16 +79,30 @@ function capResult(text,
46
79
  moreHint = "Use a more specific query to see all results.") {
47
80
  if (text.length <= MAX_RESULT_CHARS)
48
81
  return text;
49
- let truncated = text.slice(0, MAX_RESULT_CHARS - 200);
82
+ // `moreHint` lets no-input tools (the discovery lists) give accurate truncation
83
+ // guidance instead of the read-tool default (which points at a query control a
84
+ // repeated no-arg call cannot use). Existing callers keep the default message.
85
+ //
86
+ // The reserve for the notice is 200 characters, as it always was, so every
87
+ // existing truncated result keeps its exact cut; a hint longer than that
88
+ // reserve (a consumer of the /shared export may pass any sentence) widens
89
+ // the reserve to the suffix's own length instead of pushing the result over
90
+ // the cap, and a hint is bounded at MAX_HINT_CODE_POINTS so the suffix can
91
+ // never be the whole budget (review round 2 on the export, CodeRabbit).
92
+ const hint = [...moreHint].slice(0, MAX_HINT_CODE_POINTS).join("");
93
+ const suffix = `\n\n[…truncated to fit the 25K token limit. ${hint}]`;
94
+ const reserve = Math.max(200, suffix.length);
95
+ let truncated = text.slice(0, MAX_RESULT_CHARS - reserve);
50
96
  const lastCode = truncated.charCodeAt(truncated.length - 1);
51
97
  if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
52
98
  truncated = truncated.slice(0, -1);
53
99
  }
54
- // `moreHint` lets no-input tools (the discovery lists) give accurate truncation
55
- // guidance instead of the read-tool default (which points at a query control a
56
- // repeated no-arg call cannot use). Existing callers keep the default message.
57
- return `${truncated}\n\n[…truncated to fit the 25K token limit. ${moreHint}]`;
100
+ return `${truncated}${suffix}`;
58
101
  }
102
+ /** The longest `moreHint` capResult will print; the rest is cut, so the suffix
103
+ * cannot take the whole result budget. 1,000 code points is ten times the
104
+ * longest hint this package passes. */
105
+ const MAX_HINT_CODE_POINTS = 1_000;
59
106
  /**
60
107
  * A 2xx whose body does not carry what core always sends for this operation.
61
108
  *
@@ -255,7 +302,21 @@ const MEMORY_ITEM_OUTPUT = {
255
302
  * `deps.apiFetch` is the only way the tools reach the API.
256
303
  */
257
304
  export function registerMemoryTools(server, deps) {
258
- const { apiFetch } = deps;
305
+ const { wording, writeAuthor } = deps;
306
+ // STEP4-2: `wording` reaches the error text here, on every rejection, not
307
+ // in the consumer's constructors. See wordedApiFetch.
308
+ const apiFetch = wording === undefined ? deps.apiFetch : wordedApiFetch(deps.apiFetch, wording);
309
+ // Read once, defensively: `wording` crosses a public package boundary a
310
+ // caller controls only at compile time (STEP4-2, owner 2026-09-24). A
311
+ // strict-equality check rather than a truthiness check, so any value other
312
+ // than the one literal "this connector" (including a typo, a boolean, or
313
+ // a stale value from a future third option) falls back to the default
314
+ // rather than being printed. Defaults to today's wording exactly, so a
315
+ // server that supplies no `wording` at all gets byte-identical descriptions.
316
+ const serverNoun = wording?.serverNoun === "this connector" ? "this connector" : "this server";
317
+ // Same value, sentence-initial capitalisation, for the one description that
318
+ // opens a second sentence with it rather than sitting mid-clause.
319
+ const serverNounCap = serverNoun === "this connector" ? "This connector" : "This server";
259
320
  // ANNOTATIONS, decided once for every server that registers these tools
260
321
  // (owner, 2026-09-21; the stdio server and the hosted connector had answered
261
322
  // both opposite ways):
@@ -380,9 +441,22 @@ export function registerMemoryTools(server, deps) {
380
441
  // returns, needs room addresses deliberately EXEMPT and zero-width
381
442
  // characters handled (JS trim() does not strip them — verified, contrary
382
443
  // to what an earlier comment here asserted).
444
+ //
445
+ // `writeAuthor` (STEP4-5): called once, here, only for this one write,
446
+ // never for a read or a probe. `typeof` first, not a shape check: this
447
+ // dependency crosses a public package boundary a caller controls only
448
+ // at compile time, so a runtime value that is not an object (a string,
449
+ // a number, `null`) is treated the same as "no author", rather than
450
+ // reaching JSON.stringify as a field core would then have to reject.
451
+ // Whatever IS an object is sent exactly as returned: no field-level
452
+ // normalisation here, because core re-normalises server-side (see
453
+ // {@link WriteAuthor}), and this package cannot know the sixth field a
454
+ // future core release adds any better than core's own validator does.
455
+ const authorRaw = writeAuthor?.();
456
+ const author = typeof authorRaw === "object" && authorRaw !== null ? authorRaw : undefined;
383
457
  const r = await apiFetch("/memory/write", {
384
458
  method: "POST",
385
- body: JSON.stringify(writeRequestBody({ content, concepts, domain })),
459
+ body: JSON.stringify(writeRequestBody({ content, concepts, domain }, author)),
386
460
  });
387
461
  // `stored` MUST BE A BOOLEAN before either verdict below may be printed.
388
462
  //
@@ -560,7 +634,7 @@ export function registerMemoryTools(server, deps) {
560
634
  .min(CORE_LIMITS.readTopK.minimum)
561
635
  .max(CORE_LIMITS.readTopK.maximum)
562
636
  .optional()
563
- .describe("Requested number of results (default: 5, what this server asks for when you omit it; the engine's own default of 10 never applies, because the field is always sent). ⚠️ Not a hard cap: association expansion can return MORE than this, and the relevance floor can return fewer — raising it does not reliably widen the result set. For a complete, exactly-bounded listing use memory_list_recent instead."),
637
+ .describe(`Requested number of results (default: 5, what ${serverNoun} asks for when you omit it; the engine's own default of 10 never applies, because the field is always sent). ⚠️ Not a hard cap: association expansion can return MORE than this, and the relevance floor can return fewer — raising it does not reliably widen the result set. For a complete, exactly-bounded listing use memory_list_recent instead.`),
564
638
  domain: z
565
639
  .string()
566
640
  .optional()
@@ -742,7 +816,15 @@ export function registerMemoryTools(server, deps) {
742
816
  // `structuredContent` carries every item, uncapped (OD-11): the cap
743
817
  // and its legend are TEXT-side concerns, and `structuredItem`
744
818
  // (src/render.ts) is deliberately not run through either.
745
- return structured(withDomainEscapeLegend(capResult(text), ...items.map((it) => it?.domain)), { items: items.map(structuredItem) });
819
+ //
820
+ // Author names are candidates too, not just domains (I66-1..I66-3,
821
+ // issue #66): the text tag now quotes every author name through the
822
+ // same `exactLiteral` the `@domain` tag uses, so an escaped one needs
823
+ // the same "these are JSON string literals" legend an escaped domain
824
+ // already gets. `rawAuthorName` is the SAME raw value `formatAuthorTag`
825
+ // quoted, so this recomputation finds the same literal that is
826
+ // actually on the page.
827
+ return structured(withEscapeLegendAt(MAX_DOMAIN_TAG_LITERAL, capResult(text), ...items.map((it) => it?.domain), ...items.map((it) => rawAuthorName(it?.provenance))), { items: items.map(structuredItem) });
746
828
  });
747
829
  // --- Tool: memory_list_recent ---
748
830
  /**
@@ -1119,12 +1201,15 @@ export function registerMemoryTools(server, deps) {
1119
1201
  // The cursor is the last ACCEPTED batch's, never the newest one
1120
1202
  // seen: a batch that did not fit the budget was not returned, so
1121
1203
  // pointing past it would skip every entry in it.
1122
- withDomainEscapeLegend(capResult(formatRecentPage(items, acceptedCursor) +
1204
+ //
1205
+ // Author names are candidates too, not just domains: same reasoning
1206
+ // as memory_read's call site above (I66-1..I66-3, issue #66).
1207
+ withEscapeLegendAt(MAX_DOMAIN_TAG_LITERAL, capResult(formatRecentPage(items, acceptedCursor) +
1123
1208
  (stoppedEarly ? LIST_PAGE_EARLY_STOP_NOTE : ""),
1124
1209
  // Still true, and now only reachable when ONE entry is larger
1125
1210
  // than the whole budget: the case `limit` cannot fix and the
1126
1211
  // global cap has to.
1127
- "Lower `limit` or add a `domain` for smaller pages."), ...items.map((it) => it?.domain)), {
1212
+ "Lower `limit` or add a `domain` for smaller pages."), ...items.map((it) => it?.domain), ...items.map((it) => rawAuthorName(it?.provenance))), {
1128
1213
  items: items.map(structuredItem),
1129
1214
  // Same field name and meaning as the connector's `next_cursor`
1130
1215
  // (mnemoverse-mcp-remote `memoryListRecentOutput`): the cursor a
@@ -1204,7 +1289,7 @@ export function registerMemoryTools(server, deps) {
1204
1289
  .array(z.string())
1205
1290
  .min(1)
1206
1291
  .optional()
1207
- .describe("Deprecated since 0.11, removed in 0.12: atom_ids is the old name of memory_ids, still accepted on its own until then. Pass memory_ids instead."),
1292
+ .describe("Deprecated since 0.11, removed in 0.13: atom_ids is the old name of memory_ids, still accepted on its own until then. Pass memory_ids instead."),
1208
1293
  outcome: z
1209
1294
  .number()
1210
1295
  .min(CORE_LIMITS.feedbackOutcome.minimum)
@@ -1249,7 +1334,7 @@ export function registerMemoryTools(server, deps) {
1249
1334
  .int()
1250
1335
  .nonnegative()
1251
1336
  .optional()
1252
- .describe("Number of feedback-driven query/result concept co-activation edges changed by the service. This is separate from ordinary Hebbian strengthening among a memory's own concepts. This server does not send query_concepts, so live calls through this tool report 0; asynchronous acknowledgements also report 0."),
1337
+ .describe(`Number of feedback-driven query/result concept co-activation edges changed by the service. This is separate from ordinary Hebbian strengthening among a memory's own concepts. ${serverNounCap} does not send query_concepts, so live calls through this tool report 0; asynchronous acknowledgements also report 0.`),
1253
1338
  },
1254
1339
  annotations: {
1255
1340
  title: "Rate Memory Helpfulness",
@@ -1483,9 +1568,9 @@ export function registerMemoryTools(server, deps) {
1483
1568
  // count, and the same rule: a value that is not a finite number is
1484
1569
  // unknown and prints nothing, never 0. Core's async mode acks with 0
1485
1570
  // before the worker runs (memory_engine.py `feedback` docstring), which
1486
- // is why this is the service's report too; production ran the sync path
1487
- // for every rating in the week checked (Axiom 2026-09-15..22:
1488
- // feedback_completed 630, feedback_completed_async 0).
1571
+ // is why this is the service's report too; in production every rating
1572
+ // observed so far took the synchronous path (the asynchronous
1573
+ // acknowledgement is reachable but was not seen in the checked window).
1489
1574
  //
1490
1575
  // coactivation_edges is left out of the text on purpose: core links
1491
1576
  // concepts only when the request carries query_concepts, which this tool
@@ -1513,6 +1598,51 @@ export function registerMemoryTools(server, deps) {
1513
1598
  server.registerTool("memory_stats", {
1514
1599
  description: "Get an overview of the stored memory: total count, episodes vs consolidated prototypes, number of learned associations, the list of domains, and average quality scores. This memory is shared across all AI tools the user has connected to Mnemoverse. Use it to orient yourself, to confirm the exact domain name before writing to it, or when the user asks what you remember. Read-only — changes nothing.",
1515
1600
  inputSchema: {},
1601
+ // `memory_count` and `domains` are copied from the connector's
1602
+ // `memoryStatsOutput` (mnemoverse-mcp-remote, src/tools/index.ts), field
1603
+ // for field and description for description, under the CONNECTOR's
1604
+ // naming rather than core's (`total_atoms`): the same structured
1605
+ // consumer can read both servers, and a data field spelled differently
1606
+ // between them would defeat the point of one shared shape (decision Q3,
1607
+ // owner, 2026-09-23). Both are required, matching the connector: a body
1608
+ // without a usable value for either is not core's answer (see the guard
1609
+ // in the handler below). The other five fields are this package's own
1610
+ // addition, the rest of what this tool's own text already reports,
1611
+ // each optional, present only when core sent a usable number for it.
1612
+ outputSchema: {
1613
+ memory_count: z
1614
+ .number()
1615
+ .int()
1616
+ .nonnegative()
1617
+ .describe("Number of saved memories."),
1618
+ domains: z.array(z.string()).describe("User-defined memory domains."),
1619
+ episodes: z
1620
+ .number()
1621
+ .int()
1622
+ .nonnegative()
1623
+ .optional()
1624
+ .describe("Number of episodic (not yet consolidated) memories."),
1625
+ prototypes: z
1626
+ .number()
1627
+ .int()
1628
+ .nonnegative()
1629
+ .optional()
1630
+ .describe("Number of consolidated prototype memories."),
1631
+ hebbian_edges: z
1632
+ .number()
1633
+ .int()
1634
+ .nonnegative()
1635
+ .optional()
1636
+ .describe("Number of Hebbian concept-to-concept links, learned from concepts that occur together as memories are stored and used."),
1637
+ avg_valence: z
1638
+ .number()
1639
+ .optional()
1640
+ .describe("Average valence of stored memories: how well recalls turned out, on a scale from -1 to 1."),
1641
+ avg_importance: z
1642
+ .number()
1643
+ .optional()
1644
+ .describe("Average importance of stored memories, on a scale from 0 to 1."),
1645
+ },
1516
1646
  annotations: {
1517
1647
  title: "Memory Statistics",
1518
1648
  readOnlyHint: true,
@@ -1522,6 +1652,28 @@ export function registerMemoryTools(server, deps) {
1522
1652
  },
1523
1653
  }, async () => {
1524
1654
  const r = await apiFetch("/memory/stats");
1655
+ // REQUIRED-FIELD GUARD (OD-8 precedent, owner, 2026-09-23): `memory_count`
1656
+ // and `domains` are REQUIRED in the outputSchema above, matching the
1657
+ // connector's own `memoryStatsOutput`, so a body without a usable
1658
+ // `total_atoms` (a non-negative safe integer) or without an ARRAY
1659
+ // `domains` is not core's answer and there is no honest
1660
+ // structuredContent to build for it. Before this schema existed, both
1661
+ // degraded silently into this tool's own "unknown" numbers / "none
1662
+ // reported" domains text; now they are `isError`, the same shape every
1663
+ // other unreadable-answer reply in this file takes. isSafeInteger, not
1664
+ // isInteger, for the same reason memory_feedback's count check gives:
1665
+ // the schema is z.number().int(), and zod 4 rejects an integer above
1666
+ // 2^53 - 1.
1667
+ const totalAtomsRaw = r?.total_atoms;
1668
+ const memoryCount = typeof totalAtomsRaw === "number" &&
1669
+ Number.isSafeInteger(totalAtomsRaw) &&
1670
+ totalAtomsRaw >= 0
1671
+ ? totalAtomsRaw
1672
+ : undefined;
1673
+ const domainsRaw = r?.domains;
1674
+ if (memoryCount === undefined || !Array.isArray(domainsRaw)) {
1675
+ return unreadableAnswerReply("The stats answer", "the memory store's statistics", "the store is empty");
1676
+ }
1525
1677
  // A field the server did not send is UNKNOWN, not zero. Rendering it as
1526
1678
  // "0" is the same class of lie as an empty search claiming emptiness:
1527
1679
  // "Associations: 0" reads as "this memory has learned nothing", which is
@@ -1570,29 +1722,71 @@ export function registerMemoryTools(server, deps) {
1570
1722
  "",
1571
1723
  "Counts cover your own domains. Shared rooms are separate stores and are not included — see memory_list_rooms.",
1572
1724
  ].join("\n");
1573
- return {
1574
- content: [
1575
- {
1576
- type: "text",
1577
- // Array.isArray, not `?? []`: `domains` is typed as a string[] but
1578
- // arrives over the wire, and spreading a non-iterable object would
1579
- // throw here — turning a malformed payload into a dead tool instead
1580
- // of the "none reported" it degrades to two lines up.
1581
- //
1582
- // capResult is the second belt, not the mechanism: the domain list is
1583
- // already bounded above, so this only fires if some future line grows
1584
- // unboundedly. It stays because this was the ONE tool result with no
1585
- // cap at all, and "every surface is capped" is worth being an
1586
- // invariant rather than an argument about which surfaces can grow.
1587
- // Its hint names a control this no-input tool actually has — none —
1588
- // rather than the read tool's "use a more specific query".
1589
- //
1590
- // Legend AFTER the cap, as everywhere else: it must describe the names
1591
- // that SURVIVED, and it is appended at the end, where the cap cuts.
1592
- text: withDomainEscapeLegend(capResult(text, "The domain list was truncated — some domain names are not shown."), ...(Array.isArray(r?.domains) ? r.domains : [])),
1593
- },
1594
- ],
1595
- };
1725
+ // STRUCTURED TWIN of the optional numeric fields, following the same
1726
+ // rule memory_feedback's does: a value core did not send, or sent in a
1727
+ // shape the schema could not hold, is absent from structuredContent,
1728
+ // never defaulted to 0. Int fields use isSafeInteger for the reason the
1729
+ // guard above gives (the schema is z.number().int(), and zod 4 rejects
1730
+ // an integer above 2^53 - 1); the two averages use isFinite, so an
1731
+ // Infinity smuggled through a raw body (e.g. avg_valence: 1e400) never
1732
+ // reaches a structured consumer as data. The TEXT above keeps printing
1733
+ // whatever num()/dec() print for the same malformed value, and that
1734
+ // text/data divergence is disclosed in the CHANGELOG, as S5 disclosed
1735
+ // its cursor semantics.
1736
+ const safeIntOrUndefined = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : undefined;
1737
+ const finiteOrUndefined = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
1738
+ const episodesStructured = safeIntOrUndefined(r?.episodes);
1739
+ const prototypesStructured = safeIntOrUndefined(r?.prototypes);
1740
+ const hebbianEdgesStructured = safeIntOrUndefined(r?.hebbian_edges);
1741
+ const avgValenceStructured = finiteOrUndefined(r?.avg_valence);
1742
+ const avgImportanceStructured = finiteOrUndefined(r?.avg_importance);
1743
+ // DOMAINS FOR structuredContent: FILTERED, not an error and not zeroed
1744
+ // (decisions S7-1/S7-2, owner, 2026-09-23). A non-string element is
1745
+ // dropped rather than turning the whole reply into isError, and the
1746
+ // TEXT above already counts it in formatDomainList's "not shown, cannot
1747
+ // be printed exactly" clause, so the count is not silently lost, only
1748
+ // moved off the surface a structured consumer reads. What a structured
1749
+ // consumer would silently lose is the fact that anything was dropped at
1750
+ // all, so the drop is also reported once on stderr, in this package's
1751
+ // existing startup-diagnostic style ("Mnemoverse: ..." in src/index.ts),
1752
+ // the operator's channel rather than the model's: putting this in the
1753
+ // tool text would surface an implementation detail to the agent reading it.
1754
+ const domainsStructured = domainsRaw.filter((d) => typeof d === "string");
1755
+ const droppedDomains = domainsRaw.length - domainsStructured.length;
1756
+ if (droppedDomains > 0) {
1757
+ console.error(`Mnemoverse: memory_stats dropped ${droppedDomains} non-string domain ` +
1758
+ `entr${droppedDomains === 1 ? "y" : "ies"} from structuredContent.domains ` +
1759
+ `(still counted in the text's "not shown" total).`);
1760
+ }
1761
+ return structured(
1762
+ // Array.isArray, not `?? []`: `domains` is typed as a string[] but
1763
+ // arrives over the wire, and spreading a non-iterable object would
1764
+ // throw here — turning a malformed payload into a dead tool instead
1765
+ // of the "none reported" it degrades to two lines up.
1766
+ //
1767
+ // capResult is the second belt, not the mechanism: the domain list is
1768
+ // already bounded above, so this only fires if some future line grows
1769
+ // unboundedly. It stays because this was the ONE tool result with no
1770
+ // cap at all, and "every surface is capped" is worth being an
1771
+ // invariant rather than an argument about which surfaces can grow.
1772
+ // Its hint names a control this no-input tool actually has — none —
1773
+ // rather than the read tool's "use a more specific query".
1774
+ //
1775
+ // Legend AFTER the cap, as everywhere else: it must describe the names
1776
+ // that SURVIVED, and it is appended at the end, where the cap cuts.
1777
+ withDomainEscapeLegend(capResult(text, "The domain list was truncated — some domain names are not shown."), ...(Array.isArray(r?.domains) ? r.domains : [])), {
1778
+ memory_count: memoryCount,
1779
+ domains: domainsStructured,
1780
+ ...(episodesStructured === undefined ? {} : { episodes: episodesStructured }),
1781
+ ...(prototypesStructured === undefined ? {} : { prototypes: prototypesStructured }),
1782
+ ...(hebbianEdgesStructured === undefined
1783
+ ? {}
1784
+ : { hebbian_edges: hebbianEdgesStructured }),
1785
+ ...(avgValenceStructured === undefined ? {} : { avg_valence: avgValenceStructured }),
1786
+ ...(avgImportanceStructured === undefined
1787
+ ? {}
1788
+ : { avg_importance: avgImportanceStructured }),
1789
+ });
1596
1790
  });
1597
1791
  // --- Tool: memory_create_room ---
1598
1792
  //
@@ -1605,6 +1799,32 @@ export function registerMemoryTools(server, deps) {
1605
1799
  // carries the owner-chosen room name raw, which this file prints only
1606
1800
  // through roomNamePhrase (CN-032). The usage lines below say the same
1607
1801
  // things in MCP terms, scope-gated on join.
1802
+ // OUTPUT SCHEMAS for the three room tools (S8, structured-output plan):
1803
+ // `room_id`/`address`/`room_address`/`scope` copied from the connector's
1804
+ // `roomCreatedOutput`/`roomInviteOutput`/`roomJoinedOutput`
1805
+ // (mnemoverse-mcp-remote, src/tools/index.ts), field for field and
1806
+ // description for description.
1807
+ //
1808
+ // OD-13 (owner, 2026-09-23): several fields the connector marks required
1809
+ // are OPTIONAL here: `name` on create and join, `scope`/`already_member`
1810
+ // on join, and `code`/`scope`/`room_address`/`expires_at` on invite. The
1811
+ // connector's own core client types those fields as always-present; this
1812
+ // package treats every wire value as untyped (the general rule this whole
1813
+ // file follows) and already has a non-degraded THREE-state phrase for a
1814
+ // room name core did not send (`roomNamePhrase`) and for a join whose
1815
+ // scope core did not report (`roomScopeVerdict`'s "unspecified" arm), so
1816
+ // "core sent no usable value for this field" is an existing, honestly
1817
+ // representable outcome here, not an error, and the schema says so by
1818
+ // making the field optional rather than forcing a fabricated placeholder
1819
+ // into a field declared required.
1820
+ //
1821
+ // `join_url`/`share_message` on invite stay as today: at least one of the
1822
+ // two must be usable or the call is `isError` (unchanged from before this
1823
+ // schema existed), so `share_message` is the one guaranteed field, built
1824
+ // from the SAME fallback the text already prints (share_message when core
1825
+ // sent one, else join_url), and `join_url` itself is optional, present
1826
+ // only when core actually returned a string for it.
1827
+ // --- Tool: memory_create_room ---
1608
1828
  server.registerTool("memory_create_room", {
1609
1829
  description: "Create a SHARED memory room — a space OTHER people's assistants can read, and write too when their invite granted read_write (the default scope), across Claude/ChatGPT/Cursor. Use when the user wants to share context or collaborate with someone else (e.g. 'make a room for me and Olya'). Returns the room's address; pass that address as the `domain` on memory_write/memory_read to use it, and on memory_list_recent to catch up on what others added. To bring someone in, call memory_invite_to_room next.",
1610
1830
  inputSchema: {
@@ -1619,6 +1839,15 @@ export function registerMemoryTools(server, deps) {
1619
1839
  .optional()
1620
1840
  .describe("Optional description of the room."),
1621
1841
  },
1842
+ outputSchema: {
1843
+ room_id: z
1844
+ .string()
1845
+ .describe("The room's id (room_...); pass to memory_invite_to_room."),
1846
+ address: z
1847
+ .string()
1848
+ .describe("Domain address (xroom:<id>); pass as `domain` on read/write."),
1849
+ name: z.string().optional().describe("The room name as stored."),
1850
+ },
1622
1851
  annotations: {
1623
1852
  title: "Create shared room",
1624
1853
  readOnlyHint: false,
@@ -1641,7 +1870,13 @@ export function registerMemoryTools(server, deps) {
1641
1870
  const roomId = safeInline(r?.room_id);
1642
1871
  // If core returned no usable id (empty body / sanitized away), don't print
1643
1872
  // broken `domain=""` guidance — say so instead (Copilot).
1644
- const text = address
1873
+ //
1874
+ // GATE (S8-1, owner, 2026-09-23): core's RoomCreatedSchema sends
1875
+ // room_id alongside address on every create, so `roomId` now joins
1876
+ // `address` in the condition that picks this branch: a body missing
1877
+ // a usable one of either is not core's answer, the same class of
1878
+ // unreadable 2xx this file already refuses rather than describes.
1879
+ const text = address && roomId
1645
1880
  ? `Created shared room ${roomName}. Address: ${address}\n` +
1646
1881
  `Use it now: pass domain="${address}" on memory_write / memory_read, and on memory_list_recent to catch up on what others added.\n` +
1647
1882
  (roomId
@@ -1649,18 +1884,35 @@ export function registerMemoryTools(server, deps) {
1649
1884
  : "")
1650
1885
  : `Room ${roomName} was created but the server did not return a usable address — ` +
1651
1886
  `retry, or check that your API key is set.`;
1652
- return {
1653
- content: [
1654
- {
1655
- type: "text",
1656
- // Legend AFTER the cap (same rule as memory_read/memory_list_recent):
1657
- // capResult cuts from the end, so a legend applied first would be the
1658
- // first casualty; applied to the capped text it also drops itself when
1659
- // the cap removed the only escaped name.
1660
- text: withDomainEscapeLegend(capResult(text), rawName),
1661
- },
1662
- ],
1663
- };
1887
+ // Legend AFTER the cap (same rule as memory_read/memory_list_recent):
1888
+ // capResult cuts from the end, so a legend applied first would be the
1889
+ // first casualty; applied to the capped text it also drops itself when
1890
+ // the cap removed the only escaped name.
1891
+ const finalText = withDomainEscapeLegend(capResult(text), rawName);
1892
+ // Same gate as the text above: no usable address or no usable room_id
1893
+ // is not core's answer, and there is no honest structuredContent for
1894
+ // it either. The existing degrade sentence is the whole reply now,
1895
+ // with isError: true, instead of a 200-shaped "success" with nothing
1896
+ // a caller can act on.
1897
+ if (!address || !roomId) {
1898
+ return {
1899
+ content: [{ type: "text", text: finalText }],
1900
+ isError: true,
1901
+ };
1902
+ }
1903
+ // STRUCTURED `name` comes from the RESPONSE only, never from the
1904
+ // request's `name` the text above falls back to: the schema says "as
1905
+ // stored", and a body that omits `name` gives this client no evidence
1906
+ // of what core stored, so the key is absent rather than an echo of the
1907
+ // caller's own spelling dressed up as core's answer (Copilot, review
1908
+ // round 2). The text keeps its fallback: it is written for the caller
1909
+ // who chose the name and stays byte-identical.
1910
+ const nameStructured = structuredText(r?.name, 200);
1911
+ return structured(finalText, {
1912
+ room_id: roomId,
1913
+ address,
1914
+ ...(nameStructured === undefined ? {} : { name: nameStructured }),
1915
+ });
1664
1916
  });
1665
1917
  // --- Tool: memory_invite_to_room ---
1666
1918
  server.registerTool("memory_invite_to_room", {
@@ -1694,6 +1946,20 @@ export function registerMemoryTools(server, deps) {
1694
1946
  .optional()
1695
1947
  .describe("How many people may join with this invite (default 1, single-use)."),
1696
1948
  },
1949
+ outputSchema: {
1950
+ share_message: z.string().describe("Ready-to-forward invite text."),
1951
+ join_url: z.string().optional().describe("Landing URL the invitee can open to join."),
1952
+ code: z
1953
+ .string()
1954
+ .optional()
1955
+ .describe("The invite code (mnvr_...). Single-use by default, with a configurable use limit. Shown once."),
1956
+ scope: z.string().optional().describe("Role the invitee will get."),
1957
+ room_address: z
1958
+ .string()
1959
+ .optional()
1960
+ .describe("The room's domain address (xroom:<id>)."),
1961
+ expires_at: z.string().nullable().optional().describe("ISO 8601 expiry, or null."),
1962
+ },
1697
1963
  annotations: {
1698
1964
  title: "Invite to room",
1699
1965
  readOnlyHint: false,
@@ -1708,18 +1974,62 @@ export function registerMemoryTools(server, deps) {
1708
1974
  method: "POST",
1709
1975
  body: JSON.stringify({ scope, expires_in_days, max_uses }),
1710
1976
  });
1711
- return {
1712
- content: [
1713
- {
1714
- type: "text",
1715
- // Shown to the room OWNER (who minted it), not a foreign principal, so
1716
- // the core-generated share_message is fine as-is; capResult only bounds
1717
- // its length for the Connectors-Directory 25K cap.
1718
- text: capResult(`Invite ready. Forward this message to the person you're inviting:\n\n` +
1719
- `${r?.share_message ?? r?.join_url ?? "(no message returned)"}`),
1720
- },
1721
- ],
1722
- };
1977
+ // ONE selection feeds both surfaces (Copilot, review round 2): the
1978
+ // message the text forwards is core's share_message when the body has
1979
+ // one (its raw value, exactly as before this schema existed), else
1980
+ // join_url, else the "(no message returned)" sentence.
1981
+ const rawMessage = r?.share_message ?? r?.join_url;
1982
+ const text = capResult(`Invite ready. Forward this message to the person you're inviting:\n\n` +
1983
+ `${rawMessage ?? "(no message returned)"}`);
1984
+ // Shown to the room OWNER (who minted it), not a foreign principal, so
1985
+ // the core-generated share_message is fine as-is; capResult only bounds
1986
+ // its length for the Connectors-Directory 25K cap. STRUCTURED
1987
+ // `share_message` (S8-4, owner, 2026-09-23) is that SAME selected
1988
+ // value, normalised through structuredText, so the data never carries
1989
+ // a message the text did not show. A selected value that normalises to
1990
+ // nothing (no share_message and no join_url, or a share_message that is
1991
+ // empty, whitespace-only or not a string at all) is not this tool's
1992
+ // success case any more: the text above is unchanged (the blank or the
1993
+ // "(no message returned)" sentence, as before) and the reply is
1994
+ // isError: true, since there is no honest
1995
+ // structuredContent.share_message to pair it with.
1996
+ const shareMessageStructured = structuredText(rawMessage, 800);
1997
+ if (shareMessageStructured === undefined) {
1998
+ return { content: [{ type: "text", text }], isError: true };
1999
+ }
2000
+ // `join_url` through safeInline with the connector's own cap of 400,
2001
+ // as the connector does (mnemoverse-mcp-remote, memory_invite_to_room):
2002
+ // core builds it as `<base>/<code>`, which safeInline's character class
2003
+ // carries unchanged; anything else it would have to alter is not a URL
2004
+ // this client should hand on as one. Absent when nothing remains.
2005
+ const joinUrlSafe = safeInline(r?.join_url, 400);
2006
+ const joinUrlStructured = joinUrlSafe === "" ? undefined : joinUrlSafe;
2007
+ // The other machine fields the same way: safeInline returns "" for a
2008
+ // non-string, an empty string and a string with nothing it may keep,
2009
+ // and "" is not a usable code, scope or address, so the key is absent
2010
+ // (CodeRabbit, review round 2). Present only when something remains.
2011
+ const codeSafe = safeInline(r?.code);
2012
+ const codeStructured = codeSafe === "" ? undefined : codeSafe;
2013
+ const scopeSafe = safeInline(r?.scope);
2014
+ const scopeStructured = scopeSafe === "" ? undefined : scopeSafe;
2015
+ const roomAddressSafe = safeInline(r?.room_address);
2016
+ const roomAddressStructured = roomAddressSafe === "" ? undefined : roomAddressSafe;
2017
+ // `expires_at` through utcInstant's RETURN, the rule memory_read's
2018
+ // `created_at` already follows (S4): a value that states its offset is
2019
+ // carried exactly as sent, an offset-less one, which this package reads
2020
+ // as UTC by contract, is re-emitted as the UTC ISO-8601 instant, so a
2021
+ // structured consumer lands on the instant this client used rather than
2022
+ // reading the naive string as local time (CodeRabbit, review round 2).
2023
+ // `null` when core sent null; absent when the value does not parse.
2024
+ const expiresAtStructured = r?.expires_at === null ? null : (utcInstant(r?.expires_at) ?? undefined);
2025
+ return structured(text, {
2026
+ share_message: shareMessageStructured,
2027
+ ...(joinUrlStructured === undefined ? {} : { join_url: joinUrlStructured }),
2028
+ ...(codeStructured === undefined ? {} : { code: codeStructured }),
2029
+ ...(scopeStructured === undefined ? {} : { scope: scopeStructured }),
2030
+ ...(roomAddressStructured === undefined ? {} : { room_address: roomAddressStructured }),
2031
+ ...(expiresAtStructured === undefined ? {} : { expires_at: expiresAtStructured }),
2032
+ });
1723
2033
  });
1724
2034
  // --- Tool: memory_join_room ---
1725
2035
  server.registerTool("memory_join_room", {
@@ -1727,6 +2037,19 @@ export function registerMemoryTools(server, deps) {
1727
2037
  inputSchema: {
1728
2038
  code: z.string().min(1).max(200).describe("The invite code (mnvr_...)."),
1729
2039
  },
2040
+ outputSchema: {
2041
+ room_id: z.string().describe("The room's id (room_...)."),
2042
+ address: z
2043
+ .string()
2044
+ .describe("Domain address (xroom:<id>); pass as `domain` on read/write."),
2045
+ name: z.string().optional().describe("The room name."),
2046
+ scope: z.string().optional().describe("Your role in the room ('read' | 'read_write')."),
2047
+ already_member: z
2048
+ .boolean()
2049
+ .optional()
2050
+ .describe("True if you were already a member (no-op join)."),
2051
+ next_steps: z.string().describe("How to use the room now."),
2052
+ },
1730
2053
  annotations: {
1731
2054
  title: "Join room",
1732
2055
  readOnlyHint: false,
@@ -1748,6 +2071,7 @@ export function registerMemoryTools(server, deps) {
1748
2071
  const roomName = roomNamePhrase(r?.name);
1749
2072
  const scope = safeInline(r?.scope) || "member";
1750
2073
  const address = safeInline(r?.address);
2074
+ const roomId = safeInline(r?.room_id);
1751
2075
  const prefix = r?.already_member
1752
2076
  ? `You're already a member of ${roomName}.`
1753
2077
  : `Joined ${roomName} (${scope}).`;
@@ -1756,28 +2080,102 @@ export function registerMemoryTools(server, deps) {
1756
2080
  // isRoomDomain): a "read" invite gets told memory_write will be refused
1757
2081
  // rather than offered it, and a scope the response did not report at all
1758
2082
  // gets no promise about write either way.
2083
+ //
2084
+ // GATE (S8-2, mirrors create's S8-1, owner, 2026-09-23): core's
2085
+ // JoinedRoomSchema sends room_id alongside address on every join, so
2086
+ // `roomId` now joins `address` in the condition that picks this
2087
+ // sentence: same wording as before (it still names only "address" -
2088
+ // the caller cannot tell which of the two core actually omitted, and a
2089
+ // second sentence for a case indistinguishable from this one would be
2090
+ // a distinction this client cannot see either).
1759
2091
  const verdict = roomScopeVerdict(r?.scope);
1760
- const usage = !address
2092
+ const usage = !address || !roomId
1761
2093
  ? `The server did not return a room address — retry, or check that your API key is set.`
1762
2094
  : verdict === "read_write"
1763
2095
  ? `Use it: pass domain="${address}" on memory_write / memory_read to read and write the shared room, and on memory_list_recent to catch up on what is new.`
1764
2096
  : verdict === "read"
1765
2097
  ? `Use it: pass domain="${address}" on memory_read or memory_list_recent to read it; this membership is read-only, so memory_write to that address will be refused.`
1766
2098
  : `Use it: pass domain="${address}" on memory_read or memory_list_recent to read it — the server did not report this membership's write access, so whether memory_write to that address would succeed is unknown.`;
1767
- return {
1768
- content: [
1769
- {
1770
- type: "text",
1771
- // Legend after the cap — same ordering rule as everywhere else.
1772
- text: withDomainEscapeLegend(capResult(`${prefix}\n${usage}`), r?.name),
1773
- },
1774
- ],
1775
- };
2099
+ // No usable address or room_id: the reply is the same text as before
2100
+ // this schema existed (the prefix line plus the degrade sentence), now
2101
+ // isError: true, since there is no honest structuredContent for a join
2102
+ // whose room this client cannot address. The text stays byte-identical
2103
+ // on purpose: whether this reply should stop saying "Joined" is a
2104
+ // wording decision for the owner, not for this slice.
2105
+ if (!address || !roomId) {
2106
+ return {
2107
+ content: [
2108
+ {
2109
+ type: "text",
2110
+ text: withDomainEscapeLegend(capResult(`${prefix}\n${usage}`), r?.name),
2111
+ },
2112
+ ],
2113
+ isError: true,
2114
+ };
2115
+ }
2116
+ const nameStructured = structuredText(r?.name, 200);
2117
+ // STRUCTURED `next_steps` (S8-6, owner, 2026-09-23) is this SAME usage
2118
+ // sentence, never core's own `next_steps`: that field points at REST
2119
+ // endpoints and is deliberately not echoed (see the comment above
2120
+ // memory_create_room). `?? usage` is a defensive fallback for the
2121
+ // unreachable case where structuredText would normalise `usage` to
2122
+ // nothing; it is built from safe, already-sanitised parts and never
2123
+ // actually empties.
2124
+ const nextStepsStructured = structuredText(usage, 500) ?? usage;
2125
+ // `scope` absent when safeInline leaves nothing, as on invite.
2126
+ const scopeSafe = safeInline(r?.scope);
2127
+ const scopeStructured = scopeSafe === "" ? undefined : scopeSafe;
2128
+ return structured(
2129
+ // Legend after the cap, same ordering rule as everywhere else.
2130
+ withDomainEscapeLegend(capResult(`${prefix}\n${usage}`), r?.name), {
2131
+ room_id: roomId,
2132
+ address,
2133
+ ...(nameStructured === undefined ? {} : { name: nameStructured }),
2134
+ ...(scopeStructured === undefined ? {} : { scope: scopeStructured }),
2135
+ ...(typeof r?.already_member === "boolean"
2136
+ ? { already_member: r.already_member }
2137
+ : {}),
2138
+ next_steps: nextStepsStructured,
2139
+ });
1776
2140
  });
1777
2141
  // --- Tool: memory_list_rooms ---
2142
+ // OUTPUT SCHEMA (S9, structured-output plan): `room_id`/`name`/`address`/
2143
+ // `role`/`scope`/`archived` copied from the connector's `roomListOutput`
2144
+ // (mnemoverse-mcp-remote, src/tools/index.ts), field for field and
2145
+ // description for description.
2146
+ //
2147
+ // OD-14 (owner, 2026-09-23, S9-1): `name` is OPTIONAL here though the
2148
+ // connector's own schema marks it a REQUIRED z.string(); so is `scope`, for
2149
+ // the reason OD-13 gave on memory_join_room: the text already has a
2150
+ // supported, non-degraded state for a membership whose write access core
2151
+ // did not report ("this membership's write access was not reported"), and
2152
+ // a required field would turn that state into an SDK "Output validation
2153
+ // error" (review round 2). `structuredText`
2154
+ // (src/names.ts) returns undefined for a genuinely empty or absent name, a
2155
+ // real, already-tested case ("keeps '(unnamed room)' for a genuinely absent
2156
+ // or empty name", test/handlers.test.ts), and forcing that through a
2157
+ // REQUIRED field would make the SDK reject the WHOLE reply with "Output
2158
+ // validation error" on an unnamed room, which is a supported, non-error
2159
+ // outcome, not a malformed response. The alternative, falling back to the
2160
+ // literal empty string, was rejected: the text would keep saying
2161
+ // "(unnamed room)" while the data silently said `name: ""`, the same
2162
+ // text/data lie the anti-fabrication rule this package already applies to
2163
+ // every other optional field exists to prevent.
1778
2164
  server.registerTool("memory_list_rooms", {
1779
2165
  description: "List the shared memory rooms you can use — the ones you OWN plus the ones you've JOINED — each with the address to pass as `domain` on memory_read, and on memory_write too where your membership scope is read_write; a read-only membership has that write refused. Use this to RE-FIND a room in a new session (e.g. 'what rooms do I have?', 'resume the room with Olya') instead of having to create or re-join it.",
1780
2166
  inputSchema: {},
2167
+ outputSchema: {
2168
+ rooms: z.array(z.object({
2169
+ room_id: z.string().describe("The room's id (room_...)."),
2170
+ name: z.string().optional().describe("The room name."),
2171
+ address: z
2172
+ .string()
2173
+ .describe("Domain address (xroom:<id>); pass as `domain` on read/write."),
2174
+ role: z.string().describe("'owner' or 'member'."),
2175
+ scope: z.string().optional().describe("'read' or 'read_write'."),
2176
+ archived: z.boolean().describe("True if archived (owned rooms only)."),
2177
+ })),
2178
+ },
1781
2179
  annotations: {
1782
2180
  title: "List rooms",
1783
2181
  readOnlyHint: true,
@@ -1800,15 +2198,8 @@ export function registerMemoryTools(server, deps) {
1800
2198
  return unreadableAnswerReply("The room list", "a list of your rooms", "you have none");
1801
2199
  }
1802
2200
  if (rooms.state === "none") {
1803
- return {
1804
- content: [
1805
- {
1806
- type: "text",
1807
- text: "You have no shared rooms yet. Create one with memory_create_room, " +
1808
- "or join one with memory_join_room using an invite code.",
1809
- },
1810
- ],
1811
- };
2201
+ return structured("You have no shared rooms yet. Create one with memory_create_room, " +
2202
+ "or join one with memory_join_room using an invite code.", { rooms: [] });
1812
2203
  }
1813
2204
  // ARCHIVED ROOMS ARE LISTED HERE, unlike in the scope note — this tool's job
1814
2205
  // is the inventory, and the `[archived]` tag says which ones cannot be read.
@@ -1851,22 +2242,136 @@ export function registerMemoryTools(server, deps) {
1851
2242
  return `- ${name} (${role}${scope ? `, ${scope}` : ""})${tail}`;
1852
2243
  });
1853
2244
  const text = `Your shared rooms (${list.length}):\n${lines.join("\n")}`;
1854
- return {
1855
- content: [
1856
- {
1857
- type: "text",
1858
- // Legend after the cap: this is the one room surface long enough to
1859
- // actually overflow, and the legend must describe the names that
1860
- // SURVIVED the cut, not the ones it removed.
1861
- text: withDomainEscapeLegend(capResult(text, "The room list was truncated — some rooms are not shown."), ...list.map((r) => r?.name)),
1862
- },
1863
- ],
1864
- };
2245
+ // Legend after the cap: this is the one room surface long enough to
2246
+ // actually overflow, and the legend must describe the names that
2247
+ // SURVIVED the cut, not the ones it removed.
2248
+ const finalText = withDomainEscapeLegend(capResult(text, "The room list was truncated — some rooms are not shown."), ...list.map((r) => r?.name));
2249
+ // STRUCTURED rows (S9-1/S9-3, owner, 2026-09-23): room_id/address/role/
2250
+ // scope through the SAME safeInline sanitiser the text loop above
2251
+ // already applies (address keeps the identical xroom:<room_id>
2252
+ // fallback), archived through Boolean(). The name is withheld from the
2253
+ // data in exactly the cases the text withholds it: roomNamePhrase
2254
+ // prints "(room name cannot be printed exactly)" when exactLiteral
2255
+ // refuses the JSON literal (longer than MAX_DOMAIN_LITERAL once quoted
2256
+ // and escaped), so the same exactLiteral check decides whether `name`
2257
+ // is present here, and the value carried is the structuredText
2258
+ // normalisation of the raw name (control, bidi and zero-width
2259
+ // characters out), never a truncated prefix presented as the name
2260
+ // (review round 2: the two caps measure different things, a JSON
2261
+ // literal and a code-point count, so reusing the number alone did not
2262
+ // make the surfaces agree). Absent, never fabricated as "" or
2263
+ // "(unnamed room)", for a room with no usable name (OD-14 above).
2264
+ // One stated exception (review round 3): a name made only of the
2265
+ // characters structuredText removes (whitespace, control, bidi,
2266
+ // zero-width) is printed exactly in the text, as an escaped literal
2267
+ // with the legend, but leaves nothing to carry as a plain data value;
2268
+ // the data omits the key rather than carry "" (which would claim the
2269
+ // name is empty) or the raw characters (which the data surface keeps
2270
+ // out by rule). The same exception holds for every structuredText
2271
+ // field on this surface.
2272
+ //
2273
+ // Core's RoomListItemSchema sends room_id, address and role on every
2274
+ // row; a row whose room_id or role sanitises to nothing (or whose
2275
+ // address cannot even be rebuilt from room_id) is not core's row and
2276
+ // is dropped from the data rather than emitted with fabricated empty
2277
+ // strings in required fields (review round 2). `scope` is optional
2278
+ // (OD-14 above): absent from the data when the text says the write
2279
+ // access was not reported. The text keeps its existing per-row
2280
+ // degrade; the drop is counted and reported once on stderr, as
2281
+ // vault_list and memory_stats report theirs.
2282
+ const roomsStructured = [];
2283
+ let droppedRooms = 0;
2284
+ for (const r of list) {
2285
+ const roomId = safeInline(r?.room_id);
2286
+ const address = safeInline(r?.address) || (roomId ? `xroom:${roomId}` : "");
2287
+ const role = safeInline(r?.role);
2288
+ const scope = safeInline(r?.scope);
2289
+ if (!roomId || !address || !role) {
2290
+ droppedRooms += 1;
2291
+ continue;
2292
+ }
2293
+ const nameStructured = typeof r?.name === "string" && exactLiteral(r.name, MAX_DOMAIN_LITERAL)
2294
+ ? structuredText(r.name, MAX_DOMAIN_LITERAL)
2295
+ : undefined;
2296
+ roomsStructured.push({
2297
+ room_id: roomId,
2298
+ address,
2299
+ role,
2300
+ ...(scope ? { scope } : {}),
2301
+ archived: Boolean(r?.archived),
2302
+ ...(nameStructured === undefined ? {} : { name: nameStructured }),
2303
+ });
2304
+ }
2305
+ if (droppedRooms > 0) {
2306
+ console.error(`Mnemoverse: memory_list_rooms dropped ${droppedRooms} malformed room ` +
2307
+ `row${droppedRooms === 1 ? "" : "s"} from structuredContent.rooms ` +
2308
+ `(room_id, address or role missing or not usable; still shown in the text).`);
2309
+ }
2310
+ return structured(finalText, { rooms: roomsStructured });
1865
2311
  });
1866
2312
  // --- Tool: vault_list ---
2313
+ // Core's CreateSecretRequest caps (mnemoverse-core src/mnemo/api/vault_routes.py):
2314
+ // alias max_length 200, context max_length 10,000. Concepts carry no cap
2315
+ // of their own there; 200 is the alias cap reused for a tag, not a new
2316
+ // number. Not in src/limits.ts because the generator covers the memory
2317
+ // routes, not the vault ones.
2318
+ const VAULT_ALIAS_CAP = 200;
2319
+ const VAULT_CONTEXT_CAP = 10_000;
2320
+ const VAULT_CONCEPT_CAP = 200;
2321
+ // OUTPUT SCHEMA (S9, structured-output plan): `alias`/`context`/`concepts`
2322
+ // copied from the connector's `vaultListOutput` (mnemoverse-mcp-remote,
2323
+ // src/tools/index.ts), field for field and description for description,
2324
+ // all three REQUIRED, matching the connector exactly (unlike
2325
+ // memory_list_rooms's `name`, above).
2326
+ //
2327
+ // OD-15 (owner, 2026-09-23, S9-2): a row whose `alias` or `context` is not
2328
+ // a usable string is SKIPPED from `secrets` in structuredContent rather
2329
+ // than turning the whole call into `isError`. The plan's original wording
2330
+ // (isError on one bad row) directly reversed an existing, deliberately
2331
+ // named test, "a broken alias is one anonymous row, not a dead tool"
2332
+ // (test/handlers.test.ts), which the owner confirmed keeping as-is: the
2333
+ // text already substitutes "(no alias)" for that ONE row and leaves every
2334
+ // other row and the call itself untouched, so the data follows the same
2335
+ // rule this package applies everywhere else, a value the text withholds
2336
+ // must be withheld from the data too. Because both fields are REQUIRED
2337
+ // here, exactly as in the connector, there is no honest partial row to
2338
+ // emit for one that fails either check, including a row whose `context`
2339
+ // (or `alias`) was never sent at all (`typeof undefined` is not
2340
+ // `"string"`): this package does not fabricate `""` for a value it does
2341
+ // not have, the same rule OD-14 above applies to a room name. The skip is
2342
+ // reported once per call on stderr, in this package's existing
2343
+ // startup-diagnostic style (src/index.ts's "Mnemoverse: ..." lines,
2344
+ // mirroring memory_stats's S7 domain-drop diagnostic), because a
2345
+ // structured consumer reading only `structuredContent.secrets` has no
2346
+ // other way to learn the array is shorter than the count in the text's own
2347
+ // header line.
2348
+ //
2349
+ // `concepts` is a brand-new field with no text-side precedent, nothing in
2350
+ // this tool's text renders it. Core's SecretSummary sends it on every row,
2351
+ // so a row without it, or with a value that is not an array of strings,
2352
+ // is treated the same as a malformed alias/context and drops the row: no
2353
+ // `[]` is fabricated for a value core did not send, and there is no honest
2354
+ // subset of an unshaped value to keep.
2355
+ //
2356
+ // Free text on this surface (alias, context, each concept) goes through
2357
+ // structuredText with core's own caps (alias 200, context 10,000, a
2358
+ // concept 200; vault_routes.py CreateSecretRequest), the same normalisation
2359
+ // the room name gets: a value that normalises to nothing (empty,
2360
+ // whitespace-only, control characters only) is not usable, and the row is
2361
+ // dropped rather than carried as "" (review round 2). The text prints its
2362
+ // own safeInline reading of alias and context unchanged.
1867
2363
  server.registerTool("vault_list", {
1868
- description: "List the secrets stored in your Mnemoverse Vault — by ALIAS and purpose only; the secret VALUE is never returned or shown to you, and no tool on this server returns it. Use this to check WHICH secrets the user has stored and under what alias (e.g. the user says 'do I have a GitHub token saved?'). Only YOUR account's secrets are listed.",
2364
+ description: `List the secrets stored in your Mnemoverse Vault — by ALIAS and purpose only; the secret VALUE is never returned or shown to you, and no tool on ${serverNoun} returns it. Use this to check WHICH secrets the user has stored and under what alias (e.g. the user says 'do I have a GitHub token saved?'). Only YOUR account's secrets are listed.`,
1869
2365
  inputSchema: {},
2366
+ outputSchema: {
2367
+ secrets: z.array(z.object({
2368
+ alias: z
2369
+ .string()
2370
+ .describe("The secret's alias — the reference you use, never the value."),
2371
+ context: z.string().describe("The secret's purpose/context — never the value."),
2372
+ concepts: z.array(z.string()).describe("Concept tags."),
2373
+ })),
2374
+ },
1870
2375
  annotations: {
1871
2376
  title: "List vault secrets",
1872
2377
  readOnlyHint: true,
@@ -1886,14 +2391,7 @@ export function registerMemoryTools(server, deps) {
1886
2391
  return unreadableAnswerReply("The secret list", "a list of your Vault secrets", "none are stored");
1887
2392
  }
1888
2393
  if (list.length === 0) {
1889
- return {
1890
- content: [
1891
- {
1892
- type: "text",
1893
- text: "No secrets are stored in your Vault yet.",
1894
- },
1895
- ],
1896
- };
2394
+ return structured("No secrets are stored in your Vault yet.", { secrets: [] });
1897
2395
  }
1898
2396
  const lines = list.map((s) => {
1899
2397
  const alias = safeInline(s?.alias) || "(no alias)";
@@ -1902,14 +2400,38 @@ export function registerMemoryTools(server, deps) {
1902
2400
  });
1903
2401
  const text = `Your Vault secrets (${list.length}) — alias and purpose only, never the value:\n` +
1904
2402
  lines.join("\n");
1905
- return {
1906
- content: [
1907
- {
1908
- type: "text",
1909
- text: capResult(text, "The secret list was truncated — some secrets are not shown."),
1910
- },
1911
- ],
1912
- };
2403
+ const finalText = capResult(text, "The secret list was truncated — some secrets are not shown.");
2404
+ // STRUCTURED rows (S9-2, owner, 2026-09-23; see the OD-15 comment
2405
+ // above): core's SecretSummary sends alias, context and concepts on
2406
+ // every row, so a row is kept only when alias and context are strings
2407
+ // and concepts is an array of strings; a row missing any of the three,
2408
+ // or carrying one in another shape, is not core's row and is dropped
2409
+ // from the data, never defaulted (no "" for a context, no [] for
2410
+ // concepts). The call itself stands; the drop is counted and reported
2411
+ // once on stderr.
2412
+ const secretsStructured = [];
2413
+ let droppedSecrets = 0;
2414
+ for (const s of list) {
2415
+ const alias = structuredText(s?.alias, VAULT_ALIAS_CAP);
2416
+ const context = structuredText(s?.context, VAULT_CONTEXT_CAP);
2417
+ const conceptsRaw = s?.concepts;
2418
+ const concepts = Array.isArray(conceptsRaw)
2419
+ ? conceptsRaw.map((c) => structuredText(c, VAULT_CONCEPT_CAP))
2420
+ : undefined;
2421
+ const conceptsOk = concepts !== undefined && concepts.every((c) => c !== undefined);
2422
+ if (alias === undefined || context === undefined || !conceptsOk) {
2423
+ droppedSecrets += 1;
2424
+ continue;
2425
+ }
2426
+ secretsStructured.push({ alias, context, concepts: concepts });
2427
+ }
2428
+ if (droppedSecrets > 0) {
2429
+ console.error(`Mnemoverse: vault_list dropped ${droppedSecrets} malformed secret ` +
2430
+ `row${droppedSecrets === 1 ? "" : "s"} from structuredContent.secrets ` +
2431
+ `(alias, context or concepts missing, empty or not in core's shape; ` +
2432
+ `still shown in the text).`);
2433
+ }
2434
+ return structured(finalText, { secrets: secretsStructured });
1913
2435
  });
1914
2436
  }
1915
2437
  //# sourceMappingURL=tools.js.map