@zeph-to/mcp-server 1.11.0 → 1.11.2

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/crypto.d.ts CHANGED
@@ -27,13 +27,28 @@
27
27
  * sensitive-but-not-secret.
28
28
  */
29
29
  /**
30
- * Initialize crypto: sync keys with server, then fallback to local/generate.
31
- * Server is source of truth for per-user key pair.
30
+ * Initialize crypto.
31
+ *
32
+ * The MCP server is a CONSUMER of encryption keys, not a generator. Keys
33
+ * are created in the Zeph app where the user explicitly opts in (Settings
34
+ * → Encryption). This function only imports keys that the server already
35
+ * has, and only when the server confirms encryption is enabled.
36
+ *
37
+ * Any other state — server says disabled, server has no keys, server is
38
+ * unreachable — leaves encryption OFF (cache empty, no fallback). A
39
+ * previous version generated and uploaded a fresh keypair on the "no keys
40
+ * anywhere" path; combined with a transient fetch failure, that silently
41
+ * turned encryption on without user consent and locked the account into
42
+ * an "encryption enabled" state on the server.
43
+ *
44
+ * Opt-out: `ZEPH_DISABLE_ENCRYPTION=1` forces crypto off regardless of
45
+ * server state — useful while cleaning up legacy state or for users who
46
+ * never want encryption.
47
+ *
32
48
  * Safe to call concurrently — deduplicates to single init.
33
- * Returns the exported public key (Base64 SPKI).
49
+ * Returns the exported public key when encryption is active, '' otherwise.
34
50
  *
35
- * NOTE: when `apiKey` is provided, `baseUrl` is required — otherwise a
36
- * caller in a dev environment would silently upload keys to prod.
51
+ * NOTE: when `apiKey` is provided, `baseUrl` is required.
37
52
  */
38
53
  export declare const initCrypto: (apiKey?: string, baseUrl?: string) => Promise<string>;
39
54
  export declare const getKeyPair: () => CryptoKeyPair | null;
