wontopos-mcp 1.0.4 → 1.0.6
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 +10 -5
- package/index.mjs +66 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,8 +52,9 @@ Create a key in the [console](https://wontopos.com). Optional env:
|
|
|
52
52
|
argument unless you opt in with `WONTOPOS_ALLOW_STORE_OVERRIDE=1` - a confused
|
|
53
53
|
or prompt-injected model cannot read or write a sibling store. Want several
|
|
54
54
|
stores? Register the server once per store (each entry stays pinned).
|
|
55
|
-
- **Bounded results.** A tool result is capped at 128 KB
|
|
56
|
-
|
|
55
|
+
- **Bounded results.** A tool result is capped at 128 KB (measured in UTF-8
|
|
56
|
+
bytes, so the bound holds for Korean/CJK too) before it reaches the model, so
|
|
57
|
+
an oversized memory cannot blow up the conversation context.
|
|
57
58
|
- **Use a dedicated key.** Keys carry their workspace, so a key made just for
|
|
58
59
|
MCP scopes what connected tools can ever touch. Rotate it in the console
|
|
59
60
|
anytime without touching your app's keys.
|
|
@@ -106,11 +107,15 @@ automatic retries, redirect refusal, response caps, and key hygiene apply as-is.
|
|
|
106
107
|
|
|
107
108
|
## Changelog
|
|
108
109
|
|
|
109
|
-
- **1.0.
|
|
110
|
+
- **1.0.5** - correctness on the caps: they are now measured in UTF-8 **bytes**,
|
|
111
|
+
not JS chars, so "128 KB" holds for Korean/CJK (a char cap let ~384 KB of
|
|
112
|
+
Korean through). Result 128 KB, error 2 KB, content 8 KB, query 8 KB — all
|
|
113
|
+
byte-bounded, and truncation never splits a multi-byte character.
|
|
114
|
+
- **1.0.4** - the judgment + security release. Store pinning by
|
|
110
115
|
default (`user_id` arguments only exist with `WONTOPOS_ALLOW_STORE_OVERRIDE=1`);
|
|
111
|
-
tool results capped
|
|
116
|
+
tool results capped with paging guidance and error messages capped
|
|
112
117
|
too; blank / oversized / mis-shaped inputs refused before any request (empty
|
|
113
|
-
queries and memories,
|
|
118
|
+
queries and memories, oversized content, non-uuid memory ids,
|
|
114
119
|
unparseable event_date, speakers other than 'me'); stringified numbers coerced;
|
|
115
120
|
a bounded request timeout (20s, `WONTOPOS_TIMEOUT_MS`) so a stalled API fails
|
|
116
121
|
in-band instead of hanging the host. Tool descriptions now carry the
|
package/index.mjs
CHANGED
|
@@ -28,19 +28,38 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
28
28
|
import { z } from "zod";
|
|
29
29
|
import { Client, WosError } from "wontopos";
|
|
30
30
|
|
|
31
|
-
const VERSION = "1.0.
|
|
31
|
+
const VERSION = "1.0.5";
|
|
32
32
|
const flag = (v) => ["1", "true", "yes"].includes((v ?? "").trim().toLowerCase());
|
|
33
33
|
const READ_ONLY = flag(process.env.WONTOPOS_READ_ONLY);
|
|
34
34
|
// Store pinning (default ON): every call goes to WONTOPOS_USER_ID and the tools
|
|
35
35
|
// do not even expose a user_id argument — a prompt-injected or confused model
|
|
36
36
|
// cannot reach a sibling store. Opt out per install with ALLOW_STORE_OVERRIDE.
|
|
37
37
|
const ALLOW_STORE_OVERRIDE = flag(process.env.WONTOPOS_ALLOW_STORE_OVERRIDE);
|
|
38
|
-
|
|
38
|
+
// Whether the store was CHOSEN or fallen back to. Worth tracking, because the
|
|
39
|
+
// fallback is silent in the worst way: if WONTOPOS_USER_ID never reaches this
|
|
40
|
+
// process (a typo in the host config, an --env that did not take), everything goes
|
|
41
|
+
// to `default` — and since every account is seeded with a `default` store, the boot
|
|
42
|
+
// check below SUCCEEDS, so nothing looks wrong while the configured store sits
|
|
43
|
+
// untouched. Say it out loud instead.
|
|
44
|
+
const STORE_FROM_ENV = (process.env.WONTOPOS_USER_ID || "").trim();
|
|
45
|
+
const STORE = STORE_FROM_ENV || "default";
|
|
39
46
|
// Cap what a tool result may inject into the model's context. The SDK's 64MB cap
|
|
40
47
|
// protects the process; this protects the conversation (a 2MB memory must not
|
|
41
|
-
// become 2MB of context).
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
// become 2MB of context). Measured in UTF-8 BYTES, not JS chars: `str.length`
|
|
49
|
+
// counts UTF-16 units, so 131072 of them is ~384KB of Korean/CJK — the byte
|
|
50
|
+
// count is what actually lands in the model, so bound that.
|
|
51
|
+
const MAX_RESULT_BYTES = 131072; // 128 KB
|
|
52
|
+
const MAX_QUERY_BYTES = 8192; // a query is a phrase, not a document (~4K CJK chars)
|
|
53
|
+
const MAX_CONTENT_BYTES = 8192; // one memory is one fact, not a document
|
|
54
|
+
const enc = new TextEncoder();
|
|
55
|
+
const byteLen = (s) => enc.encode(s).length;
|
|
56
|
+
/** Truncate a string to at most `maxBytes` UTF-8 bytes without splitting a
|
|
57
|
+
* multi-byte character (TextEncoder.encodeInto stops on a char boundary). */
|
|
58
|
+
function truncateToBytes(s, maxBytes) {
|
|
59
|
+
const buf = new Uint8Array(maxBytes);
|
|
60
|
+
const { read } = enc.encodeInto(s, buf); // `read` = whole chars that fit
|
|
61
|
+
return s.slice(0, read);
|
|
62
|
+
}
|
|
44
63
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
45
64
|
|
|
46
65
|
// First env var with a NON-EMPTY value wins. `??` only skips null/undefined, so
|
|
@@ -84,14 +103,12 @@ function run(fn) {
|
|
|
84
103
|
try {
|
|
85
104
|
const result = await fn(args ?? {});
|
|
86
105
|
let text = JSON.stringify(result, null, 1);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
const last = text.charCodeAt(cut - 1);
|
|
91
|
-
if (last >= 0xd800 && last <= 0xdbff) cut -= 1;
|
|
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.
|
|
92
109
|
text =
|
|
93
|
-
text
|
|
94
|
-
`\n…[response truncated: ${
|
|
110
|
+
truncateToBytes(text, MAX_RESULT_BYTES - 160) +
|
|
111
|
+
`\n…[response truncated: ${bytes} bytes exceeded the ${MAX_RESULT_BYTES}-byte cap. ` +
|
|
95
112
|
"Narrow the query, lower limit, or page with cursor.]";
|
|
96
113
|
}
|
|
97
114
|
return { content: [{ type: "text", text }] };
|
|
@@ -100,7 +117,7 @@ function run(fn) {
|
|
|
100
117
|
let msg = e instanceof WosError ? e.message : String(e?.message ?? e);
|
|
101
118
|
// Errors are context too: a hostile or broken server must not bomb the
|
|
102
119
|
// conversation through the error path either.
|
|
103
|
-
if (msg
|
|
120
|
+
if (byteLen(msg) > 2000) msg = truncateToBytes(msg, 2000) + "…[error truncated]";
|
|
104
121
|
return { content: [{ type: "text", text: msg }], isError: true };
|
|
105
122
|
}
|
|
106
123
|
};
|
|
@@ -119,7 +136,7 @@ const uid = (u) => (ALLOW_STORE_OVERRIDE ? u ?? undefined : undefined);
|
|
|
119
136
|
/** Reject blank or oversized queries before they reach the API. */
|
|
120
137
|
function checkQuery(q, verb) {
|
|
121
138
|
if (!q?.trim()) throw new Error(`query is blank — say what you want to ${verb}.`);
|
|
122
|
-
if (q
|
|
139
|
+
if (byteLen(q) > MAX_QUERY_BYTES) throw new Error(`query too long (max ${MAX_QUERY_BYTES} bytes) — a query is a phrase, not a document.`);
|
|
123
140
|
}
|
|
124
141
|
|
|
125
142
|
server.registerTool(
|
|
@@ -173,8 +190,8 @@ server.registerTool(
|
|
|
173
190
|
},
|
|
174
191
|
run(({ content, speaker, event_date, user_id }) => {
|
|
175
192
|
if (!content?.trim()) throw new Error("content is blank — a memory must carry an actual fact.");
|
|
176
|
-
if (content
|
|
177
|
-
throw new Error("content too long (max 8192
|
|
193
|
+
if (byteLen(content) > MAX_CONTENT_BYTES)
|
|
194
|
+
throw new Error("content too long (max 8192 bytes) — a memory is one fact; backfill large text with the SDK's add_bulk.");
|
|
178
195
|
if (speaker && speaker !== "me")
|
|
179
196
|
throw new Error("speaker must be 'me' (something the agent itself said) or omitted (the user's fact).");
|
|
180
197
|
if (event_date && Number.isNaN(Date.parse(event_date)))
|
|
@@ -259,7 +276,7 @@ server.registerTool(
|
|
|
259
276
|
if (!UUID_RE.test(old_memory_id?.trim() ?? ""))
|
|
260
277
|
throw new Error("old_memory_id must be a memory id (uuid) from recall/search/list_memories results.");
|
|
261
278
|
if (!new_content?.trim()) throw new Error("new_content is empty — an update must carry the corrected fact (to remove a memory, use forget).");
|
|
262
|
-
if (new_content
|
|
279
|
+
if (byteLen(new_content) > MAX_CONTENT_BYTES) throw new Error("new_content too long (max 8192 bytes) — a memory is one fact.");
|
|
263
280
|
return mem.update(old_memory_id.trim(), new_content, uid(user_id));
|
|
264
281
|
})
|
|
265
282
|
);
|
|
@@ -296,10 +313,24 @@ server.registerTool(
|
|
|
296
313
|
"'deep_recall' searches then expands around the top match — thorough answer to one specific " +
|
|
297
314
|
"question; 'timeline' returns memories ordered by when events happened — 'when did X', " +
|
|
298
315
|
"histories; 'gather' expands around the top few matches — collect everything related to a " +
|
|
299
|
-
"topic
|
|
300
|
-
"
|
|
316
|
+
"topic; 'equilibrium' re-widens retrieval when a long session has narrowed it to whatever " +
|
|
317
|
+
"the current turn resembles — for replies that loop or flatten, not for a factual lookup; " +
|
|
318
|
+
"'tone_stabilizer' returns the assistant's OWN earlier words so it can come back to its " +
|
|
319
|
+
"usual register (needs turns stored with speaker \"me\"). " +
|
|
320
|
+
"Heavier than search, so reach for it when recall/search came back thin or the user asks " +
|
|
321
|
+
"for a full picture. Call the API's GET /api/v1/engram for the live catalogue.",
|
|
301
322
|
inputSchema: {
|
|
302
|
-
|
|
323
|
+
// A plain string, NOT an enum: the enum was pinned to the original three, so
|
|
324
|
+
// every engram the service added afterwards was uncallable from here until
|
|
325
|
+
// this package was republished. The API validates the name and answers 400
|
|
326
|
+
// with the list it accepts, so an unknown name fails clearly and a new engram
|
|
327
|
+
// works the day it ships.
|
|
328
|
+
name: z
|
|
329
|
+
.string()
|
|
330
|
+
.min(1)
|
|
331
|
+
.describe(
|
|
332
|
+
"Engram name: deep_recall | timeline | gather | equilibrium | tone_stabilizer (the service is the authority; an unknown name returns the accepted list)."
|
|
333
|
+
),
|
|
303
334
|
query: z.string().describe("What to retrieve, phrased like the user's need."),
|
|
304
335
|
...storeArg,
|
|
305
336
|
},
|
|
@@ -357,13 +388,26 @@ await server.connect(transport);
|
|
|
357
388
|
return inner?.(msg, extra);
|
|
358
389
|
};
|
|
359
390
|
}
|
|
360
|
-
console.error(
|
|
391
|
+
console.error(
|
|
392
|
+
`wontopos-mcp ${VERSION} ready (store: ${STORE}${STORE_FROM_ENV ? "" : " — FALLBACK: WONTOPOS_USER_ID is not set"}${READ_ONLY ? ", read-only" : ""})`
|
|
393
|
+
);
|
|
361
394
|
|
|
362
395
|
// Boot check (one metered stats call, after connect so host startup never waits):
|
|
363
396
|
// "ready" above is a claim — verify it. A wrong key can do nothing → exit loudly.
|
|
364
397
|
// A missing store is recoverable (the agent can create_store) → warn and serve.
|
|
365
398
|
mem.stats().then(
|
|
366
|
-
(s) =>
|
|
399
|
+
(s) => {
|
|
400
|
+
console.error(`wontopos-mcp: store "${STORE}" verified (${s?.total_memories ?? "?"} memories).`);
|
|
401
|
+
// Verified, but on the fallback store — the case a misconfiguration hides in.
|
|
402
|
+
// The seeded `default` always exists, so this check passes and the operator has
|
|
403
|
+
// no other signal that the store they configured is not the one in use.
|
|
404
|
+
if (!STORE_FROM_ENV) {
|
|
405
|
+
console.error(
|
|
406
|
+
'wontopos-mcp: WONTOPOS_USER_ID is not set, so this server is using the seeded "default" store. ' +
|
|
407
|
+
"If you meant a different store, set WONTOPOS_USER_ID in the MCP server's env and restart."
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
},
|
|
367
411
|
(e) => {
|
|
368
412
|
const status = e instanceof WosError ? e.status : 0;
|
|
369
413
|
if (status === 401) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wontopos-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
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": [
|