@pergy-ai/mcp 0.1.0 → 0.2.1

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,21 +4,20 @@ A voice inbox for your AI agents. This MCP server lets an agent **notify a user*
4
4
 
5
5
  ## Install
6
6
 
7
- No clone needed run it with `npx`:
7
+ No clone needed. Add it to Claude Code (`-s user` = available in every project; drop it for just the current one):
8
8
 
9
9
  ```bash
10
- npx @pergy-ai/mcp
10
+ claude mcp add pergy -s user -- npx -y @pergy-ai/mcp
11
11
  ```
12
12
 
13
- Or wire it into an MCP client config:
13
+ Or wire it into any MCP client config:
14
14
 
15
15
  ```json
16
16
  {
17
17
  "mcpServers": {
18
18
  "pergy": {
19
19
  "command": "npx",
20
- "args": ["-y", "@pergy-ai/mcp"],
21
- "env": { "PERGY_BACKEND_URL": "https://pergy.ai" }
20
+ "args": ["-y", "@pergy-ai/mcp"]
22
21
  }
23
22
  }
24
23
  }
@@ -30,10 +29,14 @@ First-time pairing (link the server to your Pergy account):
30
29
  npx -p @pergy-ai/mcp pergy-mcp-onboard
31
30
  ```
32
31
 
32
+ It talks to the hosted backend by default — no config needed. Set
33
+ `PERGY_BACKEND_URL=http://localhost:3000` only for local development.
34
+
33
35
  ## Tools
34
36
 
35
- - **`notify_user`** — notify the user (three standalone context tiers: short / medium / long, plus optional options, visuals, `urgency`, and `parentId` for a clarification). Returns a request id + thread id.
36
- - **`await_task`** — wait for the user's next reply (`reply` / `remind` / `idle`).
37
+ - **`notify_user`** — notify the user (three standalone context tiers: short / medium / long, plus optional options — each an answerable choice that can carry a sandboxed `html` or `image` preview for visual "pick one" decisions — visuals, `urgency`, and `parentId` for a clarification). Returns a request id + thread id.
38
+ - **`await_reply`** — wait for the user's reply to a specific notification (pass its id). Scoped: will not return replies meant for other notifications. Returns `reply` / `remind` / `idle`.
39
+ - **`check_replies`** — catch-up sweep: returns replies you haven't consumed yet (now marked seen) plus still-pending notifications.
37
40
  - **`poll_answer`** — fetch the answer to a specific request by id.
38
41
  - **`set_task_state`** — report progress on a request: `in_progress` / `completed` / `needs_input`.
39
42
 
package/dist/index.js CHANGED
@@ -18,7 +18,15 @@ var ContextTiersSchema = z.object({
18
18
  });
19
19
  var OptionSchema = z.object({
20
20
  id: z.string(),
21
- label: z.string()
21
+ label: z.string(),
22
+ // .describe() flows into the MCP notify_user JSON schema (zodToJsonSchema), so
23
+ // the constraints below are what an agent reads when deciding to use these.
24
+ html: z.string().max(16384).describe(
25
+ "Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
26
+ ).optional(),
27
+ image: z.string().url().describe(
28
+ "Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
29
+ ).optional()
22
30
  });
23
31
  var VisualSchema = z.object({
24
32
  url: z.string().url(),
@@ -71,6 +79,14 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
71
79
  }),
72
80
  z.object({ type: z.literal("idle") })
73
81
  ]);
