palmier 0.7.3 → 0.7.4

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
@@ -53,37 +53,47 @@ Palmier exposes an [MCP](https://modelcontextprotocol.io) server at `http://loca
53
53
  | `create-contact` | Create a new contact on the user's device | Contacts Access |
54
54
  | `read-calendar` | Read calendar events (with time range filter) | Calendar Access |
55
55
  | `create-calendar-event` | Create a calendar event on the user's device | Calendar Access |
56
- | `send-sms` | Send an SMS message from the user's device | SMS Access |
56
+ | `send-sms-message` | Send an SMS message from the user's device | SMS Access |
57
57
  | `set-alarm` | Set an alarm on the user's device | None |
58
58
  | `read-battery` | Get battery level and charging status | None |
59
59
  | `set-ringer-mode` | Set ringer mode (normal/vibrate/silent) | Do Not Disturb Control |
60
60
 
61
61
  **Available resources:**
62
- | Resource | URI | REST | Description |
63
- |----------|-----|------|-------------|
64
- | Device Notifications | `notifications://device` | `GET /notifications` | Recent notifications from the user's Android device |
65
- | Device SMS | `sms://device` | `GET /sms` | Recent SMS messages from the user's Android device |
62
+ | Resource | URI | Permission | Description |
63
+ |----------|-----|------------|-------------|
64
+ | Device Notifications | `notifications://device` | Notification Access | Recent notifications from the user's Android device |
65
+ | Device SMS | `sms-messages://device` | SMS Access | Recent SMS messages from the user's Android device |
66
66
 
67
67
  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.
68
68
 
69
69
  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. Permissions listed above must be granted via toggles in the Android app's settings menu.
70
70
 
71
+ ### Architecture
72
+
71
73
  ```
72
74
  ┌──────────────┐ HTTP ┌──────────────────┐
73
75
  │ │◄──────────────────────│ │
74
76
  │ Host Daemon │ │ PWA (Browser) │
75
- │◄──────┐ │ │
76
- └──────┬───────┘ │ └──────────────────┘
77
- │ │
78
- │ NATS (TLS) │ NATS (TLS)
79
- ┌──────────────┐ │ ┌────────┴─────────┐
80
- Agent CLIs └───────────────│ Relay Server │
81
- (Claude, │ │ (passthrough, │
82
- Gemini, │ push notify)
83
- │ Codex ...) │ └──────────────────┘
84
- └──────────────┘
77
+ (MCP Server)│◄──────┐ │ │
78
+ └──┬────────┬──┘ │ └──────────────────┘
79
+
80
+ │ NATS (TLS) │ NATS (TLS)
81
+ ┌──────┐ ┌──────┐ │ ┌────────┴─────────┐
82
+ │Agent │ │Agent │ └───────────────│ Relay Server │
83
+ CLIs │Tools/│ │ (passthrough, │
84
+ Rsrcs │◄──── FCM ───────────│ push, FCM)
85
+ └──────┘ └──────┘ └──────────────────┘
86
+
87
+ FCM │
88
+
89
+ ┌──────────────────┐
90
+ │ Android Device │
91
+ │ (notifications, │
92
+ │ SMS, contacts, │
93
+ │ calendar, GPS) │
94
+ └──────────────────┘
85
95
  Local / LAN: direct HTTP
86
- Server mode: via relay server
96
+ Server mode: via relay server + FCM
87
97
  ```
88
98
 
89
99
  ## Access Modes
@@ -0,0 +1,9 @@
1
+ export interface RegisteredDevice {
2
+ clientToken: string;
3
+ fcmToken: string;
4
+ }
5
+ export type DeviceCapability = "location" | "notifications" | "sms" | "contacts" | "calendar" | "alert" | "battery" | "dnd";
6
+ export declare function getCapabilityDevice(capability: DeviceCapability): RegisteredDevice | null;
7
+ export declare function setCapabilityDevice(capability: DeviceCapability, clientToken: string, fcmToken: string): void;
8
+ export declare function clearCapabilityDevice(capability: DeviceCapability): void;
9
+ //# sourceMappingURL=device-capabilities.d.ts.map
@@ -0,0 +1,36 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { CONFIG_DIR } from "./config.js";
4
+ const CAPABILITIES_FILE = path.join(CONFIG_DIR, "device-capabilities.json");
5
+ function readAll() {
6
+ try {
7
+ if (!fs.existsSync(CAPABILITIES_FILE))
8
+ return {};
9
+ return JSON.parse(fs.readFileSync(CAPABILITIES_FILE, "utf-8"));
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ function writeAll(map) {
16
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
17
+ fs.writeFileSync(CAPABILITIES_FILE, JSON.stringify(map, null, 2), "utf-8");
18
+ }
19
+ export function getCapabilityDevice(capability) {
20
+ const map = readAll();
21
+ const device = map[capability];
22
+ if (!device?.clientToken || !device?.fcmToken)
23
+ return null;
24
+ return device;
25
+ }
26
+ export function setCapabilityDevice(capability, clientToken, fcmToken) {
27
+ const map = readAll();
28
+ map[capability] = { clientToken, fcmToken };
29
+ writeAll(map);
30
+ }
31
+ export function clearCapabilityDevice(capability) {
32
+ const map = readAll();
33
+ delete map[capability];
34
+ writeAll(map);
35
+ }
36
+ //# sourceMappingURL=device-capabilities.js.map
package/dist/mcp-tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { StringCodec } from "nats";
2
2
  import { registerPending } from "./pending-requests.js";
3
- import { getLocationDevice } from "./location-device.js";
3
+ import { getCapabilityDevice } from "./device-capabilities.js";
4
4
  import { getNotifications } from "./notification-store.js";
5
5
  import { getSmsMessages } from "./sms-store.js";
6
6
  export class ToolError extends Error {
@@ -145,11 +145,11 @@ const deviceGeolocationTool = {
145
145
  async handler(_args, ctx) {
146
146
  if (!ctx.nc)
147
147
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
148
- const locDevice = getLocationDevice();
149
- if (!locDevice)
148
+ const device = getCapabilityDevice("location");
149
+ if (!device)
150
150
  throw new ToolError("No device has location access enabled", 400);
151
151
  const sc = StringCodec();
152
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.geolocation`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: locDevice.fcmToken })), { timeout: 5_000 });
152
+ 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 });
153
153
  const ack = JSON.parse(sc.decode(ackReply.data));
154
154
  if (ack.error)
155
155
  throw new ToolError(ack.error, 502);
@@ -186,8 +186,11 @@ const readContactsTool = {
186
186
  async handler(_args, ctx) {
187
187
  if (!ctx.nc)
188
188
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
189
+ const device = getCapabilityDevice("contacts");
190
+ if (!device)
191
+ throw new ToolError("No device has contacts access enabled", 400);
189
192
  const sc = StringCodec();
190
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, action: "read" })), { timeout: 5_000 });
193
+ 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 });
191
194
  const ack = JSON.parse(sc.decode(ackReply.data));
192
195
  if (ack.error)
193
196
  throw new ToolError(ack.error, 502);
@@ -229,12 +232,15 @@ const createContactTool = {
229
232
  async handler(args, ctx) {
230
233
  if (!ctx.nc)
231
234
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
235
+ const device = getCapabilityDevice("contacts");
236
+ if (!device)
237
+ throw new ToolError("No device has contacts access enabled", 400);
232
238
  const { name, phone, email } = args;
233
239
  if (!name)
234
240
  throw new ToolError("name is required", 400);
235
241
  const sc = StringCodec();
236
242
  const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.contacts`, sc.encode(JSON.stringify({
237
- hostId: ctx.config.hostId, requestId: ctx.sessionId,
243
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
238
244
  action: "create", name, phone, email,
239
245
  })), { timeout: 5_000 });
240
246
  const ack = JSON.parse(sc.decode(ackReply.data));
@@ -277,10 +283,13 @@ const readCalendarTool = {
277
283
  async handler(args, ctx) {
278
284
  if (!ctx.nc)
279
285
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
286
+ const device = getCapabilityDevice("calendar");
287
+ if (!device)
288
+ throw new ToolError("No device has calendar access enabled", 400);
280
289
  const { startDate, endDate } = args;
281
290
  const sc = StringCodec();
282
291
  const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, sc.encode(JSON.stringify({
283
- hostId: ctx.config.hostId, requestId: ctx.sessionId,
292
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
284
293
  action: "read",
285
294
  ...(startDate ? { startDate: String(startDate) } : {}),
286
295
  ...(endDate ? { endDate: String(endDate) } : {}),
@@ -328,12 +337,15 @@ const createCalendarEventTool = {
328
337
  async handler(args, ctx) {
329
338
  if (!ctx.nc)
330
339
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
340
+ const device = getCapabilityDevice("calendar");
341
+ if (!device)
342
+ throw new ToolError("No device has calendar access enabled", 400);
331
343
  const { title, startTime, endTime, location, description } = args;
332
344
  if (!title || !startTime || !endTime)
333
345
  throw new ToolError("title, startTime, and endTime are required", 400);
334
346
  const sc = StringCodec();
335
347
  const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.calendar`, sc.encode(JSON.stringify({
336
- hostId: ctx.config.hostId, requestId: ctx.sessionId,
348
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
337
349
  action: "create",
338
350
  title, startTime: String(startTime), endTime: String(endTime),
339
351
  ...(location ? { location } : {}),
@@ -362,7 +374,7 @@ const createCalendarEventTool = {
362
374
  },
363
375
  };
364
376
  const sendSmsTool = {
365
- name: "send-sms",
377
+ name: "send-sms-message",
366
378
  description: [
367
379
  "Send an SMS message from the user's mobile device.",
368
380
  "Blocks until the device responds (up to 30 seconds).",
@@ -379,12 +391,15 @@ const sendSmsTool = {
379
391
  async handler(args, ctx) {
380
392
  if (!ctx.nc)
381
393
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
394
+ const device = getCapabilityDevice("sms");
395
+ if (!device)
396
+ throw new ToolError("No device has SMS access enabled", 400);
382
397
  const { to, body } = args;
383
398
  if (!to || !body)
384
399
  throw new ToolError("to and body are required", 400);
385
400
  const sc = StringCodec();
386
401
  const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.sms`, sc.encode(JSON.stringify({
387
- hostId: ctx.config.hostId, requestId: ctx.sessionId,
402
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
388
403
  action: "send", to, body,
389
404
  })), { timeout: 5_000 });
390
405
  const ack = JSON.parse(sc.decode(ackReply.data));
@@ -409,48 +424,44 @@ const sendSmsTool = {
409
424
  return result;
410
425
  },
411
426
  };
412
- const setAlarmTool = {
413
- name: "set-alarm",
427
+ const sendAlertTool = {
428
+ name: "send-alert",
414
429
  description: [
415
- "Set an alarm on the user's mobile device.",
430
+ "Send an alert to the user's mobile device with an alarm sound and full-screen popup.",
431
+ "Use this to urgently get the user's attention. The device will play an alarm sound and show a full-screen dialog even on the lock screen.",
416
432
  "Blocks until the device responds (up to 30 seconds).",
417
433
  'Response: `{"ok": true}` on success, or `{"error": "..."}` on failure.',
418
434
  ],
419
435
  inputSchema: {
420
436
  type: "object",
421
437
  properties: {
422
- hour: { type: "number", description: "Hour (0-23)" },
423
- minutes: { type: "number", description: "Minutes (0-59)" },
424
- label: { type: "string", description: "Alarm label" },
425
- days: {
426
- type: "array",
427
- items: { type: "number" },
428
- description: "Recurring days (1=Sun, 2=Mon, ..., 7=Sat). Omit for one-time.",
429
- },
438
+ title: { type: "string", description: "Alert title" },
439
+ description: { type: "string", description: "Alert description/details" },
430
440
  },
431
- required: ["hour", "minutes"],
441
+ required: ["title"],
432
442
  },
433
443
  async handler(args, ctx) {
434
444
  if (!ctx.nc)
435
445
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
436
- const { hour, minutes, label, days } = args;
437
- if (hour == null || minutes == null)
438
- throw new ToolError("hour and minutes are required", 400);
446
+ const device = getCapabilityDevice("alert");
447
+ if (!device)
448
+ throw new ToolError("No device has alert access enabled", 400);
449
+ const { title, description } = args;
450
+ if (!title)
451
+ throw new ToolError("title is required", 400);
439
452
  const sc = StringCodec();
440
453
  const payload = {
441
- hostId: ctx.config.hostId, requestId: ctx.sessionId,
442
- action: "set", hour: String(hour), minutes: String(minutes),
454
+ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: device.fcmToken,
455
+ title,
443
456
  };
444
- if (label)
445
- payload.label = label;
446
- if (days?.length)
447
- payload.days = days.join(",");
448
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.alarm`, sc.encode(JSON.stringify(payload)), { timeout: 5_000 });
457
+ if (description)
458
+ payload.description = description;
459
+ const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.alert`, sc.encode(JSON.stringify(payload)), { timeout: 5_000 });
449
460
  const ack = JSON.parse(sc.decode(ackReply.data));
450
461
  if (ack.error)
451
462
  throw new ToolError(ack.error, 502);
452
463
  const responsePromise = new Promise((resolve, reject) => {
453
- const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.alarm.${ctx.sessionId}`, { max: 1 });
464
+ const sub = ctx.nc.subscribe(`host.${ctx.config.hostId}.alert.${ctx.sessionId}`, { max: 1 });
454
465
  const timer = setTimeout(() => {
455
466
  sub.unsubscribe();
456
467
  reject(new ToolError("Device did not respond within 30 seconds", 504));
@@ -482,8 +493,11 @@ const readBatteryTool = {
482
493
  async handler(_args, ctx) {
483
494
  if (!ctx.nc)
484
495
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
496
+ const device = getCapabilityDevice("battery");
497
+ if (!device)
498
+ throw new ToolError("No device has battery access enabled", 400);
485
499
  const sc = StringCodec();
486
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.battery`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId })), { timeout: 5_000 });
500
+ 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 });
487
501
  const ack = JSON.parse(sc.decode(ackReply.data));
488
502
  if (ack.error)
489
503
  throw new ToolError(ack.error, 502);
@@ -523,11 +537,14 @@ const setRingerModeTool = {
523
537
  async handler(args, ctx) {
524
538
  if (!ctx.nc)
525
539
  throw new ToolError("Not connected to server (NATS unavailable)", 503);
540
+ const device = getCapabilityDevice("dnd");
541
+ if (!device)
542
+ throw new ToolError("No device has Do Not Disturb control enabled", 400);
526
543
  const { mode } = args;
527
544
  if (!["normal", "vibrate", "silent"].includes(mode))
528
545
  throw new ToolError("mode must be 'normal', 'vibrate', or 'silent'", 400);
529
546
  const sc = StringCodec();
530
- const ackReply = await ctx.nc.request(`host.${ctx.config.hostId}.fcm.ringer`, sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, mode })), { timeout: 5_000 });
547
+ 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 });
531
548
  const ack = JSON.parse(sc.decode(ackReply.data));
532
549
  if (ack.error)
533
550
  throw new ToolError(ack.error, 502);
@@ -550,7 +567,7 @@ const setRingerModeTool = {
550
567
  return result;
551
568
  },
552
569
  };
553
- export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, setAlarmTool, readBatteryTool, setRingerModeTool];
570
+ export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendAlertTool, readBatteryTool, setRingerModeTool];
554
571
  export const agentToolMap = new Map(agentTools.map((t) => [t.name, t]));
555
572
  const deviceNotificationsResource = {
556
573
  uri: "notifications://device",
@@ -564,14 +581,14 @@ const deviceNotificationsResource = {
564
581
  read: getNotifications,
565
582
  };
566
583
  const deviceSmsResource = {
567
- uri: "sms://device",
584
+ uri: "sms-messages://device",
568
585
  name: "Device SMS",
569
586
  description: [
570
587
  "Get recent SMS messages from the user's Android device.",
571
588
  "Response: JSON array of message objects with `id`, `sender`, `body`, `timestamp`.",
572
589
  ],
573
590
  mimeType: "application/json",
574
- restPath: "/sms",
591
+ restPath: "/sms-messages",
575
592
  read: getSmsMessages,
576
593
  };
577
594
  export const agentResources = [deviceNotificationsResource, deviceSmsResource];