@zetaloop/chappie 0.1.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.
@@ -0,0 +1,189 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { basename } from "node:path";
4
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
5
+ import mime from "mime";
6
+
7
+ export interface ResourceDescriptor {
8
+ uri: string;
9
+ name: string;
10
+ mimeType: string;
11
+ size: number;
12
+ }
13
+
14
+ export interface ResourceData extends ResourceDescriptor {
15
+ blob: string;
16
+ }
17
+
18
+ type ResourceEntry =
19
+ | { type: "file"; path: string; descriptor: ResourceDescriptor }
20
+ | { type: "image"; data: string; descriptor: ResourceDescriptor };
21
+
22
+ const stores = new Map<string, Map<string, ResourceEntry>>();
23
+
24
+ export async function registerFile(
25
+ sessionId: string,
26
+ path: string,
27
+ ): Promise<ResourceDescriptor> {
28
+ const info = await stat(path);
29
+ if (!info.isFile()) throw new Error(`${path} is not a file`);
30
+ const name = basename(path);
31
+ const descriptor = resourceDescriptor(
32
+ sessionId,
33
+ "file",
34
+ randomUUID(),
35
+ name,
36
+ mime.getType(path) ?? "application/octet-stream",
37
+ info.size,
38
+ );
39
+ store(sessionId).set(descriptor.uri, { type: "file", path, descriptor });
40
+ return descriptor;
41
+ }
42
+
43
+ export function rememberImages(
44
+ sessionId: string,
45
+ content: readonly (TextContent | ImageContent)[],
46
+ ): void {
47
+ for (const block of content) {
48
+ if (block.type !== "image") continue;
49
+ const descriptor = imageDescriptor(sessionId, block);
50
+ store(sessionId).set(descriptor.uri, {
51
+ type: "image",
52
+ data: block.data,
53
+ descriptor,
54
+ });
55
+ }
56
+ }
57
+
58
+ export function imageDescriptor(
59
+ sessionId: string,
60
+ image: ImageContent,
61
+ ): ResourceDescriptor {
62
+ const digest = createHash("sha256")
63
+ .update(image.data, "base64")
64
+ .digest("hex");
65
+ const extension = mime.getExtension(image.mimeType) ?? "bin";
66
+ return resourceDescriptor(
67
+ sessionId,
68
+ "image",
69
+ digest,
70
+ `image.${extension}`,
71
+ image.mimeType,
72
+ Buffer.byteLength(image.data, "base64"),
73
+ );
74
+ }
75
+
76
+ export function contentWithImageReferences(
77
+ sessionId: string,
78
+ content: readonly (TextContent | ImageContent)[],
79
+ ): (TextContent | ImageContent)[] {
80
+ return content.flatMap((block) =>
81
+ block.type === "image"
82
+ ? [
83
+ block,
84
+ {
85
+ type: "text" as const,
86
+ text: JSON.stringify({
87
+ piImage: imageDescriptor(sessionId, block).uri,
88
+ }),
89
+ },
90
+ ]
91
+ : [block],
92
+ );
93
+ }
94
+
95
+ export function resourceDescriptors(details: unknown): ResourceDescriptor[] {
96
+ if (!details || typeof details !== "object") return [];
97
+ const resources = (details as { resources?: unknown }).resources;
98
+ if (!Array.isArray(resources)) return [];
99
+ return resources.filter((resource): resource is ResourceDescriptor => {
100
+ if (!resource || typeof resource !== "object") return false;
101
+ const value = resource as Partial<ResourceDescriptor>;
102
+ return (
103
+ typeof value.uri === "string" &&
104
+ typeof value.name === "string" &&
105
+ typeof value.mimeType === "string" &&
106
+ typeof value.size === "number"
107
+ );
108
+ });
109
+ }
110
+
111
+ export function describeResource(
112
+ sessionId: string,
113
+ uri: string,
114
+ ): ResourceDescriptor {
115
+ const parsed = parseResourceUri(uri);
116
+ if (parsed.sessionId !== sessionId) {
117
+ throw new Error("The resource belongs to another Pi session");
118
+ }
119
+ const entry = store(sessionId).get(uri);
120
+ if (!entry) throw new Error(`Unknown Chappie resource: ${uri}`);
121
+ return entry.descriptor;
122
+ }
123
+
124
+ export async function readSessionResource(
125
+ sessionId: string,
126
+ uri: string,
127
+ ): Promise<ResourceData> {
128
+ const descriptor = describeResource(sessionId, uri);
129
+ const entry = store(sessionId).get(uri);
130
+ if (!entry) throw new Error(`Unknown Chappie resource: ${uri}`);
131
+ const blob =
132
+ entry.type === "file"
133
+ ? (await readFile(entry.path)).toString("base64")
134
+ : entry.data;
135
+ return { ...descriptor, blob };
136
+ }
137
+
138
+ export function resourceSessionId(uri: string): string {
139
+ return parseResourceUri(uri).sessionId;
140
+ }
141
+
142
+ function store(sessionId: string): Map<string, ResourceEntry> {
143
+ let resources = stores.get(sessionId);
144
+ if (!resources) {
145
+ resources = new Map();
146
+ stores.set(sessionId, resources);
147
+ }
148
+ return resources;
149
+ }
150
+
151
+ function resourceDescriptor(
152
+ sessionId: string,
153
+ kind: "file" | "image",
154
+ id: string,
155
+ name: string,
156
+ mimeType: string,
157
+ size: number,
158
+ ): ResourceDescriptor {
159
+ return {
160
+ uri: `chappie://session/${encodeURIComponent(sessionId)}/${kind}/${encodeURIComponent(id)}/${encodeURIComponent(name)}`,
161
+ name,
162
+ mimeType,
163
+ size,
164
+ };
165
+ }
166
+
167
+ function parseResourceUri(uri: string): {
168
+ sessionId: string;
169
+ kind: string;
170
+ id: string;
171
+ name: string;
172
+ } {
173
+ const parsed = new URL(uri);
174
+ if (parsed.protocol !== "chappie:" || parsed.hostname !== "session") {
175
+ throw new Error(`Unsupported Chappie resource: ${uri}`);
176
+ }
177
+ const parts = parsed.pathname
178
+ .slice(1)
179
+ .split("/")
180
+ .map((part) => decodeURIComponent(part));
181
+ if (parts.length !== 4 || !parts.every(Boolean)) {
182
+ throw new Error(`Invalid Chappie resource: ${uri}`);
183
+ }
184
+ const [sessionId, kind, id, name] = parts;
185
+ if (!sessionId || !kind || !id || !name) {
186
+ throw new Error(`Invalid Chappie resource: ${uri}`);
187
+ }
188
+ return { sessionId, kind, id, name };
189
+ }
package/src/server.ts ADDED
@@ -0,0 +1,296 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
3
+ import * as z from "zod";
4
+ import packageJson from "../package.json" with { type: "json" };
5
+ import type { Broker } from "./broker.ts";
6
+ import { deliveryContent } from "./delivery.ts";
7
+ import {
8
+ directTools,
9
+ inputContent,
10
+ type ToolInput,
11
+ toolResult,
12
+ } from "./tools.ts";
13
+
14
+ const instructions = readFileSync(
15
+ new URL("./instructions.md", import.meta.url),
16
+ "utf8",
17
+ ).trim();
18
+
19
+ interface RequestContext {
20
+ mcpReq: {
21
+ _meta?: Record<string, unknown>;
22
+ signal: AbortSignal;
23
+ };
24
+ }
25
+
26
+ export function createServer(broker: Broker): McpServer {
27
+ const server = new McpServer(
28
+ {
29
+ name: "chappie",
30
+ version: packageJson.version,
31
+ },
32
+ { instructions },
33
+ );
34
+
35
+ server.registerTool(
36
+ "init",
37
+ {
38
+ title: "Connect to Pi",
39
+ description: "Connect this ChatGPT conversation to a Pi session.",
40
+ inputSchema: z.object({
41
+ sessionId: z
42
+ .string()
43
+ .optional()
44
+ .describe("Pi session to select explicitly"),
45
+ }),
46
+ annotations: {
47
+ openWorldHint: false,
48
+ },
49
+ },
50
+ async (args, context) => {
51
+ const chatId = requireChatId(context);
52
+ const { inputs, ...initialized } = await broker.initialize(
53
+ chatId,
54
+ args.sessionId,
55
+ context.mcpReq.signal,
56
+ );
57
+ return finishResult(broker, context, textResult(initialized, inputs));
58
+ },
59
+ );
60
+
61
+ server.registerTool(
62
+ "chat",
63
+ {
64
+ title: "Reply in Pi",
65
+ description: "Send one complete assistant message to a Pi session.",
66
+ inputSchema: z.object({
67
+ text: z.string().min(1).describe("Assistant message to display in Pi"),
68
+ sessionId: z
69
+ .string()
70
+ .optional()
71
+ .describe("Pi session for this operation only"),
72
+ }),
73
+ annotations: {
74
+ openWorldHint: false,
75
+ },
76
+ },
77
+ async (args, context) => {
78
+ const chatId = requireChatId(context);
79
+ const { message, inputs } = await broker.chat(
80
+ chatId,
81
+ args.sessionId,
82
+ args.text,
83
+ context.mcpReq.signal,
84
+ );
85
+ return finishResult(broker, context, textResult({ message }, inputs));
86
+ },
87
+ );
88
+
89
+ server.registerTool(
90
+ "tools",
91
+ {
92
+ title: "Pi tools",
93
+ description: "List the tools currently active in a Pi session.",
94
+ inputSchema: z.object({
95
+ sessionId: z
96
+ .string()
97
+ .optional()
98
+ .describe("Pi session for this operation only"),
99
+ }),
100
+ annotations: {
101
+ readOnlyHint: true,
102
+ idempotentHint: true,
103
+ openWorldHint: false,
104
+ },
105
+ },
106
+ async (args, context) => {
107
+ const { inputs, ...inspected } = await broker.tools(
108
+ requireChatId(context),
109
+ args.sessionId,
110
+ context.mcpReq.signal,
111
+ );
112
+ return finishResult(
113
+ broker,
114
+ context,
115
+ textResult(
116
+ { session: inspected.session, tools: inspected.tools },
117
+ inputs,
118
+ ),
119
+ );
120
+ },
121
+ );
122
+
123
+ server.registerTool(
124
+ "call",
125
+ {
126
+ title: "Call Pi tools",
127
+ description: "Execute one or more tools as a native Pi tool batch.",
128
+ inputSchema: z.object({
129
+ calls: z
130
+ .array(
131
+ z.object({
132
+ name: z.string(),
133
+ arguments: z.record(z.string(), z.unknown()),
134
+ }),
135
+ )
136
+ .min(1),
137
+ sessionId: z
138
+ .string()
139
+ .optional()
140
+ .describe("Pi session for this operation only"),
141
+ }),
142
+ annotations: {
143
+ openWorldHint: true,
144
+ },
145
+ },
146
+ async (args, context) => {
147
+ const result = await broker.call(
148
+ requireChatId(context),
149
+ args.sessionId,
150
+ args.calls,
151
+ context.mcpReq.signal,
152
+ );
153
+ return finishResult(
154
+ broker,
155
+ context,
156
+ toolResult(result.toolResults, result.sessionId, result.inputs),
157
+ );
158
+ },
159
+ );
160
+
161
+ for (const tool of directTools) {
162
+ server.registerTool(
163
+ tool.name,
164
+ {
165
+ title: tool.name,
166
+ description: tool.description,
167
+ inputSchema: tool.inputSchema,
168
+ annotations: {
169
+ readOnlyHint: tool.name === "read",
170
+ idempotentHint: tool.name === "read",
171
+ openWorldHint: tool.name === "bash",
172
+ },
173
+ ...(tool.fileParams
174
+ ? { _meta: { "openai/fileParams": tool.fileParams } }
175
+ : {}),
176
+ },
177
+ async (args, context) => {
178
+ const input = { ...args } as Record<string, unknown> & {
179
+ sessionId?: string;
180
+ };
181
+ const sessionId = input.sessionId;
182
+ delete input.sessionId;
183
+ const calls: ToolInput[] = [{ name: tool.name, arguments: input }];
184
+ const result = await broker.call(
185
+ requireChatId(context),
186
+ sessionId,
187
+ calls,
188
+ context.mcpReq.signal,
189
+ );
190
+ return finishResult(
191
+ broker,
192
+ context,
193
+ toolResult(result.toolResults, result.sessionId, result.inputs),
194
+ );
195
+ },
196
+ );
197
+ }
198
+
199
+ server.registerTool(
200
+ "sessions",
201
+ {
202
+ title: "Local sessions",
203
+ description:
204
+ "List connected Pi sessions and the current conversation binding.",
205
+ inputSchema: z.object({
206
+ sessionId: z
207
+ .string()
208
+ .optional()
209
+ .describe("Return only this Pi session when it is online"),
210
+ }),
211
+ annotations: {
212
+ readOnlyHint: true,
213
+ idempotentHint: true,
214
+ openWorldHint: false,
215
+ },
216
+ },
217
+ async (args, context) => {
218
+ const chatId = requestChatId(context);
219
+ const inputs = chatId
220
+ ? await broker.inputs(chatId, args.sessionId, context.mcpReq.signal)
221
+ : [];
222
+ const result = textResult(
223
+ {
224
+ binding: chatId ? (broker.binding(chatId) ?? null) : null,
225
+ sessions: broker.listSessions(args.sessionId),
226
+ },
227
+ inputs,
228
+ );
229
+ return chatId ? finishResult(broker, context, result) : result;
230
+ },
231
+ );
232
+
233
+ server.registerResource(
234
+ "Pi resource",
235
+ new ResourceTemplate("chappie://session/{sessionId}/{kind}/{id}/{name}", {
236
+ list: undefined,
237
+ }),
238
+ { title: "Pi resource" },
239
+ async (uri, _variables, context) => {
240
+ const resource = await broker.readResource(
241
+ uri.href,
242
+ context.mcpReq.signal,
243
+ );
244
+ return {
245
+ contents: [
246
+ {
247
+ uri: resource.uri,
248
+ mimeType: resource.mimeType,
249
+ blob: resource.blob,
250
+ },
251
+ ],
252
+ };
253
+ },
254
+ );
255
+
256
+ return server;
257
+ }
258
+
259
+ function textResult(
260
+ value: unknown,
261
+ inputs: Parameters<typeof inputContent>[0] = [],
262
+ ) {
263
+ return {
264
+ content: [
265
+ { type: "text" as const, text: JSON.stringify(value) },
266
+ ...inputContent(inputs),
267
+ ],
268
+ };
269
+ }
270
+
271
+ async function finishResult<T extends { content: object[] }>(
272
+ broker: Broker,
273
+ context: RequestContext,
274
+ result: T,
275
+ ): Promise<T> {
276
+ const chatId = requireChatId(context);
277
+ const deliveries = await broker.deliveries(chatId);
278
+ const completed = {
279
+ ...result,
280
+ content: [...result.content, ...deliveryContent(deliveries)],
281
+ } as T;
282
+ await broker.acknowledgeDeliveries(deliveries, context.mcpReq.signal);
283
+ return completed;
284
+ }
285
+
286
+ function requestChatId(context: RequestContext): string | undefined {
287
+ const value = context.mcpReq._meta?.["openai/session"];
288
+ return typeof value === "string" && value.length > 0 ? value : undefined;
289
+ }
290
+
291
+ function requireChatId(context: RequestContext): string {
292
+ const chatId = requestChatId(context);
293
+ if (!chatId)
294
+ throw new Error("ChatGPT did not provide openai/session metadata");
295
+ return chatId;
296
+ }