@zeph-to/mcp-server 1.15.1 → 1.16.0

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 CHANGED
@@ -138,15 +138,21 @@ priority: "normal"
138
138
 
139
139
  ### zeph_file
140
140
 
141
- Send a text file to the user's device.
141
+ Send a file to the user's device. Either `filePath` (a file already on disk) or
142
+ `content` (text you generated) is required.
142
143
 
143
144
  ```
144
- fileName: "report.json"
145
- content: "{\"status\": \"ok\"}"
146
- title: "Build Report" (optional, defaults to fileName)
147
- targetDeviceId: "dev_..." (optional)
145
+ filePath: "/abs/path/screenshot.png" (images, PDFs, logs — anything on disk)
146
+ content: "{\"status\": \"ok\"}" (text only; requires fileName)
147
+ fileName: "report.json" (required with content; defaults to basename of filePath)
148
+ title: "Build Report" (optional, defaults to fileName)
149
+ targetDeviceId: "dev_..." (optional)
148
150
  ```
149
151
 
152
+ Images are delivered with their real mime type and render inline on the device.
153
+ Never base64 a binary file into `content` — pass `filePath` and the server reads
154
+ the bytes off disk.
155
+
150
156
  Returns: `{ pushId: "...", fileKey: "...", fileSize: 42 }`
151
157
 
152
158
  ### zeph_session_rename
package/dist/crypto.d.ts CHANGED
@@ -54,6 +54,16 @@
54
54
  export declare const initCrypto: (apiKey?: string, baseUrl?: string) => Promise<string>;
55
55
  export declare const getKeyPair: () => CryptoKeyPair | null;
56
56
  export declare const getPublicKey: () => string | null;
57
+ /**
58
+ * Drop the cached keys so every later send goes out as plaintext.
59
+ *
60
+ * Used when the server refuses an encrypted send with `PRO_REQUIRED`
61
+ * (ADR-0008): E2E is Pro-only, and this server initialized crypto once at
62
+ * startup — a downgrade after that is only visible at send time. Same end
63
+ * state as the `ZEPH_DISABLE_ENCRYPTION` opt-out. Keys on disk are untouched;
64
+ * a restart after an upgrade re-adopts them.
65
+ */
66
+ export declare const disableCrypto: () => void;
57
67
  /**
58
68
  * Encrypt push body for self (all own devices).
59
69
  */
