@paigy/mcp 0.4.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/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/index.js +348 -0
- package/dist/onboard.js +96 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mauricio Urdaneta Uribe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @paigy/mcp
|
|
2
|
+
|
|
3
|
+
A voice inbox for your AI agents. This MCP server lets an agent **notify a user** and **await their reply** — so a long-running agent can ask a question, hand off, and resume on the answer.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
### As a Claude Code plugin (one step)
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
/plugin marketplace add mauurda/paigy
|
|
11
|
+
/plugin install paigy
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The Paigy MCP connects automatically. The first time an agent uses it while
|
|
15
|
+
unpaired, it prompts you to pair — run `/paigy-onboard` (opens your browser to
|
|
16
|
+
approve).
|
|
17
|
+
|
|
18
|
+
### Or add the MCP directly
|
|
19
|
+
|
|
20
|
+
No clone needed. Add it to Claude Code (`-s user` = available in every project; drop it for just the current one):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
claude mcp add paigy -s user -- npx -y @paigy/mcp
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or wire it into any MCP client config:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"paigy": {
|
|
32
|
+
"command": "npx",
|
|
33
|
+
"args": ["-y", "@paigy/mcp"]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
First-time pairing (link the server to your Paigy account):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx -p @paigy/mcp paigy-mcp-onboard
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
It talks to the hosted backend by default — no config needed. Set
|
|
46
|
+
`PAIGY_BACKEND_URL=http://localhost:3000` only for local development.
|
|
47
|
+
|
|
48
|
+
## Tools
|
|
49
|
+
|
|
50
|
+
- **`notify_user`** — notify the user (context: title + description chunks, 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. If a reply comes back as `{kind:'clarify', chunks:[...]}`, respond via notify_user with the SAME threadId and an expanded description.
|
|
51
|
+
- **`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`.
|
|
52
|
+
- **`check_replies`** — catch-up sweep: returns replies you haven't consumed yet (now marked seen) plus still-pending notifications.
|
|
53
|
+
- **`poll_answer`** — fetch the answer to a specific request by id.
|
|
54
|
+
- **`set_task_state`** — report progress on a request: `in_progress` / `completed` / `needs_input`.
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
- `PAIGY_BACKEND_URL` — the Paigy API base (defaults to the hosted backend).
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import {
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
ListToolsRequestSchema
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { execSync } from "child_process";
|
|
11
|
+
|
|
12
|
+
// ../../packages/schema/dist/index.js
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var ContextSchema = z.object({
|
|
15
|
+
title: z.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
|
|
16
|
+
description: z.array(z.string().min(1)).min(1).describe("Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand.")
|
|
17
|
+
});
|
|
18
|
+
var OptionSchema = z.object({
|
|
19
|
+
id: z.string(),
|
|
20
|
+
label: z.string(),
|
|
21
|
+
// .describe() flows into the MCP notify_user JSON schema (zodToJsonSchema), so
|
|
22
|
+
// the constraints below are what an agent reads when deciding to use these.
|
|
23
|
+
html: z.string().max(16384).describe(
|
|
24
|
+
"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."
|
|
25
|
+
).optional(),
|
|
26
|
+
image: z.string().url().describe(
|
|
27
|
+
"Optional image URL rendered as the option's preview (plain image, not sandboxed). For agent-generated HTML/CSS mockups, use `html` instead."
|
|
28
|
+
).optional()
|
|
29
|
+
});
|
|
30
|
+
var VisualSchema = z.object({
|
|
31
|
+
url: z.string().url(),
|
|
32
|
+
label: z.string().optional()
|
|
33
|
+
});
|
|
34
|
+
var AgentSchema = z.object({
|
|
35
|
+
name: z.string()
|
|
36
|
+
});
|
|
37
|
+
var NotifyRequestSchema = z.object({
|
|
38
|
+
context: ContextSchema,
|
|
39
|
+
options: z.array(OptionSchema).optional(),
|
|
40
|
+
visuals: z.array(VisualSchema).optional(),
|
|
41
|
+
agent: AgentSchema,
|
|
42
|
+
/** Git repo the agent is working in ("owner/name"), when applicable. */
|
|
43
|
+
repo: z.string().optional(),
|
|
44
|
+
/** Git branch the agent is on, when applicable. */
|
|
45
|
+
branch: z.string().optional(),
|
|
46
|
+
/** Continue an existing conversation; omitted = start a new thread. */
|
|
47
|
+
threadId: z.string().uuid().optional(),
|
|
48
|
+
createdAt: z.string().datetime(),
|
|
49
|
+
urgency: z.enum(["inbox", "call"]).default("inbox").describe(
|
|
50
|
+
"'inbox' (default) = sits silently in the inbox for the user to get to. 'call' = rings the user's phone now (a CallKit voice call) \u2014 use only when you genuinely need them in the moment (blocked and waiting, time-sensitive). context.title becomes what they see on the ring, so make it specific."
|
|
51
|
+
),
|
|
52
|
+
/** The request this one was spawned from, for a clarification. */
|
|
53
|
+
parentId: z.string().optional(),
|
|
54
|
+
/** How the user answers the options: pick one (default), pick several, or pick & order. */
|
|
55
|
+
select: z.enum(["one", "many", "rank"]).default("one")
|
|
56
|
+
});
|
|
57
|
+
var NotifyStatusSchema = z.enum(["pending", "answered", "ignored"]);
|
|
58
|
+
var AgentStateSchema = z.enum(["idle", "in_progress", "completed", "needs_input"]);
|
|
59
|
+
var SetTaskStateSchema = z.object({
|
|
60
|
+
state: z.enum(["in_progress", "completed", "needs_input"])
|
|
61
|
+
});
|
|
62
|
+
var UserAnswerSchema = z.discriminatedUnion("kind", [
|
|
63
|
+
z.object({ kind: z.literal("option"), optionId: z.string() }),
|
|
64
|
+
z.object({ kind: z.literal("text"), text: z.string() }),
|
|
65
|
+
z.object({ kind: z.literal("ignored") }),
|
|
66
|
+
z.object({ kind: z.literal("multi"), optionIds: z.array(z.string()) }),
|
|
67
|
+
z.object({ kind: z.literal("ranked"), optionIds: z.array(z.string()) }),
|
|
68
|
+
z.object({ kind: z.literal("clarify"), chunks: z.array(z.string()).min(1) })
|
|
69
|
+
]);
|
|
70
|
+
var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
71
|
+
z.object({
|
|
72
|
+
type: z.literal("reply"),
|
|
73
|
+
threadId: z.string(),
|
|
74
|
+
notificationId: z.string(),
|
|
75
|
+
answer: UserAnswerSchema
|
|
76
|
+
}),
|
|
77
|
+
z.object({
|
|
78
|
+
type: z.literal("remind"),
|
|
79
|
+
threadId: z.string(),
|
|
80
|
+
notificationId: z.string(),
|
|
81
|
+
remindAt: z.string().datetime({ offset: true }),
|
|
82
|
+
/** Seconds until remindAt, server-computed — pass straight to ScheduleWakeup. */
|
|
83
|
+
remindInSeconds: z.number()
|
|
84
|
+
}),
|
|
85
|
+
z.object({ type: z.literal("idle") })
|
|
86
|
+
]);
|
|
87
|
+
var PendingRepliesSchema = z.object({
|
|
88
|
+
replies: z.array(
|
|
89
|
+
z.object({ threadId: z.string(), notificationId: z.string(), answer: UserAnswerSchema })
|
|
90
|
+
),
|
|
91
|
+
pending: z.array(
|
|
92
|
+
z.object({ threadId: z.string(), notificationId: z.string(), createdAt: z.string() })
|
|
93
|
+
)
|
|
94
|
+
});
|
|
95
|
+
var NotifyResponseSchema = z.object({
|
|
96
|
+
id: z.string(),
|
|
97
|
+
status: NotifyStatusSchema,
|
|
98
|
+
createdAt: z.string().datetime(),
|
|
99
|
+
answer: UserAnswerSchema.optional(),
|
|
100
|
+
answeredAt: z.string().datetime().optional()
|
|
101
|
+
});
|
|
102
|
+
var UserResponseSchema = z.object({
|
|
103
|
+
requestId: z.string(),
|
|
104
|
+
answer: UserAnswerSchema,
|
|
105
|
+
answeredAt: z.string().datetime()
|
|
106
|
+
});
|
|
107
|
+
var InboxItemSchema = z.object({
|
|
108
|
+
id: z.string(),
|
|
109
|
+
status: NotifyStatusSchema,
|
|
110
|
+
context: ContextSchema,
|
|
111
|
+
options: z.array(OptionSchema).optional(),
|
|
112
|
+
visuals: z.array(VisualSchema).optional(),
|
|
113
|
+
agent: z.string(),
|
|
114
|
+
nickname: z.string(),
|
|
115
|
+
repo: z.string().optional(),
|
|
116
|
+
branch: z.string().optional(),
|
|
117
|
+
createdAt: z.string().datetime(),
|
|
118
|
+
snoozedUntil: z.string().datetime().optional(),
|
|
119
|
+
agentState: AgentStateSchema.default("idle"),
|
|
120
|
+
parentId: z.string().optional(),
|
|
121
|
+
select: z.enum(["one", "many", "rank"]).default("one")
|
|
122
|
+
});
|
|
123
|
+
var SnoozeRequestSchema = z.object({
|
|
124
|
+
requestId: z.string(),
|
|
125
|
+
until: z.string().datetime()
|
|
126
|
+
});
|
|
127
|
+
var PushTokenSchema = z.object({
|
|
128
|
+
voipToken: z.string().min(1),
|
|
129
|
+
platform: z.enum(["ios"])
|
|
130
|
+
});
|
|
131
|
+
var UserSettingsSchema = z.object({
|
|
132
|
+
callsEnabled: z.boolean()
|
|
133
|
+
});
|
|
134
|
+
var DeviceAgentTokenSchema = z.object({
|
|
135
|
+
token: z.string(),
|
|
136
|
+
deviceId: z.string(),
|
|
137
|
+
agentName: z.string(),
|
|
138
|
+
/** User-chosen session label; defaults to `<agent> <device>`. */
|
|
139
|
+
nickname: z.string(),
|
|
140
|
+
createdAt: z.string().datetime()
|
|
141
|
+
});
|
|
142
|
+
var PairingStatusSchema = z.enum(["pending", "approved", "denied", "expired"]);
|
|
143
|
+
var DeviceCodeSchema = z.object({
|
|
144
|
+
device_code: z.string(),
|
|
145
|
+
user_code: z.string(),
|
|
146
|
+
verification_uri: z.string().url(),
|
|
147
|
+
verification_uri_complete: z.string().url(),
|
|
148
|
+
interval: z.number(),
|
|
149
|
+
expires_in: z.number()
|
|
150
|
+
});
|
|
151
|
+
var DeviceInfoSchema = z.object({
|
|
152
|
+
code: z.string(),
|
|
153
|
+
agent: z.string(),
|
|
154
|
+
device: z.string().nullable(),
|
|
155
|
+
status: PairingStatusSchema
|
|
156
|
+
});
|
|
157
|
+
var DeviceTokenSchema = z.object({
|
|
158
|
+
access_token: z.string(),
|
|
159
|
+
nickname: z.string(),
|
|
160
|
+
agent: z.string(),
|
|
161
|
+
device: z.string().nullable()
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// src/index.ts
|
|
165
|
+
import { z as z2 } from "zod";
|
|
166
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
167
|
+
|
|
168
|
+
// src/client.ts
|
|
169
|
+
import { existsSync, readFileSync } from "fs";
|
|
170
|
+
import { homedir } from "os";
|
|
171
|
+
import { join } from "path";
|
|
172
|
+
var BACKEND_URL = process.env.PAIGY_BACKEND_URL ?? "https://paigy.ai";
|
|
173
|
+
var TOKEN_PATH = join(homedir(), ".paigy", "token.json");
|
|
174
|
+
function loadToken() {
|
|
175
|
+
if (process.env.PAIGY_TOKEN) return process.env.PAIGY_TOKEN;
|
|
176
|
+
if (existsSync(TOKEN_PATH)) {
|
|
177
|
+
try {
|
|
178
|
+
const raw = readFileSync(TOKEN_PATH, "utf8");
|
|
179
|
+
const parsed = JSON.parse(raw);
|
|
180
|
+
if (parsed.access_token) return parsed.access_token;
|
|
181
|
+
} catch {
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return "";
|
|
185
|
+
}
|
|
186
|
+
var ONBOARD_MSG = "Not paired with Paigy yet \u2014 run /paigy-onboard (or `npx -y @paigy/mcp paigy-mcp-onboard`) to pair this agent, then retry.";
|
|
187
|
+
function ensureAuthed(res) {
|
|
188
|
+
if (res.status === 401) throw new Error(ONBOARD_MSG);
|
|
189
|
+
return res;
|
|
190
|
+
}
|
|
191
|
+
async function submitNotification(req) {
|
|
192
|
+
const token = loadToken();
|
|
193
|
+
const res = ensureAuthed(await fetch(`${BACKEND_URL}/api/notify`, {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
196
|
+
body: JSON.stringify(req)
|
|
197
|
+
}));
|
|
198
|
+
if (!res.ok) throw new Error(`notify failed: ${res.status} ${await res.text()}`);
|
|
199
|
+
return await res.json();
|
|
200
|
+
}
|
|
201
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
202
|
+
async function awaitReply(notificationId, opts = {}) {
|
|
203
|
+
const intervalMs = opts.intervalMs ?? 5e3;
|
|
204
|
+
const windowMs = opts.windowMs ?? 3e5;
|
|
205
|
+
const doSleep = opts.sleep ?? sleep;
|
|
206
|
+
const now = opts.now ?? Date.now;
|
|
207
|
+
const token = loadToken();
|
|
208
|
+
const start = now();
|
|
209
|
+
while (true) {
|
|
210
|
+
const res = ensureAuthed(await fetch(`${BACKEND_URL}/api/await?notificationId=${encodeURIComponent(notificationId)}`, {
|
|
211
|
+
headers: { authorization: `Bearer ${token}` }
|
|
212
|
+
}));
|
|
213
|
+
if (!res.ok) throw new Error(`await failed: ${res.status} ${await res.text()}`);
|
|
214
|
+
const item = await res.json();
|
|
215
|
+
if (item.type !== "idle") return item;
|
|
216
|
+
if (now() - start >= windowMs) return { type: "idle" };
|
|
217
|
+
await doSleep(intervalMs);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function checkReplies() {
|
|
221
|
+
const token = loadToken();
|
|
222
|
+
const res = ensureAuthed(await fetch(`${BACKEND_URL}/api/pending`, {
|
|
223
|
+
headers: { authorization: `Bearer ${token}` }
|
|
224
|
+
}));
|
|
225
|
+
if (!res.ok) throw new Error(`check_replies failed: ${res.status} ${await res.text()}`);
|
|
226
|
+
return await res.json();
|
|
227
|
+
}
|
|
228
|
+
async function setTaskState(id, state) {
|
|
229
|
+
const res = ensureAuthed(await fetch(`${BACKEND_URL}/api/notify/${id}/state`, {
|
|
230
|
+
method: "PATCH",
|
|
231
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${loadToken()}` },
|
|
232
|
+
body: JSON.stringify({ state })
|
|
233
|
+
}));
|
|
234
|
+
if (!res.ok) throw new Error(`set_task_state failed: ${res.status} ${await res.text()}`);
|
|
235
|
+
return await res.json();
|
|
236
|
+
}
|
|
237
|
+
async function pollAnswer(id) {
|
|
238
|
+
const token = loadToken();
|
|
239
|
+
const res = ensureAuthed(await fetch(`${BACKEND_URL}/api/poll/${id}`, {
|
|
240
|
+
headers: { authorization: `Bearer ${token}` }
|
|
241
|
+
}));
|
|
242
|
+
if (!res.ok) throw new Error(`poll failed: ${res.status} ${await res.text()}`);
|
|
243
|
+
return await res.json();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/index.ts
|
|
247
|
+
var PollAnswerSchema = z2.object({
|
|
248
|
+
id: z2.string().describe("Request id returned by notify_user.")
|
|
249
|
+
});
|
|
250
|
+
var AwaitReplySchema = z2.object({
|
|
251
|
+
notificationId: z2.string().describe("The id returned by notify_user \u2014 waits for the user's reply to THIS notification only.")
|
|
252
|
+
});
|
|
253
|
+
var CheckRepliesSchema = z2.object({});
|
|
254
|
+
var SetTaskStateToolSchema = z2.object({
|
|
255
|
+
notificationId: z2.string(),
|
|
256
|
+
state: SetTaskStateSchema.shape.state
|
|
257
|
+
});
|
|
258
|
+
function detectGit() {
|
|
259
|
+
const run = (cmd) => {
|
|
260
|
+
try {
|
|
261
|
+
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
|
|
262
|
+
} catch {
|
|
263
|
+
return "";
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const remote = run("git remote get-url origin");
|
|
267
|
+
const match = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
|
|
268
|
+
const branch = run("git rev-parse --abbrev-ref HEAD");
|
|
269
|
+
return {
|
|
270
|
+
repo: match ? match[1] : void 0,
|
|
271
|
+
branch: branch && branch !== "HEAD" ? branch : void 0
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
var server = new Server(
|
|
275
|
+
{ name: "paigy", version: "0.0.0" },
|
|
276
|
+
{
|
|
277
|
+
capabilities: { tools: {} },
|
|
278
|
+
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. If a reply comes back as { kind: 'clarify', chunks: [...] }, the user wants more detail on those chunks \u2014 respond by calling notify_user again with the SAME threadId and an expanded description covering them."
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
282
|
+
tools: [
|
|
283
|
+
{
|
|
284
|
+
name: "notify_user",
|
|
285
|
+
description: "Notify the user via Paigy and get a request id to poll for their answer. Provide context.title (a specific, non-empty one-line headline \u2014 this is what the user sees first, and what shows on the ring for a call) and context.description (an array of standalone, non-empty detail chunks the user can selectively ask you to expand). Set `urgency`: 'inbox' (default) drops it silently in their inbox; 'call' rings their phone now as a voice call \u2014 use 'call' only when you genuinely need them in the moment (blocked/waiting, time-sensitive), not for routine FYIs. 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. Use `select` to control how the user answers options: 'one' (default) = pick a single option; 'many' = multi-select, user checks any subset \u2192 answer arrives as {kind:'multi', optionIds:[...]}; 'rank' = select and order, user taps options in preferred order \u2192 answer arrives as {kind:'ranked', optionIds:[...]} (ordered by choice). If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail on those chunks \u2014 respond via notify_user with the SAME threadId and an expanded description. 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.",
|
|
286
|
+
inputSchema: zodToJsonSchema(NotifyRequestSchema, { target: "openApi3" })
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "poll_answer",
|
|
290
|
+
description: "Fetch the user's answer to a previous notify_user request. Returns status pending | answered | ignored, with the answer once present. Errors if the id is unknown or expired.",
|
|
291
|
+
inputSchema: zodToJsonSchema(PollAnswerSchema, { target: "openApi3" })
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: "await_reply",
|
|
295
|
+
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.",
|
|
296
|
+
inputSchema: zodToJsonSchema(AwaitReplySchema, { target: "openApi3" })
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: "check_replies",
|
|
300
|
+
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.",
|
|
301
|
+
inputSchema: zodToJsonSchema(CheckRepliesSchema, { target: "openApi3" })
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: "set_task_state",
|
|
305
|
+
description: "Report progress on a request you received: in_progress (you started working), completed (done), or needs_input (you need more from the user \u2014 usually paired with a notify_user carrying parentId = this request's id).",
|
|
306
|
+
inputSchema: zodToJsonSchema(SetTaskStateToolSchema, { target: "openApi3" })
|
|
307
|
+
}
|
|
308
|
+
]
|
|
309
|
+
}));
|
|
310
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
311
|
+
switch (request.params.name) {
|
|
312
|
+
case "notify_user": {
|
|
313
|
+
const parsed = NotifyRequestSchema.parse(request.params.arguments);
|
|
314
|
+
const git = detectGit();
|
|
315
|
+
const enriched = {
|
|
316
|
+
...parsed,
|
|
317
|
+
repo: parsed.repo ?? git.repo,
|
|
318
|
+
branch: parsed.branch ?? git.branch
|
|
319
|
+
};
|
|
320
|
+
const result = await submitNotification(enriched);
|
|
321
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
322
|
+
}
|
|
323
|
+
case "poll_answer": {
|
|
324
|
+
const { id } = PollAnswerSchema.parse(request.params.arguments);
|
|
325
|
+
const result = await pollAnswer(id);
|
|
326
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
327
|
+
}
|
|
328
|
+
case "await_reply": {
|
|
329
|
+
const { notificationId } = AwaitReplySchema.parse(request.params.arguments);
|
|
330
|
+
const item = await awaitReply(notificationId);
|
|
331
|
+
return { content: [{ type: "text", text: JSON.stringify(item) }] };
|
|
332
|
+
}
|
|
333
|
+
case "check_replies": {
|
|
334
|
+
CheckRepliesSchema.parse(request.params.arguments ?? {});
|
|
335
|
+
const result = await checkReplies();
|
|
336
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
337
|
+
}
|
|
338
|
+
case "set_task_state": {
|
|
339
|
+
const { notificationId, state } = SetTaskStateToolSchema.parse(request.params.arguments);
|
|
340
|
+
const result = await setTaskState(notificationId, state);
|
|
341
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
342
|
+
}
|
|
343
|
+
default:
|
|
344
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
var transport = new StdioServerTransport();
|
|
348
|
+
await server.connect(transport);
|
package/dist/onboard.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/onboard.ts
|
|
4
|
+
import { execFile } from "child_process";
|
|
5
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
6
|
+
import { homedir, platform } from "os";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
var BACKEND_URL = process.env.PAIGY_BACKEND_URL ?? "https://paigy.ai";
|
|
9
|
+
var AGENT_NAME = process.env.PAIGY_AGENT ?? "mcp-agent";
|
|
10
|
+
var TOKEN_PATH = join(homedir(), ".paigy", "token.json");
|
|
11
|
+
function openBrowser(url) {
|
|
12
|
+
const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
|
|
13
|
+
const args = platform() === "win32" ? ["/c", "start", url] : [url];
|
|
14
|
+
execFile(cmd, args, (err) => {
|
|
15
|
+
if (err) {
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function saveToken(token) {
|
|
20
|
+
const dir = join(homedir(), ".paigy");
|
|
21
|
+
mkdirSync(dir, { recursive: true });
|
|
22
|
+
writeFileSync(TOKEN_PATH, JSON.stringify(token, null, 2) + "\n", { mode: 384 });
|
|
23
|
+
}
|
|
24
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
async function requestCode() {
|
|
26
|
+
const res = await fetch(`${BACKEND_URL}/api/device/code`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "content-type": "application/json" },
|
|
29
|
+
body: JSON.stringify({ agent: AGENT_NAME })
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) throw new Error(`/api/device/code failed: ${res.status} ${await res.text()}`);
|
|
32
|
+
return await res.json();
|
|
33
|
+
}
|
|
34
|
+
async function pollToken(deviceCode) {
|
|
35
|
+
const res = await fetch(`${BACKEND_URL}/api/device/token`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "content-type": "application/json" },
|
|
38
|
+
body: JSON.stringify({ device_code: deviceCode })
|
|
39
|
+
});
|
|
40
|
+
if (res.ok) return await res.json();
|
|
41
|
+
const body = await res.json().catch(() => ({ error: "unknown" }));
|
|
42
|
+
const err = body.error ?? "unknown";
|
|
43
|
+
if (err === "authorization_pending") return null;
|
|
44
|
+
if (err === "expired_token") throw new Error("Device code expired. Please re-run onboarding.");
|
|
45
|
+
if (err === "access_denied") throw new Error("Access denied by user. Onboarding cancelled.");
|
|
46
|
+
throw new Error(`Unexpected error from /api/device/token: ${err}`);
|
|
47
|
+
}
|
|
48
|
+
async function main() {
|
|
49
|
+
console.log(`
|
|
50
|
+
Paigy MCP onboarding (agent: "${AGENT_NAME}")
|
|
51
|
+
`);
|
|
52
|
+
const code = await requestCode();
|
|
53
|
+
console.log("Open this URL in your browser to approve the connection:\n");
|
|
54
|
+
console.log(` ${code.verification_uri_complete}
|
|
55
|
+
`);
|
|
56
|
+
console.log(` (User code: ${code.user_code})
|
|
57
|
+
`);
|
|
58
|
+
openBrowser(code.verification_uri_complete);
|
|
59
|
+
const intervalMs = (code.interval ?? 5) * 1e3;
|
|
60
|
+
const expiresAt = Date.now() + code.expires_in * 1e3;
|
|
61
|
+
process.stdout.write("Waiting for approval");
|
|
62
|
+
while (Date.now() < expiresAt) {
|
|
63
|
+
await sleep(intervalMs);
|
|
64
|
+
process.stdout.write(".");
|
|
65
|
+
try {
|
|
66
|
+
const token = await pollToken(code.device_code);
|
|
67
|
+
if (token !== null) {
|
|
68
|
+
saveToken(token);
|
|
69
|
+
console.log(`
|
|
70
|
+
|
|
71
|
+
Paired! Token saved to ${TOKEN_PATH} (mode 0600).`);
|
|
72
|
+
console.log("Treat this token like a password \u2014 it grants account access. Never log, echo, or commit it.");
|
|
73
|
+
console.log(` Nickname : ${token.nickname}`);
|
|
74
|
+
console.log(` Agent : ${token.agent}`);
|
|
75
|
+
console.log(` Device : ${token.device ?? "(none)"}
|
|
76
|
+
`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error(`
|
|
81
|
+
|
|
82
|
+
${err.message}
|
|
83
|
+
`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
console.error("\n\nDevice code expired without approval. Please re-run onboarding.\n");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
main().catch((err) => {
|
|
91
|
+
console.error(err.message);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
});
|
|
94
|
+
export {
|
|
95
|
+
TOKEN_PATH
|
|
96
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@paigy/mcp",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Paigy MCP server — a voice inbox for your AI agents. Lets an agent notify a user and await their reply.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp": "./dist/index.js",
|
|
9
|
+
"paigy-mcp": "./dist/index.js",
|
|
10
|
+
"paigy-mcp-onboard": "./dist/onboard.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/mauurda/paigy.git",
|
|
21
|
+
"directory": "apps/mcp"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
25
|
+
"zod": "^3.24.1",
|
|
26
|
+
"zod-to-json-schema": "^3.24.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"tsup": "^8.3.5",
|
|
31
|
+
"typescript": "^5.7.2",
|
|
32
|
+
"vitest": "^2.1.8",
|
|
33
|
+
"@paigy/schema": "0.0.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup src/index.ts src/onboard.ts --format esm --clean",
|
|
37
|
+
"dev": "tsup src/index.ts src/onboard.ts --format esm --watch",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
}
|
|
41
|
+
}
|