@matthesketh/utopia-ai 0.7.1 → 0.8.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.
@@ -1,4 +1,4 @@
1
- import { d as ChatRequest, e as ChatResponse, C as ChatChunk, E as EmbeddingRequest, f as EmbeddingResponse, c as ChatMessage, l as ToolDefinition, j as ToolCall, a as AIHooks, R as RetryConfig, A as AIAdapter } from './types-FSnS43LM.js';
1
+ import { d as ChatRequest, e as ChatResponse, C as ChatChunk, E as EmbeddingRequest, f as EmbeddingResponse, c as ChatMessage, l as ToolDefinition, j as ToolCall, a as AIHooks, R as RetryConfig, A as AIAdapter } from './types-FSnS43LM';
2
2
 
3
3
  interface AI {
4
4
  /** Send a chat completion request. */
@@ -58,14 +58,17 @@ function createMCPServer(config) {
58
58
  return { jsonrpc: "2.0", id: request.id, result };
59
59
  } catch (err) {
60
60
  const rpcErr = err;
61
+ if (typeof rpcErr.code === "number") {
62
+ return {
63
+ jsonrpc: "2.0",
64
+ id: request.id,
65
+ error: { code: rpcErr.code, message: rpcErr.message ?? "Error", data: rpcErr.data }
66
+ };
67
+ }
61
68
  return {
62
69
  jsonrpc: "2.0",
63
70
  id: request.id,
64
- error: {
65
- code: rpcErr.code ?? -32603,
66
- message: rpcErr.message ?? "Internal error",
67
- data: rpcErr.data
68
- }
71
+ error: { code: -32603, message: "Internal error" }
69
72
  };
70
73
  }
71
74
  }
@@ -93,12 +96,22 @@ function createMCPServer(config) {
93
96
  }))
94
97
  };
95
98
  case "tools/call": {
96
- const params = request.params;
97
- const tool = toolMap.get(params.name);
99
+ const params = asParamsObject(request.params, "tools/call");
100
+ const name = params.name;
101
+ if (typeof name !== "string") {
102
+ throw makeError(-32602, 'tools/call requires a string "name"');
103
+ }
104
+ const tool = toolMap.get(name);
98
105
  if (!tool) {
99
- throw makeError(-32602, `Unknown tool: ${params.name}`);
106
+ throw makeError(-32602, `Unknown tool: ${name}`);
100
107
  }
101
- return tool.handler(params.arguments ?? {});
108
+ const args = params.arguments ?? {};
109
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
110
+ throw makeError(-32602, "tool arguments must be an object");
111
+ }
112
+ const argRecord = args;
113
+ validateAgainstSchema(tool.definition.inputSchema, argRecord, name);
114
+ return tool.handler(argRecord);
102
115
  }
103
116
  case "resources/list":
104
117
  return {
@@ -110,12 +123,16 @@ function createMCPServer(config) {
110
123
  }))
111
124
  };
112
125
  case "resources/read": {
113
- const params = request.params;
114
- const resource = findResource(params.uri);
126
+ const params = asParamsObject(request.params, "resources/read");
127
+ const uri = params.uri;
128
+ if (typeof uri !== "string") {
129
+ throw makeError(-32602, 'resources/read requires a string "uri"');
130
+ }
131
+ const resource = findResource(uri);
115
132
  if (!resource) {
116
- throw makeError(-32602, `Unknown resource: ${params.uri}`);
133
+ throw makeError(-32602, `Unknown resource: ${uri}`);
117
134
  }
118
- const content = await resource.handler(params.uri);
135
+ const content = await resource.handler(uri);
119
136
  return { contents: [content] };
120
137
  }
121
138
  case "prompts/list":
@@ -127,12 +144,17 @@ function createMCPServer(config) {
127
144
  }))
128
145
  };
129
146
  case "prompts/get": {
130
- const params = request.params;
131
- const prompt = promptMap.get(params.name);
147
+ const params = asParamsObject(request.params, "prompts/get");
148
+ const name = params.name;
149
+ if (typeof name !== "string") {
150
+ throw makeError(-32602, 'prompts/get requires a string "name"');
151
+ }
152
+ const prompt = promptMap.get(name);
132
153
  if (!prompt) {
133
- throw makeError(-32602, `Unknown prompt: ${params.name}`);
154
+ throw makeError(-32602, `Unknown prompt: ${name}`);
134
155
  }
135
- return prompt.handler(params.arguments ?? {});
156
+ const args = params.arguments ?? {};
157
+ return prompt.handler(args);
136
158
  }
137
159
  case "ping":
138
160
  return {};
