palmier 0.6.9 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp-tools.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { StringCodec, type NatsConnection } 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
  import type { HostConfig } from "./types.js";
5
6
 
6
7
  export class ToolError extends Error {
@@ -205,10 +206,46 @@ const deviceGeolocationTool: ToolDefinition = {
205
206
  export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool];
206
207
  export const agentToolMap = new Map<string, ToolDefinition>(agentTools.map((t) => [t.name, t]));
207
208
 
209
+ // ── MCP Resources ─────────────────────────────────────────────────────
210
+
211
+ export interface ResourceDefinition {
212
+ /** MCP resource URI (e.g. "notifications://device"). */
213
+ uri: string;
214
+ /** Display name. */
215
+ name: string;
216
+ /** First line is the summary (used as REST endpoint header). Remaining lines become bullet points in docs. */
217
+ description: string[];
218
+ mimeType: string;
219
+ /** REST endpoint path (e.g. "/notifications"). Served as GET. */
220
+ restPath: string;
221
+ /** Return the current resource content. */
222
+ read: () => unknown;
223
+ }
224
+
225
+ const deviceNotificationsResource: ResourceDefinition = {
226
+ uri: "notifications://device",
227
+ name: "Device Notifications",
228
+ description: [
229
+ "Get recent notifications from the user's Android device.",
230
+ "Response: JSON array of notification objects with `id`, `packageName`, `appName`, `title`, `text`, `timestamp`.",
231
+ ],
232
+ mimeType: "application/json",
233
+ restPath: "/notifications",
234
+ read: getNotifications,
235
+ };
236
+
237
+ export const agentResources: ResourceDefinition[] = [deviceNotificationsResource];
238
+ export const agentResourceMap = new Map<string, ResourceDefinition>(agentResources.map((r) => [r.uri, r]));
239
+
208
240
  /**
209
241
  * Generate the HTTP Endpoints markdown section for agent-instructions.md from the tool registry.
210
242
  */
211
- export function generateEndpointDocs(port: number, taskId: string, tools: ToolDefinition[] = agentTools): string {
243
+ export function generateEndpointDocs(
244
+ port: number,
245
+ taskId: string,
246
+ tools: ToolDefinition[] = agentTools,
247
+ resources: ResourceDefinition[] = agentResources,
248
+ ): string {
212
249
  const baseUrl = `http://localhost:${port}`;
213
250
  const lines: string[] = [
214
251
  `The following HTTP endpoints are available during task execution. Use curl to call them.`,
@@ -249,5 +286,14 @@ export function generateEndpointDocs(port: number, taskId: string, tools: ToolDe
249
286
  lines.push("");
250
287
  }
251
288
 
289
+ for (const resource of resources) {
290
+ const [header, ...details] = resource.description;
291
+ lines.push(`**\`GET ${baseUrl}${resource.restPath}\`** — ${header}`);
292
+ for (const detail of details) {
293
+ lines.push(`- ${detail}`);
294
+ }
295
+ lines.push("");
296
+ }
297
+
252
298
  return lines.join("\n").trimEnd();
253
299
  }
@@ -0,0 +1,30 @@
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
+
11
+ const MAX_NOTIFICATIONS = 50;
12
+ const notifications: DeviceNotification[] = [];
13
+ const listeners = new Set<() => void>();
14
+
15
+ export function addNotification(n: DeviceNotification): void {
16
+ notifications.push(n);
17
+ if (notifications.length > MAX_NOTIFICATIONS) {
18
+ notifications.shift();
19
+ }
20
+ for (const cb of listeners) cb();
21
+ }
22
+
23
+ export function getNotifications(): DeviceNotification[] {
24
+ return [...notifications];
25
+ }
26
+
27
+ export function onNotificationsChanged(cb: () => void): () => void {
28
+ listeners.add(cb);
29
+ return () => { listeners.delete(cb); };
30
+ }
@@ -6,9 +6,10 @@ import { validateClient, addClient } from "../client-store.js";
6
6
  import { registerPending } from "../pending-requests.js";
7
7
  import * as fs from "node:fs";
8
8
  import type { HostConfig, RpcMessage, RequiredPermission } from "../types.js";
9
- import { agentToolMap, ToolError, type ToolContext } from "../mcp-tools.js";
10
- import { handleMcpRequest, getAgentName } from "../mcp-handler.js";
9
+ import { agentToolMap, agentResources, ToolError, type ToolContext } from "../mcp-tools.js";
10
+ import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
11
11
  import { getTaskDir } from "../task.js";
12
+ import { onNotificationsChanged } from "../notification-store.js";
12
13
 
13
14
  // ── Bundled PWA asset serving ───────────────────────────────────────────
14
15
 
@@ -102,9 +103,28 @@ export async function startHttpTransport(
102
103
  onReady?: () => void,
103
104
  ): Promise<void> {
104
105
  const sseClients = new Set<SseClient>();
106
+ const mcpStreams = new Map<string, http.ServerResponse>();
105
107
  const lanEnabled = config.lanEnabled ?? false;
106
108
  const bindAddress = lanEnabled ? "0.0.0.0" : "127.0.0.1";
107
109
 
110
+ /** Push notifications/resources/updated to all MCP clients subscribed to the given URI. */
111
+ function broadcastResourceUpdated(uri: string) {
112
+ const subs = getResourceSubscriptions();
113
+ for (const [sessionId, uris] of subs) {
114
+ if (!uris.has(uri)) continue;
115
+ const stream = mcpStreams.get(sessionId);
116
+ if (!stream) continue;
117
+ stream.write(`data: ${JSON.stringify({
118
+ jsonrpc: "2.0",
119
+ method: "notifications/resources/updated",
120
+ params: { uri },
121
+ })}\n\n`);
122
+ }
123
+ }
124
+
125
+ // Wire up resource change listeners
126
+ onNotificationsChanged(() => broadcastResourceUpdated("notifications://device"));
127
+
108
128
  // If a pairing code is provided, pre-register it
109
129
  if (pairingCode) {
110
130
  const EXPIRY_MS = 24 * 60 * 60 * 1000;
@@ -182,7 +202,24 @@ export async function startHttpTransport(
182
202
  if (result.sessionId) {
183
203
  res.setHeader("Mcp-Session-Id", result.sessionId);
184
204
  }
185
- sendJson(res, 200, result.body);
205
+ if (result.stream && sessionId) {
206
+ // Keep response open as SSE stream for server-initiated notifications
207
+ res.writeHead(200, {
208
+ "Content-Type": "text/event-stream",
209
+ "Cache-Control": "no-cache",
210
+ "Connection": "keep-alive",
211
+ });
212
+ res.write(`data: ${JSON.stringify(result.body)}\n\n`);
213
+ mcpStreams.set(sessionId, res);
214
+ const heartbeat = setInterval(() => { res.write(":heartbeat\n\n"); }, 15_000);
215
+ req.on("close", () => {
216
+ clearInterval(heartbeat);
217
+ mcpStreams.delete(sessionId);
218
+ getResourceSubscriptions().delete(sessionId);
219
+ });
220
+ } else {
221
+ sendJson(res, 200, result.body);
222
+ }
186
223
  } catch (err) {
187
224
  sendJson(res, 500, { error: String(err) });
188
225
  }
@@ -220,6 +257,15 @@ export async function startHttpTransport(
220
257
  return;
221
258
  }
222
259
 
260
+ // ── Auto-generated REST endpoints from MCP resource registry ────
261
+
262
+ const matchedResource = req.method === "GET" && agentResources.find((r) => r.restPath === pathname);
263
+ if (matchedResource) {
264
+ if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
265
+ sendJson(res, 200, matchedResource.read());
266
+ return;
267
+ }
268
+
223
269
  // ── Localhost-only endpoints (no auth) ─────────────────────────────
224
270
 
225
271
  if (req.method === "POST" && pathname === "/event") {
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import { fileURLToPath } from "url";
6
- import { generateEndpointDocs, type ToolDefinition } from "../src/mcp-tools.js";
6
+ import { generateEndpointDocs, type ToolDefinition, type ResourceDefinition } from "../src/mcp-tools.js";
7
7
 
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const templatePath = path.join(__dirname, "..", "src", "agents", "agent-instructions.md");
@@ -48,10 +48,25 @@ const mockTools: ToolDefinition[] = [
48
48
  },
49
49
  ];
50
50
 
51
+ /** Mock resources with a known, stable shape for testing */
52
+ const mockResources: ResourceDefinition[] = [
53
+ {
54
+ uri: "mock://data",
55
+ name: "Mock Data",
56
+ description: [
57
+ "Get mock data from the device.",
58
+ "Response: JSON array of data objects.",
59
+ ],
60
+ mimeType: "application/json",
61
+ restPath: "/mock-data",
62
+ read: () => [],
63
+ },
64
+ ];
65
+
51
66
  /** Minimal replica of getAgentInstructions that doesn't need host.json */
52
67
  function buildInstructions(taskId: string, opts?: { skipPermissions?: boolean }): string {
53
68
  let instructions = template
54
- .replace(/\{\{ENDPOINT_DOCS\}\}/g, generateEndpointDocs(9966, taskId, mockTools))
69
+ .replace(/\{\{ENDPOINT_DOCS\}\}/g, generateEndpointDocs(9966, taskId, mockTools, mockResources))
55
70
  .replace(/\{\{TASK_DESCRIPTION\}\}/g, "Test task prompt");
56
71
  if (opts?.skipPermissions) {
57
72
  instructions = instructions.replace(/## Permissions\r?\n[\s\S]*?(?=## |\r?\n---)/m, "");
@@ -104,7 +119,7 @@ describe("getAgentInstructions", () => {
104
119
 
105
120
 
106
121
  describe("generateEndpointDocs", () => {
107
- const docs = generateEndpointDocs(9966, "test-id", mockTools);
122
+ const docs = generateEndpointDocs(9966, "test-id", mockTools, mockResources);
108
123
 
109
124
  it("matches expected full output", () => {
110
125
  const expected = [
@@ -125,6 +140,9 @@ describe("generateEndpointDocs", () => {
125
140
  "- `tags` (optional, string array): Filter tags",
126
141
  "- Blocks until the device responds.",
127
142
  '- Response: `{"data": ...}` on success.',
143
+ "",
144
+ "**`GET http://localhost:9966/mock-data`** — Get mock data from the device.",
145
+ "- Response: JSON array of data objects.",
128
146
  ].join("\n");
129
147
  assert.equal(docs, expected);
130
148
  });
@@ -173,4 +191,19 @@ describe("generateEndpointDocs", () => {
173
191
  it("renders multi-line descriptions as bullet points", () => {
174
192
  assert.match(docs, /- Blocks until the device responds\./);
175
193
  });
194
+
195
+ it("generates GET endpoints for all provided resources", () => {
196
+ for (const resource of mockResources) {
197
+ assert.match(docs, new RegExp(`GET http://localhost:9966${resource.restPath}`), `Missing endpoint for ${resource.uri}`);
198
+ }
199
+ });
200
+
201
+ it("includes resource description as bullet points", () => {
202
+ assert.match(docs, /- Response: JSON array of data objects\./);
203
+ });
204
+
205
+ it("generates no resource endpoints when resources array is empty", () => {
206
+ const docsNoResources = generateEndpointDocs(9966, "test-id", mockTools, []);
207
+ assert.doesNotMatch(docsNoResources, /GET http/);
208
+ });
176
209
  });
@@ -0,0 +1,57 @@
1
+ import { describe, it, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ // Re-import fresh module state for each test file run
5
+ // Since the store is module-level state, we test the exported functions directly
6
+ import { addNotification, getNotifications, onNotificationsChanged, type DeviceNotification } from "../src/notification-store.js";
7
+
8
+ function makeNotification(id: string, overrides?: Partial<DeviceNotification>): DeviceNotification {
9
+ return {
10
+ id,
11
+ packageName: "com.example.app",
12
+ appName: "Example",
13
+ title: `Title ${id}`,
14
+ text: `Text ${id}`,
15
+ timestamp: Date.now(),
16
+ receivedAt: Date.now(),
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe("notification-store", () => {
22
+ it("stores and retrieves notifications", () => {
23
+ const before = getNotifications().length;
24
+ addNotification(makeNotification("test-1"));
25
+ const after = getNotifications();
26
+ assert.equal(after.length, before + 1);
27
+ assert.equal(after[after.length - 1].id, "test-1");
28
+ });
29
+
30
+ it("returns a defensive copy", () => {
31
+ const a = getNotifications();
32
+ const b = getNotifications();
33
+ assert.notStrictEqual(a, b);
34
+ });
35
+
36
+ it("evicts oldest when exceeding max", () => {
37
+ const before = getNotifications().length;
38
+ // Add enough to exceed 50
39
+ for (let i = 0; i < 60; i++) {
40
+ addNotification(makeNotification(`evict-${i}`));
41
+ }
42
+ const result = getNotifications();
43
+ assert.ok(result.length <= 50, `Expected <= 50, got ${result.length}`);
44
+ });
45
+
46
+ it("notifies listeners on add", () => {
47
+ let called = 0;
48
+ const unsub = onNotificationsChanged(() => { called++; });
49
+ addNotification(makeNotification("listener-1"));
50
+ assert.equal(called, 1);
51
+ addNotification(makeNotification("listener-2"));
52
+ assert.equal(called, 2);
53
+ unsub();
54
+ addNotification(makeNotification("listener-3"));
55
+ assert.equal(called, 2); // no longer called after unsubscribe
56
+ });
57
+ });