82
+ var PendingRepliesSchema = z.object({
83
+ replies: z.array(
84
+ z.object({ threadId: z.string(), notificationId: z.string(), answer: UserAnswerSchema })
85
+ ),
86
+ pending: z.array(
87
+ z.object({ threadId: z.string(), notificationId: z.string(), createdAt: z.string() })
88
+ )
89
+ });
74
90
  var NotifyResponseSchema = z.object({
75
91
  id: z.string(),
76
92
  status: NotifyStatusSchema,
@@ -85,6 +101,7 @@ var UserResponseSchema = z.object({
85
101
  });
86
102
  var InboxItemSchema = z.object({
87
103
  id: z.string(),
104
+ status: NotifyStatusSchema,
88
105
  context: ContextTiersSchema,
89
106
  options: z.array(OptionSchema).optional(),
90
107
  visuals: z.array(VisualSchema).optional(),
@@ -101,6 +118,13 @@ var SnoozeRequestSchema = z.object({
101
118
  requestId: z.string(),
102
119
  until: z.string().datetime()
103
120
  });
121
+ var PushTokenSchema = z.object({
122
+ voipToken: z.string().min(1),
123
+ platform: z.enum(["ios"])
124
+ });
125
+ var UserSettingsSchema = z.object({
126
+ callsEnabled: z.boolean()
127
+ });
104
128
  var DeviceAgentTokenSchema = z.object({
105
129
  token: z.string(),
106
130
  deviceId: z.string(),
@@ -139,7 +163,7 @@ import { zodToJsonSchema } from "zod-to-json-schema";
139
163
  import { existsSync, readFileSync } from "fs";
140
164
  import { homedir } from "os";
141
165
  import { join } from "path";
142
- var BACKEND_URL = process.env.PERGY_BACKEND_URL ?? "http://localhost:3000";
166
+ var BACKEND_URL = process.env.PERGY_BACKEND_URL ?? "https://pergy.ai";
143
167
  var TOKEN_PATH = join(homedir(), ".pergy", "token.json");
144
168
  function loadToken() {
145
169
  if (process.env.PERGY_TOKEN) return process.env.PERGY_TOKEN;
@@ -164,7 +188,7 @@ async function submitNotification(req) {
164
188
  return await res.json();
165
189
  }
166
190
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
167
- async function awaitTask(opts = {}) {
191
+ async function awaitReply(notificationId, opts = {}) {
168
192
  const intervalMs = opts.intervalMs ?? 5e3;
169
193
  const windowMs = opts.windowMs ?? 3e5;
170
194
  const doSleep = opts.sleep ?? sleep;
@@ -172,7 +196,7 @@ async function awaitTask(opts = {}) {
172
196
  const token = loadToken();
173
197
  const start = now();
174
198
  while (true) {
175
- const res = await fetch(`${BACKEND_URL}/api/await`, {
199
+ const res = await fetch(`${BACKEND_URL}/api/await?notificationId=${encodeURIComponent(notificationId)}`, {
176
200
  headers: { authorization: `Bearer ${token}` }
177
201
  });
178
202
  if (!res.ok) throw new Error(`await failed: ${res.status} ${await res.text()}`);
@@ -182,6 +206,14 @@ async function awaitTask(opts = {}) {
182
206
  await doSleep(intervalMs);
183
207
  }
184
208
  }
209
+ async function checkReplies() {
210
+ const token = loadToken();
211
+ const res = await fetch(`${BACKEND_URL}/api/pending`, {
212
+ headers: { authorization: `Bearer ${token}` }
213
+ });
214
+ if (!res.ok) throw new Error(`check_replies failed: ${res.status} ${await res.text()}`);
215
+ return await res.json();
216
+ }
185
217
  async function setTaskState(id, state) {
186
218
  const res = await fetch(`${BACKEND_URL}/api/notify/${id}/state`, {
187
219
  method: "PATCH",
@@ -204,7 +236,10 @@ async function pollAnswer(id) {
204
236
  var PollAnswerSchema = z2.object({
205
237
  id: z2.string().describe("Request id returned by notify_user.")
206
238
  });
207
- var AwaitTaskSchema = z2.object({});
239
+ var AwaitReplySchema = z2.object({
240
+ notificationId: z2.string().describe("The id returned by notify_user \u2014 waits for the user's reply to THIS notification only.")
241
+ });
242
+ var CheckRepliesSchema = z2.object({});
208
243
  var SetTaskStateToolSchema = z2.object({
209
244
  notificationId: z2.string(),
210
245
  state: SetTaskStateSchema.shape.state
@@ -227,13 +262,16 @@ function detectGit() {
227
262
  }
228
263
  var server = new Server(
229
264
  { name: "pergy", version: "0.0.0" },
230
- { capabilities: { tools: {} } }
265
+ {
266
+ capabilities: { tools: {} },
267
+ instructions: "On startup, call check_replies once to pick up any replies or pending work you missed while away. To wait for the answer to something you just asked, call await_reply with that notification's id \u2014 it's scoped to that one request, so it never returns replies meant for other requests. Use check_replies again only when re-booting or after waiting a long time on something else. Never end a turn that still needs the user without notify_user + await_reply."
268
+ }
231
269
  );
232
270
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
233
271
  tools: [
234
272
  {
235
273
  name: "notify_user",
236
- description: "Notify the user via Pergy and get a request id to poll for their answer. Provide context in three STANDALONE tiers (each must communicate on its own, not as a delta): short = minimum words, a headline; medium = ~3 sentences; long = 1-3 paragraphs. Plus optional options and visuals. Pass `threadId` from a prior notify_user result or an await_task reply to continue that conversation thread; omit it to start a new one.",
274
+ description: "Notify the user via Pergy and get a request id to poll for their answer. Provide context in three STANDALONE tiers (each must communicate on its own, not as a delta): short = minimum words, a headline; medium = ~3 sentences; long = 1-3 paragraphs. Plus optional options (answerable choices; each may carry a sandboxed `html` or an `image` preview for a visual 'pick one' \u2014 e.g. show layout/UI alternatives) and visuals. Pass `threadId` from a prior notify_user result or an await_reply reply to continue that conversation thread; omit it to start a new one.",
237
275
  inputSchema: zodToJsonSchema(NotifyRequestSchema, { target: "openApi3" })
238
276
  },
239
277
  {
@@ -242,9 +280,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
242
280
  inputSchema: zodToJsonSchema(PollAnswerSchema, { target: "openApi3" })
243
281
  },
244
282
  {
245
- name: "await_task",
246
- description: "Wait for the user's next input, then return it. Call this after notify_user to stay engaged: it polls for up to ~5 minutes (checking every few seconds) and returns { type: 'reply', threadId, notificationId, answer } when the user responds, { type: 'remind', threadId, notificationId, remindAt, remindInSeconds } when the user defers (snoozes) \u2014 call ScheduleWakeup(remindInSeconds) (it's pre-computed; if it exceeds the ~1h cap, wake at the cap and re-schedule, or use a cron), end your turn, and call await_task again when it fires \u2014 or { type: 'idle' } if nothing arrives in the window. On a reply, act on it and \u2014 if you still need the user \u2014 call notify_user again with the SAME threadId, then await_task again. On idle, call again to keep waiting, or stop and schedule a re-check. Never end a turn that still needs the user without going through notify_user + await_task.",
247
- inputSchema: zodToJsonSchema(AwaitTaskSchema, { target: "openApi3" })
283
+ name: "await_reply",
284
+ description: "Wait for the user's reply to THE specific notification you sent for the current request (pass its id from notify_user). This is how you wait for your answer in-context. Polls ~5 min; returns { type:'reply', answer } when they respond, { type:'remind', remindInSeconds } on snooze (ScheduleWakeup then await_reply again), or { type:'idle' }. Scoped to that one notification \u2014 it NEVER returns replies meant for other notifications/requests, so concurrent requests don't cross.",
285
+ inputSchema: zodToJsonSchema(AwaitReplySchema, { target: "openApi3" })
286
+ },
287
+ {
288
+ name: "check_replies",
289
+ description: "The catch-up router for everything NOT tied to your current request: returns replies the user sent that you haven't seen yet (now marked seen) plus still-pending notifications you sent. Use it when booting up / starting a session, or when you've been waiting a long time on something else, to discover acks or work you're unaware of. To wait on a request you just sent, use await_reply instead.",
290
+ inputSchema: zodToJsonSchema(CheckRepliesSchema, { target: "openApi3" })
248
291
  },
249
292
  {
250
293
  name: "set_task_state",
@@ -271,11 +314,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
271
314
  const result = await pollAnswer(id);
272
315
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
273
316
  }
274
- case "await_task": {
275
- AwaitTaskSchema.parse(request.params.arguments ?? {});
276
- const item = await awaitTask();
317
+ case "await_reply": {
318
+ const { notificationId } = AwaitReplySchema.parse(request.params.arguments);
319
+ const item = await awaitReply(notificationId);
277
320
  return { content: [{ type: "text", text: JSON.stringify(item) }] };
278
321
  }
322
+ case "check_replies": {
323
+ CheckRepliesSchema.parse(request.params.arguments ?? {});
324
+ const result = await checkReplies();
325
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
326
+ }
279
327
  case "set_task_state": {
280
328
  const { notificationId, state } = SetTaskStateToolSchema.parse(request.params.arguments);
281
329
  const result = await setTaskState(notificationId, state);
package/dist/onboard.js CHANGED
@@ -5,7 +5,7 @@ import { execFile } from "child_process";
5
5
  import { mkdirSync, writeFileSync } from "fs";
6
6
  import { homedir, platform } from "os";
7
7
  import { join } from "path";
8
- var BACKEND_URL = process.env.PERGY_BACKEND_URL ?? "http://localhost:3000";
8
+ var BACKEND_URL = process.env.PERGY_BACKEND_URL ?? "https://pergy.ai";
9
9
  var AGENT_NAME = process.env.PERGY_AGENT ?? "mcp-agent";
10
10
  var TOKEN_PATH = join(homedir(), ".pergy", "token.json");
11
11
  function openBrowser(url) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pergy-ai/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Pergy MCP server — a voice inbox for your AI agents. Lets an agent notify a user and await their reply.",
5
5
  "license": "MIT",
6
6
  "type": "module",