@@ -159,10 +181,46 @@ function matchesTemplate(pattern, uri) {
159
181
  function makeError(code, message) {
160
182
  return { code, message };
161
183
  }
184
+ function asParamsObject(params, ctx) {
185
+ if (params === null || typeof params !== "object" || Array.isArray(params)) {
186
+ throw makeError(-32602, `Invalid params for ${ctx}: expected an object`);
187
+ }
188
+ return params;
189
+ }
190
+ function validateAgainstSchema(schema, args, toolName) {
191
+ if (!schema || typeof schema !== "object") return;
192
+ const s = schema;
193
+ if (Array.isArray(s.required)) {
194
+ for (const key of s.required) {
195
+ if (typeof key === "string" && !(key in args)) {
196
+ throw makeError(-32602, `Missing required argument "${key}" for tool "${toolName}"`);
197
+ }
198
+ }
199
+ }
200
+ if (s.properties && typeof s.properties === "object") {
201
+ for (const [key, prop] of Object.entries(s.properties)) {
202
+ const value = args[key];
203
+ if (value === void 0 || value === null) continue;
204
+ const expected = prop?.type;
205
+ if (!expected) continue;
206
+ const ok = expected === "number" || expected === "integer" ? typeof value === "number" : expected === "array" ? Array.isArray(value) : expected === "object" ? typeof value === "object" && !Array.isArray(value) : typeof value === expected;
207
+ if (!ok) {
208
+ throw makeError(
209
+ -32602,
210
+ `Argument "${key}" for tool "${toolName}" must be of type ${expected}`
211
+ );
212
+ }
213
+ }
214
+ }
215
+ }
162
216
 
163
217
  // src/mcp/client.ts
164
218
  function createMCPClient(config) {
165
219
  let requestId = 0;
220
+ const parsedUrl = new URL(config.url);
221
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
222
+ throw new Error(`MCP client URL must be http(s): ${config.url}`);
223
+ }
166
224
  async function rpc(method, params) {
167
225
  const request = {
168
226
  jsonrpc: "2.0",
@@ -177,6 +235,10 @@ function createMCPClient(config) {
177
235
  ...config.headers
178
236
  },
179
237
  body: JSON.stringify(request),
238
+ // never follow redirects: a 3xx to another host would otherwise re-send
239
+ // config.headers (often a bearer token) to that host, leaking the
240
+ // credential and enabling an ssrf pivot.
241
+ redirect: "error",
180
242
  signal: AbortSignal.timeout(3e4)
181
243
  });
182
244
  if (!response.ok) {
@@ -250,6 +312,8 @@ function createMCPClient(config) {
250
312
  // src/mcp/handler.ts
251
313
  function createMCPHandler(server, options) {
252
314
  const corsOrigin = options?.corsOrigin;
315
+ const allowedOrigins = options?.allowedOrigins;
316
+ const authorize = options?.authorize;
253
317
  return async (req, res) => {
254
318
  if (corsOrigin) {
255
319
  res.setHeader("Access-Control-Allow-Origin", corsOrigin);
@@ -261,6 +325,31 @@ function createMCPHandler(server, options) {
261
325
  res.end();
262
326
  return;
263
327
  }
328
+ const origin = req.headers.origin;
329
+ if (allowedOrigins && origin !== void 0 && !allowedOrigins.includes(origin)) {
330
+ res.writeHead(403, { "Content-Type": "text/plain" });
331
+ res.end("Forbidden");
332
+ return;
333
+ }
334
+ if (authorize) {
335
+ let allowed = false;
336
+ try {
337
+ allowed = await authorize(req);
338
+ } catch {
339
+ allowed = false;
340
+ }
341
+ if (!allowed) {
342
+ res.writeHead(401, { "Content-Type": "application/json" });
343
+ res.end(
344
+ JSON.stringify({
345
+ jsonrpc: "2.0",
346
+ id: null,
347
+ error: { code: -32600, message: "Unauthorized" }
348
+ })
349
+ );
350
+ return;
351
+ }
352
+ }
264
353
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
265
354
  if (url.pathname.endsWith("/sse") && req.method === "GET") {
266
355
  handleSSE(server, req, res);
@@ -174,6 +174,19 @@ declare function createMCPClient(config: MCPClientConfig): MCPClient;
174
174
 
175
175
  interface MCPHandlerOptions {
176
176
  corsOrigin?: string;
177
+ /**
178
+ * allow-list of permitted `Origin` header values. when set, any request
179
+ * carrying an `Origin` not in the list is rejected with 403. this is the
180
+ * primary defence against dns-rebinding attacks on a locally-bound server.
181
+ */
182
+ allowedOrigins?: string[];
183
+ /**
184
+ * authorisation gate run before any request is dispatched to the server.
185
+ * return false (or throw) to reject with 401. an mcp server exposes tool
186
+ * execution, so it must not be reachable on a network without an authz
187
+ * check — there is intentionally no default-allow for remote callers.
188
+ */
189
+ authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
177
190
  }
178
191
  /**
179
192
  * Create a Node.js HTTP handler for an MCP server.
@@ -174,6 +174,19 @@ declare function createMCPClient(config: MCPClientConfig): MCPClient;
174
174
 
175
175
  interface MCPHandlerOptions {
176
176
  corsOrigin?: string;
177
+ /**
178
+ * allow-list of permitted `Origin` header values. when set, any request
179
+ * carrying an `Origin` not in the list is rejected with 403. this is the
180
+ * primary defence against dns-rebinding attacks on a locally-bound server.
181
+ */
182
+ allowedOrigins?: string[];
183
+ /**
184
+ * authorisation gate run before any request is dispatched to the server.
185
+ * return false (or throw) to reject with 401. an mcp server exposes tool
186
+ * execution, so it must not be reachable on a network without an authz
187
+ * check — there is intentionally no default-allow for remote callers.
188
+ */
189
+ authorize?: (req: IncomingMessage) => boolean | Promise<boolean>;
177
190
  }
178
191
  /**
179
192
  * Create a Node.js HTTP handler for an MCP server.
package/dist/mcp/index.js CHANGED
@@ -30,14 +30,17 @@ function createMCPServer(config) {
30
30
  return { jsonrpc: "2.0", id: request.id, result };
31
31
  } catch (err) {
32
32
  const rpcErr = err;
33
+ if (typeof rpcErr.code === "number") {
34
+ return {
35
+ jsonrpc: "2.0",
36
+ id: request.id,
37
+ error: { code: rpcErr.code, message: rpcErr.message ?? "Error", data: rpcErr.data }
38
+ };
39
+ }
33
40
  return {
34
41
  jsonrpc: "2.0",
35
42
  id: request.id,
36
- error: {
37
- code: rpcErr.code ?? -32603,
38
- message: rpcErr.message ?? "Internal error",
39
- data: rpcErr.data
40
- }
43
+ error: { code: -32603, message: "Internal error" }
41
44
  };
42
45
  }
43
46
  }
@@ -65,12 +68,22 @@ function createMCPServer(config) {
65
68
  }))
66
69
  };
67
70
  case "tools/call": {
68
- const params = request.params;
69
- const tool = toolMap.get(params.name);
71
+ const params = asParamsObject(request.params, "tools/call");
72
+ const name = params.name;
73
+ if (typeof name !== "string") {
74
+ throw makeError(-32602, 'tools/call requires a string "name"');
75
+ }
76
+ const tool = toolMap.get(name);
70
77
  if (!tool) {
71
- throw makeError(-32602, `Unknown tool: ${params.name}`);
78
+ throw makeError(-32602, `Unknown tool: ${name}`);
72
79
  }
73
- return tool.handler(params.arguments ?? {});
80
+ const args = params.arguments ?? {};
81
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
82
+ throw makeError(-32602, "tool arguments must be an object");
83
+ }
84
+ const argRecord = args;
85
+ validateAgainstSchema(tool.definition.inputSchema, argRecord, name);
86
+ return tool.handler(argRecord);
74
87
  }
75
88
  case "resources/list":
76
89
  return {
@@ -82,12 +95,16 @@ function createMCPServer(config) {
82
95
  }))
83
96
  };
84
97
  case "resources/read": {
85
- const params = request.params;
86
- const resource = findResource(params.uri);
98
+ const params = asParamsObject(request.params, "resources/read");
99
+ const uri = params.uri;
100
+ if (typeof uri !== "string") {
101
+ throw makeError(-32602, 'resources/read requires a string "uri"');
102
+ }
103
+ const resource = findResource(uri);
87
104
  if (!resource) {
88
- throw makeError(-32602, `Unknown resource: ${params.uri}`);
105
+ throw makeError(-32602, `Unknown resource: ${uri}`);
89
106
  }
90
- const content = await resource.handler(params.uri);
107
+ const content = await resource.handler(uri);
91
108
  return { contents: [content] };
92
109
  }
93
110
  case "prompts/list":
@@ -99,12 +116,17 @@ function createMCPServer(config) {
99
116
  }))
100
117
  };
101
118
  case "prompts/get": {
102
- const params = request.params;
103
- const prompt = promptMap.get(params.name);
119
+ const params = asParamsObject(request.params, "prompts/get");
120
+ const name = params.name;
121
+ if (typeof name !== "string") {
122
+ throw makeError(-32602, 'prompts/get requires a string "name"');
123
+ }
124
+ const prompt = promptMap.get(name);
104
125
  if (!prompt) {
105
- throw makeError(-32602, `Unknown prompt: ${params.name}`);
126
+ throw makeError(-32602, `Unknown prompt: ${name}`);
106
127
  }
107
- return prompt.handler(params.arguments ?? {});
128
+ const args = params.arguments ?? {};
129
+ return prompt.handler(args);
108
130
  }
109
131
  case "ping":
110
132
  return {};
@@ -131,10 +153,46 @@ function matchesTemplate(pattern, uri) {
131
153
  function makeError(code, message) {
132
154
  return { code, message };
133
155
  }
156
+ function asParamsObject(params, ctx) {
157
+ if (params === null || typeof params !== "object" || Array.isArray(params)) {
158
+ throw makeError(-32602, `Invalid params for ${ctx}: expected an object`);
159
+ }
160
+ return params;
161
+ }
162
+ function validateAgainstSchema(schema, args, toolName) {
163
+ if (!schema || typeof schema !== "object") return;
164
+ const s = schema;
165
+ if (Array.isArray(s.required)) {
166
+ for (const key of s.required) {
167
+ if (typeof key === "string" && !(key in args)) {
168
+ throw makeError(-32602, `Missing required argument "${key}" for tool "${toolName}"`);
169
+ }
170
+ }
171
+ }
172
+ if (s.properties && typeof s.properties === "object") {
173
+ for (const [key, prop] of Object.entries(s.properties)) {
174
+ const value = args[key];
175
+ if (value === void 0 || value === null) continue;
176
+ const expected = prop?.type;
177
+ if (!expected) continue;
178
+ const ok = expected === "number" || expected === "integer" ? typeof value === "number" : expected === "array" ? Array.isArray(value) : expected === "object" ? typeof value === "object" && !Array.isArray(value) : typeof value === expected;
179
+ if (!ok) {
180
+ throw makeError(
181
+ -32602,
182
+ `Argument "${key}" for tool "${toolName}" must be of type ${expected}`
183
+ );
184
+ }
185
+ }
186
+ }
187
+ }
134
188
 
135
189
  // src/mcp/client.ts
136
190
  function createMCPClient(config) {
137
191
  let requestId = 0;
192
+ const parsedUrl = new URL(config.url);
193
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
194
+ throw new Error(`MCP client URL must be http(s): ${config.url}`);
195
+ }
138
196
  async function rpc(method, params) {
139
197
  const request = {
140
198
  jsonrpc: "2.0",
@@ -149,6 +207,10 @@ function createMCPClient(config) {
149
207
  ...config.headers
150
208
  },
151
209
  body: JSON.stringify(request),
210
+ // never follow redirects: a 3xx to another host would otherwise re-send
211
+ // config.headers (often a bearer token) to that host, leaking the
212
+ // credential and enabling an ssrf pivot.
213
+ redirect: "error",
152
214
  signal: AbortSignal.timeout(3e4)
153
215
  });
154
216
  if (!response.ok) {
@@ -222,6 +284,8 @@ function createMCPClient(config) {
222
284
  // src/mcp/handler.ts
223
285
  function createMCPHandler(server, options) {
224
286
  const corsOrigin = options?.corsOrigin;
287
+ const allowedOrigins = options?.allowedOrigins;
288
+ const authorize = options?.authorize;
225
289
  return async (req, res) => {
226
290
  if (corsOrigin) {
227
291
  res.setHeader("Access-Control-Allow-Origin", corsOrigin);
@@ -233,6 +297,31 @@ function createMCPHandler(server, options) {
233
297
  res.end();
234
298
  return;
235
299
  }
300
+ const origin = req.headers.origin;
301
+ if (allowedOrigins && origin !== void 0 && !allowedOrigins.includes(origin)) {
302
+ res.writeHead(403, { "Content-Type": "text/plain" });
303
+ res.end("Forbidden");
304
+ return;
305
+ }
306
+ if (authorize) {
307
+ let allowed = false;
308
+ try {
309
+ allowed = await authorize(req);
310
+ } catch {
311
+ allowed = false;
312
+ }
313
+ if (!allowed) {
314
+ res.writeHead(401, { "Content-Type": "application/json" });
315
+ res.end(
316
+ JSON.stringify({
317
+ jsonrpc: "2.0",
318
+ id: null,
319
+ error: { code: -32600, message: "Unauthorized" }
320
+ })
321
+ );
322
+ return;
323
+ }
324
+ }
236
325
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
237
326
  if (url.pathname.endsWith("/sse") && req.method === "GET") {
238
327
  handleSSE(server, req, res);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthesketh/utopia-ai",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "AI adapters and MCP support for UtopiaJS",
5
5
  "type": "module",
6
6
  "license": "MIT",