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/README.md +9 -2
- package/dist/commands/serve.js +16 -0
- package/dist/mcp-handler.d.ts +3 -0
- package/dist/mcp-handler.js +59 -3
- package/dist/mcp-tools.d.ts +16 -1
- package/dist/mcp-tools.js +23 -1
- package/dist/notification-store.d.ts +13 -0
- package/dist/notification-store.js +19 -0
- package/dist/pwa/assets/{index-CZejk2al.js → index-DLxrL0hR.js} +42 -42
- package/dist/pwa/assets/{web-C48txJFl.js → web-CBI458eN.js} +1 -1
- package/dist/pwa/assets/{web-zj8Blync.js → web-HDs03L2B.js} +1 -1
- package/dist/pwa/index.html +1 -1
- package/dist/pwa/service-worker.js +1 -1
- package/dist/transports/http-transport.js +51 -3
- package/package.json +1 -1
- package/palmier-server/README.md +1 -1
- package/palmier-server/pwa/src/components/PlanDialog.tsx +5 -12
- package/palmier-server/pwa/src/components/TaskForm.tsx +6 -15
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/types.ts +0 -1
- package/palmier-server/server/src/index.ts +2 -0
- package/palmier-server/server/src/routes/device.ts +32 -0
- package/palmier-server/spec.md +13 -12
- package/src/commands/serve.ts +16 -1
- package/src/mcp-handler.ts +68 -3
- package/src/mcp-tools.ts +47 -1
- package/src/notification-store.ts +30 -0
- package/src/transports/http-transport.ts +49 -3
- package/test/agent-instructions.test.ts +36 -3
- package/test/notification-store.test.ts +57 -0
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(
|
|
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
|
-
|
|
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
|
+
});
|