@paigy/mcp 0.12.1 → 0.12.2
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/dist/chunk-BJ2J2AOB.js +3191 -0
- package/dist/chunk-DUTOEZEL.js +152 -0
- package/dist/index.js +59 -19
- package/dist/listen.js +5 -4
- package/dist/onboard.js +1 -2
- package/dist/statusline.js +2 -4
- package/package.json +2 -1
- package/dist/chunk-ACPCCDHI.js +0 -451
- package/dist/chunk-AILC3H3U.js +0 -81
- package/dist/chunk-GU7C5H6L.js +0 -15
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BACKEND_URL,
|
|
3
|
+
fetchRoster,
|
|
4
|
+
openSealedAnswer,
|
|
5
|
+
reach,
|
|
6
|
+
readKeyFile,
|
|
7
|
+
sealFields,
|
|
8
|
+
verifiedRecipients
|
|
9
|
+
} from "./chunk-BJ2J2AOB.js";
|
|
10
|
+
|
|
11
|
+
// src/client.ts
|
|
12
|
+
import { existsSync, readFileSync } from "fs";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
var TOKEN_PATH = join(homedir(), ".paigy", "token.json");
|
|
16
|
+
function loadToken() {
|
|
17
|
+
if (process.env.PAIGY_TOKEN) return process.env.PAIGY_TOKEN;
|
|
18
|
+
if (existsSync(TOKEN_PATH)) {
|
|
19
|
+
try {
|
|
20
|
+
const raw = readFileSync(TOKEN_PATH, "utf8");
|
|
21
|
+
const parsed = JSON.parse(raw);
|
|
22
|
+
if (parsed.access_token) return parsed.access_token;
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
var ONBOARD_MSG = "Not paired with Paigy yet \u2014 call the `pair` tool to connect this agent (it returns an approval link to show the user), then retry. Manual fallback: `npx -y @paigy/mcp paigy-mcp-onboard`.";
|
|
29
|
+
function ensureAuthed(res) {
|
|
30
|
+
if (res.status === 401) throw new Error(ONBOARD_MSG);
|
|
31
|
+
return res;
|
|
32
|
+
}
|
|
33
|
+
async function sealForE2ee(req, token, deps = {}) {
|
|
34
|
+
const keyFile = (deps.readKeyFile ?? readKeyFile)();
|
|
35
|
+
if (!keyFile?.e2ee) return req;
|
|
36
|
+
if (!keyFile.uikPub) {
|
|
37
|
+
throw new Error("E2EE pairing is missing its pinned identity key (key.json.uikPub) \u2014 re-pair; refusing to send plaintext.");
|
|
38
|
+
}
|
|
39
|
+
const roster = await (deps.fetchRoster ?? fetchRoster)(token);
|
|
40
|
+
if (!roster) {
|
|
41
|
+
throw new Error("E2EE pairing has no device roster to seal to \u2014 refusing to send plaintext. Approve a device on your phone, then retry.");
|
|
42
|
+
}
|
|
43
|
+
const recipients = verifiedRecipients(roster, keyFile.uikPub);
|
|
44
|
+
if (recipients.length === 0) {
|
|
45
|
+
throw new Error("No roster device verified against the pinned identity key \u2014 refusing to send plaintext (a server-injected device is dropped).");
|
|
46
|
+
}
|
|
47
|
+
const sealedContext = {
|
|
48
|
+
...req.context,
|
|
49
|
+
...req.repo !== void 0 ? { repo: req.repo } : {},
|
|
50
|
+
...req.branch !== void 0 ? { branch: req.branch } : {}
|
|
51
|
+
};
|
|
52
|
+
const envelope = sealFields({ context: sealedContext, options: req.options, visuals: req.visuals }, recipients);
|
|
53
|
+
const { context: _c, options: _o, visuals: _v, repo: _r, branch: _b, ...meta } = req;
|
|
54
|
+
return { ...meta, envelope };
|
|
55
|
+
}
|
|
56
|
+
async function submitNotification(req) {
|
|
57
|
+
const token = loadToken();
|
|
58
|
+
const body = await sealForE2ee(req, token);
|
|
59
|
+
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notify`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
62
|
+
body: JSON.stringify(body)
|
|
63
|
+
}));
|
|
64
|
+
if (!res.ok) throw new Error(`notify failed: ${res.status} ${await res.text()}`);
|
|
65
|
+
return await res.json();
|
|
66
|
+
}
|
|
67
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
68
|
+
function decryptAnswer(reply, deps = {}) {
|
|
69
|
+
if (!reply.sealed) return reply.answer;
|
|
70
|
+
const keyFile = (deps.readKeyFile ?? readKeyFile)();
|
|
71
|
+
if (!keyFile?.keypair) {
|
|
72
|
+
throw new Error("received a sealed answer but this agent has no device key to open it \u2014 re-pair.");
|
|
73
|
+
}
|
|
74
|
+
return openSealedAnswer(reply.sealed, keyFile.keypair);
|
|
75
|
+
}
|
|
76
|
+
async function awaitReply(notificationId, opts = {}) {
|
|
77
|
+
const intervalMs = opts.intervalMs ?? 5e3;
|
|
78
|
+
const windowMs = opts.windowMs ?? 3e5;
|
|
79
|
+
const doSleep = opts.sleep ?? sleep;
|
|
80
|
+
const now = opts.now ?? Date.now;
|
|
81
|
+
const token = loadToken();
|
|
82
|
+
const start = now();
|
|
83
|
+
while (true) {
|
|
84
|
+
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/await?notificationId=${encodeURIComponent(notificationId)}`, {
|
|
85
|
+
headers: { authorization: `Bearer ${token}` }
|
|
86
|
+
}));
|
|
87
|
+
if (!res.ok) throw new Error(`await failed: ${res.status} ${await res.text()}`);
|
|
88
|
+
const item = await res.json();
|
|
89
|
+
if (item.type !== "idle") return decryptItem(item);
|
|
90
|
+
if (now() - start >= windowMs) return { type: "idle" };
|
|
91
|
+
await doSleep(intervalMs);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function decryptItem(item) {
|
|
95
|
+
if (item.type !== "reply" || !item.sealed) return item;
|
|
96
|
+
const answer = decryptAnswer(item);
|
|
97
|
+
const { sealed: _s, ...rest } = item;
|
|
98
|
+
return { ...rest, answer };
|
|
99
|
+
}
|
|
100
|
+
async function checkReplies() {
|
|
101
|
+
const token = loadToken();
|
|
102
|
+
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/pending`, {
|
|
103
|
+
headers: { authorization: `Bearer ${token}` }
|
|
104
|
+
}));
|
|
105
|
+
if (!res.ok) throw new Error(`check_replies failed: ${res.status} ${await res.text()}`);
|
|
106
|
+
const pending = await res.json();
|
|
107
|
+
return {
|
|
108
|
+
...pending,
|
|
109
|
+
replies: pending.replies.map((r) => {
|
|
110
|
+
if (!r.sealed) return r;
|
|
111
|
+
const { sealed: _s, ...rest } = r;
|
|
112
|
+
return { ...rest, answer: decryptAnswer(r) };
|
|
113
|
+
})
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
async function setTaskState(notificationId, state) {
|
|
117
|
+
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/notify/${notificationId}/state`, {
|
|
118
|
+
method: "PATCH",
|
|
119
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${loadToken()}` },
|
|
120
|
+
body: JSON.stringify({ state })
|
|
121
|
+
}));
|
|
122
|
+
if (!res.ok) throw new Error(`set_task_state failed: ${res.status} ${await res.text()}`);
|
|
123
|
+
return await res.json();
|
|
124
|
+
}
|
|
125
|
+
async function registerDelivery(mode) {
|
|
126
|
+
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/delivery`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${loadToken()}` },
|
|
129
|
+
body: JSON.stringify({ mode })
|
|
130
|
+
}));
|
|
131
|
+
if (!res.ok) throw new Error(`register_delivery failed: ${res.status} ${await res.text()}`);
|
|
132
|
+
return await res.json();
|
|
133
|
+
}
|
|
134
|
+
async function scheduleCallback(req) {
|
|
135
|
+
const token = loadToken();
|
|
136
|
+
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/callback`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
139
|
+
body: JSON.stringify(req)
|
|
140
|
+
}));
|
|
141
|
+
if (!res.ok) throw new Error(`schedule_callback failed: ${res.status} ${await res.text()}`);
|
|
142
|
+
return await res.json();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export {
|
|
146
|
+
submitNotification,
|
|
147
|
+
awaitReply,
|
|
148
|
+
checkReplies,
|
|
149
|
+
setTaskState,
|
|
150
|
+
registerDelivery,
|
|
151
|
+
scheduleCallback
|
|
152
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
NotifyRequestSchema,
|
|
4
|
-
ScheduleCallbackSchema,
|
|
5
|
-
SetTaskStateSchema,
|
|
6
3
|
awaitReply,
|
|
7
4
|
checkReplies,
|
|
8
5
|
scheduleCallback,
|
|
9
6
|
setTaskState,
|
|
10
7
|
submitNotification
|
|
11
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-DUTOEZEL.js";
|
|
12
9
|
import {
|
|
10
|
+
NotifyRequestSchema,
|
|
11
|
+
ScheduleCallbackSchema,
|
|
12
|
+
SetTaskStateSchema,
|
|
13
|
+
deleteKeyFile,
|
|
13
14
|
deleteToken,
|
|
15
|
+
fetchCredential,
|
|
16
|
+
finalizeE2ee,
|
|
14
17
|
openBrowser,
|
|
15
|
-
|
|
18
|
+
pairStep,
|
|
19
|
+
readKeyFile,
|
|
16
20
|
readToken,
|
|
17
21
|
requestCode,
|
|
18
22
|
revokeToken,
|
|
23
|
+
saveKeyFile,
|
|
19
24
|
saveToken,
|
|
20
|
-
sleep
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
sleep,
|
|
26
|
+
startE2ee
|
|
27
|
+
} from "./chunk-BJ2J2AOB.js";
|
|
23
28
|
|
|
24
29
|
// src/index.ts
|
|
25
30
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -84,11 +89,26 @@ function detectGit() {
|
|
|
84
89
|
branch: branch && branch !== "HEAD" ? branch : void 0
|
|
85
90
|
};
|
|
86
91
|
}
|
|
92
|
+
function pairedResult(token, sas, note, e2ee) {
|
|
93
|
+
const base = {
|
|
94
|
+
status: "paired",
|
|
95
|
+
nickname: token.nickname,
|
|
96
|
+
agent: token.agent,
|
|
97
|
+
device: token.device
|
|
98
|
+
};
|
|
99
|
+
if (sas) {
|
|
100
|
+
base.e2ee = !!e2ee;
|
|
101
|
+
base.sas = sas;
|
|
102
|
+
base.verify_message = `This pairing is end-to-end encrypted. Ask the user to verify this code matches the one on their phone: ${sas}` + (e2ee ? "" : " (identity key not yet confirmed on the phone).");
|
|
103
|
+
}
|
|
104
|
+
if (note) base.note = note;
|
|
105
|
+
return { content: [{ type: "text", text: JSON.stringify(base) }] };
|
|
106
|
+
}
|
|
87
107
|
var server = new Server(
|
|
88
108
|
{ name: "paigy", version: "0.0.0" },
|
|
89
109
|
{
|
|
90
110
|
capabilities: { tools: {} },
|
|
91
|
-
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 notificationId \u2014 it's scoped to that one notification, so it never returns replies meant for other notifications. 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. When you need a decision or input, MATCH the answer shape to the question \u2014 don't default everything to free text, and don't reflexively make everything yes/no. Pick the best tool for the job: yes/no \u2192 select:'confirm'; approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve'; pick one of several \u2192 options + select:'one'; pick several / a subset \u2192 options + select:'many'; rank or prioritize \u2192 options + select:'rank'. Reserve select:'text' (free-form reply only) for plain updates and answers that genuinely can't be structured (the user can always add free text on top of any shape). On a { kind: 'clarify' } reply, see notify_user's own description for how to respond. When urgency is 'call', remember the title + description are spoken aloud \u2014 write them short and conversational, and name things instead of using IDs (e.g. 'the pull request about the agents page', not 'PR #235'). When the user asks you to follow up later \u2014 when you're done, if you're blocked, or at a set time \u2014 record it with schedule_callback so you don't drop it if you go idle. If you're about to start a genuinely long-running or blocking piece of work \u2014 one where the user would otherwise sit and wait \u2014 mention ONCE, in passing, that you can reach them when it's done or if you hit a blocker, instead of them needing to babysit the terminal. Don't offer this for quick tasks, and don't repeat the offer if they've already said yes or no earlier in the conversation. Escalate silence, don't just wait on it: if you notified at a lower urgency (inbox/push/banner) for something that's genuinely blocking real progress, call await_reply up to twice (~5 min each, ~10 min total) \u2014 if it's still idle after that AND the item is genuinely blocking, send a fresh notify_user on the SAME threadId at urgency:'call'. Skip this for anything that isn't truly blocking; a normal question can just sit in the inbox."
|
|
111
|
+
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 notificationId \u2014 it's scoped to that one notification, so it never returns replies meant for other notifications. 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. When you need a decision or input, MATCH the answer shape to the question \u2014 don't default everything to free text, and don't reflexively make everything yes/no. Pick the best tool for the job: yes/no \u2192 select:'confirm'; approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve'; pick one of several \u2192 options + select:'one'; pick several / a subset \u2192 options + select:'many'; rank or prioritize \u2192 options + select:'rank'. Reserve select:'text' (free-form reply only) for plain updates and answers that genuinely can't be structured (the user can always add free text on top of any shape). On a { kind: 'clarify' } reply, see notify_user's own description for how to respond. When urgency is 'call', remember the title + description are spoken aloud \u2014 write them short and conversational, and name things instead of using IDs (e.g. 'the pull request about the agents page', not 'PR #235'). When the user asks you to follow up later \u2014 when you're done, if you're blocked, or at a set time \u2014 record it with schedule_callback so you don't drop it if you go idle. If you're about to start a genuinely long-running or blocking piece of work \u2014 one where the user would otherwise sit and wait \u2014 mention ONCE, in passing, that you can reach them when it's done or if you hit a blocker, instead of them needing to babysit the terminal. Don't offer this for quick tasks, and don't repeat the offer if they've already said yes or no earlier in the conversation. Escalate silence, don't just wait on it: if you notified at a lower urgency (inbox/push/banner) for something that's genuinely blocking real progress, call await_reply up to twice (~5 min each, ~10 min total) \u2014 if it's still idle after that AND the item is genuinely blocking, send a fresh notify_user on the SAME threadId at urgency:'call'. Skip this for anything that isn't truly blocking; a normal question can just sit in the inbox. NEVER go quietly idle while something might still be pending for you: whenever you end a turn with any Paigy notification unanswered (or any chance the user replied through the app while you worked), schedule your own ~2-minute wake-up (harness ScheduleWakeup or equivalent) and call check_replies when it fires; if still nothing, re-schedule and keep looping until resolved or the user says stop. For legibility, always use this exact wording \u2014 reason: 'Paigy idle check \u2014 waiting on <thing>', wake-up prompt: 'Paigy idle check: call check_replies and engage with anything unacknowledged; if idle, re-schedule (~2min).' \u2014 so the user can recognize every idle check at a glance. This self-polling in your own live session (full context intact) is the PRIMARY mechanism; the plugin's Stop hooks are only the dead-session safety net."
|
|
92
112
|
}
|
|
93
113
|
);
|
|
94
114
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -135,7 +155,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
135
155
|
case "pair": {
|
|
136
156
|
const { device_code } = PairSchema.parse(request.params.arguments ?? {});
|
|
137
157
|
if (!device_code) {
|
|
138
|
-
const
|
|
158
|
+
const { keyFile, offer } = startE2ee();
|
|
159
|
+
const code = await requestCode(void 0, offer);
|
|
160
|
+
saveKeyFile({ ...keyFile, userCode: code.user_code });
|
|
139
161
|
openBrowser(code.verification_uri_complete);
|
|
140
162
|
const qr = qrcode(0, "M");
|
|
141
163
|
qr.addData(code.verification_uri_complete);
|
|
@@ -155,18 +177,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
155
177
|
}]
|
|
156
178
|
};
|
|
157
179
|
}
|
|
180
|
+
let kf = readKeyFile();
|
|
158
181
|
const start = Date.now();
|
|
159
182
|
const capMs = 9e4;
|
|
160
183
|
while (Date.now() - start < capMs) {
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
184
|
+
const step = await pairStep(device_code, kf);
|
|
185
|
+
if (step.kind === "e2ee_aborted") {
|
|
186
|
+
deleteKeyFile();
|
|
187
|
+
kf = null;
|
|
188
|
+
await sleep(2e3);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (step.kind === "paired") {
|
|
192
|
+
saveToken(step.token);
|
|
193
|
+
if (!step.sas || !kf) {
|
|
194
|
+
deleteKeyFile();
|
|
195
|
+
return pairedResult(step.token);
|
|
196
|
+
}
|
|
197
|
+
const finalDeadline = Math.min(Date.now() + 1e4, start + capMs);
|
|
198
|
+
let e2ee = false;
|
|
199
|
+
while (Date.now() < finalDeadline) {
|
|
200
|
+
const cred = kf.userCode ? await fetchCredential(kf.userCode) : null;
|
|
201
|
+
if (cred && step.uikPub) {
|
|
202
|
+
e2ee = finalizeE2ee(kf, cred, step.uikPub);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
await sleep(2e3);
|
|
206
|
+
}
|
|
207
|
+
if (!e2ee) deleteKeyFile();
|
|
208
|
+
return pairedResult(step.token, step.sas, void 0, e2ee);
|
|
170
209
|
}
|
|
171
210
|
await sleep(2e3);
|
|
172
211
|
}
|
|
@@ -192,6 +231,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
192
231
|
}
|
|
193
232
|
}
|
|
194
233
|
const removed = deleteToken();
|
|
234
|
+
deleteKeyFile();
|
|
195
235
|
return {
|
|
196
236
|
content: [{
|
|
197
237
|
type: "text",
|
package/dist/listen.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
WAKE_EVENT,
|
|
4
3
|
checkReplies,
|
|
5
|
-
registerDelivery
|
|
4
|
+
registerDelivery
|
|
5
|
+
} from "./chunk-DUTOEZEL.js";
|
|
6
|
+
import {
|
|
7
|
+
WAKE_EVENT,
|
|
6
8
|
wakeChannel
|
|
7
|
-
} from "./chunk-
|
|
8
|
-
import "./chunk-GU7C5H6L.js";
|
|
9
|
+
} from "./chunk-BJ2J2AOB.js";
|
|
9
10
|
|
|
10
11
|
// src/listen.ts
|
|
11
12
|
import { createClient } from "@supabase/supabase-js";
|
package/dist/onboard.js
CHANGED
package/dist/statusline.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
BACKEND_URL,
|
|
3
4
|
readToken
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import {
|
|
6
|
-
BACKEND_URL
|
|
7
|
-
} from "./chunk-GU7C5H6L.js";
|
|
5
|
+
} from "./chunk-BJ2J2AOB.js";
|
|
8
6
|
|
|
9
7
|
// src/statusline.ts
|
|
10
8
|
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paigy/mcp",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"description": "Paigy 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",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"tsup": "^8.3.5",
|
|
36
36
|
"typescript": "^5.7.2",
|
|
37
37
|
"vitest": "^2.1.8",
|
|
38
|
+
"@paigy/crypto": "0.0.0",
|
|
38
39
|
"@paigy/schema": "0.0.0"
|
|
39
40
|
},
|
|
40
41
|
"scripts": {
|