palmier 0.6.9 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,11 +34,11 @@ It runs on your machine as a background daemon and connects to a mobile-friendly
34
34
 
35
35
  ## How It Works
36
36
 
37
- Palmier runs as a background daemon (systemd on Linux, 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 fetching GPS location.
37
+ Palmier runs as a background daemon (systemd on Linux, 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, fetching GPS location, and reading device notifications.
38
38
 
39
39
  ### MCP Server
40
40
 
41
- Palmier exposes an [MCP](https://modelcontextprotocol.io) server at `http://localhost:<port>/mcp` (streamable HTTP transport). MCP-capable agents can register it to get tool definitions automatically. The same tools are also available as REST endpoints for curl-based agents.
41
+ Palmier exposes an [MCP](https://modelcontextprotocol.io) server at `http://localhost:<port>/mcp` (streamable HTTP transport). MCP-capable agents can register it to get tool and resource definitions automatically. The same tools and resources are also available as REST endpoints for curl-based agents.
42
42
 
43
43
  **MCP server URL:** `http://localhost:<port>/mcp`
44
44
 
@@ -50,6 +50,13 @@ Palmier exposes an [MCP](https://modelcontextprotocol.io) server at `http://loca
50
50
  | `request-confirmation` | Request confirmation from the user (blocks until response) |
51
51
  | `device-geolocation` | Get GPS location of the user's mobile device |
52
52
 
53
+ **Available resources:**
54
+ | Resource | URI | REST | Description |
55
+ |----------|-----|------|-------------|
56
+ | Device Notifications | `notifications://device` | `GET /notifications` | Recent notifications from the user's Android device |
57
+
58
+ 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. The Android app requires notification listener access to be enabled in system settings.
59
+
53
60
  ```
54
61
  ┌──────────────┐ HTTP ┌──────────────────┐
55
62
  │ │◄──────────────────────│ │
@@ -11,6 +11,8 @@ import { getPlatform } from "../platform/index.js";
11
11
  import { detectAgents } from "../agents/agent.js";
12
12
  import { saveConfig } from "../config.js";
13
13
  import { CONFIG_DIR } from "../config.js";
14
+ import { StringCodec } from "nats";
15
+ import { addNotification } from "../notification-store.js";
14
16
  const POLL_INTERVAL_MS = 30_000;
15
17
  const DAEMON_PID_FILE = path.join(CONFIG_DIR, "daemon.pid");
16
18
  /**
@@ -114,6 +116,20 @@ export async function serveCommand() {
114
116
  // Start NATS transport (loops forever, fire-and-forget)
115
117
  if (nc) {
116
118
  startNatsTransport(config, handleRpc, nc);
119
+ // Subscribe to device notifications from Android
120
+ const sc = StringCodec();
121
+ const notifSub = nc.subscribe(`host.${config.hostId}.device.notifications`);
122
+ (async () => {
123
+ for await (const msg of notifSub) {
124
+ try {
125
+ const data = JSON.parse(sc.decode(msg.data));
126
+ addNotification({ ...data, receivedAt: Date.now() });
127
+ }
128
+ catch (err) {
129
+ console.error("[nats] Failed to parse device notification:", err);
130
+ }
131
+ }
132
+ })();
117
133
  }
118
134
  // Start HTTP transport (loops forever)
119
135
  await startHttpTransport(config, handleRpc, httpPort, nc);
@@ -2,7 +2,10 @@ import { type ToolContext } from "./mcp-tools.js";
2
2
  export interface McpResponse {
3
3
  body: object;
4
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;
5
7
  }
8
+ export declare function getResourceSubscriptions(): Map<string, Set<string>>;
6
9
  export declare function getAgentName(sessionId: string): string | undefined;
7
10
  export declare function handleMcpRequest(body: string, sessionId: string | undefined, ctx: ToolContext): Promise<McpResponse>;
8
11
  //# sourceMappingURL=mcp-handler.d.ts.map
@@ -1,5 +1,10 @@
1
1
  import { randomUUID } from "crypto";
2
- import { agentTools, agentToolMap, ToolError } from "./mcp-tools.js";
2
+ import { agentTools, agentToolMap, agentResources, agentResourceMap, ToolError } from "./mcp-tools.js";
3
+ // Resource subscriptions: sessionId → Set of resource URIs
4
+ const resourceSubscriptions = new Map();
5
+ export function getResourceSubscriptions() {
6
+ return resourceSubscriptions;
7
+ }
3
8
  // Session-to-agent name map with 24h TTL
4
9
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
5
10
  const sessionAgents = new Map();
@@ -16,8 +21,10 @@ export function getAgentName(sessionId) {
16
21
  function pruneExpiredSessions() {
17
22
  const now = Date.now();
18
23
  for (const [id, entry] of sessionAgents) {
19
- if (now > entry.expiresAt)
24
+ if (now > entry.expiresAt) {
20
25
  sessionAgents.delete(id);
26
+ resourceSubscriptions.delete(id);
27
+ }
21
28
  }
22
29
  }
23
30
  function rpcError(id, code, message) {
@@ -57,7 +64,7 @@ export async function handleMcpRequest(body, sessionId, ctx) {
57
64
  return {
58
65
  body: rpcResult(id, {
59
66
  protocolVersion: "2025-03-26",
60
- capabilities: { tools: {} },
67
+ capabilities: { tools: {}, resources: { subscribe: true } },
61
68
  serverInfo: { name: "palmier", version: "1.0.0" },
62
69
  }),
63
70
  sessionId: newSessionId,
@@ -102,6 +109,55 @@ export async function handleMcpRequest(body, sessionId, ctx) {
102
109
  };
103
110
  }
104
111
  }
112
+ case "resources/list": {
113
+ return {
114
+ body: rpcResult(id, {
115
+ resources: agentResources.map((r) => ({
116
+ uri: r.uri,
117
+ name: r.name,
118
+ description: r.description[0],
119
+ mimeType: r.mimeType,
120
+ })),
121
+ }),
122
+ };
123
+ }
124
+ case "resources/read": {
125
+ const uri = req.params?.uri;
126
+ const resource = agentResourceMap.get(uri);
127
+ if (!resource) {
128
+ return { body: rpcError(id, -32602, `Unknown resource: ${uri}`) };
129
+ }
130
+ return {
131
+ body: rpcResult(id, {
132
+ contents: [{
133
+ uri: resource.uri,
134
+ mimeType: resource.mimeType,
135
+ text: JSON.stringify(resource.read()),
136
+ }],
137
+ }),
138
+ };
139
+ }
140
+ case "resources/subscribe": {
141
+ const uri = req.params?.uri;
142
+ if (!agentResourceMap.has(uri)) {
143
+ return { body: rpcError(id, -32602, `Unknown resource: ${uri}`) };
144
+ }
145
+ if (!sessionId) {
146
+ return { body: rpcError(id, -32600, "Session required for subscriptions") };
147
+ }
148
+ if (!resourceSubscriptions.has(sessionId)) {
149
+ resourceSubscriptions.set(sessionId, new Set());
150
+ }
151
+ resourceSubscriptions.get(sessionId).add(uri);
152
+ return { body: rpcResult(id, {}), stream: true };
153
+ }
154
+ case "resources/unsubscribe": {
155
+ const uri = req.params?.uri;
156
+ if (sessionId) {
157
+ resourceSubscriptions.get(sessionId)?.delete(uri);
158
+ }
159
+ return { body: rpcResult(id, {}) };
160
+ }
105
161
  default:
106
162
  console.warn(`${logPrefix} Unknown method: ${req.method}`);
107
163
  return { body: rpcError(id, -32601, `Method not found: ${req.method}`) };
@@ -20,8 +20,23 @@ export interface ToolDefinition {
20
20
  }
21
21
  export declare const agentTools: ToolDefinition[];
22
22
  export declare const agentToolMap: Map<string, ToolDefinition>;
23
+ export interface ResourceDefinition {
24
+ /** MCP resource URI (e.g. "notifications://device"). */
25
+ uri: string;
26
+ /** Display name. */
27
+ name: string;
28
+ /** First line is the summary (used as REST endpoint header). Remaining lines become bullet points in docs. */
29
+ description: string[];
30
+ mimeType: string;
31
+ /** REST endpoint path (e.g. "/notifications"). Served as GET. */
32
+ restPath: string;
33
+ /** Return the current resource content. */
34
+ read: () => unknown;
35
+ }
36
+ export declare const agentResources: ResourceDefinition[];
37
+ export declare const agentResourceMap: Map<string, ResourceDefinition>;
23
38
  /**
24
39
  * Generate the HTTP Endpoints markdown section for agent-instructions.md from the tool registry.
25
40
  */
26
- export declare function generateEndpointDocs(port: number, taskId: string, tools?: ToolDefinition[]): string;
41
+ export declare function generateEndpointDocs(port: number, taskId: string, tools?: ToolDefinition[], resources?: ResourceDefinition[]): string;
27
42
  //# sourceMappingURL=mcp-tools.d.ts.map
package/dist/mcp-tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { StringCodec } from "nats";
2
2
  import { registerPending } from "./pending-requests.js";
3
3
  import { getLocationDevice } from "./location-device.js";
4
+ import { getNotifications } from "./notification-store.js";
4
5
  export class ToolError extends Error {
5
6
  statusCode;
6
7
  constructor(message, statusCode = 500) {
@@ -172,10 +173,23 @@ const deviceGeolocationTool = {
172
173
  };
173
174
  export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool];
174
175
  export const agentToolMap = new Map(agentTools.map((t) => [t.name, t]));
176
+ const deviceNotificationsResource = {
177
+ uri: "notifications://device",
178
+ name: "Device Notifications",
179
+ description: [
180
+ "Get recent notifications from the user's Android device.",
181
+ "Response: JSON array of notification objects with `id`, `packageName`, `appName`, `title`, `text`, `timestamp`.",
182
+ ],
183
+ mimeType: "application/json",
184
+ restPath: "/notifications",
185
+ read: getNotifications,
186
+ };
187
+ export const agentResources = [deviceNotificationsResource];
188
+ export const agentResourceMap = new Map(agentResources.map((r) => [r.uri, r]));
175
189
  /**
176
190
  * Generate the HTTP Endpoints markdown section for agent-instructions.md from the tool registry.
177
191
  */
178
- export function generateEndpointDocs(port, taskId, tools = agentTools) {
192
+ export function generateEndpointDocs(port, taskId, tools = agentTools, resources = agentResources) {
179
193
  const baseUrl = `http://localhost:${port}`;
180
194
  const lines = [
181
195
  `The following HTTP endpoints are available during task execution. Use curl to call them.`,
@@ -213,6 +227,14 @@ export function generateEndpointDocs(port, taskId, tools = agentTools) {
213
227
  }
214
228
  lines.push("");
215
229
  }
230
+ for (const resource of resources) {
231
+ const [header, ...details] = resource.description;
232
+ lines.push(`**\`GET ${baseUrl}${resource.restPath}\`** — ${header}`);
233
+ for (const detail of details) {
234
+ lines.push(`- ${detail}`);
235
+ }
236
+ lines.push("");
237
+ }
216
238
  return lines.join("\n").trimEnd();
217
239
  }
218
240
  //# sourceMappingURL=mcp-tools.js.map
@@ -0,0 +1,13 @@
1
+ export interface DeviceNotification {
2
+ id: string;
3
+ packageName: string;
4
+ appName: string;
5
+ title: string;
6
+ text: string;
7
+ timestamp: number;
8
+ receivedAt: number;
9
+ }
10
+ export declare function addNotification(n: DeviceNotification): void;
11
+ export declare function getNotifications(): DeviceNotification[];
12
+ export declare function onNotificationsChanged(cb: () => void): () => void;
13
+ //# sourceMappingURL=notification-store.d.ts.map
@@ -0,0 +1,19 @@
1
+ const MAX_NOTIFICATIONS = 50;
2
+ const notifications = [];
3
+ const listeners = new Set();
4
+ export function addNotification(n) {
5
+ notifications.push(n);
6
+ if (notifications.length > MAX_NOTIFICATIONS) {
7
+ notifications.shift();
8
+ }
9
+ for (const cb of listeners)
10
+ cb();
11
+ }
12
+ export function getNotifications() {
13
+ return [...notifications];
14
+ }
15
+ export function onNotificationsChanged(cb) {
16
+ listeners.add(cb);
17
+ return () => { listeners.delete(cb); };
18
+ }
19
+ //# sourceMappingURL=notification-store.js.map