palmier 1.1.16 → 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 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
- ### MCP Server
48
+ ### Agent Capabilities
49
49
 
50
- Palmier exposes an [MCP](https://modelcontextprotocol.io) server at `http://localhost:7256/mcp` (streamable HTTP transport). MCP-capable agents can register it to get tool and resource definitions automatically. The same tools and resources are also readily available if running agent tasks directly from Palmier app, without installing the MCP server.
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
- **MCP server URL:** `http://localhost:7256/mcp`
53
-
54
- **Available tools:**
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
- **Available resources:**
73
- | Resource | URI | Description |
74
- |----------|-----|-------------|
75
- | Device Notifications | `notifications://device` | Recent notifications from the user's Android device |
76
- | Device SMS | `sms-messages://device` | Recent SMS messages from the user's Android device |
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
- │ (MCP Server)│◄──────┐ │ │
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
- sessionId: string;
12
- agentName?: string;
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, onNotificationsChanged } from "./notification-store.js";
6
- import { getSmsMessages, onSmsChanged } from "./sms-store.js";
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.sessionId, "input", questions, {
77
- session_id: ctx.sessionId,
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.sessionId,
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.sessionId, status: "aborted",
91
+ session_id: ctx.taskId, status: "aborted",
99
92
  });
100
- await abortSessionTask(ctx);
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.sessionId, status: "provided",
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.sessionId;
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.sessionId, "confirmation", undefined, {
167
- session_id: ctx.sessionId,
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.sessionId,
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.sessionId,
157
+ session_id: ctx.taskId,
184
158
  status: confirmed ? "confirmed" : "aborted",
185
159
  });
186
160
  if (!confirmed)
187
- await abortSessionTask(ctx);
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.sessionId, "choice", options, {
223
- session_id: ctx.sessionId,
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.sessionId,
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.sessionId,
215
+ session_id: ctx.taskId,
244
216
  status: aborted ? "aborted" : "chosen",
245
217
  });
246
218
  if (aborted) {
247
- await abortSessionTask(ctx);
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.sessionId, "input", [question], {
283
- session_id: ctx.sessionId,
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.sessionId,
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.sessionId, status: aborted ? "aborted" : "provided",
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 abortSessionTask(ctx);
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.sessionId, fcmToken: device.fcmToken })), { timeout: 5_000 });
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.sessionId}`, { max: 1 });
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.sessionId, fcmToken: device.fcmToken, action: "read" })), { timeout: 5_000 });
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.sessionId}`, { max: 1 });
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.sessionId, fcmToken: device.fcmToken,
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.sessionId}`, { max: 1 });
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.sessionId, fcmToken: device.fcmToken,
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.sessionId}`, { max: 1 });
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.sessionId, fcmToken: device.fcmToken,
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.sessionId}`, { max: 1 });
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.sessionId, fcmToken: device.fcmToken,
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.sessionId}`, { max: 1 });
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.sessionId, fcmToken: device.fcmToken,
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.sessionId}`, { max: 1 });
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.sessionId, fcmToken: device.fcmToken })), { timeout: 5_000 });
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.sessionId}`, { max: 1 });
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.sessionId, fcmToken: device.fcmToken, mode })), { timeout: 5_000 });
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.sessionId}`, { max: 1 });
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.sessionId, fcmToken: device.fcmToken,
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.sessionId}`, { max: 1 });
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 = [
@@ -57,10 +57,11 @@ export function getNpmInstalledVersion(npmPackage) {
57
57
  const agentRegistry = {
58
58
  claude: claudeAgent,
59
59
  codex: codexAgent,
60
- openclaw: openClawAgent,
60
+ grok: grokAgent,
61
61
  copilot: copilotAgent,
62
- qwen: qwenAgent,
63
62
  kimi: kimiAgent,
63
+ qoder: qoderAgent,
64
+ qwen: qwenAgent,
64
65
  droid: droidAgent,
65
66
  goose: gooseAgent,
66
67
  opencode: openCodeAgent,
@@ -69,9 +70,8 @@ const agentRegistry = {
69
70
  cursor: cursorAgent,
70
71
  kiro: kiroAgent,
71
72
  cline: clineAgent,
72
- qoder: qoderAgent,
73
+ openclaw: openClawAgent,
73
74
  hermes: hermesAgent,
74
- grok: grokAgent,
75
75
  };
76
76
  const TIER_ONE_ORDER = ["claude", "codex", "copilot"];
77
77
  export function listInstallableAgents() {
@@ -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 "../mcp-tools.js";
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");
@@ -7,6 +7,5 @@ export async function connectNats(config) {
7
7
  servers: config.natsUrl,
8
8
  authenticator: jwtAuthenticator(config.natsJwt, new TextEncoder().encode(config.natsNkeySeed)),
9
9
  });
10
- // Do not log — it would pollute stdout for the MCP server.
11
10
  return nc;
12
11
  }
@@ -9,4 +9,3 @@ export interface DeviceNotification {
9
9
  }
10
10
  export declare function addNotification(n: DeviceNotification): void;
11
11
  export declare function getNotifications(): DeviceNotification[];
12
- export declare function onNotificationsChanged(cb: () => void): () => void;
@@ -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
- /** Doubles as task_id for permission-type entries (the key the task uses). */
3
+ /** The task the request belongs to. */
4
4
  session_id?: string;
5
- /** Human-readable label for whoever opened the prompt — agent name for
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
- * Key is sessionId for confirmation/input/choice, taskId for permission. Only one
23
- * pending request per key at a time. `meta` is surfaced via host.info so a
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;
@@ -1,8 +1,7 @@
1
1
  const pending = new Map();
2
2
  /**
3
- * Key is sessionId for confirmation/input/choice, taskId for permission. Only one
4
- * pending request per key at a time. `meta` is surfaced via host.info so a
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)) {
@@ -7,4 +7,3 @@ export interface SmsMessage {
7
7
  }
8
8
  export declare function addSmsMessage(m: SmsMessage): void;
9
9
  export declare function getSmsMessages(): SmsMessage[];
10
- export declare function onSmsChanged(cb: () => void): () => void;
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 "../mcp-tools.js";
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(sessionId) {
116
+ function makeToolContext(taskId) {
138
117
  return {
139
- config, nc, publishEvent, sessionId, agentName: getAgentName(sessionId),
140
- abortTask: async (taskId) => { await handleRpc({ method: "task.abort", params: { id: taskId }, localhost: true }); },
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(`[mcp] REST [${taskId.slice(0, 8)}] ${tool.name}`);
154
+ console.log(`[tool] [${taskId.slice(0, 8)}] ${tool.name}`);
214
155
  const result = await tool.handler(args, ctx);
215
- console.log(`[mcp] REST [${taskId.slice(0, 8)}] ${tool.name} done:`, JSON.stringify(result).slice(0, 200));
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(`[mcp] REST ${tool.name} error:`, err.message ?? String(err));
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(`[mcp] REST [${taskId.slice(0, 8)}] ${matchedResource.name}`);
182
+ console.log(`[resource] [${taskId.slice(0, 8)}] ${matchedResource.name}`);
242
183
  const result = matchedResource.read();
243
- console.log(`[mcp] REST [${taskId.slice(0, 8)}] ${matchedResource.name} done: ${JSON.stringify(result).slice(0, 200)}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palmier",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
4
4
  "description": "Palmier host CLI - provisions, executes tasks, and serves NATS RPC",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hongxu Cai",
@@ -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>;
@@ -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
- }