wontopos-mcp 1.0.6 → 1.0.8
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 +27 -0
- package/index.mjs +102 -16
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -107,6 +107,33 @@ automatic retries, redirect refusal, response caps, and key hygiene apply as-is.
|
|
|
107
107
|
|
|
108
108
|
## Changelog
|
|
109
109
|
|
|
110
|
+
- **1.0.8** - two bugs found by driving the server over stdio rather than reading
|
|
111
|
+
it. A store id with stray whitespace **split the store in two**: the client was
|
|
112
|
+
built from the raw env while the boot check, the ready line and `create_store`
|
|
113
|
+
used the trimmed value, so `create_store` made `alice` while every read and write
|
|
114
|
+
went to `" alice \n"` — a different collection — and the console still printed
|
|
115
|
+
`store "alice" verified`. And an oversized result was capped by slicing the
|
|
116
|
+
serialized JSON, handing the model a fragment with no closing bracket and a final
|
|
117
|
+
memory cut mid-sentence (measured: 131,041 bytes of unparseable text from a 764 KB
|
|
118
|
+
search). Now whole entries are dropped instead, the reply stays valid JSON, and
|
|
119
|
+
what was dropped is stated in the payload — a truncation nobody can see is worse
|
|
120
|
+
than a short answer.
|
|
121
|
+
- **1.0.7** - the version in the MCP handshake is read from `package.json` instead
|
|
122
|
+
of a hand-kept copy (1.0.6 announced itself as "1.0.5", so hosts logged and bug
|
|
123
|
+
reports cited the wrong build). `event_date` accepts a plain date: the API needs a
|
|
124
|
+
full RFC3339 date-TIME and answered a bare `2026-03-14` with an opaque 400 — the
|
|
125
|
+
form a model naturally emits, and the one this tool's own example suggested. A
|
|
126
|
+
date-only value is completed by string append, never through `Date`, so the stated
|
|
127
|
+
day cannot shift a zone. `remember` now says dedup matches by MEANING, not exact
|
|
128
|
+
text, and what to do when a new fact only narrows an existing one.
|
|
129
|
+
- **1.0.6** - `engram` takes a plain string, not an enum pinned to the original
|
|
130
|
+
three: every engram the service added afterwards was uncallable from here until
|
|
131
|
+
this package was republished. The API validates the name and answers 400 with the
|
|
132
|
+
list it accepts, so a new engram works the day it ships. The store fallback is no
|
|
133
|
+
longer silent — if `WONTOPOS_USER_ID` never reaches the process, everything goes to
|
|
134
|
+
`default`, and since every account is seeded with a `default` store the boot check
|
|
135
|
+
SUCCEEDS while the configured store sits untouched. Both the ready line and the
|
|
136
|
+
boot check now say so out loud.
|
|
110
137
|
- **1.0.5** - correctness on the caps: they are now measured in UTF-8 **bytes**,
|
|
111
138
|
not JS chars, so "128 KB" holds for Korean/CJK (a char cap let ~384 KB of
|
|
112
139
|
Korean through). Result 128 KB, error 2 KB, content 8 KB, query 8 KB — all
|
package/index.mjs
CHANGED
|
@@ -27,8 +27,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
27
27
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
28
28
|
import { z } from "zod";
|
|
29
29
|
import { Client, WosError } from "wontopos";
|
|
30
|
+
import { createRequire } from "node:module";
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
// Read the version from package.json rather than restating it: a hand-kept copy
|
|
33
|
+
// silently drifts (1.0.6 shipped announcing itself as "1.0.5" in the MCP
|
|
34
|
+
// handshake, so hosts logged — and bug reports cited — the wrong build).
|
|
35
|
+
const VERSION = createRequire(import.meta.url)("./package.json").version;
|
|
32
36
|
const flag = (v) => ["1", "true", "yes"].includes((v ?? "").trim().toLowerCase());
|
|
33
37
|
const READ_ONLY = flag(process.env.WONTOPOS_READ_ONLY);
|
|
34
38
|
// Store pinning (default ON): every call goes to WONTOPOS_USER_ID and the tools
|
|
@@ -61,6 +65,22 @@ function truncateToBytes(s, maxBytes) {
|
|
|
61
65
|
return s.slice(0, read);
|
|
62
66
|
}
|
|
63
67
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
68
|
+
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
69
|
+
/** Canonicalize event_date to the full RFC3339 the API requires.
|
|
70
|
+
* The API accepts only a complete date-TIME: a bare "2026-03-14" — the form a
|
|
71
|
+
* model naturally emits, and the form this tool's own example suggested — came
|
|
72
|
+
* back as an opaque 400 "Invalid request body or parameters." Normalize here
|
|
73
|
+
* instead of asking every agent to guess the shape.
|
|
74
|
+
* Returns null when the value is not a date at all, so the caller can refuse it. */
|
|
75
|
+
function normalizeEventDate(raw) {
|
|
76
|
+
const s = String(raw).trim();
|
|
77
|
+
if (Number.isNaN(Date.parse(s))) return null;
|
|
78
|
+
// Date-only is completed by string append, never through Date: parsing and
|
|
79
|
+
// re-serializing would re-interpret it in the host's zone and can shift the
|
|
80
|
+
// stated day by one, which is exactly the fact the user was pinning down.
|
|
81
|
+
if (DATE_ONLY_RE.test(s)) return `${s}T00:00:00Z`;
|
|
82
|
+
return new Date(s).toISOString();
|
|
83
|
+
}
|
|
64
84
|
|
|
65
85
|
// First env var with a NON-EMPTY value wins. `??` only skips null/undefined, so
|
|
66
86
|
// an empty WONTOPOS_API_KEY="" would shadow a valid WOS_API_KEY — the exact bug
|
|
@@ -87,7 +107,14 @@ const model = process.env.WONTOPOS_MODEL?.trim();
|
|
|
87
107
|
|
|
88
108
|
const mem = new Client({
|
|
89
109
|
apiKey,
|
|
90
|
-
|
|
110
|
+
// STORE, not the raw env: the value is trimmed once at the top and everything —
|
|
111
|
+
// the boot check, the "ready" line, create_store — speaks in terms of that. Reading
|
|
112
|
+
// the env again here re-introduced the untrimmed value, so a config with a stray
|
|
113
|
+
// space or newline (easy in JSON/YAML env blocks, and heredocs add one) SPLIT the
|
|
114
|
+
// store in two: create_store made "alice" while every read and write went to
|
|
115
|
+
// " alice \n", which the API sanitizes into a different collection entirely — and
|
|
116
|
+
// the console still printed 'store "alice" verified'. One value, one store.
|
|
117
|
+
userId: STORE,
|
|
91
118
|
// A stalled API must fail fast and in-band, not dangle until the MCP host
|
|
92
119
|
// gives up. 20s covers a slow engram; override with WONTOPOS_TIMEOUT_MS.
|
|
93
120
|
timeoutMs,
|
|
@@ -97,20 +124,75 @@ const mem = new Client({
|
|
|
97
124
|
|
|
98
125
|
const server = new McpServer({ name: "wontopos", version: VERSION });
|
|
99
126
|
|
|
127
|
+
/** The result fields that hold lists of memories. Trimming happens here first,
|
|
128
|
+
* because dropping whole entries keeps the reply parseable. */
|
|
129
|
+
const LIST_FIELDS = ["memories", "self_memories", "results", "turns", "context"];
|
|
130
|
+
|
|
131
|
+
/** Shrink a result until it fits the byte cap, WITHOUT breaking the JSON.
|
|
132
|
+
*
|
|
133
|
+
* Cutting the serialized string mid-array (what this used to do) hands the model
|
|
134
|
+
* a fragment: no closing bracket, and a final memory sliced mid-sentence that
|
|
135
|
+
* reads as if that is all it said. Measured: a 764KB search came back as 131,041
|
|
136
|
+
* bytes of unparseable text. Drop whole entries instead and say how many went,
|
|
137
|
+
* so what arrives is valid JSON and its incompleteness is a stated fact.
|
|
138
|
+
* Returns the text to send. */
|
|
139
|
+
function fitToCap(result) {
|
|
140
|
+
const ser = (v) => JSON.stringify(v, null, 1);
|
|
141
|
+
let text = ser(result);
|
|
142
|
+
if (byteLen(text) <= MAX_RESULT_BYTES) return text;
|
|
143
|
+
|
|
144
|
+
// Trim the largest list field, longest-first, halving until it fits.
|
|
145
|
+
if (result && typeof result === "object") {
|
|
146
|
+
const clone = Array.isArray(result) ? [...result] : { ...result };
|
|
147
|
+
const dropped = {};
|
|
148
|
+
const lists = Array.isArray(clone)
|
|
149
|
+
? [["", clone]]
|
|
150
|
+
: LIST_FIELDS.filter((f) => Array.isArray(clone[f]) && clone[f].length).map((f) => [f, clone[f]]);
|
|
151
|
+
if (lists.length) {
|
|
152
|
+
// Shrink every list proportionally until the whole payload fits. A plain
|
|
153
|
+
// loop, not a clever estimate: entry sizes vary wildly (one 8KB memory next
|
|
154
|
+
// to twenty short ones) so measuring is the only honest stop condition.
|
|
155
|
+
let guard = 64;
|
|
156
|
+
while (byteLen(text) > MAX_RESULT_BYTES && guard-- > 0) {
|
|
157
|
+
let cutAny = false;
|
|
158
|
+
for (const [name, arr] of lists) {
|
|
159
|
+
if (arr.length <= 1) continue;
|
|
160
|
+
const keep = Math.max(1, Math.floor(arr.length / 2));
|
|
161
|
+
dropped[name || "items"] = (dropped[name || "items"] ?? 0) + (arr.length - keep);
|
|
162
|
+
arr.length = keep;
|
|
163
|
+
cutAny = true;
|
|
164
|
+
}
|
|
165
|
+
if (!cutAny) break;
|
|
166
|
+
text = ser(Array.isArray(clone) ? clone : { ...clone, _truncated: dropped });
|
|
167
|
+
}
|
|
168
|
+
if (byteLen(text) <= MAX_RESULT_BYTES) {
|
|
169
|
+
const note =
|
|
170
|
+
`dropped ${Object.entries(dropped).map(([k, n]) => `${n} ${k}`).join(", ")} to fit the ` +
|
|
171
|
+
`${MAX_RESULT_BYTES}-byte cap — narrow the query, lower limit, or page with cursor.`;
|
|
172
|
+
// An array result (search) has nowhere to hang a note, so the cut would be
|
|
173
|
+
// SILENT — the model would read a short list as the whole answer. Append a
|
|
174
|
+
// sentinel entry instead: still valid JSON, still an array, and the reader
|
|
175
|
+
// is told what is missing. A truncation nobody can see is the worse bug.
|
|
176
|
+
return Array.isArray(clone) ? ser([...clone, { _truncated: note }]) : ser({ ...clone, _truncated: note });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Last resort — a single entry too big to drop (one 400KB memory): the string
|
|
181
|
+
// cut, still labelled. Reserve the note's REAL byte length, not a guessed 160:
|
|
182
|
+
// the note grew past that guess and pushed the reply back over the cap, which is
|
|
183
|
+
// what the existing byte-cap tests caught.
|
|
184
|
+
const note =
|
|
185
|
+
`\n…[response truncated: ${byteLen(text)} bytes exceeded the ${MAX_RESULT_BYTES}-byte cap. ` +
|
|
186
|
+
"Narrow the query, lower limit, or page with cursor. NOTE: this text is a fragment, not valid JSON.]";
|
|
187
|
+
return truncateToBytes(text, MAX_RESULT_BYTES - byteLen(note)) + note;
|
|
188
|
+
}
|
|
189
|
+
|
|
100
190
|
/** Wrap a handler: JSON out, typed WosError in-band so the agent can react. */
|
|
101
191
|
function run(fn) {
|
|
102
192
|
return async (args) => {
|
|
103
193
|
try {
|
|
104
194
|
const result = await fn(args ?? {});
|
|
105
|
-
|
|
106
|
-
const bytes = byteLen(text);
|
|
107
|
-
if (bytes > MAX_RESULT_BYTES) {
|
|
108
|
-
// Leave room for the note itself so the whole reply stays under the cap.
|
|
109
|
-
text =
|
|
110
|
-
truncateToBytes(text, MAX_RESULT_BYTES - 160) +
|
|
111
|
-
`\n…[response truncated: ${bytes} bytes exceeded the ${MAX_RESULT_BYTES}-byte cap. ` +
|
|
112
|
-
"Narrow the query, lower limit, or page with cursor.]";
|
|
113
|
-
}
|
|
195
|
+
const text = fitToCap(result);
|
|
114
196
|
return { content: [{ type: "text", text }] };
|
|
115
197
|
} catch (e) {
|
|
116
198
|
// WosError.message already carries "[status] ... (request_id: ...)".
|
|
@@ -174,7 +256,10 @@ server.registerTool(
|
|
|
174
256
|
"('Sunwoo prefers dark mode in the console'), not a chat log. For your own side, store only " +
|
|
175
257
|
"commitments you actually SAID to the user (speaker 'me') — never internal reasoning or thinking " +
|
|
176
258
|
"output. Do NOT store fetched material: web search results, crawled pages, file contents, and " +
|
|
177
|
-
"tool outputs are session context, not memories.
|
|
259
|
+
"tool outputs are session context, not memories. A memory close enough to one already stored is " +
|
|
260
|
+
"rejected as status 'duplicate' and is NOT saved — the match is by meaning, not by exact text, so " +
|
|
261
|
+
"when a new fact only narrows or contrasts an existing one ('allergic to shellfish' next to " +
|
|
262
|
+
"'allergic to peanuts'), write it as one sentence that states both, or supersede the old one with update.",
|
|
178
263
|
inputSchema: {
|
|
179
264
|
content: z.string().describe("The memory, as one self-contained sentence."),
|
|
180
265
|
speaker: z
|
|
@@ -184,7 +269,7 @@ server.registerTool(
|
|
|
184
269
|
event_date: z
|
|
185
270
|
.string()
|
|
186
271
|
.nullish()
|
|
187
|
-
.describe("
|
|
272
|
+
.describe("When the event actually happened, if different from now. A plain date ('2026-03-14') or a full RFC3339 timestamp; a bare date is read as UTC midnight."),
|
|
188
273
|
...storeArg,
|
|
189
274
|
},
|
|
190
275
|
},
|
|
@@ -194,11 +279,12 @@ server.registerTool(
|
|
|
194
279
|
throw new Error("content too long (max 8192 bytes) — a memory is one fact; backfill large text with the SDK's add_bulk.");
|
|
195
280
|
if (speaker && speaker !== "me")
|
|
196
281
|
throw new Error("speaker must be 'me' (something the agent itself said) or omitted (the user's fact).");
|
|
197
|
-
|
|
198
|
-
|
|
282
|
+
const eventDate = event_date ? normalizeEventDate(event_date) : null;
|
|
283
|
+
if (event_date && !eventDate)
|
|
284
|
+
throw new Error("event_date is not a parseable date — use a date (2026-07-01) or RFC3339 (2026-07-01T09:00:00Z).");
|
|
199
285
|
const metadata = {};
|
|
200
286
|
if (speaker) metadata.speaker = speaker;
|
|
201
|
-
if (
|
|
287
|
+
if (eventDate) metadata.event_date = eventDate;
|
|
202
288
|
return mem.add(content, uid(user_id), metadata);
|
|
203
289
|
})
|
|
204
290
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wontopos-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
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": [
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
35
|
-
"wontopos": "^2.2.
|
|
35
|
+
"wontopos": "^2.2.22",
|
|
36
36
|
"zod": "^3.23.0"
|
|
37
37
|
}
|
|
38
38
|
}
|