@zeph-to/mcp-server 2.1.0 → 2.3.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
 
@@ -184,7 +187,9 @@ Returns: `{ actionId: "yes", timedOut: false }`
184
187
 
185
188
  ### zeph_ask
186
189
 
187
- 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. Blocks until response or timeout.
190
+ Ask the user a question with quick-reply buttons and a text input field. Combines prompt (buttons) and input (text) in a single notification. Blocks until response or timeout.
191
+
192
+ `actions` is the steering surface: pass 2–4 buttons on nearly every ask (the next-step candidates plus a safe Done-like `fallback`) and leave it out only when the answer is inherently free-form text — a bare text box on a "done — what next?" ask gives the phone nothing to tap.
188
193
 
189
194
  Requires `ZEPH_HOOK_ID`.
190
195
 
@@ -339,12 +344,12 @@ Create an API key with the **MCP** preset in Settings > API Keys for the correct
339
344
 
340
345
  ## Encryption
341
346
 
342
- 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.
347
+ 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.
343
348
 
344
- 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.**
349
+ 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.
345
350
 
346
351
  **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:
347
- - **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).
352
+ - **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).
348
353
  - **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.
349
354
  - **`senderPublicKey` is unsigned**, so a swapped one makes a push undecryptable — that direction fails closed rather than leaking.
350
355
 
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;
@@ -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,SA+H7H,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
@@ -8,6 +8,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
10
  const response_files_js_1 = require("../response-files.js");
11
+ const remote_state_js_1 = require("../remote-state.js");
11
12
  // The device feed shows a short preview of the body. Anything longer than
12
13
  // this gets truncated there, so we attach the full text as a file — the
13
14
  // user can always open the complete content instead of squinting at a
@@ -21,9 +22,31 @@ const buildAskMarkdown = (title, body, actions) => {
21
22
  }
22
23
  return parts.join('\n');
23
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
+ };
24
47
  const registerAskTool = (server, client, config, waiter) => {
25
48
  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. 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.',
49
+ description: 'Ask the user a question with 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. `actions` is the steering surface, not decoration: pass 2–4 buttons on nearly every ask — the next-step candidates you would otherwise write as prose (next command, review, stop) plus a safe Done-like `fallback`. Leave `actions` out ONLY when the answer is inherently free-form text (a name, a path, a paragraph); a bare text box on a "done — what next?" ask leaves the phone with nothing to tap. 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
50
  annotations: {
28
51
  readOnlyHint: false,
29
52
  destructiveHint: false,
@@ -42,7 +65,7 @@ const registerAskTool = (server, client, config, waiter) => {
42
65
  .min(1)
43
66
  .max(4)
44
67
  .optional()
45
- .describe('Quick-reply buttons (1-4). Omit for text-only input'),
68
+ .describe('Quick-reply buttons (1-4). Expected on nearly every ask — the phone steers by tapping, so put the next-step candidates here (next command, review, stop) plus a Done-like fallback. Omit ONLY when the answer is inherently free-form text; never omit on a "done — what next?" ask.'),
46
69
  placeholder: zod_1.z.string().optional().describe('Input field placeholder hint'),
47
70
  inputType: zod_1.z
48
71
  .enum(['text', 'multiline'])
@@ -108,8 +131,13 @@ const registerAskTool = (server, client, config, waiter) => {
108
131
  });
109
132
  const event = await (0, poll_js_1.pollForResponse)(client, config.hookId, trigger.data.eventId, timeout, ctx, waiter);
110
133
  if (!event) {
111
- if (fallback)
112
- 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
+ }
113
141
  return (0, error_format_js_1.timeoutError)(timeout, 'Try again or use zeph_notify for one-way communication');
114
142
  }
115
143
  const response = event.data.response;
@@ -118,9 +146,19 @@ const registerAskTool = (server, client, config, waiter) => {
118
146
  // silently for the one caller that never looks at `value`.
119
147
  const attachments = await (0, response_files_js_1.saveResponseFiles)(client, trigger.data.eventId, response?.files);
120
148
  if (response?.actionId) {
121
- return (0, error_format_js_1.textResult)({ actionId: response.actionId, timedOut: false, ...(0, response_files_js_1.attachmentNote)(attachments) });
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
+ });
122
155
  }
123
- return (0, error_format_js_1.textResult)({ value: response?.value ?? '', timedOut: false, ...(0, response_files_js_1.attachmentNote)(attachments) });
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
+ });
124
162
  }
125
163
  catch (err) {
126
164
  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": "2.1.0",
3
+ "version": "2.3.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",