@zeph-to/mcp-server 1.16.0 → 2.1.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.
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ /**
3
+ * Saving the files a user attached to a hook answer.
4
+ *
5
+ * `zeph_ask` and `zeph_input` hand the agent a JSON result, and an agent
6
+ * cannot look at an S3 key — so an answered screenshot only becomes useful
7
+ * once it is a path on this machine. This module downloads each attachment
8
+ * to `~/.zeph/attachments/hook-<eventId>/` and returns the absolute paths,
9
+ * which the tool then names in its result for the agent to read.
10
+ *
11
+ * The files are plaintext by contract: the hook route carries no sender key,
12
+ * so the server rejects an encrypted attachment on a response rather than
13
+ * let one arrive here as bytes nothing can open.
14
+ *
15
+ * Failure is per file, never fatal. A question that was answered has been
16
+ * answered; losing one image must not turn that into a tool error and make
17
+ * the user answer again.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.saveResponseFiles = exports.attachmentNote = void 0;
21
+ const node_fs_1 = require("node:fs");
22
+ const node_os_1 = require("node:os");
23
+ const node_path_1 = require("node:path");
24
+ /** Shared with the `zeph listener` daemon, which writes push attachments here. */
25
+ const ATTACHMENTS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), '.zeph', 'attachments');
26
+ /**
27
+ * Make a filesystem-safe single path segment: take the basename (drops any
28
+ * `../` prefix), strip control characters and embedded separators, remove
29
+ * leading dots, and cap the length. Empty or dot-only names use the fallback.
30
+ */
31
+ const safeSegment = (raw, fallback) => {
32
+ const cleaned = (0, node_path_1.basename)(raw)
33
+ .replace(/[\x00-\x1f\x7f]/g, '')
34
+ .replace(/[/\\]/g, '_')
35
+ .replace(/^\.+/, '')
36
+ .trim();
37
+ return (cleaned || fallback).slice(0, 200);
38
+ };
39
+ /**
40
+ * The part of a tool result that tells the agent about saved attachments.
41
+ * Spread into the result object; empty when nothing was attached, so a plain
42
+ * answer keeps the shape it has always had.
43
+ *
44
+ * The instruction is explicit because a bare list of paths in a JSON result
45
+ * reads as metadata — the agent has to be told these are the user's answer
46
+ * and that opening them is part of reading it.
47
+ */
48
+ const attachmentNote = (paths) => paths.length
49
+ ? {
50
+ attachments: paths,
51
+ attachmentsNote: `The user attached ${paths.length} file(s) to this answer. Read each path above to see them.`,
52
+ }
53
+ : {};
54
+ exports.attachmentNote = attachmentNote;
55
+ const saveResponseFiles = async (client, eventId, files, deps = {}) => {
56
+ if (!files?.length)
57
+ return [];
58
+ const fetchBytes = deps.fetchBytes ?? ((key) => client.downloadFile(key));
59
+ const dir = (0, node_path_1.join)(deps.dir ?? ATTACHMENTS_DIR, safeSegment(`hook-${eventId}`, 'hook'));
60
+ const paths = [];
61
+ for (const [i, file] of files.entries()) {
62
+ try {
63
+ const bytes = await fetchBytes(file.fileKey);
64
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
65
+ const abs = (0, node_path_1.join)(dir, safeSegment(file.fileName || `file-${i}`, `file-${i}`));
66
+ (0, node_fs_1.writeFileSync)(abs, bytes);
67
+ paths.push(abs);
68
+ }
69
+ catch (err) {
70
+ console.error(`[zeph] attachment "${file.fileName}" failed to save: ${err.message}`);
71
+ }
72
+ }
73
+ return paths;
74
+ };
75
+ exports.saveResponseFiles = saveResponseFiles;
@@ -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;AAItD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAwBxD,eAAO,MAAM,eAAe,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,EAAE,SAAS,kBAAkB,SAmI7H,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;AAItD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAwBxD,eAAO,MAAM,eAAe,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,EAAE,SAAS,kBAAkB,SA+H7H,CAAC"}
package/dist/tools/ask.js CHANGED
@@ -5,9 +5,9 @@ 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 crypto_js_1 = require("../crypto.js");
9
8
  const mime_js_1 = require("../mime.js");
10
9
  const sanitize_js_1 = require("../sanitize.js");
10
+ const response_files_js_1 = require("../response-files.js");
11
11
  // The device feed shows a short preview of the body. Anything longer than
