palmier 0.6.6 → 0.6.7
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 +1 -1
- package/dist/agents/agent-instructions.md +28 -6
- package/dist/commands/plan-generation.md +1 -0
- package/dist/commands/run.js +3 -3
- package/dist/location-device.d.ts +8 -0
- package/dist/location-device.js +32 -0
- package/dist/mcp-handler.d.ts +8 -0
- package/dist/mcp-handler.js +110 -0
- package/dist/mcp-tools.d.ts +22 -0
- package/dist/mcp-tools.js +152 -0
- package/dist/pwa/assets/{index-DhvJN8ie.css → index-DAI3J-jU.css} +1 -1
- package/dist/pwa/assets/index-RrJvjqz9.js +118 -0
- package/dist/pwa/assets/web-DQteXlI7.js +1 -0
- package/dist/pwa/assets/web-EzNEHXEh.js +1 -0
- package/dist/pwa/index.html +3 -3
- package/dist/pwa/service-worker.js +2 -2
- package/dist/rpc-handler.js +20 -7
- package/dist/transports/http-transport.js +61 -129
- package/package.json +1 -1
- package/palmier-server/README.md +6 -1
- package/palmier-server/package.json +7 -1
- package/palmier-server/pnpm-lock.yaml +1025 -1
- package/palmier-server/pwa/index.html +1 -1
- package/palmier-server/pwa/package.json +3 -0
- package/palmier-server/pwa/src/App.css +55 -0
- package/palmier-server/pwa/src/api.ts +8 -2
- package/palmier-server/pwa/src/components/HostMenu.tsx +102 -1
- package/palmier-server/pwa/src/components/TaskListView.tsx +94 -78
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +2 -1
- package/palmier-server/pwa/src/hooks/usePushSubscription.ts +3 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +5 -2
- package/palmier-server/pwa/src/pages/PairHost.tsx +10 -1
- package/palmier-server/pwa/src/service-worker.ts +7 -7
- package/palmier-server/server/.env.example +4 -0
- package/palmier-server/server/package.json +1 -0
- package/palmier-server/server/src/db.ts +10 -0
- package/palmier-server/server/src/fcm.ts +74 -0
- package/palmier-server/server/src/index.ts +101 -21
- package/palmier-server/server/src/notify.ts +34 -0
- package/palmier-server/server/src/push.ts +1 -1
- package/palmier-server/server/src/routes/fcm.ts +64 -0
- package/palmier-server/server/src/routes/push.ts +6 -5
- package/palmier-server/spec.md +4 -2
- package/src/agents/agent-instructions.md +28 -6
- package/src/commands/plan-generation.md +1 -0
- package/src/commands/run.ts +3 -3
- package/src/location-device.ts +35 -0
- package/src/mcp-handler.ts +133 -0
- package/src/mcp-tools.ts +182 -0
- package/src/rpc-handler.ts +21 -7
- package/src/transports/http-transport.ts +58 -128
- package/dist/pwa/assets/index-CXqKVvmk.js +0 -118
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { agentTools, agentToolMap, ToolError, type ToolContext } from "./mcp-tools.js";
|
|
3
|
+
|
|
4
|
+
interface JsonRpcRequest {
|
|
5
|
+
jsonrpc: string;
|
|
6
|
+
id?: string | number | null;
|
|
7
|
+
method: string;
|
|
8
|
+
params?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface McpResponse {
|
|
12
|
+
body: object;
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Session-to-agent name map with 24h TTL
|
|
17
|
+
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
const sessionAgents = new Map<string, { agentName: string; expiresAt: number }>();
|
|
19
|
+
|
|
20
|
+
export function getAgentName(sessionId: string): string | undefined {
|
|
21
|
+
const entry = sessionAgents.get(sessionId);
|
|
22
|
+
if (!entry) return undefined;
|
|
23
|
+
if (Date.now() > entry.expiresAt) {
|
|
24
|
+
sessionAgents.delete(sessionId);
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return entry.agentName;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pruneExpiredSessions(): void {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
for (const [id, entry] of sessionAgents) {
|
|
33
|
+
if (now > entry.expiresAt) sessionAgents.delete(id);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rpcError(id: string | number | null, code: number, message: string): object {
|
|
38
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rpcResult(id: string | number | null, result: unknown): object {
|
|
42
|
+
return { jsonrpc: "2.0", id, result };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function handleMcpRequest(body: string, sessionId: string | undefined, ctx: ToolContext): Promise<McpResponse> {
|
|
46
|
+
let req: JsonRpcRequest;
|
|
47
|
+
try {
|
|
48
|
+
req = JSON.parse(body);
|
|
49
|
+
} catch {
|
|
50
|
+
return { body: rpcError(null, -32700, "Parse error") };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const id = req.id ?? null;
|
|
54
|
+
|
|
55
|
+
if (req.jsonrpc !== "2.0") {
|
|
56
|
+
return { body: rpcError(id, -32600, "Invalid Request: missing jsonrpc 2.0") };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const agent = sessionId ? getAgentName(sessionId) : undefined;
|
|
60
|
+
const sid = sessionId?.slice(0, 8) ?? "none";
|
|
61
|
+
const logPrefix = agent ? `[mcp] [${sid}] [${agent}]` : `[mcp] [${sid}]`;
|
|
62
|
+
console.log(`${logPrefix} ${req.method}${req.method === "tools/call" ? ` → ${req.params?.name}` : ""}`);
|
|
63
|
+
|
|
64
|
+
switch (req.method) {
|
|
65
|
+
case "initialize": {
|
|
66
|
+
const newSessionId = randomUUID();
|
|
67
|
+
const clientInfo = req.params?.clientInfo as { name?: string; version?: string } | undefined;
|
|
68
|
+
const agentName = clientInfo
|
|
69
|
+
? `${clientInfo.name || "unknown"}${clientInfo.version ? ` ${clientInfo.version}` : ""}`
|
|
70
|
+
: undefined;
|
|
71
|
+
|
|
72
|
+
if (agentName) {
|
|
73
|
+
sessionAgents.set(newSessionId, { agentName, expiresAt: Date.now() + SESSION_TTL_MS });
|
|
74
|
+
pruneExpiredSessions();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(`[mcp] [${newSessionId.slice(0, 8)}] Session initialized${agentName ? ` (${agentName})` : ""}`);
|
|
78
|
+
return {
|
|
79
|
+
body: rpcResult(id, {
|
|
80
|
+
protocolVersion: "2025-03-26",
|
|
81
|
+
capabilities: { tools: {} },
|
|
82
|
+
serverInfo: { name: "palmier", version: "1.0.0" },
|
|
83
|
+
}),
|
|
84
|
+
sessionId: newSessionId,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case "tools/list": {
|
|
89
|
+
return {
|
|
90
|
+
body: rpcResult(id, {
|
|
91
|
+
tools: agentTools.map((t) => ({
|
|
92
|
+
name: t.name,
|
|
93
|
+
description: t.description,
|
|
94
|
+
inputSchema: t.inputSchema,
|
|
95
|
+
})),
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
case "tools/call": {
|
|
101
|
+
const name = req.params?.name as string | undefined;
|
|
102
|
+
const args = (req.params?.arguments ?? {}) as Record<string, unknown>;
|
|
103
|
+
|
|
104
|
+
if (!name) return { body: rpcError(id, -32602, "Missing params.name") };
|
|
105
|
+
|
|
106
|
+
const tool = agentToolMap.get(name);
|
|
107
|
+
if (!tool) return { body: rpcError(id, -32602, `Unknown tool: ${name}`) };
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const result = await tool.handler(args, ctx);
|
|
111
|
+
console.log(`${logPrefix} tools/call ${name} done:`, JSON.stringify(result).slice(0, 200));
|
|
112
|
+
return {
|
|
113
|
+
body: rpcResult(id, {
|
|
114
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
const message = err instanceof ToolError ? err.message : String(err);
|
|
119
|
+
console.error(`${logPrefix} tools/call ${name} error:`, message);
|
|
120
|
+
return {
|
|
121
|
+
body: rpcResult(id, {
|
|
122
|
+
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
|
123
|
+
isError: true,
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
default:
|
|
130
|
+
console.warn(`${logPrefix} Unknown method: ${req.method}`);
|
|
131
|
+
return { body: rpcError(id, -32601, `Method not found: ${req.method}`) };
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/mcp-tools.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { StringCodec, type NatsConnection } from "nats";
|
|
2
|
+
import { registerPending } from "./pending-requests.js";
|
|
3
|
+
import { getLocationDevice } from "./location-device.js";
|
|
4
|
+
import type { HostConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export class ToolError extends Error {
|
|
7
|
+
constructor(message: string, public statusCode: number = 500) {
|
|
8
|
+
super(message);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ToolContext {
|
|
13
|
+
config: HostConfig;
|
|
14
|
+
nc: NatsConnection | undefined;
|
|
15
|
+
publishEvent: (id: string, payload: Record<string, unknown>) => Promise<void>;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
agentName?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ToolDefinition {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
inputSchema: object;
|
|
24
|
+
handler: (args: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const notifyTool: ToolDefinition = {
|
|
28
|
+
name: "notify",
|
|
29
|
+
description: "Send a push notification to the user's device.",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
title: { type: "string", description: "Notification title" },
|
|
34
|
+
body: { type: "string", description: "Notification body" },
|
|
35
|
+
},
|
|
36
|
+
required: ["title", "body"],
|
|
37
|
+
},
|
|
38
|
+
async handler(args, ctx) {
|
|
39
|
+
const { title, body } = args as { title: string; body: string };
|
|
40
|
+
if (!title || !body) throw new ToolError("title and body are required", 400);
|
|
41
|
+
if (!ctx.nc) throw new ToolError("NATS not connected — push notifications require server mode", 503);
|
|
42
|
+
|
|
43
|
+
const sc = StringCodec();
|
|
44
|
+
const payload: Record<string, string> = { hostId: ctx.config.hostId, title, body };
|
|
45
|
+
if (ctx.sessionId) payload.session_id = ctx.sessionId;
|
|
46
|
+
if (ctx.agentName) payload.agent_name = ctx.agentName;
|
|
47
|
+
const subject = `host.${ctx.config.hostId}.push.send`;
|
|
48
|
+
const reply = await ctx.nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
|
|
49
|
+
const result = JSON.parse(sc.decode(reply.data)) as { ok?: boolean; error?: string };
|
|
50
|
+
|
|
51
|
+
if (result.ok) return { ok: true };
|
|
52
|
+
throw new ToolError(result.error ?? "Push notification failed", 502);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const requestInputTool: ToolDefinition = {
|
|
57
|
+
name: "request-input",
|
|
58
|
+
description: "Request input from the user. The request blocks until the user responds.",
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: {
|
|
62
|
+
description: { type: "string", description: "Context or heading for the input request" },
|
|
63
|
+
questions: {
|
|
64
|
+
type: "array",
|
|
65
|
+
items: { type: "string" },
|
|
66
|
+
description: "Questions to present to the user",
|
|
67
|
+
minItems: 1,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
required: ["questions"],
|
|
71
|
+
},
|
|
72
|
+
async handler(args, ctx) {
|
|
73
|
+
const { description, questions } = args as { description?: string; questions: string[] };
|
|
74
|
+
if (!questions?.length) throw new ToolError("questions is required", 400);
|
|
75
|
+
|
|
76
|
+
const pendingPromise = registerPending(ctx.sessionId, "input", questions);
|
|
77
|
+
|
|
78
|
+
await ctx.publishEvent("_input", {
|
|
79
|
+
event_type: "input-request",
|
|
80
|
+
host_id: ctx.config.hostId,
|
|
81
|
+
session_id: ctx.sessionId,
|
|
82
|
+
agent_name: ctx.agentName,
|
|
83
|
+
description,
|
|
84
|
+
input_questions: questions,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const response = await pendingPromise;
|
|
88
|
+
|
|
89
|
+
if (response.length === 1 && response[0] === "aborted") {
|
|
90
|
+
await ctx.publishEvent("_input", { event_type: "input-resolved", host_id: ctx.config.hostId, session_id: ctx.sessionId, status: "aborted" });
|
|
91
|
+
return { aborted: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await ctx.publishEvent("_input", { event_type: "input-resolved", host_id: ctx.config.hostId, session_id: ctx.sessionId, status: "provided" });
|
|
95
|
+
return { values: response };
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const requestConfirmationTool: ToolDefinition = {
|
|
100
|
+
name: "request-confirmation",
|
|
101
|
+
description: "Request confirmation from the user. The request blocks until the user confirms or aborts.",
|
|
102
|
+
inputSchema: {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: {
|
|
105
|
+
description: { type: "string", description: "What the user is confirming" },
|
|
106
|
+
},
|
|
107
|
+
required: ["description"],
|
|
108
|
+
},
|
|
109
|
+
async handler(args, ctx) {
|
|
110
|
+
const { description } = args as { description: string };
|
|
111
|
+
if (!description) throw new ToolError("description is required", 400);
|
|
112
|
+
|
|
113
|
+
const pendingPromise = registerPending(ctx.sessionId, "confirmation");
|
|
114
|
+
|
|
115
|
+
await ctx.publishEvent("_confirm", {
|
|
116
|
+
event_type: "confirm-request",
|
|
117
|
+
host_id: ctx.config.hostId,
|
|
118
|
+
session_id: ctx.sessionId,
|
|
119
|
+
agent_name: ctx.agentName,
|
|
120
|
+
description,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const response = await pendingPromise;
|
|
124
|
+
const confirmed = response[0] === "confirmed";
|
|
125
|
+
|
|
126
|
+
await ctx.publishEvent("_confirm", {
|
|
127
|
+
event_type: "confirm-resolved",
|
|
128
|
+
host_id: ctx.config.hostId,
|
|
129
|
+
session_id: ctx.sessionId,
|
|
130
|
+
status: confirmed ? "confirmed" : "aborted",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return { confirmed };
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const deviceGeolocationTool: ToolDefinition = {
|
|
138
|
+
name: "device-geolocation",
|
|
139
|
+
description: "Get the GPS location of the user's mobile device. Blocks until the device responds (up to 30 seconds).",
|
|
140
|
+
inputSchema: {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties: {},
|
|
143
|
+
},
|
|
144
|
+
async handler(_args, ctx) {
|
|
145
|
+
if (!ctx.nc) throw new ToolError("Not connected to server (NATS unavailable)", 503);
|
|
146
|
+
|
|
147
|
+
const locDevice = getLocationDevice();
|
|
148
|
+
if (!locDevice) throw new ToolError("No device has location access enabled", 400);
|
|
149
|
+
|
|
150
|
+
const sc = StringCodec();
|
|
151
|
+
|
|
152
|
+
const ackReply = await ctx.nc.request(
|
|
153
|
+
`host.${ctx.config.hostId}.fcm.geolocation`,
|
|
154
|
+
sc.encode(JSON.stringify({ hostId: ctx.config.hostId, requestId: ctx.sessionId, fcmToken: locDevice.fcmToken })),
|
|
155
|
+
{ timeout: 5_000 },
|
|
156
|
+
);
|
|
157
|
+
const ack = JSON.parse(sc.decode(ackReply.data)) as { ok?: boolean; error?: string };
|
|
158
|
+
if (ack.error) throw new ToolError(ack.error, 502);
|
|
159
|
+
|
|
160
|
+
const locationPromise = new Promise<string>((resolve, reject) => {
|
|
161
|
+
const sub = ctx.nc!.subscribe(`host.${ctx.config.hostId}.geolocation.${ctx.sessionId}`, { max: 1 });
|
|
162
|
+
const timer = setTimeout(() => {
|
|
163
|
+
sub.unsubscribe();
|
|
164
|
+
reject(new ToolError("Device did not respond within 30 seconds", 504));
|
|
165
|
+
}, 30_000);
|
|
166
|
+
|
|
167
|
+
(async () => {
|
|
168
|
+
for await (const msg of sub) {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
resolve(sc.decode(msg.data));
|
|
171
|
+
}
|
|
172
|
+
})();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const locationData = JSON.parse(await locationPromise);
|
|
176
|
+
if (locationData.error) return { error: locationData.error };
|
|
177
|
+
return locationData;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const agentTools: ToolDefinition[] = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool];
|
|
182
|
+
export const agentToolMap = new Map<string, ToolDefinition>(agentTools.map((t) => [t.name, t]));
|
package/src/rpc-handler.ts
CHANGED
|
@@ -13,6 +13,7 @@ import crossSpawn from "cross-spawn";
|
|
|
13
13
|
import { getAgent } from "./agents/agent.js";
|
|
14
14
|
import { validateClient } from "./client-store.js";
|
|
15
15
|
import { publishHostEvent } from "./events.js";
|
|
16
|
+
import { getLocationDevice, setLocationDevice, clearLocationDevice } from "./location-device.js";
|
|
16
17
|
import { currentVersion, performUpdate } from "./update-checker.js";
|
|
17
18
|
import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
|
|
18
19
|
import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
|
|
@@ -166,27 +167,29 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
166
167
|
body: task.body,
|
|
167
168
|
status: status ? {
|
|
168
169
|
...status,
|
|
169
|
-
...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
|
|
170
170
|
...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
|
|
171
|
-
...(pending?.type === "input" ? { pending_input: pending.params } : {}),
|
|
172
171
|
} : undefined,
|
|
173
172
|
};
|
|
174
173
|
}
|
|
175
174
|
|
|
176
175
|
async function handleRpc(request: RpcMessage): Promise<unknown> {
|
|
177
|
-
// Client token validation: skip for trusted localhost requests
|
|
178
|
-
|
|
176
|
+
// Client token validation: skip for trusted localhost requests and
|
|
177
|
+
// task.user_input (server-originated push responses; gated by getPending instead)
|
|
178
|
+
const skipAuth = request.method === "task.user_input";
|
|
179
|
+
if (!skipAuth && !request.localhost && (!request.clientToken || !validateClient(request.clientToken))) {
|
|
179
180
|
return { error: "Unauthorized" };
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
switch (request.method) {
|
|
183
184
|
case "task.list": {
|
|
184
185
|
const tasks = listTasks(config.projectRoot);
|
|
186
|
+
const locDevice = getLocationDevice();
|
|
185
187
|
return {
|
|
186
188
|
tasks: tasks.map((task) => flattenTask(task)),
|
|
187
189
|
agents: config.agents ?? [],
|
|
188
190
|
version: currentVersion,
|
|
189
191
|
host_platform: process.platform,
|
|
192
|
+
location_client_token: locDevice?.clientToken ?? null,
|
|
190
193
|
};
|
|
191
194
|
}
|
|
192
195
|
|
|
@@ -440,7 +443,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
440
443
|
const child = crossSpawn(cmd, cmdArgs, {
|
|
441
444
|
cwd: followupRunDir,
|
|
442
445
|
stdio: [stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
|
443
|
-
env: { ...process.env, ...followupAgentEnv
|
|
446
|
+
env: { ...process.env, ...followupAgentEnv },
|
|
444
447
|
windowsHide: true,
|
|
445
448
|
});
|
|
446
449
|
if (stdin != null) child.stdin!.end(stdin);
|
|
@@ -577,9 +580,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
577
580
|
return {
|
|
578
581
|
task_id: params.id,
|
|
579
582
|
...status,
|
|
580
|
-
...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
|
|
581
583
|
...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
|
|
582
|
-
...(pending?.type === "input" ? { pending_input: pending.params } : {}),
|
|
583
584
|
};
|
|
584
585
|
}
|
|
585
586
|
|
|
@@ -690,6 +691,19 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
690
691
|
return { ok: true };
|
|
691
692
|
}
|
|
692
693
|
|
|
694
|
+
case "device.location.enable": {
|
|
695
|
+
const params = request.params as { fcmToken: string };
|
|
696
|
+
if (!params.fcmToken) return { error: "fcmToken is required" };
|
|
697
|
+
const clientToken = request.clientToken ?? "";
|
|
698
|
+
setLocationDevice(clientToken, params.fcmToken);
|
|
699
|
+
return { ok: true };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
case "device.location.disable": {
|
|
703
|
+
clearLocationDevice();
|
|
704
|
+
return { ok: true };
|
|
705
|
+
}
|
|
706
|
+
|
|
693
707
|
default:
|
|
694
708
|
return { error: `Unknown method: ${request.method}` };
|
|
695
709
|
}
|
|
@@ -5,8 +5,10 @@ import { StringCodec, type NatsConnection } from "nats";
|
|
|
5
5
|
import { validateClient, addClient } from "../client-store.js";
|
|
6
6
|
import { registerPending } from "../pending-requests.js";
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
|
-
import { getTaskDir, parseTaskFile, spliceUserMessage } from "../task.js";
|
|
9
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";
|
|
11
|
+
import { getTaskDir } from "../task.js";
|
|
10
12
|
|
|
11
13
|
// ── Bundled PWA asset serving ───────────────────────────────────────────
|
|
12
14
|
|
|
@@ -87,18 +89,6 @@ export function detectLanIp(): string {
|
|
|
87
89
|
return "127.0.0.1";
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
/** Find the latest (highest-numbered) run directory for a task. */
|
|
91
|
-
function findLatestRunId(taskDir: string): string | null {
|
|
92
|
-
try {
|
|
93
|
-
const dirs = fs.readdirSync(taskDir)
|
|
94
|
-
.filter((f) => /^\d+$/.test(f) && fs.statSync(`${taskDir}/${f}`).isDirectory())
|
|
95
|
-
.sort();
|
|
96
|
-
return dirs.length > 0 ? dirs[dirs.length - 1] : null;
|
|
97
|
-
} catch {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
92
|
/**
|
|
103
93
|
* Start the HTTP transport: server with RPC, SSE, PWA proxy, pairing, and
|
|
104
94
|
* localhost-only agent endpoints (notify, request-input, confirmation, permission).
|
|
@@ -172,10 +162,65 @@ export async function startHttpTransport(
|
|
|
172
162
|
broadcastSseEvent({ task_id: taskId, ...payload });
|
|
173
163
|
}
|
|
174
164
|
|
|
165
|
+
function makeToolContext(sessionId: string): ToolContext {
|
|
166
|
+
return { config, nc, publishEvent, sessionId, agentName: getAgentName(sessionId) };
|
|
167
|
+
}
|
|
168
|
+
|
|
175
169
|
const server = http.createServer(async (req, res) => {
|
|
176
170
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
177
171
|
const pathname = url.pathname;
|
|
178
172
|
|
|
173
|
+
// ── MCP streamable HTTP endpoint ──────────────────────────────────
|
|
174
|
+
|
|
175
|
+
if (req.method === "POST" && pathname === "/mcp") {
|
|
176
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
177
|
+
try {
|
|
178
|
+
const body = await readBody(req);
|
|
179
|
+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
180
|
+
const ctx = makeToolContext(sessionId ?? "");
|
|
181
|
+
const result = await handleMcpRequest(body, sessionId, ctx);
|
|
182
|
+
if (result.sessionId) {
|
|
183
|
+
res.setHeader("Mcp-Session-Id", result.sessionId);
|
|
184
|
+
}
|
|
185
|
+
sendJson(res, 200, result.body);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
sendJson(res, 500, { error: String(err) });
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Auto-generated REST endpoints from MCP tool registry ──────────
|
|
193
|
+
|
|
194
|
+
if (req.method === "POST" && agentToolMap.has(pathname.slice(1))) {
|
|
195
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
196
|
+
const tool = agentToolMap.get(pathname.slice(1))!;
|
|
197
|
+
try {
|
|
198
|
+
const body = await readBody(req);
|
|
199
|
+
const args = body.trim() ? JSON.parse(body) : {};
|
|
200
|
+
const { taskId } = args as { taskId?: string };
|
|
201
|
+
if (!taskId) {
|
|
202
|
+
sendJson(res, 400, { error: "taskId is required" });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
206
|
+
if (!fs.existsSync(taskDir)) {
|
|
207
|
+
sendJson(res, 404, { error: `Task not found: ${taskId}` });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
delete args.taskId;
|
|
211
|
+
const ctx = makeToolContext(taskId);
|
|
212
|
+
console.log(`[mcp] REST [${taskId.slice(0, 8)}] ${tool.name}`);
|
|
213
|
+
const result = await tool.handler(args, ctx);
|
|
214
|
+
console.log(`[mcp] REST [${taskId.slice(0, 8)}] ${tool.name} done:`, JSON.stringify(result).slice(0, 200));
|
|
215
|
+
sendJson(res, 200, result);
|
|
216
|
+
} catch (err: any) {
|
|
217
|
+
const status = err instanceof ToolError ? err.statusCode : 500;
|
|
218
|
+
console.error(`[mcp] REST ${tool.name} error:`, err.message ?? String(err));
|
|
219
|
+
sendJson(res, status, { error: err.message ?? String(err) });
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
179
224
|
// ── Localhost-only endpoints (no auth) ─────────────────────────────
|
|
180
225
|
|
|
181
226
|
if (req.method === "POST" && pathname === "/event") {
|
|
@@ -217,121 +262,6 @@ export async function startHttpTransport(
|
|
|
217
262
|
return;
|
|
218
263
|
}
|
|
219
264
|
|
|
220
|
-
// ── POST /notify — send push notification via NATS ─────────────────
|
|
221
|
-
|
|
222
|
-
if (req.method === "POST" && pathname === "/notify") {
|
|
223
|
-
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
224
|
-
if (!nc) { sendJson(res, 503, { error: "NATS not connected — push notifications require server mode" }); return; }
|
|
225
|
-
|
|
226
|
-
try {
|
|
227
|
-
const body = await readBody(req);
|
|
228
|
-
const { taskId: notifTaskId, title, body: notifBody } = JSON.parse(body) as { taskId?: string; title: string; body: string };
|
|
229
|
-
if (!title || !notifBody) { sendJson(res, 400, { error: "title and body are required" }); return; }
|
|
230
|
-
|
|
231
|
-
const sc = StringCodec();
|
|
232
|
-
const payload: Record<string, string> = { hostId: config.hostId, title, body: notifBody };
|
|
233
|
-
if (notifTaskId) payload.task_id = notifTaskId;
|
|
234
|
-
const subject = `host.${config.hostId}.push.send`;
|
|
235
|
-
const reply = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
|
|
236
|
-
const result = JSON.parse(sc.decode(reply.data)) as { ok?: boolean; error?: string };
|
|
237
|
-
|
|
238
|
-
if (result.ok) {
|
|
239
|
-
sendJson(res, 200, { ok: true });
|
|
240
|
-
} else {
|
|
241
|
-
sendJson(res, 502, { error: result.error ?? "Push notification failed" });
|
|
242
|
-
}
|
|
243
|
-
} catch (err) {
|
|
244
|
-
sendJson(res, 500, { error: `Failed to send notification: ${err}` });
|
|
245
|
-
}
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// ── POST /request-input — held connection until user responds ────────
|
|
250
|
-
|
|
251
|
-
if (req.method === "POST" && pathname === "/request-input") {
|
|
252
|
-
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
253
|
-
try {
|
|
254
|
-
const body = await readBody(req);
|
|
255
|
-
const { taskId, runId, descriptions } = JSON.parse(body) as {
|
|
256
|
-
taskId: string; runId?: string; descriptions: string[];
|
|
257
|
-
};
|
|
258
|
-
if (!taskId || !descriptions?.length) {
|
|
259
|
-
sendJson(res, 400, { error: "taskId and descriptions are required" });
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
264
|
-
const task = parseTaskFile(taskDir);
|
|
265
|
-
|
|
266
|
-
// Resolve runId: use provided value, otherwise find the latest run directory
|
|
267
|
-
const effectiveRunId = runId ?? findLatestRunId(taskDir);
|
|
268
|
-
|
|
269
|
-
const pendingPromise = registerPending(taskId, "input", descriptions);
|
|
270
|
-
|
|
271
|
-
await publishEvent(taskId, {
|
|
272
|
-
event_type: "input-request",
|
|
273
|
-
host_id: config.hostId,
|
|
274
|
-
input_descriptions: descriptions,
|
|
275
|
-
name: task.frontmatter.name,
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
const response = await pendingPromise;
|
|
279
|
-
|
|
280
|
-
const questionsBlock = "\n\n" + descriptions.map((d) => `**${d}**`).join("\n");
|
|
281
|
-
|
|
282
|
-
if (response.length === 1 && response[0] === "aborted") {
|
|
283
|
-
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "aborted" });
|
|
284
|
-
if (effectiveRunId) {
|
|
285
|
-
spliceUserMessage(taskDir, effectiveRunId, { role: "user", time: Date.now(), content: "Aborted", type: "input" }, questionsBlock);
|
|
286
|
-
await publishEvent(taskId, { event_type: "result-updated", run_id: effectiveRunId });
|
|
287
|
-
}
|
|
288
|
-
sendJson(res, 200, { aborted: true });
|
|
289
|
-
} else {
|
|
290
|
-
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "provided" });
|
|
291
|
-
if (effectiveRunId) {
|
|
292
|
-
spliceUserMessage(taskDir, effectiveRunId, { role: "user", time: Date.now(), content: response.join("\n"), type: "input" }, questionsBlock);
|
|
293
|
-
await publishEvent(taskId, { event_type: "result-updated", run_id: effectiveRunId });
|
|
294
|
-
}
|
|
295
|
-
sendJson(res, 200, { values: response });
|
|
296
|
-
}
|
|
297
|
-
} catch (err) {
|
|
298
|
-
sendJson(res, 500, { error: String(err) });
|
|
299
|
-
}
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ── POST /request-confirmation — held connection ────────────────────
|
|
304
|
-
|
|
305
|
-
if (req.method === "POST" && pathname === "/request-confirmation") {
|
|
306
|
-
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
307
|
-
try {
|
|
308
|
-
const body = await readBody(req);
|
|
309
|
-
const { taskId } = JSON.parse(body) as { taskId: string };
|
|
310
|
-
if (!taskId) { sendJson(res, 400, { error: "taskId is required" }); return; }
|
|
311
|
-
|
|
312
|
-
const pendingPromise = registerPending(taskId, "confirmation");
|
|
313
|
-
|
|
314
|
-
await publishEvent(taskId, {
|
|
315
|
-
event_type: "confirm-request",
|
|
316
|
-
host_id: config.hostId,
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
const response = await pendingPromise;
|
|
320
|
-
const confirmed = response[0] === "confirmed";
|
|
321
|
-
|
|
322
|
-
await publishEvent(taskId, {
|
|
323
|
-
event_type: "confirm-resolved",
|
|
324
|
-
host_id: config.hostId,
|
|
325
|
-
status: confirmed ? "confirmed" : "aborted",
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
sendJson(res, 200, { confirmed });
|
|
329
|
-
} catch (err) {
|
|
330
|
-
sendJson(res, 500, { error: String(err) });
|
|
331
|
-
}
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
265
|
// ── POST /request-permission — held connection ──────────────────────
|
|
336
266
|
|
|
337
267
|
if (req.method === "POST" && pathname === "/request-permission") {
|