@@ -1 +1 @@
1
- {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AA2JH;;;;;;;;GAQG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAyE5E,CAAC;AAqCF,eAAO,MAAM,UAAU,QAAO,aAAa,GAAG,IAAqB,CAAC;AACpE,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAA+B,CAAC;AAEzE;;GAEG;AACH,eAAO,MAAM,sBAAsB,GACjC,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,KACrD,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAaA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,kBAAkB,GAC7B,SAAS,MAAM,KACd,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAQlE,CAAC"}
1
+ {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AA4JH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAsE5E,CAAC;AA8BF,eAAO,MAAM,UAAU,QAAO,aAAa,GAAG,IAAqB,CAAC;AACpE,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAA+B,CAAC;AAEzE;;GAEG;AACH,eAAO,MAAM,sBAAsB,GACjC,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,KACrD,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAaA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,kBAAkB,GAC7B,SAAS,MAAM,KACd,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAQlE,CAAC"}
package/dist/crypto.js CHANGED
@@ -52,14 +52,8 @@ const fromBase64 = (base64) => {
52
52
  };
53
53
  // ─── ECDH key management ───
54
54
  const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' };
55
- const generateKeyPair = async () => crypto.subtle.generateKey(ECDH_PARAMS, true, ['deriveKey', 'deriveBits']);
56
- const exportKeyPair = async (keyPair) => {
57
- const [publicRaw, privateRaw] = await Promise.all([
58
- crypto.subtle.exportKey('spki', keyPair.publicKey),
59
- crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
60
- ]);
61
- return { publicKey: toBase64(publicRaw), privateKey: toBase64(privateRaw) };
62
- };
55
+ // generateKeyPair / exportKeyPair were removed in fix/no-auto-encryption.
56
+ // This module imports keys only; it never creates or exports them.
63
57
  const importPublicKey = async (base64) => crypto.subtle.importKey('spki', fromBase64(base64), ECDH_PARAMS, true, []);
64
58
  const importPrivateKey = async (base64) => crypto.subtle.importKey('pkcs8', fromBase64(base64), ECDH_PARAMS, true, ['deriveKey', 'deriveBits']);
65
59
  const importKeyPair = async (exported) => {
@@ -117,80 +111,102 @@ const storeKeys = (exported) => {
117
111
  (0, fs_1.mkdirSync)(KEYS_DIR, { recursive: true, mode: 0o700 });
118
112
  (0, fs_1.writeFileSync)(KEYS_PATH, JSON.stringify(exported, null, 2), { mode: 0o600 });
119
113
  };
114
+ const deleteStoredKeys = () => {
115
+ try {
116
+ (0, fs_1.unlinkSync)(KEYS_PATH);
117
+ }
118
+ catch { /* not present — fine */ }
119
+ };
120
+ const envIsTrue = (key) => {
121
+ const v = process.env[key];
122
+ return !!v && /^(1|true|yes|on)$/i.test(v.trim());
123
+ };
120
124
  // ─── Cached state ───
121
125
  let cachedKeyPair = null;
122
126
  let cachedExportedPublicKey = null;
123
127
  let cachedOwnPublicKey = null;
124
128
  let initPromise = null;
125
129
  /**
126
- * Initialize crypto: sync keys with server, then fallback to local/generate.
127
- * Server is source of truth for per-user key pair.
130
+ * Initialize crypto.
131
+ *
132
+ * The MCP server is a CONSUMER of encryption keys, not a generator. Keys
133
+ * are created in the Zeph app where the user explicitly opts in (Settings
134
+ * → Encryption). This function only imports keys that the server already
135
+ * has, and only when the server confirms encryption is enabled.
136
+ *
137
+ * Any other state — server says disabled, server has no keys, server is
138
+ * unreachable — leaves encryption OFF (cache empty, no fallback). A
139
+ * previous version generated and uploaded a fresh keypair on the "no keys
140
+ * anywhere" path; combined with a transient fetch failure, that silently
141
+ * turned encryption on without user consent and locked the account into
142
+ * an "encryption enabled" state on the server.
143
+ *
144
+ * Opt-out: `ZEPH_DISABLE_ENCRYPTION=1` forces crypto off regardless of
145
+ * server state — useful while cleaning up legacy state or for users who
146
+ * never want encryption.
147
+ *
128
148
  * Safe to call concurrently — deduplicates to single init.
129
- * Returns the exported public key (Base64 SPKI).
149
+ * Returns the exported public key when encryption is active, '' otherwise.
130
150
  *
131
- * NOTE: when `apiKey` is provided, `baseUrl` is required — otherwise a
132
- * caller in a dev environment would silently upload keys to prod.
151
+ * NOTE: when `apiKey` is provided, `baseUrl` is required.
133
152
  */
134
153
  const initCrypto = (apiKey, baseUrl) => {
154
+ // Hard opt-out — skip everything, leave cache empty.
155
+ if (envIsTrue('ZEPH_DISABLE_ENCRYPTION')) {
156
+ cachedKeyPair = null;
157
+ cachedExportedPublicKey = null;
158
+ cachedOwnPublicKey = null;
159
+ return Promise.resolve('');
160
+ }
135
161
  if (apiKey && !baseUrl) {
136
162
  return Promise.reject(new Error('initCrypto: baseUrl is required when apiKey is provided. ' +
137
- 'Pass the resolved config.baseUrl to avoid silently syncing dev keys to prod.'));
163
+ 'Pass the resolved config.baseUrl to avoid talking to the wrong environment.'));
138
164
  }
139
165
  if (initPromise)
140
166
  return initPromise;
141
- // The check above guarantees baseUrl is defined when apiKey is — narrow once.
142
167
  const baseUrlRequired = apiKey ? baseUrl : baseUrl;
143
168
  initPromise = (async () => {
144
- const stored = loadStoredKeys();
145
- // Try server sync if API key available
146
169
  if (apiKey) {
147
170
  const serverResult = await fetchServerKeys(apiKey, baseUrlRequired);
148
- // Server says encryption disabled skip crypto init
149
- if (serverResult && !serverResult.encryptionEnabled) {
171
+ // The only path that turns encryption ON: server confirms enabled AND
172
+ // hands us a real keypair. Everything else leaves the cache empty.
173
+ const haveServerKeys = !!serverResult && serverResult.encryptionEnabled && !!serverResult.keys;
174
+ if (!haveServerKeys) {
150
175
  cachedKeyPair = null;
151
176
  cachedExportedPublicKey = null;
152
177
  cachedOwnPublicKey = null;
153
- return '';
154
- }
155
- if (serverResult?.keys) {
156
- if (!stored || stored.publicKey !== serverResult.keys.publicKey) {
157
- storeKeys(serverResult.keys);
178
+ // If the server is reachable and explicitly says encryption is off,
179
+ // drop any stale local cache so a future regression can't resurrect
180
+ // a keypair that the user already disabled.
181
+ if (serverResult && !serverResult.encryptionEnabled) {
182
+ deleteStoredKeys();
158
183
  }
159
- cachedKeyPair = await importKeyPair(serverResult.keys);
160
- cachedExportedPublicKey = serverResult.keys.publicKey;
161
- cachedOwnPublicKey = cachedKeyPair.publicKey;
162
- return serverResult.keys.publicKey;
184
+ return '';
163
185
  }
164
- if (stored) {
165
- await uploadServerKeys(stored, apiKey, baseUrlRequired);
166
- cachedKeyPair = await importKeyPair(stored);
167
- cachedExportedPublicKey = stored.publicKey;
168
- cachedOwnPublicKey = cachedKeyPair.publicKey;
169
- return stored.publicKey;
186
+ const keys = serverResult.keys;
187
+ const stored = loadStoredKeys();
188
+ if (!stored || stored.publicKey !== keys.publicKey) {
189
+ storeKeys(keys);
170
190
  }
171
- const keyPair = await generateKeyPair();
172
- const exported = await exportKeyPair(keyPair);
173
- storeKeys(exported);
174
- await uploadServerKeys(exported, apiKey, baseUrlRequired);
175
- cachedKeyPair = keyPair;
176
- cachedExportedPublicKey = exported.publicKey;
177
- cachedOwnPublicKey = keyPair.publicKey;
178
- return exported.publicKey;
179
- }
180
- // No API key — local-only mode
181
- if (stored) {
182
- cachedKeyPair = await importKeyPair(stored);
183
- cachedExportedPublicKey = stored.publicKey;
191
+ cachedKeyPair = await importKeyPair(keys);
192
+ cachedExportedPublicKey = keys.publicKey;
184
193
  cachedOwnPublicKey = cachedKeyPair.publicKey;
185
- return stored.publicKey;
194
+ return keys.publicKey;
186
195
  }
187
- const keyPair = await generateKeyPair();
188
- const exported = await exportKeyPair(keyPair);
189
- storeKeys(exported);
190
- cachedKeyPair = keyPair;
191
- cachedExportedPublicKey = exported.publicKey;
192
- cachedOwnPublicKey = keyPair.publicKey;
193
- return exported.publicKey;
196
+ // Local-only mode (no apiKey): load stored keys if they exist; do NOT
197
+ // generate. Used by tests and offline / pre-provisioned setups where
198
+ // a keypair has been dropped into ~/.config/zeph/keys.json out-of-band.
199
+ const stored = loadStoredKeys();
200
+ if (!stored) {
201
+ cachedKeyPair = null;
202
+ cachedExportedPublicKey = null;
203
+ cachedOwnPublicKey = null;
204
+ return '';
205
+ }
206
+ cachedKeyPair = await importKeyPair(stored);
207
+ cachedExportedPublicKey = stored.publicKey;
208
+ cachedOwnPublicKey = cachedKeyPair.publicKey;
209
+ return stored.publicKey;
194
210
  })().catch((err) => {
195
211
  initPromise = null;
196
212
  throw err;
@@ -216,17 +232,9 @@ const fetchServerKeys = async (apiKey, baseUrl) => {
216
232
  return null;
217
233
  }
218
234
  };
219
- const uploadServerKeys = async (keys, apiKey, baseUrl) => {
220
- try {
221
- const url = `${baseUrl.replace(/\/$/, '')}/users/me/keys`;
222
- await fetch(url, {
223
- method: 'PUT',
224
- headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
225
- body: JSON.stringify(keys),
226
- });
227
- }
228
- catch { /* non-critical */ }
229
- };
235
+ // uploadServerKeys was removed in fix/no-auto-encryption the MCP server
236
+ // must never write to /users/me/keys. Keys are created by the Zeph app
237
+ // where the user explicitly opts in.
230
238
  const getKeyPair = () => cachedKeyPair;
231
239
  exports.getKeyPair = getKeyPair;
232
240
  const getPublicKey = () => cachedExportedPublicKey;
@@ -1 +1 @@
1
- {"version":3,"file":"poll.d.ts","sourceRoot":"","sources":["../src/poll.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAE5C,gBAAgB,EAAE,CAAC,YAAY,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxD;AAOD,eAAO,MAAM,eAAe,GAC1B,QAAQ,aAAa,EACrB,QAAQ,MAAM,EACd,SAAS,MAAM,EACf,gBAAgB,MAAM,EACtB,KAAK,WAAW,KACf,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAsClC,CAAC"}
1
+ {"version":3,"file":"poll.d.ts","sourceRoot":"","sources":["../src/poll.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAE5C,gBAAgB,EAAE,CAAC,YAAY,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxD;AAOD,eAAO,MAAM,eAAe,GAC1B,QAAQ,aAAa,EACrB,QAAQ,MAAM,EACd,SAAS,MAAM,EACf,gBAAgB,MAAM,EACtB,KAAK,WAAW,KACf,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAwClC,CAAC"}
package/dist/poll.js CHANGED
@@ -33,8 +33,10 @@ const pollForResponse = async (client, hookId, eventId, timeoutSeconds, ctx) =>
33
33
  });
34
34
  }
35
35
  }
36
- // Adaptive interval: 2s for first 5 attempts, then 3s
37
- const interval = attempt < 5 ? 2000 : 3000;
36
+ // Adaptive interval: poll every 1s while the user is likely still at
37
+ // their device (first ~60 attempts 1 min), then back off to 3s for
38
+ // long waits. The tight 1s window keeps post-tap detection snappy.
39
+ const interval = attempt < 60 ? 1000 : 3000;
38
40
  await sleep(interval);
39
41
  attempt++;
40
42
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Defensive cleanup for tool arguments.
3
+ *
4
+ * An agent occasionally emits a malformed tool call where a parameter's
5
+ * closing tag is wrong, so the serialized markup of the *following*
6
+ * parameters bleeds into an earlier string argument.
7
+ *
8
+ * Observed in the wild — a zeph_ask `body` arrived as:
9
+ *
10
+ * "...Commit now or test first?</body>
11
+ * <parameter name=\"actions\">[{\"id\":\"commit\",...}]"
12
+ *
13
+ * The actions JSON leaked into `body`, and `actions` itself arrived
14
+ * undefined — so the push showed the raw markup and no buttons.
15
+ *
16
+ * sanitizeText strips the leaked markup; recoverActions pulls the
17
+ * swallowed actions array back out so the call still works.
18
+ */
19
+ export interface RecoveredAction {
20
+ id: string;
21
+ label: string;
22
+ style?: 'primary' | 'secondary' | 'danger';
23
+ }
24
+ /**
25
+ * Strip leaked tool-call markup from a free-text argument. Returns the
26
+ * text unchanged when no leak is detected.
27
+ */
28
+ export declare const sanitizeText: (text: string | undefined) => string | undefined;
29
+ /**
30
+ * Recover an `actions` array that leaked into the body of a malformed
31
+ * zeph_ask call. Returns undefined when nothing parseable is found.
32
+ */
33
+ export declare const recoverActions: (text: string | undefined) => RecoveredAction[] | undefined;
34
+ //# sourceMappingURL=sanitize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,SAAS,GAAG,WAAW,GAAG,QAAQ,CAAC;CAC5C;AAeD;;;GAGG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,GAAG,SAAS,KAAG,MAAM,GAAG,SAIhE,CAAC;AASF;;;GAGG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,GAAG,SAAS,KAAG,eAAe,EAAE,GAAG,SAgB7E,CAAC"}
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ /**
3
+ * Defensive cleanup for tool arguments.
4
+ *
5
+ * An agent occasionally emits a malformed tool call where a parameter's
6
+ * closing tag is wrong, so the serialized markup of the *following*
7
+ * parameters bleeds into an earlier string argument.
8
+ *
9
+ * Observed in the wild — a zeph_ask `body` arrived as:
10
+ *
11
+ * "...Commit now or test first?</body>
12
+ * <parameter name=\"actions\">[{\"id\":\"commit\",...}]"
13
+ *
14
+ * The actions JSON leaked into `body`, and `actions` itself arrived
15
+ * undefined — so the push showed the raw markup and no buttons.
16
+ *
17
+ * sanitizeText strips the leaked markup; recoverActions pulls the
18
+ * swallowed actions array back out so the call still works.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.recoverActions = exports.sanitizeText = void 0;
22
+ // Markers that should never appear in a real notification body — their
23
+ // presence means tool-call markup has leaked in. Cut at the earliest one.
24
+ const LEAK_MARKERS = ['</body>', '</parameter>', '<parameter name=', '<parameter '];
25
+ const findLeakStart = (text) => {
26
+ let earliest = -1;
27
+ for (const marker of LEAK_MARKERS) {
28
+ const idx = text.indexOf(marker);
29
+ if (idx !== -1 && (earliest === -1 || idx < earliest))
30
+ earliest = idx;
31
+ }
32
+ return earliest;
33
+ };
34
+ /**
35
+ * Strip leaked tool-call markup from a free-text argument. Returns the
36
+ * text unchanged when no leak is detected.
37
+ */
38
+ const sanitizeText = (text) => {
39
+ if (!text)
40
+ return text;
41
+ const cut = findLeakStart(text);
42
+ return cut === -1 ? text : text.slice(0, cut).trimEnd();
43
+ };
44
+ exports.sanitizeText = sanitizeText;
45
+ const isActionArray = (value) => Array.isArray(value) &&
46
+ value.length > 0 &&
47
+ value.every((a) => a && typeof a.id === 'string' && typeof a.label === 'string');
48
+ /**
49
+ * Recover an `actions` array that leaked into the body of a malformed
50
+ * zeph_ask call. Returns undefined when nothing parseable is found.
51
+ */
52
+ const recoverActions = (text) => {
53
+ if (!text)
54
+ return undefined;
55
+ const marker = text.search(/<(?:antml:)?parameter name="actions">/);
56
+ if (marker === -1)
57
+ return undefined;
58
+ const after = text.slice(marker);
59
+ const start = after.indexOf('[');
60
+ const end = after.lastIndexOf(']');
61
+ if (start === -1 || end <= start)
62
+ return undefined;
63
+ try {
64
+ const parsed = JSON.parse(after.slice(start, end + 1));
65
+ return isActionArray(parsed) ? parsed : undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ };
71
+ exports.recoverActions = recoverActions;
@@ -1 +1 @@
1
- {"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/tools/ask.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAuBrE,eAAO,MAAM,eAAe,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAwHhG,CAAC"}
1
+ {"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../../src/tools/ask.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAwBrE,eAAO,MAAM,eAAe,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAgIhG,CAAC"}
package/dist/tools/ask.js CHANGED
@@ -7,6 +7,7 @@ const poll_js_1 = require("../poll.js");
7
7
  const config_js_1 = require("../config.js");
8
8
  const crypto_js_1 = require("../crypto.js");
9
9
  const mime_js_1 = require("../mime.js");
10
+ const sanitize_js_1 = require("../sanitize.js");
10
11
  // The device feed shows a short preview of the body. Anything longer than
11
12
  // this gets truncated there, so we attach the full text as a file — the
12
13
  // user can always open the complete content instead of squinting at a
@@ -63,16 +64,22 @@ const registerAskTool = (server, client, config) => {
63
64
  return (0, error_format_js_1.hookNotConfiguredError)();
64
65
  try {
65
66
  const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title);
67
+ // Defend against malformed tool calls where the actions array leaked
68
+ // into the body (a mis-closed `body` parameter). Recover the actions
69
+ // from the raw body first, then strip the leaked markup. Without this
70
+ // the push arrives with no buttons and raw markup in the text.
71
+ const effectiveActions = actions && actions.length > 0 ? actions : (0, sanitize_js_1.recoverActions)(body);
72
+ const cleanBody = (0, sanitize_js_1.sanitizeText)(body);
66
73
  // Attach a file whenever the body would be clipped in the feed preview.
67
- const exceedsPreview = !!body && body.length > PREVIEW_LENGTH;
68
- let triggerBody = body;
74
+ const exceedsPreview = !!cleanBody && cleanBody.length > PREVIEW_LENGTH;
75
+ let triggerBody = cleanBody;
69
76
  let files;
70
- if (exceedsPreview && body) {
77
+ if (exceedsPreview && cleanBody) {
71
78
  const fileName = 'response.md';
72
79
  const fileType = (0, mime_js_1.inferMimeType)(fileName);
73
80
  const canEncrypt = !!(0, crypto_js_1.getKeyPair)() && !!(0, crypto_js_1.getPublicKey)();
74
81
  // Self-contained Markdown so the file alone tells the whole story.
75
- const fileMarkdown = buildAskMarkdown(title, body, actions);
82
+ const fileMarkdown = buildAskMarkdown(title, cleanBody, effectiveActions);
76
83
  const fileBytes = new TextEncoder().encode(fileMarkdown).byteLength;
77
84
  let uploadContent = fileMarkdown;
78
85
  let uploadContentType = fileType;
@@ -92,13 +99,13 @@ const registerAskTool = (server, client, config) => {
92
99
  }
93
100
  const upload = await client.requestUpload({ fileName, fileType: uploadContentType, fileSize: typeof uploadContent === 'string' ? fileBytes : uploadContent.length });
94
101
  await client.uploadToS3(upload.data.uploadUrl, uploadContent, uploadContentType);
95
- triggerBody = body.slice(0, PREVIEW_LENGTH) + '...';
102
+ triggerBody = cleanBody.slice(0, PREVIEW_LENGTH) + '...';
96
103
  files = [{ fileKey: upload.data.fileKey, fileName, fileSize: fileBytes, fileType, iv: fileIv, encryptedKey: fileEncryptedKey }];
97
104
  }
98
105
  const trigger = await client.triggerHook(config.hookId, {
99
106
  title: pushTitle,
100
107
  body: triggerBody,
101
- actions,
108
+ actions: effectiveActions,
102
109
  timeout,
103
110
  fallback,
104
111
  hookType: 'combo',
@@ -1 +1 @@
1
- {"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../../src/tools/input.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAErE,eAAO,MAAM,iBAAiB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAwDlG,CAAC"}
1
+ {"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../../src/tools/input.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAGrE,eAAO,MAAM,iBAAiB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAwDlG,CAAC"}
@@ -5,6 +5,7 @@ const zod_1 = require("zod");
5
5
  const error_format_js_1 = require("../error-format.js");
6
6
  const poll_js_1 = require("../poll.js");
7
7
  const config_js_1 = require("../config.js");
8
+ const sanitize_js_1 = require("../sanitize.js");
8
9
  const registerInputTool = (server, client, config) => {
9
10
  server.registerTool('zeph_input', {
10
11
  description: 'Request text input from the user via push notification. The tool blocks until the user responds or the timeout is reached. Requires ZEPH_HOOK_ID environment variable.',
@@ -34,7 +35,7 @@ const registerInputTool = (server, client, config) => {
34
35
  try {
35
36
  const trigger = await client.triggerHook(config.hookId, {
36
37
  title: (0, config_js_1.formatPushTitle)(config.projectName, title),
37
- body,
38
+ body: (0, sanitize_js_1.sanitizeText)(body),
38
39
  timeout,
39
40
  hookType: 'input',
40
41
  metadata: { placeholder, inputType },
@@ -1 +1 @@
1
- {"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../../src/tools/notify.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAQrE,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAiHnG,CAAC"}
1
+ {"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../../src/tools/notify.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AASrE,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAmHnG,CAAC"}
@@ -6,6 +6,7 @@ const error_format_js_1 = require("../error-format.js");
6
6
  const config_js_1 = require("../config.js");
7
7
  const crypto_js_1 = require("../crypto.js");
8
8
  const mime_js_1 = require("../mime.js");
9
+ const sanitize_js_1 = require("../sanitize.js");
9
10
  // The device feed shows a short preview of the body. Anything longer gets
10
11
  // truncated there, so we attach the full text as a file for full viewing.
11
12
  const PREVIEW_LENGTH = 200;
@@ -31,14 +32,16 @@ const registerNotifyTool = (server, client, config) => {
31
32
  try {
32
33
  const deviceId = targetDeviceId ?? config.deviceId;
33
34
  const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title);
35
+ // Strip any tool-call markup that leaked into the body argument.
36
+ const cleanBody = (0, sanitize_js_1.sanitizeText)(body);
34
37
  // Attach a file whenever the body would be clipped in the feed preview.
35
- const isLongBody = !!body && body.length > PREVIEW_LENGTH;
38
+ const isLongBody = !!cleanBody && cleanBody.length > PREVIEW_LENGTH;
36
39
  const canEncrypt = !!(0, crypto_js_1.getKeyPair)() && !!(0, crypto_js_1.getPublicKey)();
37
- if (isLongBody && body) {
40
+ if (isLongBody && cleanBody) {
38
41
  const fileName = 'response.md';
39
42
  const fileType = (0, mime_js_1.inferMimeType)(fileName);
40
43
  // Self-contained Markdown so the file alone carries the full text.
41
- const fileMarkdown = `# ${title}\n\n${body}`;
44
+ const fileMarkdown = `# ${title}\n\n${cleanBody}`;
42
45
  const fileBytes = new TextEncoder().encode(fileMarkdown).byteLength;
43
46
  // Encrypt file content if keys available
44
47
  let uploadContent = fileMarkdown;
@@ -59,7 +62,7 @@ const registerNotifyTool = (server, client, config) => {
59
62
  }
60
63
  const upload = await client.requestUpload({ fileName, fileType: uploadContentType, fileSize: typeof uploadContent === 'string' ? fileBytes : uploadContent.length });
61
64
  await client.uploadToS3(upload.data.uploadUrl, uploadContent, uploadContentType);
62
- const preview = body.slice(0, PREVIEW_LENGTH) + '...';
65
+ const preview = cleanBody.slice(0, PREVIEW_LENGTH) + '...';
63
66
  // Encrypt push body (title/preview/url) if keys available
64
67
  let pushPayload = {
65
68
  title: pushTitle,
@@ -86,7 +89,7 @@ const registerNotifyTool = (server, client, config) => {
86
89
  // Short body — encrypt push only
87
90
  let pushPayload = {
88
91
  title: pushTitle,
89
- body,
92
+ body: cleanBody,
90
93
  url,
91
94
  type: 'hook',
92
95
  priority,
@@ -95,7 +98,7 @@ const registerNotifyTool = (server, client, config) => {
95
98
  };
96
99
  if (canEncrypt) {
97
100
  try {
98
- const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: pushTitle, body, url });
101
+ const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: pushTitle, body: cleanBody, url });
99
102
  pushPayload = { ...pushPayload, title: undefined, body: enc.body, isEncrypted: enc.isEncrypted, encryptedKey: enc.encryptedKey, senderPublicKey: enc.senderPublicKey };
100
103
  }
101
104
  catch (err) {
@@ -1 +1 @@
1
- {"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/tools/prompt.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAErE,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAuEnG,CAAC"}
1
+ {"version":3,"file":"prompt.d.ts","sourceRoot":"","sources":["../../src/tools/prompt.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAGrE,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAuEnG,CAAC"}
@@ -5,6 +5,7 @@ const zod_1 = require("zod");
5
5
  const error_format_js_1 = require("../error-format.js");
6
6
  const poll_js_1 = require("../poll.js");
7
7
  const config_js_1 = require("../config.js");
8
+ const sanitize_js_1 = require("../sanitize.js");
8
9
  const registerPromptTool = (server, client, config) => {
9
10
  server.registerTool('zeph_prompt', {
10
11
  description: 'Ask the user to choose from predefined options via push notification. The tool blocks until the user responds or the timeout is reached. Requires ZEPH_HOOK_ID environment variable.',
@@ -43,7 +44,7 @@ const registerPromptTool = (server, client, config) => {
43
44
  try {
44
45
  const trigger = await client.triggerHook(config.hookId, {
45
46
  title: (0, config_js_1.formatPushTitle)(config.projectName, title),
46
- body,
47
+ body: (0, sanitize_js_1.sanitizeText)(body),
47
48
  actions,
48
49
  timeout,
49
50
  fallback,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/mcp-server",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
4
4
  "description": "Zeph MCP server — AI agent notifications, prompts, and input via MCP protocol",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",