@ericmhalvorsen/aperture 0.2.1

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,237 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { CallToolRequestSchema, InitializeRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
3
+ import { WebSocket } from "ws";
4
+ import { BROWSER_TOOLS } from "./tools.js";
5
+ export function createApertureMcpServer(sessions, pendingRequests, sharedState) {
6
+ const server = new Server({ name: "aperture", version: "0.2.0" }, { capabilities: { tools: {} } });
7
+ server.setRequestHandler(InitializeRequestSchema, async (request) => {
8
+ sharedState.mcpInitialized = true;
9
+ const msg = JSON.stringify({ type: "agent_connected" });
10
+ for (const session of sessions.values()) {
11
+ if (session.ws.readyState === WebSocket.OPEN) {
12
+ session.ws.send(msg);
13
+ }
14
+ }
15
+ return {
16
+ protocolVersion: request.params.protocolVersion,
17
+ capabilities: { tools: {} },
18
+ serverInfo: { name: "aperture", version: "0.2.0" },
19
+ };
20
+ });
21
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
22
+ const tools = Object.entries(BROWSER_TOOLS).map(([name, def]) => ({
23
+ name,
24
+ description: def.description,
25
+ inputSchema: def.inputSchema,
26
+ }));
27
+ const addedCustomTools = new Set();
28
+ for (const session of sessions.values()) {
29
+ if (session.approved && session.customTools) {
30
+ for (const ct of session.customTools) {
31
+ if (!addedCustomTools.has(ct.name)) {
32
+ addedCustomTools.add(ct.name);
33
+ tools.push(ct);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ return { tools };
39
+ });
40
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
41
+ const params = request.params;
42
+ const toolName = params.name;
43
+ const args = (params.arguments || {});
44
+ if (toolName === "browser_list_sessions") {
45
+ const sessionList = Array.from(sessions.entries()).map(([id, s]) => ({
46
+ sessionId: id,
47
+ url: s.url,
48
+ title: s.title,
49
+ approved: s.approved,
50
+ lastActiveAt: s.lastActiveAt,
51
+ capabilities: Array.from(s.capabilities),
52
+ }));
53
+ return {
54
+ content: [
55
+ {
56
+ type: "text",
57
+ text: JSON.stringify({ sessions: sessionList }, null, 2),
58
+ },
59
+ ],
60
+ };
61
+ }
62
+ let isValid = !!BROWSER_TOOLS[toolName];
63
+ if (!isValid) {
64
+ for (const session of sessions.values()) {
65
+ if (session.approved &&
66
+ session.customTools?.some((t) => t.name === toolName)) {
67
+ isValid = true;
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ if (!toolName || !isValid) {
73
+ return {
74
+ content: [{ type: "text", text: `Tool not found: ${toolName}` }],
75
+ isError: true,
76
+ };
77
+ }
78
+ const sessionId = args.sessionId;
79
+ if (sessionId) {
80
+ const session = sessions.get(sessionId);
81
+ if (!session || session.ws.readyState !== WebSocket.OPEN) {
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: "Session not found or closed",
87
+ },
88
+ ],
89
+ isError: true,
90
+ };
91
+ }
92
+ return forwardToolCall(sessions, pendingRequests, session, toolName, args);
93
+ }
94
+ const lastActiveSession = getLastActiveSession(sessions);
95
+ if (lastActiveSession) {
96
+ return forwardToolCall(sessions, pendingRequests, lastActiveSession, toolName, args);
97
+ }
98
+ const approvedCount = Array.from(sessions.values()).filter((s) => s.approved && s.ws.readyState === WebSocket.OPEN).length;
99
+ if (approvedCount > 1) {
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text",
104
+ text: "Multiple approved browser sessions are connected. Use browser_list_sessions to get sessionIds, then pass sessionId in subsequent tool calls.",
105
+ },
106
+ ],
107
+ isError: true,
108
+ };
109
+ }
110
+ const connectedSessions = Array.from(sessions.values()).filter((s) => s.ws.readyState === WebSocket.OPEN);
111
+ if (connectedSessions.length === 0) {
112
+ return {
113
+ content: [
114
+ {
115
+ type: "text",
116
+ text: "No browser session connected. Ask the user to enable aperture in their dev session.",
117
+ },
118
+ ],
119
+ isError: true,
120
+ };
121
+ }
122
+ const requestId = crypto.randomUUID();
123
+ for (const s of connectedSessions) {
124
+ s.ws.send(JSON.stringify({
125
+ type: "tool_call",
126
+ requestId,
127
+ tool: toolName,
128
+ args,
129
+ }));
130
+ }
131
+ const result = await waitForFirstBrowserResult(pendingRequests, requestId, 60000);
132
+ if (result) {
133
+ return {
134
+ content: [
135
+ {
136
+ type: "text",
137
+ text: typeof result === "string"
138
+ ? result
139
+ : JSON.stringify(result, null, 2),
140
+ },
141
+ ],
142
+ };
143
+ }
144
+ return {
145
+ content: [
146
+ {
147
+ type: "text",
148
+ text: "Browser session did not respond in time. The user may have dismissed the approval dialog.",
149
+ },
150
+ ],
151
+ isError: true,
152
+ };
153
+ });
154
+ return server;
155
+ }
156
+ async function forwardToolCall(_sessions, pendingRequests, session, toolName, args) {
157
+ if (toolName === "browser_evaluate" &&
158
+ !session.capabilities.has("evaluate")) {
159
+ return {
160
+ content: [
161
+ {
162
+ type: "text",
163
+ text: "browser_evaluate requires explicit approval. Prompt the user to allow JS evaluation.",
164
+ },
165
+ ],
166
+ isError: true,
167
+ };
168
+ }
169
+ const requestId = crypto.randomUUID();
170
+ session.ws.send(JSON.stringify({
171
+ type: "tool_call",
172
+ requestId,
173
+ tool: toolName,
174
+ args,
175
+ }));
176
+ const timeout = toolName === "browser_screenshot" ? 60000 : 15000;
177
+ const result = await waitForBrowserResult(pendingRequests, requestId, timeout);
178
+ if (result) {
179
+ return {
180
+ content: [
181
+ {
182
+ type: "text",
183
+ text: typeof result === "string"
184
+ ? result
185
+ : JSON.stringify(result, null, 2),
186
+ },
187
+ ],
188
+ };
189
+ }
190
+ return {
191
+ content: [
192
+ { type: "text", text: "Browser session did not respond in time." },
193
+ ],
194
+ isError: true,
195
+ };
196
+ }
197
+ function getLastActiveSession(sessions) {
198
+ let best;
199
+ for (const session of sessions.values()) {
200
+ if (session.approved &&
201
+ session.ws.readyState === WebSocket.OPEN &&
202
+ (!best || session.lastActiveAt > best.lastActiveAt)) {
203
+ best = session;
204
+ }
205
+ }
206
+ return best;
207
+ }
208
+ function waitForBrowserResult(pendingRequests, requestId, timeoutMs) {
209
+ return new Promise((resolve) => {
210
+ const timer = setTimeout(() => {
211
+ pendingRequests.delete(requestId);
212
+ resolve(null);
213
+ }, timeoutMs);
214
+ pendingRequests.set(requestId, (result) => {
215
+ clearTimeout(timer);
216
+ pendingRequests.delete(requestId);
217
+ resolve(result);
218
+ });
219
+ });
220
+ }
221
+ function waitForFirstBrowserResult(pendingRequests, requestId, timeoutMs) {
222
+ return new Promise((resolve) => {
223
+ const timer = setTimeout(() => {
224
+ pendingRequests.delete(requestId);
225
+ resolve(null);
226
+ }, timeoutMs);
227
+ let settled = false;
228
+ pendingRequests.set(requestId, (result) => {
229
+ if (settled)
230
+ return;
231
+ settled = true;
232
+ clearTimeout(timer);
233
+ pendingRequests.delete(requestId);
234
+ resolve(result);
235
+ });
236
+ });
237
+ }
@@ -0,0 +1,8 @@
1
+ import type { CustomToolDefinition } from "./client.js";
2
+ interface ApertureProps {
3
+ port?: number;
4
+ serverUrl?: string;
5
+ customTools?: Record<string, CustomToolDefinition>;
6
+ }
7
+ export declare function Aperture({ port, serverUrl, customTools }: ApertureProps): null;
8
+ export {};
package/dist/react.js ADDED
@@ -0,0 +1,20 @@
1
+ "use client";
2
+ import { useEffect, useRef } from "react";
3
+ import { initAperture } from "./client.js";
4
+ export function Aperture({ port, serverUrl, customTools }) {
5
+ const customToolsRef = useRef(customTools);
6
+ customToolsRef.current = customTools;
7
+ useEffect(() => {
8
+ const client = initAperture({
9
+ port,
10
+ serverUrl,
11
+ customTools: customToolsRef.current,
12
+ });
13
+ return () => {
14
+ if (client) {
15
+ client.disconnect();
16
+ }
17
+ };
18
+ }, [port, serverUrl]);
19
+ return null;
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { initAperture } from "./client.js";
2
+ initAperture();
@@ -0,0 +1,25 @@
1
+ export declare class ApertureServer {
2
+ private wss;
3
+ private sessions;
4
+ private pendingRequests;
5
+ private sharedState;
6
+ private port;
7
+ private options;
8
+ private streamableSessions;
9
+ private sseSessions;
10
+ constructor(port?: number, options?: {
11
+ verbose?: boolean;
12
+ silentStartup?: boolean;
13
+ stdio?: boolean;
14
+ });
15
+ private handleStreamableHttp;
16
+ private handleStreamableHttpGet;
17
+ private handleStreamableHttpDelete;
18
+ private handleSSEConnection;
19
+ private handleSSEMessage;
20
+ private serveClientScript;
21
+ private setupWSS;
22
+ private handleBrowserConnection;
23
+ private handleMCPConnection;
24
+ private setupStdio;
25
+ }
package/dist/server.js ADDED
@@ -0,0 +1,315 @@
1
+ import * as fs from "node:fs/promises";
2
+ import { createServer, } from "node:http";
3
+ import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { randomUUID } from "node:crypto";
8
+ import { WebSocketServer } from "ws";
9
+ import { createApertureMcpServer, } from "./mcp-server.js";
10
+ import { parseJsonRpcBody, SseTransport, WebSocketTransport, writeParseError, } from "./transports.js";
11
+ export class ApertureServer {
12
+ wss;
13
+ sessions = new Map();
14
+ pendingRequests = new Map();
15
+ sharedState = { mcpInitialized: false };
16
+ port;
17
+ options;
18
+ streamableSessions = new Map();
19
+ sseSessions = new Map();
20
+ constructor(port = 3456, options = {}) {
21
+ this.port = port;
22
+ this.options = options;
23
+ const server = createServer(async (req, res) => {
24
+ res.setHeader("Access-Control-Allow-Origin", "*");
25
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
26
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, MCP-Protocol-Version, Accept");
27
+ if (req.method === "OPTIONS") {
28
+ res.writeHead(204);
29
+ res.end();
30
+ return;
31
+ }
32
+ if (req.method === "GET" && req.url === "/sse") {
33
+ await this.handleSSEConnection(req, res);
34
+ return;
35
+ }
36
+ if (req.method === "POST" && req.url?.startsWith("/messages")) {
37
+ await this.handleSSEMessage(req, res);
38
+ return;
39
+ }
40
+ if (req.url === "/mcp") {
41
+ if (req.method === "POST") {
42
+ await this.handleStreamableHttp(req, res);
43
+ return;
44
+ }
45
+ if (req.method === "GET") {
46
+ await this.handleStreamableHttpGet(req, res);
47
+ return;
48
+ }
49
+ if (req.method === "DELETE") {
50
+ await this.handleStreamableHttpDelete(req, res);
51
+ return;
52
+ }
53
+ }
54
+ if (req.url === "/aperture.js") {
55
+ await this.serveClientScript(res);
56
+ }
57
+ else {
58
+ res.writeHead(404, { "Content-Type": "text/plain" });
59
+ res.end("Not Found");
60
+ }
61
+ });
62
+ this.wss = new WebSocketServer({ server, path: "/mcp" });
63
+ this.setupWSS();
64
+ if (this.options.stdio) {
65
+ this.setupStdio();
66
+ }
67
+ server.listen(port, "127.0.0.1", () => {
68
+ if (!this.options.silentStartup) {
69
+ console.error(`[Aperture] MCP server on http://localhost:${this.port}/mcp`);
70
+ console.error(`[Aperture] Browser client script: http://localhost:${this.port}/aperture.js`);
71
+ }
72
+ });
73
+ }
74
+ async handleStreamableHttp(req, res) {
75
+ const sessionId = req.headers["mcp-session-id"];
76
+ if (sessionId && this.streamableSessions.has(sessionId)) {
77
+ const session = this.streamableSessions.get(sessionId);
78
+ if (session) {
79
+ await session.transport.handleRequest(req, res);
80
+ return;
81
+ }
82
+ }
83
+ const transport = new StreamableHTTPServerTransport({
84
+ sessionIdGenerator: () => randomUUID(),
85
+ onsessioninitialized: (sid) => {
86
+ this.streamableSessions.set(sid, { transport, server: mcpServer });
87
+ console.error(`[Aperture] Streamable HTTP session ${sid.slice(0, 8)} connected`);
88
+ },
89
+ });
90
+ const mcpServer = createApertureMcpServer(this.sessions, this.pendingRequests, this.sharedState);
91
+ transport.onclose = () => {
92
+ const sid = transport.sessionId;
93
+ if (sid && this.streamableSessions.has(sid)) {
94
+ this.streamableSessions.delete(sid);
95
+ console.error(`[Aperture] Streamable HTTP session ${sid.slice(0, 8)} disconnected`);
96
+ }
97
+ mcpServer.close().catch(() => { });
98
+ };
99
+ await mcpServer.connect(transport);
100
+ await transport.handleRequest(req, res);
101
+ }
102
+ async handleStreamableHttpGet(req, res) {
103
+ const sessionId = req.headers["mcp-session-id"];
104
+ if (!sessionId || !this.streamableSessions.has(sessionId)) {
105
+ res.writeHead(400, { "Content-Type": "application/json" });
106
+ res.end(JSON.stringify({ error: "Invalid or missing session ID" }));
107
+ return;
108
+ }
109
+ const session = this.streamableSessions.get(sessionId);
110
+ if (session) {
111
+ await session.transport.handleRequest(req, res);
112
+ }
113
+ }
114
+ async handleStreamableHttpDelete(req, res) {
115
+ const sessionId = req.headers["mcp-session-id"];
116
+ if (!sessionId || !this.streamableSessions.has(sessionId)) {
117
+ res.writeHead(404, { "Content-Type": "application/json" });
118
+ res.end(JSON.stringify({ error: "Session not found" }));
119
+ return;
120
+ }
121
+ const session = this.streamableSessions.get(sessionId);
122
+ if (session) {
123
+ await session.transport.handleRequest(req, res);
124
+ this.streamableSessions.delete(sessionId);
125
+ await session.server.close();
126
+ console.error(`[Aperture] Streamable HTTP session ${sessionId.slice(0, 8)} terminated`);
127
+ }
128
+ }
129
+ async handleSSEConnection(req, res) {
130
+ const transport = new SseTransport(res);
131
+ const _host = req.headers.host || `localhost:${this.port}`;
132
+ const messageUrl = `/messages/${transport.sessionId}?sessionId=${transport.sessionId}`;
133
+ const mcpServer = createApertureMcpServer(this.sessions, this.pendingRequests, this.sharedState);
134
+ this.sseSessions.set(transport.sessionId, {
135
+ transport,
136
+ server: mcpServer,
137
+ res,
138
+ });
139
+ res.writeHead(200, {
140
+ "Content-Type": "text/event-stream",
141
+ "Cache-Control": "no-cache, no-transform",
142
+ Connection: "keep-alive",
143
+ "Mcp-Session-Id": transport.sessionId,
144
+ });
145
+ res.write(`event: endpoint\ndata: ${messageUrl}\n\n`);
146
+ try {
147
+ await mcpServer.connect(transport);
148
+ }
149
+ catch (_err) {
150
+ this.sseSessions.delete(transport.sessionId);
151
+ mcpServer.close().catch(() => { });
152
+ res.end();
153
+ return;
154
+ }
155
+ console.error(`[Aperture] SSE session ${transport.sessionId.slice(0, 8)} connected`);
156
+ const pingInterval = setInterval(() => {
157
+ res.write(": ping\n\n");
158
+ }, 30000);
159
+ res.on("close", () => {
160
+ clearInterval(pingInterval);
161
+ this.sseSessions.delete(transport.sessionId);
162
+ mcpServer.close().catch(() => { });
163
+ console.error(`[Aperture] SSE session ${transport.sessionId.slice(0, 8)} disconnected`);
164
+ });
165
+ }
166
+ async handleSSEMessage(req, res) {
167
+ const url = new URL(req.url || "/", `http://localhost:${this.port}`);
168
+ const headerSessionId = req.headers["mcp-session-id"];
169
+ const pathParts = url.pathname.split("/");
170
+ const pathSessionId = pathParts.length > 2 ? pathParts[2] : null;
171
+ const sessionId = pathSessionId ||
172
+ url.searchParams.get("sessionId") ||
173
+ (Array.isArray(headerSessionId) ? headerSessionId[0] : headerSessionId);
174
+ const session = sessionId ? this.sseSessions.get(sessionId) : undefined;
175
+ if (!session) {
176
+ res.writeHead(404, {
177
+ "Content-Type": "text/plain",
178
+ "Mcp-Session-Id": sessionId || "",
179
+ });
180
+ res.end("Session not found");
181
+ return;
182
+ }
183
+ try {
184
+ const message = await parseJsonRpcBody(req);
185
+ session.transport.receiveMessage(message);
186
+ res.writeHead(202, {
187
+ "Content-Type": "text/plain",
188
+ "Mcp-Session-Id": sessionId,
189
+ });
190
+ res.end("Accepted");
191
+ }
192
+ catch {
193
+ writeParseError(res);
194
+ }
195
+ }
196
+ async serveClientScript(res) {
197
+ try {
198
+ const fileUrl = new URL(import.meta.url);
199
+ const __dirname = path.dirname(fileURLToPath(fileUrl));
200
+ const possiblePaths = [
201
+ path.join(__dirname, "../dist-browser/client.js"),
202
+ path.join(__dirname, "../../dist-browser/client.js"),
203
+ path.join(__dirname, "dist-browser/client.js"),
204
+ path.join(__dirname, "client.ts"),
205
+ path.join(__dirname, "../src/client.ts"),
206
+ ];
207
+ let clientPath = possiblePaths[0];
208
+ for (const p of possiblePaths) {
209
+ try {
210
+ await fs.access(p);
211
+ clientPath = p;
212
+ break;
213
+ }
214
+ catch { }
215
+ }
216
+ const content = await fs.readFile(clientPath, "utf-8");
217
+ res.writeHead(200, { "Content-Type": "application/javascript" });
218
+ res.end(content);
219
+ }
220
+ catch (err) {
221
+ console.error("[Aperture] Error loading client script:", err);
222
+ res.writeHead(500, { "Content-Type": "text/plain" });
223
+ res.end("Error loading aperture client script");
224
+ }
225
+ }
226
+ setupWSS() {
227
+ this.wss.on("connection", (ws, req) => {
228
+ const url = new URL(req.url || "/", "http://localhost");
229
+ const clientType = url.searchParams.get("type") || "unknown";
230
+ if (clientType === "browser") {
231
+ this.handleBrowserConnection(ws, req);
232
+ }
233
+ else {
234
+ this.handleMCPConnection(ws);
235
+ }
236
+ });
237
+ }
238
+ handleBrowserConnection(ws, _req) {
239
+ const sessionId = crypto.randomUUID();
240
+ const session = {
241
+ ws,
242
+ url: "",
243
+ title: "",
244
+ approved: false,
245
+ lastActiveAt: Date.now(),
246
+ capabilities: new Set(),
247
+ customTools: [],
248
+ };
249
+ this.sessions.set(sessionId, session);
250
+ ws.on("message", (raw) => {
251
+ try {
252
+ const msg = JSON.parse(raw.toString());
253
+ if (msg.type === "register") {
254
+ session.url = msg.url;
255
+ session.title = msg.title;
256
+ session.customTools = msg.customTools || [];
257
+ console.error(`[Aperture] Session ${sessionId.slice(0, 8)} registered: ${msg.title}`);
258
+ ws.send(JSON.stringify({ type: "registered", sessionId }));
259
+ if (this.sharedState.mcpInitialized) {
260
+ ws.send(JSON.stringify({ type: "agent_connected" }));
261
+ }
262
+ }
263
+ if (msg.type === "approval") {
264
+ session.approved = msg.approved;
265
+ session.capabilities = new Set(msg.capabilities || []);
266
+ console.error(`[Aperture] Session ${sessionId.slice(0, 8)} ${msg.approved ? "approved" : "denied"}`);
267
+ }
268
+ if (msg.type === "focus") {
269
+ if (msg.focused) {
270
+ session.lastActiveAt = Date.now();
271
+ }
272
+ }
273
+ if (msg.type === "result") {
274
+ const resolvePending = this.pendingRequests.get(msg.requestId);
275
+ if (resolvePending) {
276
+ resolvePending(msg.result);
277
+ }
278
+ }
279
+ }
280
+ catch { }
281
+ });
282
+ ws.on("close", () => {
283
+ this.sessions.delete(sessionId);
284
+ });
285
+ }
286
+ handleMCPConnection(ws) {
287
+ const transport = new WebSocketTransport(ws);
288
+ const mcpServer = createApertureMcpServer(this.sessions, this.pendingRequests, this.sharedState);
289
+ mcpServer.connect(transport).catch((err) => {
290
+ console.error("[Aperture] MCP connection error:", err.message);
291
+ });
292
+ ws.on("close", () => {
293
+ mcpServer.close().catch(() => { });
294
+ });
295
+ }
296
+ setupStdio() {
297
+ const transport = new StdioServerTransport();
298
+ const mcpServer = createApertureMcpServer(this.sessions, this.pendingRequests, this.sharedState);
299
+ mcpServer.connect(transport).catch((err) => {
300
+ console.error("[Aperture] Stdio connection error:", err.message);
301
+ });
302
+ process.on("SIGINT", () => {
303
+ mcpServer.close().catch(() => { });
304
+ process.exit(0);
305
+ });
306
+ process.on("SIGTERM", () => {
307
+ mcpServer.close().catch(() => { });
308
+ process.exit(0);
309
+ });
310
+ }
311
+ }
312
+ if (import.meta.url === `file://${process.argv[1]}`) {
313
+ const port = Number(process.env.APERTURE_PORT) || 3456;
314
+ new ApertureServer(port);
315
+ }