Skip to main content
Glama
zeph-to

@zeph-to/mcp-server

by zeph-to
README.md
# @zeph-to/mcp-server

[![npm](https://img.shields.io/npm/v/@zeph-to/mcp-server.svg)](https://www.npmjs.com/package/@zeph-to/mcp-server)
[![downloads](https://img.shields.io/npm/dm/@zeph-to/mcp-server.svg)](https://www.npmjs.com/package/@zeph-to/mcp-server)
[![node](https://img.shields.io/node/v/@zeph-to/mcp-server.svg)](https://nodejs.org)
[![license](https://img.shields.io/npm/l/@zeph-to/mcp-server.svg)](./LICENSE)
[![docs](https://img.shields.io/badge/docs-docs.zeph.to-1f6feb)](https://docs.zeph.to)

**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.**

Zeph's MCP server is the agent side of that round trip — plus one-way notifications, clipboard, files, and channel broadcasts, all over the [Model Context Protocol](https://modelcontextprotocol.io). Works with Claude Code, Cursor, Windsurf, Gemini CLI, and any MCP client.

<p align="center">
  <img src="https://zeph.to/readme/demo.gif" alt="Agent calls zeph_ask; the question hits the phone; you tap Deploy; the tool call returns" width="560"><br>
  <sub><em>Your agent calls <code>zeph_ask</code> → the question hits your phone → you tap <b>Deploy</b> → the call returns and the agent ships.</em></sub>
</p>

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.

> **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.

## Setup

The easiest way to set up for all agents at once:

```bash
npm install -g @zeph-to/cli
zeph install
```

This saves credentials to `~/.zeph/config.json` and configures your agents automatically. The MCP server reads from this file — no env vars needed. Install globally so `zeph cc` (phone-driven sessions) works and hooks skip an npx cold-start; `npx @zeph-to/cli install` is a notifications-only alternative.

### Claude Code (manual)

Add to `~/.claude/settings.json`:

```json
{
  "mcpServers": {
    "zeph": {
      "command": "npx",
      "args": ["-y", "@zeph-to/mcp-server"]
    }
  }
}
```

No `env` block needed: credentials come from `~/.zeph/config.json` (written
by `zeph install`). Add env vars only to override the file —
e.g. a second account:

```json
      "env": { "ZEPH_API_KEY": "ak_other_account" }
```

### Cursor / Other MCP Clients

```json
{
  "command": "npx",
  "args": ["-y", "@zeph-to/mcp-server"]
}
```

## Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `ZEPH_API_KEY` | Yes* | API key from Settings > API Keys |
| `ZEPH_HOOK_ID` | No | Hook ID (optional — only needed for interactive tools like `zeph_ask`/`zeph_prompt`/`zeph_input`) |
| `ZEPH_DEVICE_ID` | No | Target device ID (optional — only needed for interactive tools like `zeph_ask`/`zeph_prompt`/`zeph_input`). Omit to send to all devices |
| `ZEPH_BASE_URL` | No | API base URL (default: `https://api.zeph.to/v1`) |
| `ZEPH_WS_URL` | No | **Deprecated.** WebSocket endpoint for the hook-response fast path. `wsUrl` in `~/.zeph/config.json` wins over it and is where the value belongs; this is read only when the file has none, so a machine that predates the config field keeps working. It will stop being read |
| `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). |
| `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 |
| `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)) |

\* If env vars are not set, the server reads from `~/.zeph/config.json` (created by `zeph install`). Unresolved `${...}` interpolations are also treated as unset.

## Tools

Push titles are automatically prefixed with the project directory name — `myapp · Build complete` — so the phone feed stays scannable when several sessions push at once.

### zeph_notify

Send a one-way push notification. Supports optional URL (auto-switches to link type).

```
title:          "Build complete"
body:           "All 42 tests passed"
url:            "https://github.com/org/repo/actions/runs/123"  (optional)
priority:       "low" | "normal" | "high" | "urgent"
targetDeviceId: "dev_..."  (optional, overrides ZEPH_DEVICE_ID)
```

### zeph_clipboard

Copy text to the user's device clipboard.

```
text:           "npm install @zeph-to/mcp-server"
targetDeviceId: "dev_..."  (optional)
```

### zeph_list

List recent push notifications.

```
limit: 5         (1-20, default: 5)
type:  "note"    (optional filter: note, link, file, clipboard, hook)
```

Returns: `{ pushes: [...], total: 5, hasMore: true }`

### zeph_dismiss

Mark a specific push as read.

```
pushId: "push_01HX..."
```

### zeph_dismiss_all

Clear all notifications at once. No parameters.

Returns: `{ dismissed: 12, badge: 0 }`

### zeph_broadcast

Send a notification to all subscribers of a channel.

```
channelId: "ch_..."
title:     "Deploy complete"
body:      "v2.1.0 is live"
url:       "https://..."  (optional)
priority:  "normal"
```

### zeph_file

Send a file to the user's device. Either `filePath` (a file already on disk) or
`content` (text you generated) is required.

```
filePath:       "/abs/path/screenshot.png"  (images, PDFs, logs — anything on disk)
content:        "{\"status\": \"ok\"}"       (text only; requires fileName)
fileName:       "report.json"               (required with content; defaults to basename of filePath)
title:          "Build Report"              (optional, defaults to fileName)
targetDeviceId: "dev_..."                   (optional)
```

Images are delivered with their real mime type and render inline on the device.
Never base64 a binary file into `content` — pass `filePath` and the server reads
the bytes off disk.

Returns: `{ pushId: "...", fileKey: "...", fileSize: 42 }`

### zeph_session_rename

Set a custom display name for the **current** agent session, shown in the Zeph app's **Streams › Agents** list. Lets an agent label what it's working on — `"Prod deploy"`, `"Auth refactor"` — so parallel sessions are easy to tell apart on your phone. Renames the session this server runs in (resolved from the listener device id + tmux session name); the name persists until changed.

```
alias: "Prod deploy watcher"   (1-60 chars)
```

Returns: `{ renamed: true, session: "zeph-myapp", alias: "Prod deploy watcher" }`, or `{ renamed: false, reason: "..." }` when there's no active session to rename (not running inside a `zeph listener` tmux session).

### zeph_prompt

Ask the user to choose from 2-4 options. Blocks until response or timeout.

Requires `ZEPH_HOOK_ID`.

```
title:    "Deploy to production?"
body:     "3 migrations pending"
actions:  [{ id: "yes", label: "Deploy", style: "primary" },
           { id: "no",  label: "Cancel", style: "danger" }]
timeout:  120        (seconds, default: 120, max: 300)
fallback: "no"       (auto-select on timeout, optional)
```

Returns: `{ actionId: "yes", timedOut: false }`

### zeph_ask

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.

`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.

Requires `ZEPH_HOOK_ID`.

<p align="center">
  <img src="https://zeph.to/readme/ask-phone.png" alt="zeph_ask rendered on the phone: a question with tappable answer buttons and a text field" width="300">
</p>

```
title:       "What should we do?"
body:        "3 tests failed in auth module"  (optional)
actions:     [{ id: "fix", label: "Fix now", style: "primary" },
              { id: "skip", label: "Skip", style: "secondary" }]  (optional, 1-4)
placeholder: "Or type a custom response..."  (optional)
inputType:   "text" | "multiline"  (default: text)
timeout:     120    (seconds, default: 120, max: 600)
fallback:    "skip" (auto-select on timeout, optional)
```

Returns: `{ actionId: "fix", timedOut: false }` or `{ value: "custom text", timedOut: false }`

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:

```
{ value: "look at this", attachments: ["/Users/you/.zeph/attachments/hook-hevt_1/screen.png"],
  attachmentsNote: "The user attached 1 file(s) to this answer. Read each path above to see them.",
  timedOut: false }
```

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.

### zeph_input

Request free-form text input from the user. Blocks until response or timeout.

Requires `ZEPH_HOOK_ID`.

```
title:       "Commit message"
body:        "Summarize the changes"
placeholder: "feat: ..."
inputType:   "text" | "password" | "multiline"
timeout:     120    (seconds, default: 120, max: 600)
```

Returns: `{ value: "feat: add clipboard sync", timedOut: false }` — plus `attachments` when the user attached files, exactly as in `zeph_ask` above.

### Client timeouts

`zeph_ask`, `zeph_prompt`, and `zeph_input` block until the user responds, up to their `timeout` (max 600s). With `wsUrl` set in `~/.zeph/config.json` the response arrives over WebSocket the instant it's submitted; otherwise the server polls. Either way the MCP request stays open the whole time. To keep the client from giving up early, the server emits a `notifications/progress` every 5s while waiting. Clients must either set a per-request timeout above the tool's `timeout`, or reset their timeout on progress notifications. Claude Code does the latter by default.

## Resources

### zeph://devices

Lists connected devices with online status. Use to check which devices will receive notifications.

### zeph://channels

Lists channels the user owns or subscribes to. Use to find `channelId` for `zeph_broadcast`.

## Usage Guide

### When to use each tool

| Situation | Tool | Example |
|-----------|------|---------|
| Long task finished | `zeph_notify` | Build complete, test results, deploy done |
| Need a decision (buttons + optional free text) | `zeph_ask` | "Tests green. Deploy?" with a custom-instruction escape hatch |
| Decision from fixed options only | `zeph_prompt` | Choose deploy target, confirm destructive action |
| Free-form input only | `zeph_input` | Commit message, env var value, description |
| Share code/logs | `zeph_file` | Error logs, test reports, generated config |
| Share snippet | `zeph_clipboard` | API key, URL, shell command |
| Label this session | `zeph_session_rename` | Name the run "Prod deploy" so parallel sessions stay distinguishable on the phone |

### Recommended patterns

**Decision gate with an escape hatch (preferred):**
```
zeph_ask(
  title: "Tests green. Deploy to production?",
  actions: [
    { id: "deploy", label: "Deploy", style: "primary" },
    { id: "hold", label: "Hold", style: "secondary" }
  ],
  placeholder: "Or tell me what to do instead...",
  fallback: "hold"
)
```

**Task completion notification:**
```
zeph_notify(
  title: "Build complete: web app",
  body: "All 42 tests passed. Bundle size: 1.2MB (-3%)"
)
```

**Decision gate in CI/deploy flow:**
```
zeph_prompt(
  title: "Deploy to production?",
  body: "3 migrations pending. Last deploy: 2h ago.",
  actions: [
    { id: "deploy", label: "Deploy", style: "primary" },
    { id: "staging", label: "Staging only", style: "secondary" },
    { id: "cancel", label: "Cancel", style: "danger" }
  ],
  fallback: "cancel"
)
```

**Collecting user input remotely:**
```
zeph_input(
  title: "Commit message",
  body: "Changed: hooks.ts, input.ts, prompt.ts",
  placeholder: "feat: ..."
)
```

**Error alert with link:**
```
zeph_notify(
  title: "CI failed: lint errors",
  body: "2 errors in src/auth.ts",
  url: "https://github.com/org/repo/actions/runs/456",
  priority: "high"
)
```

### When NOT to use

- Short responses the user can see immediately in the terminal
- Read-only operations (file search, code analysis)
- Every single tool call — only notify on meaningful milestones

### Multi-session workflow

When running multiple AI agent sessions in parallel, use `zeph_notify` to signal completion so the user knows which session finished without checking each terminal.

## API Key Permissions

The API key needs the following scopes:

- `push:read` — for `zeph_list`
- `push:write` — for `zeph_notify`, `zeph_clipboard`, `zeph_dismiss`, `zeph_dismiss_all`, `zeph_file`
- `hook:write` — for `zeph_ask`, `zeph_prompt`, and `zeph_input`
- `device:write` — for `zeph_session_rename`
- `channel:read` — for `zeph://channels` resource

Create an API key with the **MCP** preset in Settings > API Keys for the correct permissions.

## Encryption

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.

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.

**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:
- **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).
- **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.
- **`senderPublicKey` is unsigned**, so a swapped one makes a push undecryptable — that direction fails closed rather than leaking.

A device that has not registered a per-device public key cannot be sent to; it is skipped, and if no device qualifies the push goes out in the clear rather than arriving as something nothing can open.

## License

Apache-2.0

TDQS

A3.9/5.0

Scored across 11 tools

Disambiguation4/5

Most tools are clearly distinct: notification history and dismissal tools are separate from sending and interactive-request tools. A few sending-oriented tools (notify vs broadcast, notify vs file, prompt vs input vs ask) could be confused at a glance, but the descriptions clearly define their boundaries.

Naming Consistency3/5

All tools share the zeph_ prefix and snake_case naming, which provides a consistent overall look. However, the structure is mixed: some are bare verbs (list, dismiss, notify, ask), some are nouns (clipboard, file), and one is object-verb (session_rename), so the pattern is readable but not fully predictable.

Tool Count5/5

11 tools is a well-scoped set for a device-notification and user-interaction server. Each tool has a clear role in sending, receiving, dismissing, requesting input, or managing the session, with no obvious bloat or thinness.

Completeness5/5

The tool surface covers the core domain: sending one-way notifications, broadcasting to channels, sending files, prompting for choices, collecting text input, listing/dismissing notifications, copying to clipboard, and renaming sessions. There are no obvious dead ends for typical agent workflows.

Maintenance

ActivityActive
ResponsivenessNo issues