@zeph-to/mcp-server 2.0.0 → 2.2.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
@@ -4,6 +4,7 @@
4
4
  [![downloads](https://img.shields.io/npm/dm/@zeph-to/mcp-server.svg)](https://www.npmjs.com/package/@zeph-to/mcp-server)
5
5
  [![node](https://img.shields.io/node/v/@zeph-to/mcp-server.svg)](https://nodejs.org)
6
6
  [![license](https://img.shields.io/npm/l/@zeph-to/mcp-server.svg)](./LICENSE)
7
+ [![docs](https://img.shields.io/badge/docs-docs.zeph.to-1f6feb)](https://docs.zeph.to)
7
8
 
8
9
  **Your agent calls `zeph_ask`; the question lands on your phone as buttons + a text field; your reply comes back into the same tool call and the agent keeps going.**
9
10
 
@@ -16,6 +17,8 @@ Zeph's MCP server is the agent side of that round trip — plus one-way notifica
16
17
 
17
18
  Part of the Zeph toolchain: [`@zeph-to/cli`](https://github.com/zeph-to/cli) (installer, push CLI, tmux remote control) · [`zeph-to/plugin`](https://github.com/zeph-to/plugin) (Claude Code plugin bundling this server) · the [Zeph app](https://zeph.to) on your phone.
18
19
 
20
+ > **New here?** [docs.zeph.to](https://docs.zeph.to) walks the whole setup — one command on your machine, the app on your phone, and a restart. The reference below assumes that is already done.
21
+
19
22
  ## Setup
20
23
 
21
24
  The easiest way to set up for all agents at once:
@@ -70,7 +73,7 @@ e.g. a second account:
70
73
  | `ZEPH_WS_URL` | No | WebSocket endpoint for the hook-response fast path — `zeph_ask`/`zeph_prompt`/`zeph_input` answers arrive the moment the user submits them instead of on the next poll. Falls back to pure polling when unset. Also read from `wsUrl` in `~/.zeph/config.json` |
71
74
  | `ZEPH_DISABLE_SESSION_CACHE` | No | Set to `1`/`true` to skip writing the session-id handoff file under `~/.cache/zeph/`. Useful for read-only filesystems, ephemeral CI runners, or sandboxed envs that audit filesystem writes. The plugin's stop hook still works without it (transcript-path UUID extraction is the primary path; the cache is a fallback for older Claude Code versions). |
72
75
  | `ZEPH_SESSION_ID` | No | Override the session id attached to pushes (grouping in the app). Auto-detected from the newest Claude Code transcript when unset |
73
- | `ZEPH_DISABLE_ENCRYPTION` | No | Set to `1`/`true` to force E2E-style push encryption off, even when the account has keys. Useful while cleaning up legacy key state |
76
+ | `ZEPH_DISABLE_ENCRYPTION` | No | Set to `1`/`true` to force push encryption off even when the account has it enabled. A local override for debugging what the server actually received — encryption is already off unless the account opted in (see [Encryption](#encryption)) |
74
77
 
75
78
  \* If env vars are not set, the server reads from `~/.zeph/config.json` (created by `zeph install`). Unresolved `${...}` interpolations are also treated as unset.
76
79
 
@@ -205,6 +208,16 @@ fallback: "skip" (auto-select on timeout, optional)
205
208
 
206
209
  Returns: `{ actionId: "fix", timedOut: false }` or `{ value: "custom text", timedOut: false }`
207
210
 
211
+ The user can also attach screenshots or files to their answer. Those are downloaded to `~/.zeph/attachments/hook-<eventId>/` and the result gains an `attachments` array of absolute local paths, alongside the button or the text:
212
+
213
+ ```
214
+ { value: "look at this", attachments: ["/Users/you/.zeph/attachments/hook-hevt_1/screen.png"],
215
+ attachmentsNote: "The user attached 1 file(s) to this answer. Read each path above to see them.",
216
+ timedOut: false }
217
+ ```
218
+
219
+ Reading those paths is part of reading the answer. Note that hook attachments are never end-to-end encrypted — the same limitation as the question itself, since the hook route carries no sender key.
220
+
208
221
  ### zeph_input
209
222
 
210
223
  Request free-form text input from the user. Blocks until response or timeout.
@@ -219,7 +232,7 @@ inputType: "text" | "password" | "multiline"
219
232
  timeout: 120 (seconds, default: 120, max: 600)
220
233
  ```
221
234
 
222
- Returns: `{ value: "feat: add clipboard sync", timedOut: false }`
235
+ Returns: `{ value: "feat: add clipboard sync", timedOut: false }` — plus `attachments` when the user attached files, exactly as in `zeph_ask` above.
223
236
 
224
237
  ### Client timeouts
225
238
 
@@ -329,12 +342,12 @@ Create an API key with the **MCP** preset in Settings > API Keys for the correct
329
342
 
330
343
  ## Encryption
331
344
 
332
- Push bodies and file attachments are encrypted with AES-256-GCM. This server holds its own ECDH P-256 keypair, generated on first use and stored in `~/.config/zeph/device-keys.json` the private half never leaves the machine. Each push is encrypted once, and its AES key is wrapped separately for every device on your account using ECDH against that device's public key.
345
+ End-to-end encryption is **off by default** and turning it on needs Zeph Pro. The switch is in the app under Settings → E2E Encryption; until you flip it, every push leaves this server in plaintext. If the account later loses Pro the server answers `PRO_REQUIRED` and this one drops back to plaintext for the rest of the process. No configuration either way but the opt-in is read **once at startup**, so turning it on while this server is running takes effect only after a restart.
333
346
 
334
- Toggle encryption in the Zeph app (Settings Encryption); when it is off, pushes go out as plaintext. No configuration needed. The opt-in is read once at startup, so **turning it on while this server is running takes effect only after a restart.**
347
+ With it on, push bodies and file attachments are encrypted with AES-256-GCM. This server holds its own ECDH P-256 keypair, generated on first use and stored in `~/.config/zeph/device-keys.json` the private half never leaves the machine, and the backend stores public keys only and rejects a private-key upload. Each push is encrypted once, and its AES key is wrapped separately for every device on your account using ECDH against that device's public key.
335
348
 
336
349
  **Threat model:** against a passive backend — a leaked snapshot, an operator reading the table — the stored ciphertext and wrapped keys are useless, so push contents stay private. Three limits worth knowing:
337
- - **No protection from an active malicious operator.** Recipient public keys come from `GET /devices` on that same server, unsigned and unpinned. A backend that injects a device record carrying its own key gets the message key wrapped for it, and reads everything. Closing this needs out-of-band device verification (ADR-0007 Phase 4, not built).
350
+ - **No protection from an active malicious operator.** Recipient public keys come from `GET /devices` on that same server, unsigned and unpinned. A backend that injects a device record carrying its own key gets the message key wrapped for it, and reads everything. The Zeph app ships the counter-measure — compare device fingerprints, mark a device verified, and strict mode then wraps only for verified devices — but it defaults off, its verified list is per browser profile, and this server does not consult it: `selectRecipients` asks only whether a device has a public key, and whether that key is the legacy account-wide one (ADR-0007 Phase 4).
338
351
  - **No forward secrecy.** The ECDH secret for a given sender/device pair is static, so compromising either private key opens every past push wrapped for that pair.
339
352
  - **`senderPublicKey` is unsigned**, so a swapped one makes a push undecryptable — that direction fails closed rather than leaking.
340
353
 
@@ -57,6 +57,12 @@ export declare class ZephApiClient {
57
57
  fileType: string;
58
58
  fileSize: number;
59
59
  }): Promise<UploadRequestResponse>;
60
+ /**
61
+ * Resolve a fileKey to its bytes. Two hops by design: the API hands back a
62
+ * presigned URL (files never travel through Lambda), and that URL is
63
+ * self-authenticating, so the second fetch carries no API key.
64
+ */
65
+ downloadFile(fileKey: string): Promise<Uint8Array>;
60
66
  uploadToS3(url: string, content: string | Buffer, contentType: string): Promise<void>;
61
67
  private request;
62
68
  }
@@ -1 +1 @@
1
- {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
@@ -59,6 +59,22 @@ class ZephApiClient {
59
59
  async requestUpload(params) {
60
60
  return this.request('POST', '/files/upload-request', params);
61
61
  }
62
+ /**
63
+ * Resolve a fileKey to its bytes. Two hops by design: the API hands back a
64
+ * presigned URL (files never travel through Lambda), and that URL is
65
+ * self-authenticating, so the second fetch carries no API key.
66
+ */
67
+ async downloadFile(fileKey) {
68
+ const meta = await this.request('GET', `/files/${encodeURIComponent(fileKey)}`);
69
+ const downloadUrl = meta.data?.downloadUrl;
70
+ if (!downloadUrl)
71
+ throw new ApiError('Download URL missing', 'DOWNLOAD_FAILED', 502);
72
+ const response = await fetch(downloadUrl, { signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) });
73
+ if (!response.ok) {
74
+ throw new ApiError(`File download failed with status ${response.status}`, 'DOWNLOAD_FAILED', response.status);
75
+ }
76
+ return new Uint8Array(await response.arrayBuffer());
77
+ }
62
78
  async uploadToS3(url, content, contentType) {
63
79
  const isText = typeof content === 'string';
64
80
  const body = isText ? content : new Uint8Array(content);
package/dist/config.d.ts CHANGED
@@ -16,6 +16,15 @@ export interface McpServerConfig {
16
16
  /** Last path segment of the project directory — prefixed onto push titles. */
17
17
  projectName: string;
18
18
  }
19
+ /** The project directory the agent runs in, across supported agents. */
20
+ /**
21
+ * The key every per-project zeph file is named by. Shelling out to `cksum`
22
+ * (rather than a pure-JS CRC) is what guarantees it matches the files the bash
23
+ * hooks have already written — they key off `printf '%s' "$dir" | cksum`. Null
24
+ * when `cksum` is unavailable, which means no per-project file can be resolved.
25
+ */
26
+ export declare const projectHash: (dir: string) => string | null;
27
+ export declare const detectProjectDir: () => string;
19
28
  /**
20
29
  * Prefix a push title with the project name so the device feed stays
21
30
  * scannable — "zeph · Build finished" instead of a bare "Build finished".
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,eAAe;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB;0EACsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;uCAGmC;IACnC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,8EAA8E;IAC9E,WAAW,EAAE,MAAM,CAAC;CACvB;AAiBD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,aAAa,MAAM,EAAE,OAAO,MAAM,KAAG,MAGpE,CAAC;AAuGF,eAAO,MAAM,UAAU,QAAO,eAiC7B,CAAC;AA4BF;;;;;sEAKsE;AACtE,eAAO,MAAM,gBAAgB,QAAO,MAQnC,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,eAAe;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB;0EACsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;uCAGmC;IACnC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,8EAA8E;IAC9E,WAAW,EAAE,MAAM,CAAC;CACvB;AAID,wEAAwE;AACxE;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAMlD,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAO,MAMnC,CAAC;AAMF;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,aAAa,MAAM,EAAE,OAAO,MAAM,KAAG,MAGpE,CAAC;AAwGF,eAAO,MAAM,UAAU,QAAO,eAiC7B,CAAC;AA4BF;;;;;sEAKsE;AACtE,eAAO,MAAM,gBAAgB,QAAO,MAQnC,CAAC"}
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.listenerDeviceId = exports.loadConfig = exports.formatPushTitle = void 0;
3
+ exports.listenerDeviceId = exports.loadConfig = exports.formatPushTitle = exports.detectProjectDir = exports.projectHash = void 0;
4
4
  const fs_1 = require("fs");
5
5
  const os_1 = require("os");
6
6
  const crypto_1 = require("crypto");
@@ -9,6 +9,21 @@ const path_1 = require("path");
9
9
  const DEFAULT_BASE_URL = 'https://api.zeph.to/v1';
10
10
  const PROJECT_DIR_ENV_KEYS = ['CLAUDE_PROJECT_DIR', 'CURSOR_PROJECT_DIR', 'WINDSURF_PROJECT_DIR'];
11
11
  /** The project directory the agent runs in, across supported agents. */
12
+ /**
13
+ * The key every per-project zeph file is named by. Shelling out to `cksum`
14
+ * (rather than a pure-JS CRC) is what guarantees it matches the files the bash
15
+ * hooks have already written — they key off `printf '%s' "$dir" | cksum`. Null
16
+ * when `cksum` is unavailable, which means no per-project file can be resolved.
17
+ */
18
+ const projectHash = (dir) => {
19
+ try {
20
+ return (0, child_process_1.execFileSync)('cksum', { input: dir, encoding: 'utf-8' }).split(' ')[0] || null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ };
26
+ exports.projectHash = projectHash;
12
27
  const detectProjectDir = () => {
13
28
  for (const key of PROJECT_DIR_ENV_KEYS) {
14
29
  const val = process.env[key];
@@ -17,6 +32,7 @@ const detectProjectDir = () => {
17
32
  }
18
33
  return process.cwd();
19
34
  };
35
+ exports.detectProjectDir = detectProjectDir;
20
36
  /** Last path segment of a directory: "/Users/me/code/zeph" -> "zeph". */
21
37
  const projectNameFromDir = (dir) => dir.split('/').filter(Boolean).pop() ?? 'project';
22
38
  /**
@@ -106,7 +122,9 @@ const writeSessionCache = (sessionId, projectDir) => {
106
122
  if (envIsTrue('ZEPH_DISABLE_SESSION_CACHE'))
107
123
  return;
108
124
  try {
109
- const hash = (0, child_process_1.execFileSync)('cksum', { input: projectDir, encoding: 'utf-8' }).split(' ')[0];
125
+ const hash = (0, exports.projectHash)(projectDir);
126
+ if (!hash)
127
+ return;
110
128
  const cacheDir = (0, path_1.join)(process.env.XDG_CACHE_HOME ?? (0, path_1.join)((0, os_1.homedir)(), '.cache'), 'zeph');
111
129
  (0, fs_1.mkdirSync)(cacheDir, { recursive: true, mode: 0o700 });
112
130
  const cachePath = (0, path_1.join)(cacheDir, `session-${hash}`);
@@ -130,7 +148,7 @@ const loadConfig = () => {
130
148
  if (!apiKey) {
131
149
  throw new Error('ZEPH_API_KEY not found. Run "npx @zeph-to/cli install" or set ZEPH_API_KEY env var.');
132
150
  }
133
- const projectDir = detectProjectDir();
151
+ const projectDir = (0, exports.detectProjectDir)();
134
152
  // Claude Code names the running session outright; take it over the
135
153
  // transcript scan, which picks the newest file in the project directory and
136
154
  // so returns a sibling agent's id whenever two run in one project — the
@@ -0,0 +1,34 @@
1
+ /**
2
+ * True when this button ends the session. Case-insensitive by contract — the
3
+ * rules have always said so, but until now nothing enforced it, so a skill
4
+ * emitting `Done` would have read as a non-exit answer and left the user in
5
+ * REMOTE for the whole TTL.
6
+ */
7
+ export declare const isSessionExitId: (actionId: string) => boolean;
8
+ /**
9
+ * What a `zeph_ask` outcome does to the mode.
10
+ *
11
+ * - **exit** — a Done-like id, whether the user tapped it or a timeout
12
+ * resolved to it. This is the one case a fallback ends the session.
13
+ * - **enter** — the user answered with anything else: another button, or free
14
+ * text (no id at all). Free text the server cannot judge — "thanks, that's
15
+ * it" is a meaning call that stays with the model — so it counts as staying.
16
+ * - **keep** — the ask timed out onto a non-exit fallback. Rule 5 recommends
17
+ * `wait`/`review` there, and ask.ts returns the fallback id verbatim, so
18
+ * reading it as an exit would drop a user out of REMOTE over an ask they
19
+ * simply had not answered yet. Nor is it an entry: a timeout is not a user
20
+ * action, and a NORMAL session must not become remote because nobody
21
+ * replied. The mode is left exactly as it was.
22
+ */
23
+ export type RemoteTransition = 'enter' | 'exit' | 'keep';
24
+ export declare const remoteTransitionFor: (answer: {
25
+ actionId?: string;
26
+ timedOut: boolean;
27
+ }) => RemoteTransition;
28
+ /** `<stateDir>/remote-active-<projectHash>`, or null when the key can't be built. */
29
+ export declare const remoteStatePath: () => string | null;
30
+ /** Enter REMOTE, or push its expiry back. Best-effort: never throws. */
31
+ export declare const enterRemote: () => void;
32
+ /** Leave REMOTE. Best-effort: never throws, and a missing file is success. */
33
+ export declare const exitRemote: () => void;
34
+ //# sourceMappingURL=remote-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-state.d.ts","sourceRoot":"","sources":["../src/remote-state.ts"],"names":[],"mappings":"AA8BA;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,UAAU,MAAM,KAAG,OAC8B,CAAC;AAElF;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;AAEzD,eAAO,MAAM,mBAAmB,GAC9B,QAAQ;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,KAC/C,gBAGF,CAAC;AAKF,qFAAqF;AACrF,eAAO,MAAM,eAAe,QAAO,MAAM,GAAG,IAG3C,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,WAAW,QAAO,IAS9B,CAAC;AAEF,8EAA8E;AAC9E,eAAO,MAAM,UAAU,QAAO,IAQ7B,CAAC"}
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.exitRemote = exports.enterRemote = exports.remoteStatePath = exports.remoteTransitionFor = exports.isSessionExitId = void 0;
4
+ /**
5
+ * Sticky REMOTE state — the server's half.
6
+ *
7
+ * REMOTE used to have nowhere to live: the model re-derived it every turn by
8
+ * rescanning the conversation. It now lives in a file, and this module is one
9
+ * of the two places that owns a transition. The other is the plugin's
10
+ * UserPromptSubmit hook, which enters REMOTE when a prompt matches a
11
+ * phone-injection marker; this module handles the transitions the server
12
+ * already knows about — a `zeph_ask` that came back answered.
13
+ *
14
+ * **Three implementations share the file format** — plugin/hooks/gate.sh
15
+ * (`zeph_remote_active` / `zeph_remote_touch`), cli/src/gate.ts
16
+ * (`isRemoteActive` / `touchRemoteActive`), and this file. Nothing checks them
17
+ * against each other at build time, so the format is pinned by literal
18
+ * assertions in remote-state.test.ts. The bash reader sweeps any non-numeric
19
+ * body on sight (`case "$ts" in *[!0-9]*)`), which means a JSON or ISO-8601
20
+ * body written here would enter REMOTE and be deleted by the very next prompt
21
+ * hook — silently, and invisibly from either repo's tests.
22
+ *
23
+ * There is no TTL logic here on purpose: expiry is the readers' job, and
24
+ * duplicating the window would give it two definitions.
25
+ */
26
+ const fs_1 = require("fs");
27
+ const os_1 = require("os");
28
+ const path_1 = require("path");
29
+ const config_js_1 = require("./config.js");
30
+ /** Action ids that end a remote session (CORE_RULES: case-insensitive). */
31
+ const SESSION_EXIT_IDS = ['done', 'stop', 'exit'];
32
+ /**
33
+ * True when this button ends the session. Case-insensitive by contract — the
34
+ * rules have always said so, but until now nothing enforced it, so a skill
35
+ * emitting `Done` would have read as a non-exit answer and left the user in
36
+ * REMOTE for the whole TTL.
37
+ */
38
+ const isSessionExitId = (actionId) => SESSION_EXIT_IDS.includes(actionId.trim().toLowerCase());
39
+ exports.isSessionExitId = isSessionExitId;
40
+ const remoteTransitionFor = (answer) => {
41
+ if (answer.actionId !== undefined && (0, exports.isSessionExitId)(answer.actionId))
42
+ return 'exit';
43
+ return answer.timedOut ? 'keep' : 'enter';
44
+ };
45
+ exports.remoteTransitionFor = remoteTransitionFor;
46
+ const stateDir = () => (0, path_1.join)(process.env.XDG_STATE_HOME || (0, path_1.join)((0, os_1.homedir)(), '.local', 'state'), 'zeph');
47
+ /** `<stateDir>/remote-active-<projectHash>`, or null when the key can't be built. */
48
+ const remoteStatePath = () => {
49
+ const hash = (0, config_js_1.projectHash)((0, config_js_1.detectProjectDir)());
50
+ return hash ? (0, path_1.join)(stateDir(), `remote-active-${hash}`) : null;
51
+ };
52
+ exports.remoteStatePath = remoteStatePath;
53
+ /** Enter REMOTE, or push its expiry back. Best-effort: never throws. */
54
+ const enterRemote = () => {
55
+ const file = (0, exports.remoteStatePath)();
56
+ if (!file)
57
+ return;
58
+ try {
59
+ (0, fs_1.mkdirSync)(stateDir(), { recursive: true });
60
+ (0, fs_1.writeFileSync)(file, `${Math.floor(Date.now() / 1000)}\n`);
61
+ }
62
+ catch {
63
+ /* an answered ask is still an answer — state IO must not reach the caller */
64
+ }
65
+ };
66
+ exports.enterRemote = enterRemote;
67
+ /** Leave REMOTE. Best-effort: never throws, and a missing file is success. */
68
+ const exitRemote = () => {
69
+ const file = (0, exports.remoteStatePath)();
70
+ if (!file)
71
+ return;
72
+ try {
73
+ (0, fs_1.unlinkSync)(file);
74
+ }
75
+ catch {
76
+ /* already gone, or unwritable — either way the caller has an answer */
77
+ }
78
+ };
79
+ exports.exitRemote = exitRemote;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Saving the files a user attached to a hook answer.
3
+ *
4
+ * `zeph_ask` and `zeph_input` hand the agent a JSON result, and an agent
5
+ * cannot look at an S3 key — so an answered screenshot only becomes useful
6
+ * once it is a path on this machine. This module downloads each attachment
7
+ * to `~/.zeph/attachments/hook-<eventId>/` and returns the absolute paths,
8
+ * which the tool then names in its result for the agent to read.
9
+ *
10
+ * The files are plaintext by contract: the hook route carries no sender key,
11
+ * so the server rejects an encrypted attachment on a response rather than
12
+ * let one arrive here as bytes nothing can open.
13
+ *
14
+ * Failure is per file, never fatal. A question that was answered has been
15
+ * answered; losing one image must not turn that into a tool error and make
16
+ * the user answer again.
17
+ */
18
+ import type { ZephApiClient } from './api-client.js';
19
+ import type { AttachedFile } from './types.js';
20
+ /**
21
+ * The part of a tool result that tells the agent about saved attachments.
22
+ * Spread into the result object; empty when nothing was attached, so a plain
23
+ * answer keeps the shape it has always had.
24
+ *
25
+ * The instruction is explicit because a bare list of paths in a JSON result
26
+ * reads as metadata — the agent has to be told these are the user's answer
27
+ * and that opening them is part of reading it.
28
+ */
29
+ export declare const attachmentNote: (paths: string[]) => Record<string, unknown>;
30
+ export interface SaveResponseFilesDeps {
31
+ /** Test seam for the two-step fetch (metadata → presigned URL → bytes). */
32
+ fetchBytes?: (fileKey: string) => Promise<Uint8Array>;
33
+ dir?: string;
34
+ }
35
+ export declare const saveResponseFiles: (client: ZephApiClient, eventId: string, files: AttachedFile[] | undefined, deps?: SaveResponseFilesDeps) => Promise<string[]>;
36
+ //# sourceMappingURL=response-files.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response-files.d.ts","sourceRoot":"","sources":["../src/response-files.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAmB/C;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,GAAI,OAAO,MAAM,EAAE,KAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAM/D,CAAC;AAET,MAAM,WAAW,qBAAqB;IACpC,2EAA2E;IAC3E,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACtD,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,eAAO,MAAM,iBAAiB,GAC5B,QAAQ,aAAa,EACrB,SAAS,MAAM,EACf,OAAO,YAAY,EAAE,GAAG,SAAS,EACjC,OAAM,qBAA0B,KAC/B,OAAO,CAAC,MAAM,EAAE,CAiBlB,CAAC"}
@@ -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;AAuBxD,eAAO,MAAM,eAAe,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,EAAE,SAAS,kBAAkB,SAyH7H,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;AAkDxD,eAAO,MAAM,eAAe,GAAI,QAAQ,SAAS,EAAE,QAAQ,aAAa,EAAE,QAAQ,eAAe,EAAE,SAAS,kBAAkB,SA+I7H,CAAC"}
package/dist/tools/ask.js CHANGED
@@ -7,6 +7,8 @@ const poll_js_1 = require("../poll.js");
7
7
  const config_js_1 = require("../config.js");
8
8
  const mime_js_1 = require("../mime.js");
9
9
  const sanitize_js_1 = require("../sanitize.js");
10
+ const response_files_js_1 = require("../response-files.js");
11
+ const remote_state_js_1 = require("../remote-state.js");
10
12
  // The device feed shows a short preview of the body. Anything longer than
11
13
  // this gets truncated there, so we attach the full text as a file — the
12
14
  // user can always open the complete content instead of squinting at a
@@ -20,9 +22,31 @@ const buildAskMarkdown = (title, body, actions) => {
20
22
  }
21
23
  return parts.join('\n');
22
24
  };
25
+ /**
26
+ * Apply the mode transition this answer implies, and tell the model where it
27
+ * landed. The state itself lives in a file the prompt hooks also read, so the
28
+ * two carriers agree without either re-deriving REMOTE from the transcript.
29
+ *
30
+ * `zephState` is reported only when the server actually decided something. A
31
+ * timeout onto a safe fallback changes nothing, and naming a state there would
32
+ * mean re-reading the file — a fourth parser of a format three already share,
33
+ * to answer a question the server was not asked.
34
+ */
35
+ const settleRemoteState = (answer) => {
36
+ switch ((0, remote_state_js_1.remoteTransitionFor)(answer)) {
37
+ case 'exit':
38
+ (0, remote_state_js_1.exitRemote)();
39
+ return { zephState: 'NORMAL' };
40
+ case 'enter':
41
+ (0, remote_state_js_1.enterRemote)();
42
+ return { zephState: 'REMOTE' };
43
+ case 'keep':
44
+ return {};
45
+ }
46
+ };
23
47
  const registerAskTool = (server, client, config, waiter) => {
24
48
  server.registerTool('zeph_ask', {
25
- 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. 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.',
49
+ 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.',
26
50
  annotations: {
27
51
  readOnlyHint: false,
28
52
  destructiveHint: false,
@@ -107,14 +131,34 @@ const registerAskTool = (server, client, config, waiter) => {
107
131
  });
108
132
  const event = await (0, poll_js_1.pollForResponse)(client, config.hookId, trigger.data.eventId, timeout, ctx, waiter);
109
133
  if (!event) {
110
- if (fallback)
111
- return (0, error_format_js_1.textResult)({ actionId: fallback, timedOut: true });
134
+ if (fallback) {
135
+ return (0, error_format_js_1.textResult)({
136
+ actionId: fallback,
137
+ timedOut: true,
138
+ ...settleRemoteState({ actionId: fallback, timedOut: true }),
139
+ });
140
+ }
112
141
  return (0, error_format_js_1.timeoutError)(timeout, 'Try again or use zeph_notify for one-way communication');
113
142
  }
114
143
  const response = event.data.response;
115
- if (response?.actionId)
116
- return (0, error_format_js_1.textResult)({ actionId: response.actionId, timedOut: false });
117
- return (0, error_format_js_1.textResult)({ value: response?.value ?? '', timedOut: false });
144
+ // Saved before the button branch returns: a user can tap a quick reply
145
+ // AND attach a screenshot, and dropping the files there would lose them
146
+ // silently for the one caller that never looks at `value`.
147
+ const attachments = await (0, response_files_js_1.saveResponseFiles)(client, trigger.data.eventId, response?.files);
148
+ if (response?.actionId) {
149
+ return (0, error_format_js_1.textResult)({
150
+ actionId: response.actionId,
151
+ timedOut: false,
152
+ ...settleRemoteState({ actionId: response.actionId, timedOut: false }),
153
+ ...(0, response_files_js_1.attachmentNote)(attachments),
154
+ });
155
+ }
156
+ return (0, error_format_js_1.textResult)({
157
+ value: response?.value ?? '',
158
+ timedOut: false,
159
+ ...settleRemoteState({ timedOut: false }),
160
+ ...(0, response_files_js_1.attachmentNote)(attachments),
161
+ });
118
162
  }
119
163
  catch (err) {
120
164
  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);
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,6 +92,11 @@ 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;
@@ -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":"AAAA,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE;QACL,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE;QACJ,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE;QACJ,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;CACH;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE;QACJ,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,SAAS,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;QAC5D,QAAQ,CAAC,EAAE;YACT,QAAQ,CAAC,EAAE,MAAM,CAAC;YAClB,KAAK,CAAC,EAAE,MAAM,CAAC;YACf;mFACuE;YACvE,KAAK,CAAC,EAAE,YAAY,EAAE,CAAC;YACvB,iBAAiB,CAAC,EAAE,MAAM,CAAC;SAC5B,CAAC;KACH,CAAC;CACH;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,YAAY,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,UAAU,EAAE,CAAC;IACnB,UAAU,EAAE;QACV,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,OAAO,CAAC;KAClB,CAAC;CACH;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE;QACJ,SAAS,EAAE,OAAO,GAAG,MAAM,CAAC;QAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,aAAa,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE;QACJ,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;CACH;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE;QACJ,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;CACH;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/mcp-server",
3
- "version": "2.0.0",
3
+ "version": "2.2.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",