12
12
  // this gets truncated there, so we attach the full text as a file — the
13
13
  // user can always open the complete content instead of squinting at a
@@ -23,7 +23,7 @@ const buildAskMarkdown = (title, body, actions) => {
23
23
  };
24
24
  const registerAskTool = (server, client, config, waiter) => {
25
25
  server.registerTool('zeph_ask', {
26
- description: 'Ask the user a question with optional quick-reply buttons and a text input field. Combines prompt (buttons) and input (text) in a single notification. The user can either tap a button or type a response. Blocks until the user responds or the timeout is reached. Requires ZEPH_HOOK_ID environment variable.',
26
+ description: 'Ask the user a question with optional quick-reply buttons and a text input field. Combines prompt (buttons) and input (text) in a single notification. The user can either tap a button or type a response. Blocks until the user responds or the timeout is reached. Requires ZEPH_HOOK_ID environment variable. The user may also attach screenshots or files to the answer: those arrive as local absolute paths in the `attachments` field of the result, and reading them is part of reading the answer. NOTE: unlike zeph_notify and zeph_file, this tool is never end-to-end encrypted — the hook route it uses cannot carry the sender key — so do not put secrets in the question or expect a private answer.',
27
27
  annotations: {
28
28
  readOnlyHint: false,
29
29
  destructiveHint: false,
@@ -77,30 +77,21 @@ const registerAskTool = (server, client, config, waiter) => {
77
77
  if (exceedsPreview && cleanBody) {
78
78
  const fileName = 'response.md';
79
79
  const fileType = (0, mime_js_1.inferMimeType)(fileName);
80
- const canEncrypt = !!(0, crypto_js_1.getKeyPair)() && !!(0, crypto_js_1.getPublicKey)();
81
80
  // Self-contained Markdown so the file alone tells the whole story.
82
81
  const fileMarkdown = buildAskMarkdown(title, cleanBody, effectiveActions);
83
82
  const fileBytes = new TextEncoder().encode(fileMarkdown).byteLength;
84
- let uploadContent = fileMarkdown;
85
- let uploadContentType = fileType;
86
- let fileIv;
87
- let fileEncryptedKey;
88
- if (canEncrypt) {
89
- try {
90
- const encrypted = await (0, crypto_js_1.encryptFileForSelf)(fileMarkdown);
91
- uploadContent = encrypted.ciphertext;
92
- uploadContentType = 'application/octet-stream';
93
- fileIv = encrypted.iv;
94
- fileEncryptedKey = encrypted.encryptedKey;
95
- }
96
- catch (err) {
97
- console.error('[Crypto] File encryption failed, sending plaintext:', err);
98
- }
99
- }
100
- const upload = await client.requestUpload({ fileName, fileType: uploadContentType, fileSize: typeof uploadContent === 'string' ? fileBytes : uploadContent.length });
101
- await client.uploadToS3(upload.data.uploadUrl, uploadContent, uploadContentType);
83
+ // Deliberately NOT encrypted, unlike zeph_notify / zeph_file.
84
+ //
85
+ // This attachment rides `POST /hooks/:id/trigger`, which creates a
86
+ // plaintext push: it neither accepts nor persists `isEncrypted` or
87
+ // `senderPublicKey` (apps/server/src/functions/hooks.ts). Clients
88
+ // gate decryption on both, so an encrypted attachment here would be
89
+ // downloaded as raw ciphertext named response.md. Encrypting this
90
+ // path needs the hook route to carry the sender key first.
91
+ const upload = await client.requestUpload({ fileName, fileType, fileSize: fileBytes });
92
+ await client.uploadToS3(upload.data.uploadUrl, fileMarkdown, fileType);
102
93
  triggerBody = cleanBody.slice(0, PREVIEW_LENGTH) + '...';
103
- files = [{ fileKey: upload.data.fileKey, fileName, fileSize: fileBytes, fileType, iv: fileIv, encryptedKey: fileEncryptedKey }];
94
+ files = [{ fileKey: upload.data.fileKey, fileName, fileSize: fileBytes, fileType }];
104
95
  }
105
96
  const trigger = await client.triggerHook(config.hookId, {
106
97
  title: pushTitle,
@@ -122,9 +113,14 @@ const registerAskTool = (server, client, config, waiter) => {
122
113
  return (0, error_format_js_1.timeoutError)(timeout, 'Try again or use zeph_notify for one-way communication');
123
114
  }
124
115
  const response = event.data.response;
125
- if (response?.actionId)
126
- return (0, error_format_js_1.textResult)({ actionId: response.actionId, timedOut: false });
127
- return (0, error_format_js_1.textResult)({ value: response?.value ?? '', timedOut: false });
116
+ // Saved before the button branch returns: a user can tap a quick reply
117
+ // AND attach a screenshot, and dropping the files there would lose them
118
+ // silently for the one caller that never looks at `value`.
119
+ const attachments = await (0, response_files_js_1.saveResponseFiles)(client, trigger.data.eventId, response?.files);
120
+ if (response?.actionId) {
121
+ return (0, error_format_js_1.textResult)({ actionId: response.actionId, timedOut: false, ...(0, response_files_js_1.attachmentNote)(attachments) });
122
+ }
123
+ return (0, error_format_js_1.textResult)({ value: response?.value ?? '', timedOut: false, ...(0, response_files_js_1.attachmentNote)(attachments) });
128
124
  }
129
125
  catch (err) {
130
126
  return (0, error_format_js_1.formatToolError)(err);
@@ -1 +1 @@
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"}
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,SAmIjG,CAAC"}
@@ -81,53 +81,65 @@ const registerFileTool = (server, client, config) => {
81
81
  // Runs a second time as plaintext if the server refuses E2E (Pro-only,
82
82
  // ADR-0008). The retry re-uploads the file unencrypted, so the whole
83
83
  // upload-then-send sequence has to sit inside this closure.
84
- const send = async (canEncrypt) => {
85
- let fileType = (0, mime_js_1.inferMimeType)(fileName);
86
- // Step 1: Optionally encrypt file content
87
- let uploadContent = body;
88
- let uploadSize = originalSize;
89
- let fileIv;
90
- let fileEncryptedKey;
91
- if (canEncrypt) {
84
+ const send = async (recipients) => {
85
+ const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title ?? fileName);
86
+ // Step 1: Encrypt the attachment and the push body together, before
87
+ // anything is uploaded. Doing them one at a time around the upload let
88
+ // a failure land in between and ship ciphertext under a push with no
89
+ // `isEncrypted` — an attachment no client would even try to open.
90
+ let encrypted = null;
91
+ if (recipients) {
92
92
  try {
93
- const encrypted = await (0, crypto_js_1.encryptFileForSelf)(body);
94
- uploadContent = encrypted.ciphertext;
95
- uploadSize = encrypted.ciphertext.length;
96
- fileType = 'application/octet-stream';
97
- fileIv = encrypted.iv;
98
- fileEncryptedKey = encrypted.encryptedKey;
93
+ encrypted = {
94
+ file: await (0, crypto_js_1.encryptFileForDevices)(body, recipients),
95
+ push: await (0, crypto_js_1.encryptPushBodyForDevices)({ title: pushTitle }, recipients),
96
+ };
99
97
  }
100
98
  catch (err) {
101
- console.error('[Crypto] File encryption failed, sending plaintext:', err);
99
+ console.error('[Crypto] Encryption failed, sending plaintext:', err);
102
100
  }
103
101
  }
102
+ const uploadContent = encrypted?.file.ciphertext ?? body;
103
+ const uploadType = encrypted ? 'application/octet-stream' : (0, mime_js_1.inferMimeType)(fileName);
104
+ const uploadSize = encrypted ? encrypted.file.ciphertext.length : originalSize;
104
105
  // Step 2: Request upload URL
105
- const upload = await client.requestUpload({ fileName, fileType, fileSize: uploadSize });
106
+ const upload = await client.requestUpload({ fileName, fileType: uploadType, fileSize: uploadSize });
106
107
  // Step 3: Upload content to S3
107
- await client.uploadToS3(upload.data.uploadUrl, uploadContent, fileType);
108
- // Step 4: Send file push (encrypt push body if possible)
109
- const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title ?? fileName);
110
- let pushPayload = {
111
- title: pushTitle,
108
+ await client.uploadToS3(upload.data.uploadUrl, uploadContent, uploadType);
109
+ // Step 4: Send the push. `fileType` on the descriptor stays the real
110
+ // type it drives how the client renders the decrypted bytes.
111
+ const pushPayload = {
112
+ title: encrypted ? undefined : pushTitle,
112
113
  type: 'file',
113
- files: [{ fileKey: upload.data.fileKey, fileName, fileSize: originalSize, fileType: (0, mime_js_1.inferMimeType)(fileName), iv: fileIv, encryptedKey: fileEncryptedKey }],
114
+ files: [{
115
+ fileKey: upload.data.fileKey,
116
+ fileName,
117
+ fileSize: originalSize,
118
+ fileType: (0, mime_js_1.inferMimeType)(fileName),
119
+ iv: encrypted?.file.iv,
120
+ deviceKeyMap: encrypted?.file.deviceKeyMap,
121
+ }],
114
122
  targetDeviceId: targetDeviceId ?? config.deviceId,
115
123
  sessionId: config.sessionId,
124
+ ...(encrypted && {
125
+ body: encrypted.push.body,
126
+ isEncrypted: encrypted.push.isEncrypted,
127
+ deviceKeyMap: encrypted.push.deviceKeyMap,
128
+ senderPublicKey: encrypted.push.senderPublicKey,
129
+ }),
116
130
  };
117
- if (canEncrypt) {
118
- try {
119
- const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: pushTitle });
120
- pushPayload = { ...pushPayload, title: undefined, body: enc.body, isEncrypted: enc.isEncrypted, encryptedKey: enc.encryptedKey, senderPublicKey: enc.senderPublicKey };
121
- }
122
- catch (err) {
123
- console.error('[Crypto] Push encryption failed, sending plaintext:', err);
124
- }
125
- }
126
131
  const result = await client.sendPush(pushPayload);
127
- return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, fileKey: upload.data.fileKey, fileSize: originalSize, encrypted: canEncrypt });
132
+ // Report what actually went out an encryption failure above falls
133
+ // back to plaintext, so the recipient list alone would over-claim.
134
+ return (0, error_format_js_1.textResult)({
135
+ pushId: result.data.pushId,
136
+ fileKey: upload.data.fileKey,
137
+ fileSize: originalSize,
138
+ encrypted: !!encrypted,
139
+ });
128
140
  };
129
141
  try {
130
- return await (0, e2e_fallback_js_1.withPlaintextFallback)(send);
142
+ return await (0, e2e_fallback_js_1.withPlaintextFallback)(client, send);
131
143
  }
132
144
  catch (err) {
133
145
  return (0, error_format_js_1.formatToolError)(err);
@@ -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,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAExD,eAAO,MAAM,iBAAiB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,EAAE,SAAS,kBAAkB,SA2D/H,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,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAExD,eAAO,MAAM,iBAAiB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,EAAE,SAAS,kBAAkB,SAgE/H,CAAC"}
@@ -6,9 +6,10 @@ 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
8
  const sanitize_js_1 = require("../sanitize.js");
9
+ const response_files_js_1 = require("../response-files.js");
9
10
  const registerInputTool = (server, client, config, waiter) => {
10
11
  server.registerTool('zeph_input', {
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.',
12
+ 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. The user may also attach screenshots or files: those arrive as local absolute paths in the `attachments` field of the result, and reading them is part of reading the answer.',
12
13
  annotations: {
13
14
  readOnlyHint: false,
14
15
  destructiveHint: false,
@@ -46,7 +47,12 @@ const registerInputTool = (server, client, config, waiter) => {
46
47
  const event = await (0, poll_js_1.pollForResponse)(client, config.hookId, trigger.data.eventId, timeout, ctx, waiter);
47
48
  if (!event)
48
49
  return (0, error_format_js_1.timeoutError)(timeout, 'Try again with a longer timeout');
49
- return (0, error_format_js_1.textResult)({ value: event.data.response?.value ?? '', timedOut: false });
50
+ const attachments = await (0, response_files_js_1.saveResponseFiles)(client, trigger.data.eventId, event.data.response?.files);
51
+ return (0, error_format_js_1.textResult)({
52
+ value: event.data.response?.value ?? '',
53
+ timedOut: false,
54
+ ...(0, response_files_js_1.attachmentNote)(attachments),
55
+ });
50
56
  }
51
57
  catch (err) {
52
58
  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;AAUrE,eAAO,MAAM,kBAAkB,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,SAgInG,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,SAuInG,CAAC"}
@@ -32,7 +32,7 @@ const registerNotifyTool = (server, client, config) => {
32
32
  }, async ({ title, body, url, priority, targetDeviceId }) => {
33
33
  // Runs a second time as plaintext if the server refuses E2E (Pro-only,
34
34
  // ADR-0008) — hence everything after the encryption decision lives here.
35
- const send = async (canEncrypt) => {
35
+ const send = async (recipients) => {
36
36
  const deviceId = targetDeviceId ?? config.deviceId;
37
37
  const pushTitle = (0, config_js_1.formatPushTitle)(config.projectName, title);
38
38
  // Strip any tool-call markup that leaked into the body argument.
@@ -45,70 +45,74 @@ const registerNotifyTool = (server, client, config) => {
45
45
  // Self-contained Markdown so the file alone carries the full text.
46
46
  const fileMarkdown = `# ${title}\n\n${cleanBody}`;
47
47
  const fileBytes = new TextEncoder().encode(fileMarkdown).byteLength;
48
- // Encrypt file content if keys available
49
- let uploadContent = fileMarkdown;
50
- let uploadContentType = fileType;
51
- let fileIv;
52
- let fileEncryptedKey;
53
- let fileEncrypted = false;
54
- if (canEncrypt) {
48
+ const preview = cleanBody.slice(0, PREVIEW_LENGTH) + '...';
49
+ // Encrypt the attachment and the push body together, before anything
50
+ // is uploaded. Doing them one at a time around the upload let a
51
+ // failure land in between and ship ciphertext under a push with no
52
+ // `isEncrypted` — an attachment no client would even try to open.
53
+ let encrypted = null;
54
+ if (recipients) {
55
55
  try {
56
- const encrypted = await (0, crypto_js_1.encryptFileForSelf)(fileMarkdown);
57
- uploadContent = encrypted.ciphertext;
58
- uploadContentType = 'application/octet-stream';
59
- fileIv = encrypted.iv;
60
- fileEncryptedKey = encrypted.encryptedKey;
61
- fileEncrypted = true;
56
+ encrypted = {
57
+ file: await (0, crypto_js_1.encryptFileForDevices)(fileMarkdown, recipients),
58
+ push: await (0, crypto_js_1.encryptPushBodyForDevices)({ title: pushTitle, body: preview, url }, recipients),
59
+ };
62
60
  }
63
61
  catch (err) {
64
- console.error('[Crypto] File encryption failed, sending plaintext:', err);
62
+ console.error('[Crypto] Encryption failed, sending plaintext:', err);
65
63
  }
66
64
  }
65
+ const uploadContent = encrypted?.file.ciphertext ?? fileMarkdown;
66
+ const uploadContentType = encrypted ? 'application/octet-stream' : fileType;
67
67
  const upload = await client.requestUpload({ fileName, fileType: uploadContentType, fileSize: typeof uploadContent === 'string' ? fileBytes : uploadContent.length });
68
68
  await client.uploadToS3(upload.data.uploadUrl, uploadContent, uploadContentType);
69
- const preview = cleanBody.slice(0, PREVIEW_LENGTH) + '...';
70
- // Encrypt push body (title/preview/url) if keys available
71
- let pushPayload = {
72
- title: pushTitle,
73
- body: preview,
74
- url,
69
+ const pushPayload = {
70
+ title: encrypted ? undefined : pushTitle,
71
+ body: encrypted ? encrypted.push.body : preview,
72
+ // Dropped alongside the title when encrypted: the url is already
73
+ // sealed inside the ciphertext, and for a link push it is the whole
74
+ // payload. Leaving the plaintext copy here would hand the server the
75
+ // one thing `isEncrypted` promises it cannot see.
76
+ url: encrypted ? undefined : url,
75
77
  type: 'file',
76
78
  priority,
77
- files: [{ fileKey: upload.data.fileKey, fileName, fileSize: fileBytes, fileType, iv: fileIv, encryptedKey: fileEncryptedKey }],
79
+ files: [{
80
+ fileKey: upload.data.fileKey,
81
+ fileName,
82
+ fileSize: fileBytes,
83
+ fileType,
84
+ iv: encrypted?.file.iv,
85
+ deviceKeyMap: encrypted?.file.deviceKeyMap,
86
+ }],
78
87
  targetDeviceId: deviceId,
79
88
  sessionId: config.sessionId,
89
+ ...(encrypted && {
90
+ isEncrypted: encrypted.push.isEncrypted,
91
+ deviceKeyMap: encrypted.push.deviceKeyMap,
92
+ senderPublicKey: encrypted.push.senderPublicKey,
93
+ }),
80
94
  };
81
- let pushEncrypted = false;
82
- if (canEncrypt) {
83
- try {
84
- const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: pushTitle, body: preview, url });
85
- pushPayload = { ...pushPayload, title: undefined, body: enc.body, isEncrypted: enc.isEncrypted, encryptedKey: enc.encryptedKey, senderPublicKey: enc.senderPublicKey };
86
- pushEncrypted = true;
87
- }
88
- catch (err) {
89
- console.error('[Crypto] Push encryption failed, sending plaintext:', err);
90
- }
91
- }
92
95
  const result = await client.sendPush(pushPayload);
93
96
  // Report what actually went out — an encryption failure above falls
94
- // back to plaintext, so `canEncrypt` alone would over-claim.
95
- return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, fileKey: upload.data.fileKey, autoFile: true, encrypted: fileEncrypted && pushEncrypted });
97
+ // back to plaintext, so the recipient list alone would over-claim.
98
+ return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, fileKey: upload.data.fileKey, autoFile: true, encrypted: !!encrypted });
96
99
  }
97
100
  // Short body — encrypt push only
98
101
  let pushPayload = {
99
102
  title: pushTitle,
100
103
  body: cleanBody,
101
- url,
104
+ url, // replaced below when the encrypted envelope takes over
102
105
  type: 'hook',
103
106
  priority,
104
107
  targetDeviceId: deviceId,
105
108
  sessionId: config.sessionId,
106
109
  };
107
110
  let pushEncrypted = false;
108
- if (canEncrypt) {
111
+ if (recipients) {
109
112
  try {
110
- const enc = await (0, crypto_js_1.encryptPushBodyForSelf)({ title: pushTitle, body: cleanBody, url });
111
- pushPayload = { ...pushPayload, title: undefined, body: enc.body, isEncrypted: enc.isEncrypted, encryptedKey: enc.encryptedKey, senderPublicKey: enc.senderPublicKey };
113
+ const enc = await (0, crypto_js_1.encryptPushBodyForDevices)({ title: pushTitle, body: cleanBody, url }, recipients);
114
+ // `url: undefined` for the same reason as `title` see the file branch.
115
+ pushPayload = { ...pushPayload, title: undefined, url: undefined, body: enc.body, isEncrypted: enc.isEncrypted, deviceKeyMap: enc.deviceKeyMap, senderPublicKey: enc.senderPublicKey };
112
116
  pushEncrypted = true;
113
117
  }
114
118
  catch (err) {
@@ -119,7 +123,7 @@ const registerNotifyTool = (server, client, config) => {
119
123
  return (0, error_format_js_1.textResult)({ pushId: result.data.pushId, encrypted: pushEncrypted });
120
124
  };
121
125
  try {
122
- return await (0, e2e_fallback_js_1.withPlaintextFallback)(send);
126
+ return await (0, e2e_fallback_js_1.withPlaintextFallback)(client, send);
123
127
  }
124
128
  catch (err) {
125
129
  return (0, error_format_js_1.formatToolError)(err);
package/dist/types.d.ts CHANGED
@@ -23,6 +23,9 @@ export interface HookEventResponse {
23
23
  response?: {
24
24
  actionId?: string;
25
25
  value?: string;
26
+ /** Screenshots and files the user attached to the answer — plaintext,
27
+ * since the hook route carries no key this end could decrypt with. */
28
+ files?: AttachedFile[];
26
29
  respondedDeviceId?: string;
27
30
  };
28
31
  };
@@ -89,13 +92,24 @@ export interface UploadRequestResponse {
89
92
  uploadUrl: string;
90
93
  };
91
94
  }
95
+ export interface DownloadUrlResponse {
96
+ data: {
97
+ downloadUrl: string;
98
+ };
99
+ }
92
100
  export interface AttachedFile {
93
101
  fileKey: string;
94
102
  fileName: string;
95
103
  fileSize: number;
96
104
  fileType: string;
97
105
  iv?: string;
98
- encryptedKey?: string;
106
+ /**
107
+ * E2E: the file AES key wrapped once per recipient device, keyed by
108
+ * `deviceId`. Each value is a JSON string `{ encryptedKey, keyIv }`.
109
+ * (The superseded account-wide `encryptedKey` field is not written here —
110
+ * nothing can unwrap it since key escrow was removed.)
111
+ */
112
+ deviceKeyMap?: Record<string, string>;
99
113
  }
100
114
  export interface ToolError {
101
115
  error: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/mcp-server",
3
- "version": "1.16.0",
3
+ "version": "2.1.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",