@@ -70,7 +80,7 @@ export declare const encryptPushBodyForSelf: (input: {
70
80
  /**
71
81
  * Encrypt file content for self (all own devices).
72
82
  */
73
- export declare const encryptFileForSelf: (content: string) => Promise<{
83
+ export declare const encryptFileForSelf: (content: string | Buffer) => Promise<{
74
84
  ciphertext: Buffer;
75
85
  iv: string;
76
86
  encryptedKey: string;
@@ -1 +1 @@
1
- {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AA0JH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAsE5E,CAAC;AAmCF,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAiKH;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAsE5E,CAAC;AAmCF,eAAO,MAAM,UAAU,QAAO,aAAa,GAAG,IAAqB,CAAC;AACpE,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAA+B,CAAC;AAEzE;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,QAAO,IAIhC,CAAC;AAEF;;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,GAAG,MAAM,KACvB,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
@@ -29,7 +29,7 @@
29
29
  * sensitive-but-not-secret.
30
30
  */
31
31
  Object.defineProperty(exports, "__esModule", { value: true });
32
- exports.encryptFileForSelf = exports.encryptPushBodyForSelf = exports.getPublicKey = exports.getKeyPair = exports.initCrypto = void 0;
32
+ exports.encryptFileForSelf = exports.encryptPushBodyForSelf = exports.disableCrypto = exports.getPublicKey = exports.getKeyPair = exports.initCrypto = void 0;
33
33
  /// <reference lib="dom" />
34
34
  const fs_1 = require("fs");
35
35
  const os_1 = require("os");
@@ -78,7 +78,13 @@ const encrypt = async (plaintext, senderPrivateKey, recipientPublicKey) => {
78
78
  };
79
79
  // ─── File encryption ───
80
80
  const encryptFileContent = async (content, senderPrivateKey, recipientPublicKey) => {
81
- const buffer = new TextEncoder().encode(content).buffer;
81
+ // Binary attachments arrive as a Buffer and must be encrypted byte for byte —
82
+ // running them through TextEncoder would UTF-8 mangle every non-ASCII byte.
83
+ // Both branches are views; subtle.encrypt honours byteOffset/byteLength, so a
84
+ // Buffer carved out of Node's pool encrypts only its own bytes.
85
+ const buffer = typeof content === 'string'
86
+ ? new TextEncoder().encode(content)
87
+ : new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
82
88
  const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
83
89
  const iv = crypto.getRandomValues(new Uint8Array(12));
84
90
  const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, fileKey, buffer);
@@ -241,6 +247,21 @@ const getKeyPair = () => cachedKeyPair;
241
247
  exports.getKeyPair = getKeyPair;
242
248
  const getPublicKey = () => cachedExportedPublicKey;
243
249
  exports.getPublicKey = getPublicKey;
250
+ /**
251
+ * Drop the cached keys so every later send goes out as plaintext.
252
+ *
253
+ * Used when the server refuses an encrypted send with `PRO_REQUIRED`
254
+ * (ADR-0008): E2E is Pro-only, and this server initialized crypto once at
255
+ * startup — a downgrade after that is only visible at send time. Same end
256
+ * state as the `ZEPH_DISABLE_ENCRYPTION` opt-out. Keys on disk are untouched;
257
+ * a restart after an upgrade re-adopts them.
258
+ */
259
+ const disableCrypto = () => {
260
+ cachedKeyPair = null;
261
+ cachedExportedPublicKey = null;
262
+ cachedOwnPublicKey = null;
263
+ };
264
+ exports.disableCrypto = disableCrypto;
244
265
  /**
245
266
  * Encrypt push body for self (all own devices).
246
267
  */
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Run a send, and repeat it unencrypted if the server says E2E needs Pro.
3
+ *
4
+ * E2E is Pro-only (ADR-0008) and `POST /pushes/send` rejects `isEncrypted`
5
+ * from a free account with 403 `PRO_REQUIRED`. Crypto initializes once at
6
+ * server startup, so a downgrade after that is only visible at send time — and
7
+ * a notification must not be lost over a billing state change. The keys are
8
+ * dropped before the retry, so later sends skip encryption outright and the
9
+ * retry rebuilds the whole payload (a file re-uploads as plaintext instead of
10
+ * leaving an undecryptable blob in S3).
11
+ *
12
+ * `send` receives whether it may encrypt, and must be safe to run twice — the
13
+ * encrypted first upload is left orphaned in S3, which is the accepted cost of
14
+ * not shipping an unreadable attachment. The retry is not itself retried: a
15
+ * second `PRO_REQUIRED` propagates.
16
+ */
17
+ export declare const withPlaintextFallback: <T>(send: (canEncrypt: boolean) => Promise<T>) => Promise<T>;
18
+ //# sourceMappingURL=e2e-fallback.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"e2e-fallback.d.ts","sourceRoot":"","sources":["../src/e2e-fallback.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,qBAAqB,GAAU,CAAC,EAC3C,MAAM,CAAC,UAAU,EAAE,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,KACxC,OAAO,CAAC,CAAC,CAYX,CAAC"}
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withPlaintextFallback = void 0;
4
+ const api_client_js_1 = require("./api-client.js");
5
+ const crypto_js_1 = require("./crypto.js");
6
+ /**
7
+ * Run a send, and repeat it unencrypted if the server says E2E needs Pro.
8
+ *
9
+ * E2E is Pro-only (ADR-0008) and `POST /pushes/send` rejects `isEncrypted`
10
+ * from a free account with 403 `PRO_REQUIRED`. Crypto initializes once at
11
+ * server startup, so a downgrade after that is only visible at send time — and
12
+ * a notification must not be lost over a billing state change. The keys are
13
+ * dropped before the retry, so later sends skip encryption outright and the
14
+ * retry rebuilds the whole payload (a file re-uploads as plaintext instead of
15
+ * leaving an undecryptable blob in S3).
16
+ *
17
+ * `send` receives whether it may encrypt, and must be safe to run twice — the
18
+ * encrypted first upload is left orphaned in S3, which is the accepted cost of
19
+ * not shipping an unreadable attachment. The retry is not itself retried: a
20
+ * second `PRO_REQUIRED` propagates.
21
+ */
22
+ const withPlaintextFallback = async (send) => {
23
+ const canEncrypt = !!(0, crypto_js_1.getKeyPair)() && !!(0, crypto_js_1.getPublicKey)();
24
+ if (!canEncrypt)
25
+ return send(false);
26
+ try {
27
+ return await send(true);
28
+ }
29
+ catch (err) {
30
+ if (!(err instanceof api_client_js_1.ApiError) || err.code !== 'PRO_REQUIRED')
31
+ throw err;
32
+ (0, crypto_js_1.disableCrypto)();
33
+ console.error('[Crypto] End-to-end encryption requires Zeph Pro — resending as plaintext.');
34
+ return send(false);
35
+ }
36
+ };
37
+ exports.withPlaintextFallback = withPlaintextFallback;
package/dist/index.js CHANGED
@@ -49,7 +49,7 @@ const createServer = (config) => {
49
49
  '- zeph_dismiss: Mark a push as read',
50
50
  '- zeph_dismiss_all: Clear all notifications',
51
51
  '- zeph_broadcast: Send to all subscribers of a channel',
52
- '- zeph_file: Send a text file (logs, reports, code)',
52
+ '- zeph_file: Send a file — pass filePath for anything on disk (images, PDFs, logs), or content for generated text',
53
53
  '- zeph_prompt: Ask user to choose from options (requires ZEPH_HOOK_ID)',
54
54
  '- zeph_input: Request text input from user (requires ZEPH_HOOK_ID)',
55
55
  '- zeph_ask: Ask user with buttons + text input combined (requires ZEPH_HOOK_ID). Prefer this over zeph_prompt/zeph_input when you need both options and free-text.',
@@ -1 +1 @@
1
- {"version":3,"file":"mime.d.ts","sourceRoot":"","sources":["../src/mime.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAkBH,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,KAAG,MAGhD,CAAC"}
1
+ {"version":3,"file":"mime.d.ts","sourceRoot":"","sources":["../src/mime.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAsCH,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,KAAG,MAGhD,CAAC"}
package/dist/mime.js CHANGED
@@ -22,6 +22,26 @@ const EXT_TO_MIME = {
22
22
  js: 'text/javascript',
23
23
  py: 'text/x-python',
24
24
  sh: 'text/x-shellscript',
25
+ // Keep this image set in sync with IMAGE_EXTENSIONS in the Zeph client
26
+ // (`libs/shared/src/utils/file.ts`) — an extension the client calls an image
27
+ // but this map doesn't will be labelled text/plain and decoded as UTF-8 on
28
+ // open, which corrupts it. Separate repos, so nothing enforces the match.
29
+ png: 'image/png',
30
+ jpg: 'image/jpeg',
31
+ jpeg: 'image/jpeg',
32
+ gif: 'image/gif',
33
+ webp: 'image/webp',
34
+ heic: 'image/heic',
35
+ heif: 'image/heif',
36
+ bmp: 'image/bmp',
37
+ avif: 'image/avif',
38
+ ico: 'image/x-icon',
39
+ tiff: 'image/tiff',
40
+ tif: 'image/tiff',
41
+ // No svg entry on purpose: an SVG is a script-bearing document, and the
42
+ // clients open decrypted attachments from a same-origin blob URL. It falls
43
+ // through to text/plain, which the text viewer handles fine.
44
+ pdf: 'application/pdf',
25
45
  };
26
46
  const inferMimeType = (fileName) => {
27
47
  const ext = fileName.split('.').pop()?.toLowerCase();
@@ -1 +1 @@
1
- {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../src/tools/file.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAKrE,eAAO,MAAM,gBAAgB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SA2EjG,CAAC"}
1
+ {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../src/tools/file.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AA4BrE,eAAO,MAAM,gBAAgB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAoHjG,CAAC"}
@@ -2,37 +2,95 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerFileTool = void 0;
4
4
  const zod_1 = require("zod");
5
+ const promises_1 = require("node:fs/promises");
6
+ const node_path_1 = require("node:path");
5
7
  const config_js_1 = require("../config.js");
6
8
  const error_format_js_1 = require("../error-format.js");
7
9
  const crypto_js_1 = require("../crypto.js");
10
+ const e2e_fallback_js_1 = require("../e2e-fallback.js");
8
11
  const mime_js_1 = require("../mime.js");
12
+ /**
13
+ * Collapse the two accepted input shapes into one payload.
14
+ *
15
+ * A `filePath` stays a Buffer end to end — images and PDFs only survive the
16
+ * trip as raw bytes, and reading from disk keeps a several-hundred-KB
17
+ * attachment out of the tool call itself (base64 in the arguments would
18
+ * inflate it by a third and burn the agent's context).
19
+ */
20
+ const resolvePayload = async (args) => {
21
+ if (args.filePath) {
22
+ const body = await (0, promises_1.readFile)(args.filePath);
23
+ return { fileName: args.fileName ?? (0, node_path_1.basename)(args.filePath), body, size: body.byteLength };
24
+ }
25
+ return {
26
+ fileName: args.fileName,
27
+ body: args.content,
28
+ size: new TextEncoder().encode(args.content).byteLength,
29
+ };
30
+ };
9
31
  const registerFileTool = (server, client, config) => {
10
32
  server.registerTool('zeph_file', {
11
- description: 'Send a text file to the user\'s device. The content is uploaded and delivered as a file push. Use for logs, reports, code snippets, or any text content.',
33
+ description: 'Send a file to the user\'s device. Pass `filePath` to send a file that already exists on disk images (png/jpg/gif/webp/heic), PDFs, logs, anything. Pass `content` instead to send text you generated. Images render inline on the device.',
12
34
  annotations: {
13
35
  readOnlyHint: false,
14
36
  destructiveHint: false,
15
37
  openWorldHint: true,
16
38
  },
17
39
  inputSchema: {
18
- fileName: zod_1.z.string().describe('File name with extension (e.g., "report.txt", "output.json")'),
19
- content: zod_1.z.string().describe('Text content of the file'),
40
+ filePath: zod_1.z
41
+ .string()
42
+ .optional()
43
+ .describe('Absolute path to a local file to send. Required for images, PDFs, and any other binary — never base64 a file into `content`.'),
44
+ content: zod_1.z.string().optional().describe('Text content of the file. Use only for text you generated; requires `fileName`.'),
45
+ fileName: zod_1.z
46
+ .string()
47
+ .optional()
48
+ .describe('File name with extension (e.g., "report.txt"). Required with `content`; defaults to the basename of `filePath`.'),
20
49
  title: zod_1.z.string().optional().describe('Notification title (defaults to fileName)'),
21
50
  targetDeviceId: zod_1.z.string().optional().describe('Target device ID. Omit to use configured default or send to all devices.'),
22
51
  },
23
- }, async ({ fileName, content, title, targetDeviceId }) => {
52
+ }, async ({ filePath, fileName: fileNameArg, content, title, targetDeviceId }) => {
53
+ if (!filePath && content === undefined) {
54
+ return (0, error_format_js_1.errorResult)({
55
+ error: 'INVALID_INPUT',
56
+ message: 'Either filePath or content is required',
57
+ suggestion: 'Pass filePath for a file on disk (images, PDFs), or content + fileName for generated text',
58
+ });
59
+ }
60
+ if (!filePath && !fileNameArg) {
61
+ return (0, error_format_js_1.errorResult)({
62
+ error: 'INVALID_INPUT',
63
+ message: 'fileName is required when sending content',
64
+ suggestion: 'Add fileName with an extension, e.g. "report.md"',
65
+ });
66
+ }
67
+ let payload;
24
68
  try {
25
- const canEncrypt = !!(0, crypto_js_1.getKeyPair)() && !!(0, crypto_js_1.getPublicKey)();
69
+ // Resolved once, outside `send`, so the plaintext retry below doesn't
70
+ // read the file from disk a second time.
71
+ payload = await resolvePayload({ filePath, content, fileName: fileNameArg });
72
+ }
73
+ catch (err) {
74
+ return (0, error_format_js_1.errorResult)({
75
+ error: 'FILE_READ_FAILED',
76
+ message: err instanceof Error ? err.message : String(err),
77
+ suggestion: 'Check that filePath is an absolute path to a readable file',
78
+ });
79
+ }
80
+ const { fileName, body, size: originalSize } = payload;
81
+ // Runs a second time as plaintext if the server refuses E2E (Pro-only,
82
+ // ADR-0008). The retry re-uploads the file unencrypted, so the whole
83
+ // upload-then-send sequence has to sit inside this closure.
84
+ const send = async (canEncrypt) => {
26
85
  let fileType = (0, mime_js_1.inferMimeType)(fileName);
27
- const originalSize = new TextEncoder().encode(content).byteLength;
28
86
  // Step 1: Optionally encrypt file content
29
- let uploadContent = content;
87
+ let uploadContent = body;
30
88
  let uploadSize = originalSize;
31
89
  let fileIv;
32
90
  let fileEncryptedKey;
33
91
  if (canEncrypt) {
34
92
  try {
35
- const encrypted = await (0, crypto_js_1.encryptFileForSelf)(content);
93
+ const encrypted = await (0, crypto_js_1.encryptFileForSelf)(body);
36
94
  uploadContent = encrypted.ciphertext;
37
95
  uploadSize = encrypted.ciphertext.length;
38
96
  fileType = 'application/octet-stream';
@@ -67,6 +125,9 @@ const registerFileTool = (server, client, config) => {
67
125
  }
68
126
  const result = await client.sendPush(pushPayload);
69
127
  return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, fileKey: upload.data.fileKey, fileSize: originalSize, encrypted: canEncrypt });
128
+ };
129
+ try {
130
+ return await (0, e2e_fallback_js_1.withPlaintextFallback)(send);
70
131
  }
71
132
  catch (err) {
72
133
  return (0, error_format_js_1.formatToolError)(err);
@@ -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;AASrE,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SA2HnG,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;AAUrE,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAgInG,CAAC"}
@@ -5,6 +5,7 @@ const zod_1 = require("zod");
5
5
  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
+ const e2e_fallback_js_1 = require("../e2e-fallback.js");
8
9
  const mime_js_1 = require("../mime.js");
9
10
  const sanitize_js_1 = require("../sanitize.js");
10
11
  // The device feed shows a short preview of the body. Anything longer gets
@@ -29,14 +30,15 @@ const registerNotifyTool = (server, client, config) => {
29
30
  targetDeviceId: zod_1.z.string().optional().describe('Target device ID. Omit to use configured default or send to all devices.'),
30
31
  },
31
32
  }, async ({ title, body, url, priority, targetDeviceId }) => {
32
- try {
33
+ // Runs a second time as plaintext if the server refuses E2E (Pro-only,
34
+ // ADR-0008) — hence everything after the encryption decision lives here.
35
+ const send = async (canEncrypt) => {
33
36
  const deviceId = targetDeviceId ?? config.deviceId;
34
37
  const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title);
35
38
  // Strip any tool-call markup that leaked into the body argument.
36
39
  const cleanBody = (0, sanitize_js_1.sanitizeText)(body);
37
40
  // Attach a file whenever the body would be clipped in the feed preview.
38
41
  const isLongBody = !!cleanBody && cleanBody.length > PREVIEW_LENGTH;
39
- const canEncrypt = !!(0, crypto_js_1.getKeyPair)() && !!(0, crypto_js_1.getPublicKey)();
40
42
  if (isLongBody && cleanBody) {
41
43
  const fileName = 'response.md';
42
44
  const fileType = (0, mime_js_1.inferMimeType)(fileName);
@@ -115,6 +117,9 @@ const registerNotifyTool = (server, client, config) => {
115
117
  }
116
118
  const result = await client.sendPush(pushPayload);
117
119
  return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, encrypted: pushEncrypted });
120
+ };
121
+ try {
122
+ return await (0, e2e_fallback_js_1.withPlaintextFallback)(send);
118
123
  }
119
124
  catch (err) {
120
125
  return (0, error_format_js_1.formatToolError)(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/mcp-server",
3
- "version": "1.15.1",
3
+ "version": "1.16.0",
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",