wontopos-mcp 1.0.4 → 1.0.5

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 +10 -5
  2. package/index.mjs +26 -16
  3. 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 before it reaches the
56
- model, so an oversized memory cannot blow up the conversation context.
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.4** (unreleased) - the judgment + security release. Store pinning by
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 at 128 KB with paging guidance and error messages 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, >8 KB content, >4 KB queries, non-uuid memory ids,
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,7 +28,7 @@ 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.4";
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
@@ -38,9 +38,21 @@ const ALLOW_STORE_OVERRIDE = flag(process.env.WONTOPOS_ALLOW_STORE_OVERRIDE);
38
38
  const STORE = process.env.WONTOPOS_USER_ID || "default";
39
39
  // Cap what a tool result may inject into the model's context. The SDK's 64MB cap
40
40
  // protects the process; this protects the conversation (a 2MB memory must not
41
- // become 2MB of context).
42
- const MAX_RESULT_CHARS = 131072;
43
- const MAX_QUERY_CHARS = 4096; // a query is a phrase, not a document
41
+ // become 2MB of context). Measured in UTF-8 BYTES, not JS chars: `str.length`
42
+ // counts UTF-16 units, so 131072 of them is ~384KB of Korean/CJK — the byte
43
+ // count is what actually lands in the model, so bound that.
44
+ const MAX_RESULT_BYTES = 131072; // 128 KB
45
+ const MAX_QUERY_BYTES = 8192; // a query is a phrase, not a document (~4K CJK chars)
46
+ const MAX_CONTENT_BYTES = 8192; // one memory is one fact, not a document
47
+ const enc = new TextEncoder();
48
+ const byteLen = (s) => enc.encode(s).length;
49
+ /** Truncate a string to at most `maxBytes` UTF-8 bytes without splitting a
50
+ * multi-byte character (TextEncoder.encodeInto stops on a char boundary). */
51
+ function truncateToBytes(s, maxBytes) {
52
+ const buf = new Uint8Array(maxBytes);
53
+ const { read } = enc.encodeInto(s, buf); // `read` = whole chars that fit
54
+ return s.slice(0, read);
55
+ }
44
56
  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
57
 
46
58
  // First env var with a NON-EMPTY value wins. `??` only skips null/undefined, so
@@ -84,14 +96,12 @@ function run(fn) {
84
96
  try {
85
97
  const result = await fn(args ?? {});
86
98
  let text = JSON.stringify(result, null, 1);
87
- if (text.length > MAX_RESULT_CHARS) {
88
- let cut = MAX_RESULT_CHARS;
89
- // never cut between surrogate halves (would emit a lone surrogate)
90
- const last = text.charCodeAt(cut - 1);
91
- if (last >= 0xd800 && last <= 0xdbff) cut -= 1;
99
+ const bytes = byteLen(text);
100
+ if (bytes > MAX_RESULT_BYTES) {
101
+ // Leave room for the note itself so the whole reply stays under the cap.
92
102
  text =
93
- text.slice(0, cut) +
94
- `\n…[response truncated: ${text.length} chars exceeded the ${MAX_RESULT_CHARS}-char cap. ` +
103
+ truncateToBytes(text, MAX_RESULT_BYTES - 160) +
104
+ `\n…[response truncated: ${bytes} bytes exceeded the ${MAX_RESULT_BYTES}-byte cap. ` +
95
105
  "Narrow the query, lower limit, or page with cursor.]";
96
106
  }
97
107
  return { content: [{ type: "text", text }] };
@@ -100,7 +110,7 @@ function run(fn) {
100
110
  let msg = e instanceof WosError ? e.message : String(e?.message ?? e);
101
111
  // Errors are context too: a hostile or broken server must not bomb the
102
112
  // conversation through the error path either.
103
- if (msg.length > 2000) msg = msg.slice(0, 2000) + "…[error truncated]";
113
+ if (byteLen(msg) > 2000) msg = truncateToBytes(msg, 2000) + "…[error truncated]";
104
114
  return { content: [{ type: "text", text: msg }], isError: true };
105
115
  }
106
116
  };
@@ -119,7 +129,7 @@ const uid = (u) => (ALLOW_STORE_OVERRIDE ? u ?? undefined : undefined);
119
129
  /** Reject blank or oversized queries before they reach the API. */
120
130
  function checkQuery(q, verb) {
121
131
  if (!q?.trim()) throw new Error(`query is blank — say what you want to ${verb}.`);
122
- if (q.length > MAX_QUERY_CHARS) throw new Error(`query too long (max ${MAX_QUERY_CHARS} chars) — a query is a phrase, not a document.`);
132
+ 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
133
  }
124
134
 
125
135
  server.registerTool(
@@ -173,8 +183,8 @@ server.registerTool(
173
183
  },
174
184
  run(({ content, speaker, event_date, user_id }) => {
175
185
  if (!content?.trim()) throw new Error("content is blank — a memory must carry an actual fact.");
176
- if (content.length > 8192)
177
- throw new Error("content too long (max 8192 chars) — a memory is one fact; backfill large text with the SDK's add_bulk.");
186
+ if (byteLen(content) > MAX_CONTENT_BYTES)
187
+ throw new Error("content too long (max 8192 bytes) — a memory is one fact; backfill large text with the SDK's add_bulk.");
178
188
  if (speaker && speaker !== "me")
179
189
  throw new Error("speaker must be 'me' (something the agent itself said) or omitted (the user's fact).");
180
190
  if (event_date && Number.isNaN(Date.parse(event_date)))
@@ -259,7 +269,7 @@ server.registerTool(
259
269
  if (!UUID_RE.test(old_memory_id?.trim() ?? ""))
260
270
  throw new Error("old_memory_id must be a memory id (uuid) from recall/search/list_memories results.");
261
271
  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.length > 8192) throw new Error("new_content too long (max 8192 chars) — a memory is one fact.");
272
+ if (byteLen(new_content) > MAX_CONTENT_BYTES) throw new Error("new_content too long (max 8192 bytes) — a memory is one fact.");
263
273
  return mem.update(old_memory_id.trim(), new_content, uid(user_id));
264
274
  })
265
275
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wontopos-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
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": [