palmier 1.1.17 → 1.1.18
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 +11 -15
- package/dist/{mcp-tools.d.ts → agent-tools.d.ts} +2 -9
- package/dist/{mcp-tools.js → agent-tools.js} +46 -83
- package/dist/agents/shared-prompt.js +1 -1
- package/dist/nats-client.js +0 -1
- package/dist/notification-store.d.ts +0 -1
- package/dist/notification-store.js +0 -7
- package/dist/pending-requests.d.ts +4 -6
- package/dist/pending-requests.js +2 -3
- package/dist/sms-store.d.ts +0 -1
- package/dist/sms-store.js +0 -7
- package/dist/transports/http-transport.js +9 -68
- package/package.json +1 -1
- package/dist/mcp-handler.d.ts +0 -10
- package/dist/mcp-handler.js +0 -167
package/README.md
CHANGED
|
@@ -45,15 +45,13 @@ Capability access is opt-in per device: each capability is gated behind an Andro
|
|
|
45
45
|
|
|
46
46
|
Palmier runs as a background daemon (systemd on Linux, launchd on macOS, Task Scheduler on Windows). It invokes your agent CLIs directly, schedules tasks via native OS timers, and exposes an API that the PWA connects to — either directly over HTTP or remotely through a relay server. Agents can interact with the user's mobile device during execution — requesting input, sending push notifications and full-screen alarms, reading SMS/notifications, managing contacts and calendar, and more.
|
|
47
47
|
|
|
48
|
-
###
|
|
48
|
+
### Agent Capabilities
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
Every task Palmier runs gets these capabilities automatically. Their usage is written into the agent's instructions at launch, so no agent-side setup is needed.
|
|
51
51
|
|
|
52
|
-
**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
| Tool | Description |
|
|
56
|
-
|------|-------------|
|
|
52
|
+
**Actions:**
|
|
53
|
+
| Capability | Description |
|
|
54
|
+
|------------|-------------|
|
|
57
55
|
| `notify` | Send a push notification to the user's device |
|
|
58
56
|
| `request-input` | Request input from the user (blocks until response; aborting the prompt aborts the task) |
|
|
59
57
|
| `request-choice` | Ask the user a question with a fixed set of options and return the one picked; a single option serves as a confirmation (blocks until response; aborting aborts the task) |
|
|
@@ -69,13 +67,11 @@ Palmier exposes an [MCP](https://modelcontextprotocol.io) server at `http://loca
|
|
|
69
67
|
| `read-battery` | Get battery level and charging status |
|
|
70
68
|
| `set-ringer-mode` | Set ringer mode (normal/vibrate/silent) |
|
|
71
69
|
|
|
72
|
-
**
|
|
73
|
-
|
|
|
74
|
-
|
|
75
|
-
| Device Notifications |
|
|
76
|
-
| Device SMS |
|
|
77
|
-
|
|
78
|
-
Resources support MCP subscriptions — clients can subscribe via `resources/subscribe` and receive real-time `notifications/resources/updated` events via the streamable HTTP transport when the resource changes.
|
|
70
|
+
**Device data:**
|
|
71
|
+
| Capability | Description |
|
|
72
|
+
|------------|-------------|
|
|
73
|
+
| Device Notifications | Recent notifications from the user's Android device |
|
|
74
|
+
| Device SMS | Recent SMS messages from the user's Android device |
|
|
79
75
|
|
|
80
76
|
All device tools work while the Palmier Android app is in the background — they communicate via FCM data messages which wake the app's service even when it's not in the foreground. Each host has one **linked device**: the phone the host uses for SMS, contacts, location, and other device capabilities. Choose it at pair time (the "Link the host to this device" checkbox) or later from the drawer. Each capability must be enabled and its Android permission granted via toggles in the linked device's drawer.
|
|
81
77
|
|
|
@@ -89,7 +85,7 @@ Agents run with the task's run directory (`~/palmier/tasks/<task-id>/<run-id>/`)
|
|
|
89
85
|
┌──────────────┐ HTTP ┌──────────────────┐
|
|
90
86
|
│ │◄──────────────────────│ │
|
|
91
87
|
│ Host Daemon │ │ PWA (Browser) │
|
|
92
|
-
│
|
|
88
|
+
│ │◄──────┐ │ │
|
|
93
89
|
└──┬────────┬──┘ │ └──────────────────┘
|
|
94
90
|
│ │ │ │
|
|
95
91
|
▼ ▼ │ NATS (TLS) │ NATS (TLS)
|
|
@@ -8,9 +8,8 @@ export interface ToolContext {
|
|
|
8
8
|
config: HostConfig;
|
|
9
9
|
nc: NatsConnection | undefined;
|
|
10
10
|
publishEvent: (id: string, payload: Record<string, unknown>) => Promise<void>;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
abortTask: (taskId: string) => Promise<void>;
|
|
11
|
+
taskId: string;
|
|
12
|
+
abortTask: () => Promise<void>;
|
|
14
13
|
}
|
|
15
14
|
export interface ToolDefinition {
|
|
16
15
|
name: string;
|
|
@@ -23,20 +22,14 @@ export declare const requestConfirmationTool: ToolDefinition;
|
|
|
23
22
|
export declare const agentTools: ToolDefinition[];
|
|
24
23
|
export declare const agentToolMap: Map<string, ToolDefinition>;
|
|
25
24
|
export interface ResourceDefinition {
|
|
26
|
-
/** MCP resource URI (e.g. "notifications://device"). */
|
|
27
|
-
uri: string;
|
|
28
25
|
/** Display name. */
|
|
29
26
|
name: string;
|
|
30
27
|
/** First line is the summary (used as REST endpoint header). Remaining lines become bullet points in docs. */
|
|
31
28
|
description: string[];
|
|
32
|
-
mimeType: string;
|
|
33
29
|
/** REST endpoint path (e.g. "/notifications"). Served as GET. */
|
|
34
30
|
restPath: string;
|
|
35
31
|
/** Return the current resource content. */
|
|
36
32
|
read: () => unknown;
|
|
37
|
-
/** Register a listener for content changes. Returns an unsubscribe function. */
|
|
38
|
-
subscribe: (listener: () => void) => () => void;
|
|
39
33
|
}
|
|
40
34
|
export declare const agentResources: ResourceDefinition[];
|
|
41
|
-
export declare const agentResourceMap: Map<string, ResourceDefinition>;
|
|
42
35
|
export declare function generateEndpointDocs(port: number, taskId: string, tools?: ToolDefinition[], resources?: ResourceDefinition[]): string;
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import * as fs from "fs";
|
|
2
1
|
import { StringCodec } from "nats";
|
|
3
2
|
import { registerPending } from "./pending-requests.js";
|
|
4
3
|
import { getLinkedDevice } from "./linked-device.js";
|
|
5
|
-
import { getNotifications
|
|
6
|
-
import { getSmsMessages
|
|
4
|
+
import { getNotifications } from "./notification-store.js";
|
|
5
|
+
import { getSmsMessages } from "./sms-store.js";
|
|
7
6
|
import { getTaskDir, readHistory, spliceUserMessage } from "./task.js";
|
|
8
7
|
import { lookupPassword, savePassword, normalizeOrigin } from "./password-store.js";
|
|
9
8
|
import { fillPasswordInBrowser } from "./playwright-fill.js";
|
|
@@ -35,11 +34,7 @@ const notifyTool = {
|
|
|
35
34
|
if (!ctx.nc)
|
|
36
35
|
throw new ToolError("NATS not connected — push notifications require server mode", 503);
|
|
37
36
|
const sc = StringCodec();
|
|
38
|
-
const payload = { hostId: ctx.config.hostId, title, body };
|
|
39
|
-
if (ctx.sessionId)
|
|
40
|
-
payload.session_id = ctx.sessionId;
|
|
41
|
-
if (ctx.agentName)
|
|
42
|
-
payload.agent_name = ctx.agentName;
|
|
37
|
+
const payload = { hostId: ctx.config.hostId, title, body, session_id: ctx.taskId };
|
|
43
38
|
const subject = `host.${ctx.config.hostId}.push.send`;
|
|
44
39
|
const reply = await ctx.nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
|
|
45
40
|
const result = JSON.parse(sc.decode(reply.data));
|
|
@@ -73,17 +68,15 @@ const requestInputTool = {
|
|
|
73
68
|
const { description, questions } = args;
|
|
74
69
|
if (!questions?.length)
|
|
75
70
|
throw new ToolError("questions is required", 400);
|
|
76
|
-
const pendingPromise = registerPending(ctx.
|
|
77
|
-
session_id: ctx.
|
|
78
|
-
session_name: ctx.agentName,
|
|
71
|
+
const pendingPromise = registerPending(ctx.taskId, "input", questions, {
|
|
72
|
+
session_id: ctx.taskId,
|
|
79
73
|
description,
|
|
80
74
|
input_questions: questions,
|
|
81
75
|
});
|
|
82
76
|
await ctx.publishEvent("_input", {
|
|
83
77
|
event_type: "input-request",
|
|
84
78
|
host_id: ctx.config.hostId,
|
|
85
|
-
session_id: ctx.
|
|
86
|
-
session_name: ctx.agentName,
|
|
79
|
+
session_id: ctx.taskId,
|
|
87
80
|
description,
|
|
88
81
|
input_questions: questions,
|
|
89
82
|
});
|
|
@@ -95,32 +88,22 @@ const requestInputTool = {
|
|
|
95
88
|
if (aborted) {
|
|
96
89
|
await ctx.publishEvent("_input", {
|
|
97
90
|
event_type: "input-resolved", host_id: ctx.config.hostId,
|
|
98
|
-
session_id: ctx.
|
|
91
|
+
session_id: ctx.taskId, status: "aborted",
|
|
99
92
|
});
|
|
100
|
-
await
|
|
93
|
+
await ctx.abortTask();
|
|
101
94
|
return { aborted: true };
|
|
102
95
|
}
|
|
103
96
|
await ctx.publishEvent("_input", {
|
|
104
97
|
event_type: "input-resolved", host_id: ctx.config.hostId,
|
|
105
|
-
session_id: ctx.
|
|
98
|
+
session_id: ctx.taskId, status: "provided",
|
|
106
99
|
});
|
|
107
100
|
return { values: response };
|
|
108
101
|
},
|
|
109
102
|
};
|
|
110
|
-
/**
|
|
111
|
-
* Splice the user's answer into the task run file so the conversation history
|
|
112
|
-
* shows what the user typed, independent of whether the agent echoes it.
|
|
113
|
-
* Only writes when sessionId resolves to a task with an active run (i.e. the
|
|
114
|
-
* agent called via the REST endpoint with taskId); silently skips for pure
|
|
115
|
-
* MCP-protocol sessions where sessionId is a random UUID.
|
|
116
|
-
*/
|
|
103
|
+
/** Splice the user's answer into the run file so history shows it even if the agent never echoes it. */
|
|
117
104
|
function recordUserResponseToRun(ctx, content) {
|
|
118
|
-
const taskId = ctx.
|
|
119
|
-
if (!taskId)
|
|
120
|
-
return;
|
|
105
|
+
const taskId = ctx.taskId;
|
|
121
106
|
const taskDir = getTaskDir(ctx.config.projectRoot, taskId);
|
|
122
|
-
if (!fs.existsSync(taskDir))
|
|
123
|
-
return;
|
|
124
107
|
const { entries } = readHistory(ctx.config.projectRoot, { task_id: taskId, limit: 1 });
|
|
125
108
|
const runId = entries[0]?.run_id;
|
|
126
109
|
if (!runId)
|
|
@@ -138,13 +121,6 @@ function recordUserResponseToRun(ctx, content) {
|
|
|
138
121
|
// Run file missing or unwritable — best-effort, do not fail the tool call.
|
|
139
122
|
}
|
|
140
123
|
}
|
|
141
|
-
/** No-op for pure MCP-protocol sessions, whose sessionId is a random UUID rather than a task. */
|
|
142
|
-
async function abortSessionTask(ctx) {
|
|
143
|
-
const taskId = ctx.sessionId;
|
|
144
|
-
if (!taskId || !fs.existsSync(getTaskDir(ctx.config.projectRoot, taskId)))
|
|
145
|
-
return;
|
|
146
|
-
await ctx.abortTask(taskId);
|
|
147
|
-
}
|
|
148
124
|
export const requestConfirmationTool = {
|
|
149
125
|
name: "request-confirmation",
|
|
150
126
|
description: [
|
|
@@ -163,16 +139,14 @@ export const requestConfirmationTool = {
|
|
|
163
139
|
const { description } = args;
|
|
164
140
|
if (!description)
|
|
165
141
|
throw new ToolError("description is required", 400);
|
|
166
|
-
const pendingPromise = registerPending(ctx.
|
|
167
|
-
session_id: ctx.
|
|
168
|
-
session_name: ctx.agentName,
|
|
142
|
+
const pendingPromise = registerPending(ctx.taskId, "confirmation", undefined, {
|
|
143
|
+
session_id: ctx.taskId,
|
|
169
144
|
description,
|
|
170
145
|
});
|
|
171
146
|
await ctx.publishEvent("_confirm", {
|
|
172
147
|
event_type: "confirm-request",
|
|
173
148
|
host_id: ctx.config.hostId,
|
|
174
|
-
session_id: ctx.
|
|
175
|
-
session_name: ctx.agentName,
|
|
149
|
+
session_id: ctx.taskId,
|
|
176
150
|
description,
|
|
177
151
|
});
|
|
178
152
|
const response = await pendingPromise;
|
|
@@ -180,11 +154,11 @@ export const requestConfirmationTool = {
|
|
|
180
154
|
await ctx.publishEvent("_confirm", {
|
|
181
155
|
event_type: "confirm-resolved",
|
|
182
156
|
host_id: ctx.config.hostId,
|
|
183
|
-
session_id: ctx.
|
|
157
|
+
session_id: ctx.taskId,
|
|
184
158
|
status: confirmed ? "confirmed" : "aborted",
|
|
185
159
|
});
|
|
186
160
|
if (!confirmed)
|
|
187
|
-
await
|
|
161
|
+
await ctx.abortTask();
|
|
188
162
|
return { confirmed };
|
|
189
163
|
},
|
|
190
164
|
};
|
|
@@ -219,16 +193,14 @@ const requestChoiceTool = {
|
|
|
219
193
|
}
|
|
220
194
|
if (new Set(options).size !== options.length)
|
|
221
195
|
throw new ToolError("options must be unique", 400);
|
|
222
|
-
const pendingPromise = registerPending(ctx.
|
|
223
|
-
session_id: ctx.
|
|
224
|
-
session_name: ctx.agentName,
|
|
196
|
+
const pendingPromise = registerPending(ctx.taskId, "choice", options, {
|
|
197
|
+
session_id: ctx.taskId,
|
|
225
198
|
description: question,
|
|
226
199
|
});
|
|
227
200
|
await ctx.publishEvent("_choice", {
|
|
228
201
|
event_type: "choice-request",
|
|
229
202
|
host_id: ctx.config.hostId,
|
|
230
|
-
session_id: ctx.
|
|
231
|
-
session_name: ctx.agentName,
|
|
203
|
+
session_id: ctx.taskId,
|
|
232
204
|
description: question,
|
|
233
205
|
options,
|
|
234
206
|
});
|
|
@@ -240,11 +212,11 @@ const requestChoiceTool = {
|
|
|
240
212
|
await ctx.publishEvent("_choice", {
|
|
241
213
|
event_type: "choice-resolved",
|
|
242
214
|
host_id: ctx.config.hostId,
|
|
243
|
-
session_id: ctx.
|
|
215
|
+
session_id: ctx.taskId,
|
|
244
216
|
status: aborted ? "aborted" : "chosen",
|
|
245
217
|
});
|
|
246
218
|
if (aborted) {
|
|
247
|
-
await
|
|
219
|
+
await ctx.abortTask();
|
|
248
220
|
return { aborted: true };
|
|
249
221
|
}
|
|
250
222
|
return { choice };
|
|
@@ -279,9 +251,8 @@ const fillPasswordTool = {
|
|
|
279
251
|
if (password === undefined) {
|
|
280
252
|
const question = `Password or PIN for ${username} on ${origin}`;
|
|
281
253
|
const description = `Enter the password or PIN to sign in as ${username} on ${origin}.`;
|
|
282
|
-
const pendingPromise = registerPending(ctx.
|
|
283
|
-
session_id: ctx.
|
|
284
|
-
session_name: ctx.agentName,
|
|
254
|
+
const pendingPromise = registerPending(ctx.taskId, "input", [question], {
|
|
255
|
+
session_id: ctx.taskId,
|
|
285
256
|
description,
|
|
286
257
|
input_questions: [question],
|
|
287
258
|
secret: true,
|
|
@@ -289,8 +260,7 @@ const fillPasswordTool = {
|
|
|
289
260
|
await ctx.publishEvent("_input", {
|
|
290
261
|
event_type: "input-request",
|
|
291
262
|
host_id: ctx.config.hostId,
|
|
292
|
-
session_id: ctx.
|
|
293
|
-
session_name: ctx.agentName,
|
|
263
|
+
session_id: ctx.taskId,
|
|
294
264
|
description,
|
|
295
265
|
input_questions: [question],
|
|
296
266
|
secret: true,
|
|
@@ -299,11 +269,11 @@ const fillPasswordTool = {
|
|
|
299
269
|
const aborted = response.length === 1 && response[0] === "aborted";
|
|
300
270
|
await ctx.publishEvent("_input", {
|
|
301
271
|
event_type: "input-resolved", host_id: ctx.config.hostId,
|
|
302
|
-
session_id: ctx.
|
|
272
|
+
session_id: ctx.taskId, status: aborted ? "aborted" : "provided",
|
|
303
273
|
});
|
|
304
274
|
// Never record the secret to the task run file (unlike request-input).
|
|
305
275
|
if (aborted) {
|
|
306
|
-
await
|
|
276
|
+
await ctx.abortTask();
|
|
307
277
|
return { aborted: true };
|
|
308
278
|
}
|
|
309
279
|
password = response[0];
|
|
@@ -340,12 +310,12 @@ const deviceGeolocationTool = {
|
|
|
340
310
|
if (!device)
|
|
341
311
|
throw new ToolError("No linked device configured", 400);
|
|
342
312
|
const sc = StringCodec();
|
|
343
|
-
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.geolocation`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.
|
|
313
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.geolocation`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken })), { timeout: 5_000 });
|
|
344
314
|
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
345
315
|
if (ack.error)
|
|
346
316
|
throw new ToolError(ack.error, 502);
|
|
347
317
|
const locationPromise = new Promise((resolve, reject) => {
|
|
348
|
-
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.geolocation.${ctx.
|
|
318
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.geolocation.${ctx.taskId}`, { max: 1 });
|
|
349
319
|
const timer = setTimeout(() => {
|
|
350
320
|
sub.unsubscribe();
|
|
351
321
|
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
@@ -381,12 +351,12 @@ const readContactsTool = {
|
|
|
381
351
|
if (!device)
|
|
382
352
|
throw new ToolError("No linked device configured", 400);
|
|
383
353
|
const sc = StringCodec();
|
|
384
|
-
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.
|
|
354
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken, action: "read" })), { timeout: 5_000 });
|
|
385
355
|
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
386
356
|
if (ack.error)
|
|
387
357
|
throw new ToolError(ack.error, 502);
|
|
388
358
|
const responsePromise = new Promise((resolve, reject) => {
|
|
389
|
-
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.
|
|
359
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.taskId}`, { max: 1 });
|
|
390
360
|
const timer = setTimeout(() => {
|
|
391
361
|
sub.unsubscribe();
|
|
392
362
|
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
@@ -431,14 +401,14 @@ const createContactTool = {
|
|
|
431
401
|
throw new ToolError("name is required", 400);
|
|
432
402
|
const sc = StringCodec();
|
|
433
403
|
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({
|
|
434
|
-
hostId: ctx.config.hostId, requestId: ctx.
|
|
404
|
+
hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
|
|
435
405
|
action: "create", name, phone, email,
|
|
436
406
|
})), { timeout: 5_000 });
|
|
437
407
|
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
438
408
|
if (ack.error)
|
|
439
409
|
throw new ToolError(ack.error, 502);
|
|
440
410
|
const responsePromise = new Promise((resolve, reject) => {
|
|
441
|
-
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.
|
|
411
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.contacts.${ctx.taskId}`, { max: 1 });
|
|
442
412
|
const timer = setTimeout(() => {
|
|
443
413
|
sub.unsubscribe();
|
|
444
414
|
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
@@ -480,7 +450,7 @@ const readCalendarTool = {
|
|
|
480
450
|
const { startDate, endDate } = args;
|
|
481
451
|
const sc = StringCodec();
|
|
482
452
|
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, sc.encode(JSON.stringify({
|
|
483
|
-
hostId: ctx.config.hostId, requestId: ctx.
|
|
453
|
+
hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
|
|
484
454
|
action: "read",
|
|
485
455
|
...(startDate ? { startDate: String(startDate) } : {}),
|
|
486
456
|
...(endDate ? { endDate: String(endDate) } : {}),
|
|
@@ -489,7 +459,7 @@ const readCalendarTool = {
|
|
|
489
459
|
if (ack.error)
|
|
490
460
|
throw new ToolError(ack.error, 502);
|
|
491
461
|
const responsePromise = new Promise((resolve, reject) => {
|
|
492
|
-
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.
|
|
462
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.taskId}`, { max: 1 });
|
|
493
463
|
const timer = setTimeout(() => {
|
|
494
464
|
sub.unsubscribe();
|
|
495
465
|
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
@@ -536,7 +506,7 @@ const createCalendarEventTool = {
|
|
|
536
506
|
throw new ToolError("title, startTime, and endTime are required", 400);
|
|
537
507
|
const sc = StringCodec();
|
|
538
508
|
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, sc.encode(JSON.stringify({
|
|
539
|
-
hostId: ctx.config.hostId, requestId: ctx.
|
|
509
|
+
hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
|
|
540
510
|
action: "create",
|
|
541
511
|
title, startTime: String(startTime), endTime: String(endTime),
|
|
542
512
|
...(location ? { location } : {}),
|
|
@@ -546,7 +516,7 @@ const createCalendarEventTool = {
|
|
|
546
516
|
if (ack.error)
|
|
547
517
|
throw new ToolError(ack.error, 502);
|
|
548
518
|
const responsePromise = new Promise((resolve, reject) => {
|
|
549
|
-
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.
|
|
519
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.calendar.${ctx.taskId}`, { max: 1 });
|
|
550
520
|
const timer = setTimeout(() => {
|
|
551
521
|
sub.unsubscribe();
|
|
552
522
|
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
@@ -590,14 +560,14 @@ const sendSmsTool = {
|
|
|
590
560
|
throw new ToolError("to and body are required", 400);
|
|
591
561
|
const sc = StringCodec();
|
|
592
562
|
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.sms`, sc.encode(JSON.stringify({
|
|
593
|
-
hostId: ctx.config.hostId, requestId: ctx.
|
|
563
|
+
hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
|
|
594
564
|
action: "send", to, body,
|
|
595
565
|
})), { timeout: 5_000 });
|
|
596
566
|
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
597
567
|
if (ack.error)
|
|
598
568
|
throw new ToolError(ack.error, 502);
|
|
599
569
|
const responsePromise = new Promise((resolve, reject) => {
|
|
600
|
-
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.sms.${ctx.
|
|
570
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.sms.${ctx.taskId}`, { max: 1 });
|
|
601
571
|
const timer = setTimeout(() => {
|
|
602
572
|
sub.unsubscribe();
|
|
603
573
|
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
@@ -642,7 +612,7 @@ const sendAlarmTool = {
|
|
|
642
612
|
throw new ToolError("title is required", 400);
|
|
643
613
|
const sc = StringCodec();
|
|
644
614
|
const payload = {
|
|
645
|
-
hostId: ctx.config.hostId, requestId: ctx.
|
|
615
|
+
hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
|
|
646
616
|
title,
|
|
647
617
|
};
|
|
648
618
|
if (description)
|
|
@@ -652,7 +622,7 @@ const sendAlarmTool = {
|
|
|
652
622
|
if (ack.error)
|
|
653
623
|
throw new ToolError(ack.error, 502);
|
|
654
624
|
const responsePromise = new Promise((resolve, reject) => {
|
|
655
|
-
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.alarm.${ctx.
|
|
625
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.alarm.${ctx.taskId}`, { max: 1 });
|
|
656
626
|
const timer = setTimeout(() => {
|
|
657
627
|
sub.unsubscribe();
|
|
658
628
|
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
@@ -688,12 +658,12 @@ const readBatteryTool = {
|
|
|
688
658
|
if (!device)
|
|
689
659
|
throw new ToolError("No linked device configured", 400);
|
|
690
660
|
const sc = StringCodec();
|
|
691
|
-
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.battery`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.
|
|
661
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.battery`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken })), { timeout: 5_000 });
|
|
692
662
|
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
693
663
|
if (ack.error)
|
|
694
664
|
throw new ToolError(ack.error, 502);
|
|
695
665
|
const responsePromise = new Promise((resolve, reject) => {
|
|
696
|
-
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.battery.${ctx.
|
|
666
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.battery.${ctx.taskId}`, { max: 1 });
|
|
697
667
|
const timer = setTimeout(() => {
|
|
698
668
|
sub.unsubscribe();
|
|
699
669
|
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
@@ -735,12 +705,12 @@ const setRingerModeTool = {
|
|
|
735
705
|
if (!["normal", "vibrate", "silent"].includes(mode))
|
|
736
706
|
throw new ToolError("mode must be 'normal', 'vibrate', or 'silent'", 400);
|
|
737
707
|
const sc = StringCodec();
|
|
738
|
-
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.ringer`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.
|
|
708
|
+
const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.ringer`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken, mode })), { timeout: 5_000 });
|
|
739
709
|
const ack = JSON.parse(sc.decode(ackReply.data));
|
|
740
710
|
if (ack.error)
|
|
741
711
|
throw new ToolError(ack.error, 502);
|
|
742
712
|
const responsePromise = new Promise((resolve, reject) => {
|
|
743
|
-
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.ringer.${ctx.
|
|
713
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.ringer.${ctx.taskId}`, { max: 1 });
|
|
744
714
|
const timer = setTimeout(() => {
|
|
745
715
|
sub.unsubscribe();
|
|
746
716
|
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
@@ -787,7 +757,7 @@ const sendEmailTool = {
|
|
|
787
757
|
throw new ToolError("to is required", 400);
|
|
788
758
|
const sc = StringCodec();
|
|
789
759
|
const payload = {
|
|
790
|
-
hostId: ctx.config.hostId, requestId: ctx.
|
|
760
|
+
hostId: ctx.config.hostId, requestId: ctx.taskId, fcmToken: device.fcmToken,
|
|
791
761
|
to,
|
|
792
762
|
};
|
|
793
763
|
if (subject)
|
|
@@ -803,7 +773,7 @@ const sendEmailTool = {
|
|
|
803
773
|
if (ack.error)
|
|
804
774
|
throw new ToolError(ack.error, 502);
|
|
805
775
|
const responsePromise = new Promise((resolve, reject) => {
|
|
806
|
-
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.email.${ctx.
|
|
776
|
+
const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.email.${ctx.taskId}`, { max: 1 });
|
|
807
777
|
const timer = setTimeout(() => {
|
|
808
778
|
sub.unsubscribe();
|
|
809
779
|
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
@@ -824,31 +794,24 @@ const sendEmailTool = {
|
|
|
824
794
|
export const agentTools = [notifyTool, requestInputTool, requestChoiceTool, fillPasswordTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlarmTool, readBatteryTool, setRingerModeTool];
|
|
825
795
|
export const agentToolMap = new Map(agentTools.map((t) => [t.name, t]));
|
|
826
796
|
const deviceNotificationsResource = {
|
|
827
|
-
uri: "notifications://device",
|
|
828
797
|
name: "Device Notifications",
|
|
829
798
|
description: [
|
|
830
799
|
"Get recent notifications from the user's Android device.",
|
|
831
800
|
"Response: JSON array of notification objects with `id`, `packageName`, `appName`, `title`, `text`, `timestamp`.",
|
|
832
801
|
],
|
|
833
|
-
mimeType: "application/json",
|
|
834
802
|
restPath: "/notifications",
|
|
835
803
|
read: getNotifications,
|
|
836
|
-
subscribe: onNotificationsChanged,
|
|
837
804
|
};
|
|
838
805
|
const deviceSmsResource = {
|
|
839
|
-
uri: "sms-messages://device",
|
|
840
806
|
name: "Device SMS",
|
|
841
807
|
description: [
|
|
842
808
|
"Get recent SMS messages from the user's Android device.",
|
|
843
809
|
"Response: JSON array of message objects with `id`, `sender`, `body`, `timestamp`.",
|
|
844
810
|
],
|
|
845
|
-
mimeType: "application/json",
|
|
846
811
|
restPath: "/sms-messages",
|
|
847
812
|
read: getSmsMessages,
|
|
848
|
-
subscribe: onSmsChanged,
|
|
849
813
|
};
|
|
850
814
|
export const agentResources = [deviceNotificationsResource, deviceSmsResource];
|
|
851
|
-
export const agentResourceMap = new Map(agentResources.map((r) => [r.uri, r]));
|
|
852
815
|
export function generateEndpointDocs(port, taskId, tools = agentTools, resources = agentResources) {
|
|
853
816
|
const baseUrl = `http://localhost:${port}`;
|
|
854
817
|
const lines = [
|
|
@@ -2,7 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
|
-
import { generateEndpointDocs } from "../
|
|
5
|
+
import { generateEndpointDocs } from "../agent-tools.js";
|
|
6
6
|
import { getAgent } from "./agent.js";
|
|
7
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
const AGENT_INSTRUCTIONS_TEMPLATE = fs.readFileSync(path.join(__dirname, "agent-instructions.md"), "utf-8");
|
package/dist/nats-client.js
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
const MAX_NOTIFICATIONS = 50;
|
|
2
2
|
const notifications = [];
|
|
3
|
-
const listeners = new Set();
|
|
4
3
|
export function addNotification(n) {
|
|
5
4
|
notifications.push(n);
|
|
6
5
|
if (notifications.length > MAX_NOTIFICATIONS) {
|
|
7
6
|
notifications.shift();
|
|
8
7
|
}
|
|
9
|
-
for (const cb of listeners)
|
|
10
|
-
cb();
|
|
11
8
|
}
|
|
12
9
|
export function getNotifications() {
|
|
13
10
|
return [...notifications];
|
|
14
11
|
}
|
|
15
|
-
export function onNotificationsChanged(cb) {
|
|
16
|
-
listeners.add(cb);
|
|
17
|
-
return () => { listeners.delete(cb); };
|
|
18
|
-
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { RequiredPermission } from "./types.js";
|
|
2
2
|
export interface PendingRequestMeta {
|
|
3
|
-
/**
|
|
3
|
+
/** The task the request belongs to. */
|
|
4
4
|
session_id?: string;
|
|
5
|
-
/**
|
|
6
|
-
* confirm/input/choice, task name for permission. */
|
|
5
|
+
/** Task name, set for permission requests. */
|
|
7
6
|
session_name?: string;
|
|
8
7
|
description?: string;
|
|
9
8
|
input_questions?: string[];
|
|
@@ -19,9 +18,8 @@ export interface PendingRequest {
|
|
|
19
18
|
meta?: PendingRequestMeta;
|
|
20
19
|
}
|
|
21
20
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* freshly-connected PWA can render the modal without replaying events.
|
|
21
|
+
* Keyed by taskId. Only one pending request per key at a time. `meta` is surfaced
|
|
22
|
+
* via host.info so a freshly-connected PWA can render the modal without replaying events.
|
|
25
23
|
*/
|
|
26
24
|
export declare function registerPending(key: string, type: PendingRequest["type"], params?: PendingRequest["params"], meta?: PendingRequestMeta): Promise<string[]>;
|
|
27
25
|
export declare function resolvePending(key: string, value: string[]): boolean;
|
package/dist/pending-requests.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
const pending = new Map();
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* freshly-connected PWA can render the modal without replaying events.
|
|
3
|
+
* Keyed by taskId. Only one pending request per key at a time. `meta` is surfaced
|
|
4
|
+
* via host.info so a freshly-connected PWA can render the modal without replaying events.
|
|
6
5
|
*/
|
|
7
6
|
export function registerPending(key, type, params, meta) {
|
|
8
7
|
if (pending.has(key)) {
|
package/dist/sms-store.d.ts
CHANGED
package/dist/sms-store.js
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
const MAX_MESSAGES = 50;
|
|
2
2
|
const messages = [];
|
|
3
|
-
const listeners = new Set();
|
|
4
3
|
export function addSmsMessage(m) {
|
|
5
4
|
messages.push(m);
|
|
6
5
|
if (messages.length > MAX_MESSAGES) {
|
|
7
6
|
messages.shift();
|
|
8
7
|
}
|
|
9
|
-
for (const cb of listeners)
|
|
10
|
-
cb();
|
|
11
8
|
}
|
|
12
9
|
export function getSmsMessages() {
|
|
13
10
|
return [...messages];
|
|
14
11
|
}
|
|
15
|
-
export function onSmsChanged(cb) {
|
|
16
|
-
listeners.add(cb);
|
|
17
|
-
return () => { listeners.delete(cb); };
|
|
18
|
-
}
|
|
@@ -7,8 +7,7 @@ import { registerPending } from "../pending-requests.js";
|
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import { saveConfig } from "../config.js";
|
|
9
9
|
import { detectDefaultInterface } from "../network.js";
|
|
10
|
-
import { agentToolMap, agentResources, requestConfirmationTool, ToolError } from "../
|
|
11
|
-
import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
|
|
10
|
+
import { agentToolMap, agentResources, requestConfirmationTool, ToolError } from "../agent-tools.js";
|
|
12
11
|
import { getTaskDir } from "../task.js";
|
|
13
12
|
import { popEvent } from "../event-queues.js";
|
|
14
13
|
const assetCache = new Map();
|
|
@@ -64,29 +63,9 @@ async function persistDefaultInterface(config) {
|
|
|
64
63
|
}
|
|
65
64
|
export async function startHttpTransport(config, handleRpc, port, nc, pairingCode, onReady) {
|
|
66
65
|
const sseClients = new Set();
|
|
67
|
-
const mcpStreams = new Map();
|
|
68
66
|
// Always bind 0.0.0.0 so other devices on the LAN can reach /rpc and /health.
|
|
69
67
|
// The web UI, /pair, and /events are individually gated to loopback.
|
|
70
68
|
const bindAddress = "0.0.0.0";
|
|
71
|
-
/** Push notifications/resources/updated to all MCP clients subscribed to the given URI. */
|
|
72
|
-
function broadcastResourceUpdated(uri) {
|
|
73
|
-
const subs = getResourceSubscriptions();
|
|
74
|
-
for (const [sessionId, uris] of subs) {
|
|
75
|
-
if (!uris.has(uri))
|
|
76
|
-
continue;
|
|
77
|
-
const stream = mcpStreams.get(sessionId);
|
|
78
|
-
if (!stream)
|
|
79
|
-
continue;
|
|
80
|
-
stream.write(`data: ${JSON.stringify({
|
|
81
|
-
jsonrpc: "2.0",
|
|
82
|
-
method: "notifications/resources/updated",
|
|
83
|
-
params: { uri },
|
|
84
|
-
})}\n\n`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
for (const resource of agentResources) {
|
|
88
|
-
resource.subscribe(() => broadcastResourceUpdated(resource.uri));
|
|
89
|
-
}
|
|
90
69
|
if (pairingCode) {
|
|
91
70
|
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
92
71
|
const timer = setTimeout(() => { pendingPairs.delete(pairingCode); }, EXPIRY_MS);
|
|
@@ -134,10 +113,10 @@ export async function startHttpTransport(config, handleRpc, port, nc, pairingCod
|
|
|
134
113
|
}
|
|
135
114
|
broadcastSseEvent({ task_id: taskId, ...payload });
|
|
136
115
|
}
|
|
137
|
-
function makeToolContext(
|
|
116
|
+
function makeToolContext(taskId) {
|
|
138
117
|
return {
|
|
139
|
-
config, nc, publishEvent,
|
|
140
|
-
abortTask: async (
|
|
118
|
+
config, nc, publishEvent, taskId,
|
|
119
|
+
abortTask: async () => { await handleRpc({ method: "task.abort", params: { id: taskId }, localhost: true }); },
|
|
141
120
|
};
|
|
142
121
|
}
|
|
143
122
|
const server = http.createServer(async (req, res) => {
|
|
@@ -151,44 +130,6 @@ export async function startHttpTransport(config, handleRpc, port, nc, pairingCod
|
|
|
151
130
|
res.end(JSON.stringify({ ok: true, hostId: config.hostId }));
|
|
152
131
|
return;
|
|
153
132
|
}
|
|
154
|
-
if (req.method === "POST" && pathname === "/mcp") {
|
|
155
|
-
if (!isLocalhost(req)) {
|
|
156
|
-
sendJson(res, 403, { error: "localhost only" });
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
try {
|
|
160
|
-
const body = await readBody(req);
|
|
161
|
-
const sessionId = req.headers["mcp-session-id"];
|
|
162
|
-
const ctx = makeToolContext(sessionId ?? "");
|
|
163
|
-
const result = await handleMcpRequest(body, sessionId, ctx);
|
|
164
|
-
if (result.sessionId) {
|
|
165
|
-
res.setHeader("Mcp-Session-Id", result.sessionId);
|
|
166
|
-
}
|
|
167
|
-
if (result.stream && sessionId) {
|
|
168
|
-
// Keep the response open as SSE for server-initiated notifications.
|
|
169
|
-
res.writeHead(200, {
|
|
170
|
-
"Content-Type": "text/event-stream",
|
|
171
|
-
"Cache-Control": "no-cache",
|
|
172
|
-
"Connection": "keep-alive",
|
|
173
|
-
});
|
|
174
|
-
res.write(`data: ${JSON.stringify(result.body)}\n\n`);
|
|
175
|
-
mcpStreams.set(sessionId, res);
|
|
176
|
-
const heartbeat = setInterval(() => { res.write(":heartbeat\n\n"); }, 15_000);
|
|
177
|
-
req.on("close", () => {
|
|
178
|
-
clearInterval(heartbeat);
|
|
179
|
-
mcpStreams.delete(sessionId);
|
|
180
|
-
getResourceSubscriptions().delete(sessionId);
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
else {
|
|
184
|
-
sendJson(res, 200, result.body);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
catch (err) {
|
|
188
|
-
sendJson(res, 500, { error: String(err) });
|
|
189
|
-
}
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
133
|
const tool = agentToolMap.get(pathname.slice(1))
|
|
193
134
|
?? (pathname === `/${requestConfirmationTool.name}` ? requestConfirmationTool : undefined);
|
|
194
135
|
if (req.method === "POST" && tool) {
|
|
@@ -210,14 +151,14 @@ export async function startHttpTransport(config, handleRpc, port, nc, pairingCod
|
|
|
210
151
|
const body = await readBody(req);
|
|
211
152
|
const args = body.trim() ? JSON.parse(body) : {};
|
|
212
153
|
const ctx = makeToolContext(taskId);
|
|
213
|
-
console.log(`[
|
|
154
|
+
console.log(`[tool] [${taskId.slice(0, 8)}] ${tool.name}`);
|
|
214
155
|
const result = await tool.handler(args, ctx);
|
|
215
|
-
console.log(`[
|
|
156
|
+
console.log(`[tool] [${taskId.slice(0, 8)}] ${tool.name} done:`, JSON.stringify(result).slice(0, 200));
|
|
216
157
|
sendJson(res, 200, result);
|
|
217
158
|
}
|
|
218
159
|
catch (err) {
|
|
219
160
|
const status = err instanceof ToolError ? err.statusCode : 500;
|
|
220
|
-
console.error(`[
|
|
161
|
+
console.error(`[tool] ${tool.name} error:`, err.message ?? String(err));
|
|
221
162
|
sendJson(res, status, { error: err.message ?? String(err) });
|
|
222
163
|
}
|
|
223
164
|
return;
|
|
@@ -238,9 +179,9 @@ export async function startHttpTransport(config, handleRpc, port, nc, pairingCod
|
|
|
238
179
|
sendJson(res, 404, { error: `Task not found: ${taskId}` });
|
|
239
180
|
return;
|
|
240
181
|
}
|
|
241
|
-
console.log(`[
|
|
182
|
+
console.log(`[resource] [${taskId.slice(0, 8)}] ${matchedResource.name}`);
|
|
242
183
|
const result = matchedResource.read();
|
|
243
|
-
console.log(`[
|
|
184
|
+
console.log(`[resource] [${taskId.slice(0, 8)}] ${matchedResource.name} done: ${JSON.stringify(result).slice(0, 200)}`);
|
|
244
185
|
sendJson(res, 200, result);
|
|
245
186
|
return;
|
|
246
187
|
}
|
package/package.json
CHANGED
package/dist/mcp-handler.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { type ToolContext } from "./mcp-tools.js";
|
|
2
|
-
export interface McpResponse {
|
|
3
|
-
body: object;
|
|
4
|
-
sessionId?: string;
|
|
5
|
-
/** If true, the HTTP transport should keep the response open as an SSE stream for server-initiated notifications. */
|
|
6
|
-
stream?: boolean;
|
|
7
|
-
}
|
|
8
|
-
export declare function getResourceSubscriptions(): Map<string, Set<string>>;
|
|
9
|
-
export declare function getAgentName(sessionId: string): string | undefined;
|
|
10
|
-
export declare function handleMcpRequest(body: string, sessionId: string | undefined, ctx: ToolContext): Promise<McpResponse>;
|
package/dist/mcp-handler.js
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "crypto";
|
|
2
|
-
import { agentTools, agentToolMap, agentResources, agentResourceMap, ToolError } from "./mcp-tools.js";
|
|
3
|
-
/** sessionId → subscribed resource URIs */
|
|
4
|
-
const resourceSubscriptions = new Map();
|
|
5
|
-
export function getResourceSubscriptions() {
|
|
6
|
-
return resourceSubscriptions;
|
|
7
|
-
}
|
|
8
|
-
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
9
|
-
const sessionAgents = new Map();
|
|
10
|
-
export function getAgentName(sessionId) {
|
|
11
|
-
const entry = sessionAgents.get(sessionId);
|
|
12
|
-
if (!entry)
|
|
13
|
-
return undefined;
|
|
14
|
-
if (Date.now() > entry.expiresAt) {
|
|
15
|
-
sessionAgents.delete(sessionId);
|
|
16
|
-
return undefined;
|
|
17
|
-
}
|
|
18
|
-
return entry.agentName;
|
|
19
|
-
}
|
|
20
|
-
function pruneExpiredSessions() {
|
|
21
|
-
const now = Date.now();
|
|
22
|
-
for (const [id, entry] of sessionAgents) {
|
|
23
|
-
if (now > entry.expiresAt) {
|
|
24
|
-
sessionAgents.delete(id);
|
|
25
|
-
resourceSubscriptions.delete(id);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
function rpcError(id, code, message) {
|
|
30
|
-
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
31
|
-
}
|
|
32
|
-
function rpcResult(id, result) {
|
|
33
|
-
return { jsonrpc: "2.0", id, result };
|
|
34
|
-
}
|
|
35
|
-
export async function handleMcpRequest(body, sessionId, ctx) {
|
|
36
|
-
let req;
|
|
37
|
-
try {
|
|
38
|
-
req = JSON.parse(body);
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
return { body: rpcError(null, -32700, "Parse error") };
|
|
42
|
-
}
|
|
43
|
-
const id = req.id ?? null;
|
|
44
|
-
if (req.jsonrpc !== "2.0") {
|
|
45
|
-
return { body: rpcError(id, -32600, "Invalid Request: missing jsonrpc 2.0") };
|
|
46
|
-
}
|
|
47
|
-
const agent = sessionId ? getAgentName(sessionId) : undefined;
|
|
48
|
-
const sid = sessionId?.slice(0, 8) ?? "none";
|
|
49
|
-
const logPrefix = agent ? `[mcp] [${sid}] [${agent}]` : `[mcp] [${sid}]`;
|
|
50
|
-
console.log(`${logPrefix} ${req.method}${req.method === "tools/call" ? ` → ${req.params?.name}` : ""}`);
|
|
51
|
-
switch (req.method) {
|
|
52
|
-
case "initialize": {
|
|
53
|
-
const newSessionId = randomUUID();
|
|
54
|
-
const clientInfo = req.params?.clientInfo;
|
|
55
|
-
const agentName = clientInfo
|
|
56
|
-
? `${clientInfo.name || "unknown"}${clientInfo.version ? ` ${clientInfo.version}` : ""}`
|
|
57
|
-
: undefined;
|
|
58
|
-
if (agentName) {
|
|
59
|
-
sessionAgents.set(newSessionId, { agentName, expiresAt: Date.now() + SESSION_TTL_MS });
|
|
60
|
-
pruneExpiredSessions();
|
|
61
|
-
}
|
|
62
|
-
console.log(`[mcp] [${newSessionId.slice(0, 8)}] Session initialized${agentName ? ` (${agentName})` : ""}`);
|
|
63
|
-
return {
|
|
64
|
-
body: rpcResult(id, {
|
|
65
|
-
protocolVersion: "2025-03-26",
|
|
66
|
-
capabilities: { tools: {}, resources: { subscribe: true } },
|
|
67
|
-
serverInfo: { name: "palmier", version: "1.0.0" },
|
|
68
|
-
}),
|
|
69
|
-
sessionId: newSessionId,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
case "tools/list": {
|
|
73
|
-
return {
|
|
74
|
-
body: rpcResult(id, {
|
|
75
|
-
tools: agentTools.map((t) => ({
|
|
76
|
-
name: t.name,
|
|
77
|
-
description: t.description.join(" "),
|
|
78
|
-
inputSchema: t.inputSchema,
|
|
79
|
-
})),
|
|
80
|
-
}),
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
case "tools/call": {
|
|
84
|
-
const name = req.params?.name;
|
|
85
|
-
const args = (req.params?.arguments ?? {});
|
|
86
|
-
if (!name)
|
|
87
|
-
return { body: rpcError(id, -32602, "Missing params.name") };
|
|
88
|
-
const tool = agentToolMap.get(name);
|
|
89
|
-
if (!tool)
|
|
90
|
-
return { body: rpcError(id, -32602, `Unknown tool: ${name}`) };
|
|
91
|
-
try {
|
|
92
|
-
const result = await tool.handler(args, ctx);
|
|
93
|
-
console.log(`${logPrefix} tools/call ${name} done:`, JSON.stringify(result).slice(0, 200));
|
|
94
|
-
return {
|
|
95
|
-
body: rpcResult(id, {
|
|
96
|
-
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
97
|
-
}),
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
catch (err) {
|
|
101
|
-
const message = err instanceof ToolError ? err.message : String(err);
|
|
102
|
-
console.error(`${logPrefix} tools/call ${name} error:`, message);
|
|
103
|
-
return {
|
|
104
|
-
body: rpcResult(id, {
|
|
105
|
-
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
|
106
|
-
isError: true,
|
|
107
|
-
}),
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
case "resources/list": {
|
|
112
|
-
return {
|
|
113
|
-
body: rpcResult(id, {
|
|
114
|
-
resources: agentResources.map((r) => ({
|
|
115
|
-
uri: r.uri,
|
|
116
|
-
name: r.name,
|
|
117
|
-
description: r.description[0],
|
|
118
|
-
mimeType: r.mimeType,
|
|
119
|
-
})),
|
|
120
|
-
}),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
case "resources/read": {
|
|
124
|
-
const uri = req.params?.uri;
|
|
125
|
-
const resource = agentResourceMap.get(uri);
|
|
126
|
-
if (!resource) {
|
|
127
|
-
return { body: rpcError(id, -32602, `Unknown resource: ${uri}`) };
|
|
128
|
-
}
|
|
129
|
-
console.log(`${logPrefix} resources/read ${uri}`);
|
|
130
|
-
const content = resource.read();
|
|
131
|
-
console.log(`${logPrefix} resources/read ${uri} done: ${JSON.stringify(content).slice(0, 200)}`);
|
|
132
|
-
return {
|
|
133
|
-
body: rpcResult(id, {
|
|
134
|
-
contents: [{
|
|
135
|
-
uri: resource.uri,
|
|
136
|
-
mimeType: resource.mimeType,
|
|
137
|
-
text: JSON.stringify(content),
|
|
138
|
-
}],
|
|
139
|
-
}),
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
case "resources/subscribe": {
|
|
143
|
-
const uri = req.params?.uri;
|
|
144
|
-
if (!agentResourceMap.has(uri)) {
|
|
145
|
-
return { body: rpcError(id, -32602, `Unknown resource: ${uri}`) };
|
|
146
|
-
}
|
|
147
|
-
if (!sessionId) {
|
|
148
|
-
return { body: rpcError(id, -32600, "Session required for subscriptions") };
|
|
149
|
-
}
|
|
150
|
-
if (!resourceSubscriptions.has(sessionId)) {
|
|
151
|
-
resourceSubscriptions.set(sessionId, new Set());
|
|
152
|
-
}
|
|
153
|
-
resourceSubscriptions.get(sessionId).add(uri);
|
|
154
|
-
return { body: rpcResult(id, {}), stream: true };
|
|
155
|
-
}
|
|
156
|
-
case "resources/unsubscribe": {
|
|
157
|
-
const uri = req.params?.uri;
|
|
158
|
-
if (sessionId) {
|
|
159
|
-
resourceSubscriptions.get(sessionId)?.delete(uri);
|
|
160
|
-
}
|
|
161
|
-
return { body: rpcResult(id, {}) };
|
|
162
|
-
}
|
|
163
|
-
default:
|
|
164
|
-
console.warn(`${logPrefix} Unknown method: ${req.method}`);
|
|
165
|
-
return { body: rpcError(id, -32601, `Method not found: ${req.method}`) };
|
|
166
|
-
}
|
|
167
|
-
}
|