@valbuild/next 0.114.0 → 0.116.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,2 +1,4 @@
1
1
  import "server-only";
2
2
  export { initValServer } from "./initValServer.js";
3
+ export { initValMcp } from "./initValMcp.js";
4
+ export type { ValMcp, ValMcpAuthorizationResult } from "./initValMcp.js";
@@ -0,0 +1,42 @@
1
+ import { ValConfig, ValModules } from "@valbuild/core";
2
+ import { type ValToolContext, type ValTools } from "@valbuild/server";
3
+ /**
4
+ * Val's tools over MCP, and the two checks that have to happen before a request
5
+ * gets to them.
6
+ *
7
+ * Nothing here imports an MCP SDK. The app owns the transport — which SDK, which
8
+ * route, which framework — and this owns the parts that must not be re-decided
9
+ * per app: whether the request is allowed to reach the tools at all, and whose
10
+ * credential it carries. `docs/plans/mcp.md` Part A has the reasoning; the short
11
+ * version is that the SDK reorganised itself once already, and the security
12
+ * checks should not move when it does again.
13
+ */
14
+ export type ValMcpAuthorizationResult = {
15
+ status: "ok";
16
+ tools: ValTools;
17
+ ctx: ValToolContext;
18
+ } | {
19
+ status: "refused";
20
+ response: Response;
21
+ };
22
+ export type ValMcp = {
23
+ /**
24
+ * Check a request and, if it is allowed, hand back the tools and the context
25
+ * to call them with.
26
+ *
27
+ * Call this per request — both halves are per request. Refusing early is the
28
+ * point: a refused request must not reach the protocol layer, let alone a
29
+ * tool.
30
+ */
31
+ valMcpAuthorize: (request: Request | undefined) => Promise<ValMcpAuthorizationResult>;
32
+ /**
33
+ * The registry itself, for listing tools at startup.
34
+ *
35
+ * Listing needs no credential — it reads no content — so registering tools
36
+ * with an MCP server can happen once rather than per request.
37
+ */
38
+ valMcpTools: () => Promise<ValTools>;
39
+ };
40
+ export declare function initValMcp(valModules: ValModules, config: ValConfig, opts?: {
41
+ formatter?: (code: string, filePath: string) => string | Promise<string>;
42
+ }): ValMcp;
package/package.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "next",
13
13
  "react"
14
14
  ],
15
- "version": "0.114.0",
15
+ "version": "0.116.0",
16
16
  "main": "dist/valbuild-next.cjs.js",
17
17
  "module": "dist/valbuild-next.esm.js",
18
18
  "exports": {
@@ -48,11 +48,11 @@
48
48
  "client-only": "^0.0.1",
49
49
  "server-only": "^0.0.1",
50
50
  "@valbuild/core": "0.111.0",
51
- "@valbuild/server": "0.114.0",
52
- "@valbuild/language-server": "0.114.0",
53
- "@valbuild/react": "0.114.0",
54
- "@valbuild/shared": "0.114.0",
55
- "@valbuild/ui": "0.114.0"
51
+ "@valbuild/language-server": "0.116.0",
52
+ "@valbuild/react": "0.116.0",
53
+ "@valbuild/server": "0.116.0",
54
+ "@valbuild/shared": "0.116.0",
55
+ "@valbuild/ui": "0.116.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@testing-library/react": "^16.3.3",
@@ -120,4 +120,262 @@ function initValServer(valModules, config, nextConfig) {
120
120
  };
121
121
  }
122
122
 
123
+ /**
124
+ * Val's tools over MCP, and the two checks that have to happen before a request
125
+ * gets to them.
126
+ *
127
+ * Nothing here imports an MCP SDK. The app owns the transport — which SDK, which
128
+ * route, which framework — and this owns the parts that must not be re-decided
129
+ * per app: whether the request is allowed to reach the tools at all, and whose
130
+ * credential it carries. `docs/plans/mcp.md` Part A has the reasoning; the short
131
+ * version is that the SDK reorganised itself once already, and the security
132
+ * checks should not move when it does again.
133
+ */
134
+
135
+ function initValMcp(valModules, config, opts) {
136
+ var route = "/api/val"; // TODO: get from config, as initValServer does
137
+ var coreVersion = core.Internal.VERSION.core;
138
+ if (!coreVersion) {
139
+ throw new Error("Could not get @valbuild/core package version");
140
+ }
141
+ var nextVersion = version.VERSION;
142
+ if (!nextVersion) {
143
+ throw new Error("Could not get @valbuild/next package version");
144
+ }
145
+
146
+ // Resolved once at module-eval time, awaited per request. The no-op catch is
147
+ // load bearing for the same reason it is in createValApiRouter: a config error
148
+ // on a promise with no handler attached becomes an unhandledRejection and
149
+ // takes the dev server down, and the error is reported per request below
150
+ // anyway.
151
+ var setupPromise = version._asyncToGenerator(/*#__PURE__*/version._regenerator().m(function _callee() {
152
+ var options;
153
+ return version._regenerator().w(function (_context) {
154
+ while (1) switch (_context.n) {
155
+ case 0:
156
+ _context.n = 1;
157
+ return server.initHandlerOptions(route, objectSpread2._objectSpread2(objectSpread2._objectSpread2({}, config), {}, {
158
+ versions: {
159
+ core: coreVersion,
160
+ next: nextVersion
161
+ }
162
+ }), config);
163
+ case 1:
164
+ options = _context.v;
165
+ return _context.a(2, {
166
+ mode: options.mode,
167
+ tools: server.createValTools(valModules, objectSpread2._objectSpread2(objectSpread2._objectSpread2({}, options), {}, {
168
+ formatter: opts === null || opts === void 0 ? void 0 : opts.formatter
169
+ }))
170
+ });
171
+ }
172
+ }, _callee);
173
+ }))();
174
+ setupPromise["catch"](function () {
175
+ // handled per request
176
+ });
177
+ return {
178
+ valMcpTools: function valMcpTools() {
179
+ return version._asyncToGenerator(/*#__PURE__*/version._regenerator().m(function _callee2() {
180
+ return version._regenerator().w(function (_context2) {
181
+ while (1) switch (_context2.n) {
182
+ case 0:
183
+ _context2.n = 1;
184
+ return setupPromise;
185
+ case 1:
186
+ return _context2.a(2, _context2.v.tools);
187
+ }
188
+ }, _callee2);
189
+ }))();
190
+ },
191
+ valMcpAuthorize: function valMcpAuthorize(request) {
192
+ return version._asyncToGenerator(/*#__PURE__*/version._regenerator().m(function _callee3() {
193
+ var setup, refusal, pat, _t;
194
+ return version._regenerator().w(function (_context3) {
195
+ while (1) switch (_context3.p = _context3.n) {
196
+ case 0:
197
+ _context3.p = 0;
198
+ _context3.n = 1;
199
+ return setupPromise;
200
+ case 1:
201
+ setup = _context3.v;
202
+ _context3.n = 3;
203
+ break;
204
+ case 2:
205
+ _context3.p = 2;
206
+ _t = _context3.v;
207
+ return _context3.a(2, {
208
+ status: "refused",
209
+ response: jsonResponse(500, {
210
+ error: "Val: could not start the Val MCP server",
211
+ details: _t instanceof Error ? _t.message : String(_t)
212
+ })
213
+ });
214
+ case 3:
215
+ if (!(request === undefined)) {
216
+ _context3.n = 4;
217
+ break;
218
+ }
219
+ return _context3.a(2, {
220
+ status: "refused",
221
+ response: jsonResponse(401, {
222
+ error: "Val: this MCP server needs the HTTP request to authorize a call, and none was available."
223
+ })
224
+ });
225
+ case 4:
226
+ refusal = refuseUnsafeRequest(request, setup.mode);
227
+ if (!refusal) {
228
+ _context3.n = 5;
229
+ break;
230
+ }
231
+ return _context3.a(2, {
232
+ status: "refused",
233
+ response: refusal
234
+ });
235
+ case 5:
236
+ pat = readBearerToken(request);
237
+ return _context3.a(2, {
238
+ status: "ok",
239
+ tools: setup.tools,
240
+ ctx: {
241
+ // Passed through unverified, deliberately: this app is not the
242
+ // authority on what a token may do, and the registry sends it to the
243
+ // backend that is. See `docs/plans/mcp.md` D.2.
244
+ auth: pat === null ? null : {
245
+ pat: pat
246
+ },
247
+ // Not the MCP session id. Val's patch `sessionId` names a Val AI
248
+ // session, and putting an unrelated id in it would claim a
249
+ // relationship that does not exist.
250
+ sessionId: null
251
+ }
252
+ });
253
+ }
254
+ }, _callee3, null, [[0, 2]]);
255
+ }))();
256
+ }
257
+ };
258
+ }
259
+
260
+ /**
261
+ * The two ways this route is dangerous, both refused here.
262
+ *
263
+ * 1. **Local filesystem mode outside development.** In fs mode there is no
264
+ * credential and no backend: the tools read and write the running process's
265
+ * own working tree, and every permission check Val has lives on the other
266
+ * side of a backend that is not in this path. Exposed on a deployed host,
267
+ * that is unauthenticated write access to the site's content for anyone who
268
+ * can reach the port. There is no configuration that makes it safe, so there
269
+ * is no flag to turn this off — a project that wants MCP in production wants
270
+ * proxy mode, where every call carries its caller's own token.
271
+ *
272
+ * 2. **A browser driving the local server.** A page on any origin can `fetch`
273
+ * `http://localhost:3000/api/mcp` while a developer has the app running, and
274
+ * with DNS rebinding it can do so with a `Host` of its own choosing. Neither
275
+ * needs a credential in fs mode. So a cross-origin `Origin` is refused, and
276
+ * in fs mode the request must actually be addressed to a loopback host.
277
+ *
278
+ * MCP clients are not browsers and send no `Origin`, so the check costs them
279
+ * nothing.
280
+ */
281
+ function refuseUnsafeRequest(request, mode) {
282
+ if (mode === "fs" && process.env.NODE_ENV !== "development") {
283
+ return jsonResponse(403, {
284
+ error: "Val: the MCP endpoint is disabled. This project is running in local filesystem mode, where MCP calls are unauthenticated and write directly to the working tree, so it is only served in development. Configure Val for proxy mode to use MCP on a deployed host."
285
+ });
286
+ }
287
+ var host = requestHost(request);
288
+ var origin = request.headers.get("origin");
289
+ if (origin !== null) {
290
+ // `Origin: null` is refused along with the rest. It is the *opaque* origin —
291
+ // a sandboxed iframe, a `file://` page, some redirects — so it cannot be
292
+ // compared to anything, and "cannot be compared" has to mean refuse: a page
293
+ // that would fail the check can otherwise pass it by arranging to have no
294
+ // origin at all. Absent entirely is the case that is allowed, and that is
295
+ // the one MCP clients produce.
296
+ var originHost = origin === "null" ? null : hostOf(origin);
297
+ if (originHost === null || host === null || originHost !== host) {
298
+ return jsonResponse(403, {
299
+ error: "Val: refusing a cross-origin MCP request from ".concat(JSON.stringify(origin), ". MCP clients do not send an Origin header; a browser does.")
300
+ });
301
+ }
302
+ }
303
+ if (mode === "fs") {
304
+ var hostname = host === null ? null : hostnameOf(host);
305
+ if (hostname === null || !LOOPBACK_HOSTNAMES.has(hostname)) {
306
+ return jsonResponse(403, {
307
+ error: "Val: refusing an MCP request addressed to ".concat(JSON.stringify(host !== null && host !== void 0 ? host : "an unknown host"), ". In local filesystem mode this endpoint only answers on localhost, because a name that resolves to 127.0.0.1 is how a web page reaches a developer's own machine.")
308
+ });
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+ var LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
314
+
315
+ /**
316
+ * Which host the request was addressed to, as `hostname:port`.
317
+ *
318
+ * `Host` only, and deliberately **not** `X-Forwarded-Host`. The forwarded header
319
+ * is what a client asked for behind a proxy, but nothing stops a client sending
320
+ * it directly — so preferring it hands an attacker the value both checks below
321
+ * are decided on. `Host`, by contrast, a browser sets from the URL and page
322
+ * script cannot override, which is exactly the property the loopback check
323
+ * depends on.
324
+ *
325
+ * The cost is that behind a proxy that rewrites `Host`, a *browser* request
326
+ * whose `Origin` is the public name no longer matches. That is acceptable: such
327
+ * a request carries no personal access token, so proxy mode refuses it anyway,
328
+ * and a non-browser MCP client sends no `Origin` and never reaches the
329
+ * comparison. Trusting the forwarded header would need an explicit trusted-proxy
330
+ * configuration, which is a bigger thing than this needs.
331
+ */
332
+ function requestHost(request) {
333
+ var host = request.headers.get("host");
334
+ if (host) {
335
+ return host.trim().toLowerCase();
336
+ }
337
+ // Last resort: the URL the framework saw.
338
+ try {
339
+ return new URL(request.url).host.toLowerCase();
340
+ } catch (_unused) {
341
+ return null;
342
+ }
343
+ }
344
+ function hostOf(origin) {
345
+ try {
346
+ return new URL(origin).host.toLowerCase();
347
+ } catch (_unused2) {
348
+ return null;
349
+ }
350
+ }
351
+
352
+ /** Strips the port, keeping IPv6 brackets — `[::1]:3000` is hostname `[::1]`. */
353
+ function hostnameOf(host) {
354
+ if (host.startsWith("[")) {
355
+ var end = host.indexOf("]");
356
+ return end === -1 ? host : host.slice(0, end + 1);
357
+ }
358
+ var colon = host.indexOf(":");
359
+ return colon === -1 ? host : host.slice(0, colon);
360
+ }
361
+ function readBearerToken(request) {
362
+ var _match$;
363
+ var header = request.headers.get("authorization");
364
+ if (!header) {
365
+ return null;
366
+ }
367
+ var match = /^Bearer\s+(.+)$/i.exec(header.trim());
368
+ var token = match === null || match === void 0 || (_match$ = match[1]) === null || _match$ === void 0 ? void 0 : _match$.trim();
369
+ return token ? token : null;
370
+ }
371
+ function jsonResponse(status, body) {
372
+ return new Response(JSON.stringify(body), {
373
+ status: status,
374
+ headers: {
375
+ "Content-Type": "application/json"
376
+ }
377
+ });
378
+ }
379
+
380
+ exports.initValMcp = initValMcp;
123
381
  exports.initValServer = initValServer;
@@ -120,4 +120,262 @@ function initValServer(valModules, config, nextConfig) {
120
120
  };
121
121
  }
122
122
 
123
+ /**
124
+ * Val's tools over MCP, and the two checks that have to happen before a request
125
+ * gets to them.
126
+ *
127
+ * Nothing here imports an MCP SDK. The app owns the transport — which SDK, which
128
+ * route, which framework — and this owns the parts that must not be re-decided
129
+ * per app: whether the request is allowed to reach the tools at all, and whose
130
+ * credential it carries. `docs/plans/mcp.md` Part A has the reasoning; the short
131
+ * version is that the SDK reorganised itself once already, and the security
132
+ * checks should not move when it does again.
133
+ */
134
+
135
+ function initValMcp(valModules, config, opts) {
136
+ var route = "/api/val"; // TODO: get from config, as initValServer does
137
+ var coreVersion = core.Internal.VERSION.core;
138
+ if (!coreVersion) {
139
+ throw new Error("Could not get @valbuild/core package version");
140
+ }
141
+ var nextVersion = version.VERSION;
142
+ if (!nextVersion) {
143
+ throw new Error("Could not get @valbuild/next package version");
144
+ }
145
+
146
+ // Resolved once at module-eval time, awaited per request. The no-op catch is
147
+ // load bearing for the same reason it is in createValApiRouter: a config error
148
+ // on a promise with no handler attached becomes an unhandledRejection and
149
+ // takes the dev server down, and the error is reported per request below
150
+ // anyway.
151
+ var setupPromise = version._asyncToGenerator(/*#__PURE__*/version._regenerator().m(function _callee() {
152
+ var options;
153
+ return version._regenerator().w(function (_context) {
154
+ while (1) switch (_context.n) {
155
+ case 0:
156
+ _context.n = 1;
157
+ return server.initHandlerOptions(route, objectSpread2._objectSpread2(objectSpread2._objectSpread2({}, config), {}, {
158
+ versions: {
159
+ core: coreVersion,
160
+ next: nextVersion
161
+ }
162
+ }), config);
163
+ case 1:
164
+ options = _context.v;
165
+ return _context.a(2, {
166
+ mode: options.mode,
167
+ tools: server.createValTools(valModules, objectSpread2._objectSpread2(objectSpread2._objectSpread2({}, options), {}, {
168
+ formatter: opts === null || opts === void 0 ? void 0 : opts.formatter
169
+ }))
170
+ });
171
+ }
172
+ }, _callee);
173
+ }))();
174
+ setupPromise["catch"](function () {
175
+ // handled per request
176
+ });
177
+ return {
178
+ valMcpTools: function valMcpTools() {
179
+ return version._asyncToGenerator(/*#__PURE__*/version._regenerator().m(function _callee2() {
180
+ return version._regenerator().w(function (_context2) {
181
+ while (1) switch (_context2.n) {
182
+ case 0:
183
+ _context2.n = 1;
184
+ return setupPromise;
185
+ case 1:
186
+ return _context2.a(2, _context2.v.tools);
187
+ }
188
+ }, _callee2);
189
+ }))();
190
+ },
191
+ valMcpAuthorize: function valMcpAuthorize(request) {
192
+ return version._asyncToGenerator(/*#__PURE__*/version._regenerator().m(function _callee3() {
193
+ var setup, refusal, pat, _t;
194
+ return version._regenerator().w(function (_context3) {
195
+ while (1) switch (_context3.p = _context3.n) {
196
+ case 0:
197
+ _context3.p = 0;
198
+ _context3.n = 1;
199
+ return setupPromise;
200
+ case 1:
201
+ setup = _context3.v;
202
+ _context3.n = 3;
203
+ break;
204
+ case 2:
205
+ _context3.p = 2;
206
+ _t = _context3.v;
207
+ return _context3.a(2, {
208
+ status: "refused",
209
+ response: jsonResponse(500, {
210
+ error: "Val: could not start the Val MCP server",
211
+ details: _t instanceof Error ? _t.message : String(_t)
212
+ })
213
+ });
214
+ case 3:
215
+ if (!(request === undefined)) {
216
+ _context3.n = 4;
217
+ break;
218
+ }
219
+ return _context3.a(2, {
220
+ status: "refused",
221
+ response: jsonResponse(401, {
222
+ error: "Val: this MCP server needs the HTTP request to authorize a call, and none was available."
223
+ })
224
+ });
225
+ case 4:
226
+ refusal = refuseUnsafeRequest(request, setup.mode);
227
+ if (!refusal) {
228
+ _context3.n = 5;
229
+ break;
230
+ }
231
+ return _context3.a(2, {
232
+ status: "refused",
233
+ response: refusal
234
+ });
235
+ case 5:
236
+ pat = readBearerToken(request);
237
+ return _context3.a(2, {
238
+ status: "ok",
239
+ tools: setup.tools,
240
+ ctx: {
241
+ // Passed through unverified, deliberately: this app is not the
242
+ // authority on what a token may do, and the registry sends it to the
243
+ // backend that is. See `docs/plans/mcp.md` D.2.
244
+ auth: pat === null ? null : {
245
+ pat: pat
246
+ },
247
+ // Not the MCP session id. Val's patch `sessionId` names a Val AI
248
+ // session, and putting an unrelated id in it would claim a
249
+ // relationship that does not exist.
250
+ sessionId: null
251
+ }
252
+ });
253
+ }
254
+ }, _callee3, null, [[0, 2]]);
255
+ }))();
256
+ }
257
+ };
258
+ }
259
+
260
+ /**
261
+ * The two ways this route is dangerous, both refused here.
262
+ *
263
+ * 1. **Local filesystem mode outside development.** In fs mode there is no
264
+ * credential and no backend: the tools read and write the running process's
265
+ * own working tree, and every permission check Val has lives on the other
266
+ * side of a backend that is not in this path. Exposed on a deployed host,
267
+ * that is unauthenticated write access to the site's content for anyone who
268
+ * can reach the port. There is no configuration that makes it safe, so there
269
+ * is no flag to turn this off — a project that wants MCP in production wants
270
+ * proxy mode, where every call carries its caller's own token.
271
+ *
272
+ * 2. **A browser driving the local server.** A page on any origin can `fetch`
273
+ * `http://localhost:3000/api/mcp` while a developer has the app running, and
274
+ * with DNS rebinding it can do so with a `Host` of its own choosing. Neither
275
+ * needs a credential in fs mode. So a cross-origin `Origin` is refused, and
276
+ * in fs mode the request must actually be addressed to a loopback host.
277
+ *
278
+ * MCP clients are not browsers and send no `Origin`, so the check costs them
279
+ * nothing.
280
+ */
281
+ function refuseUnsafeRequest(request, mode) {
282
+ if (mode === "fs" && "production" !== "development") {
283
+ return jsonResponse(403, {
284
+ error: "Val: the MCP endpoint is disabled. This project is running in local filesystem mode, where MCP calls are unauthenticated and write directly to the working tree, so it is only served in development. Configure Val for proxy mode to use MCP on a deployed host."
285
+ });
286
+ }
287
+ var host = requestHost(request);
288
+ var origin = request.headers.get("origin");
289
+ if (origin !== null) {
290
+ // `Origin: null` is refused along with the rest. It is the *opaque* origin —
291
+ // a sandboxed iframe, a `file://` page, some redirects — so it cannot be
292
+ // compared to anything, and "cannot be compared" has to mean refuse: a page
293
+ // that would fail the check can otherwise pass it by arranging to have no
294
+ // origin at all. Absent entirely is the case that is allowed, and that is
295
+ // the one MCP clients produce.
296
+ var originHost = origin === "null" ? null : hostOf(origin);
297
+ if (originHost === null || host === null || originHost !== host) {
298
+ return jsonResponse(403, {
299
+ error: "Val: refusing a cross-origin MCP request from ".concat(JSON.stringify(origin), ". MCP clients do not send an Origin header; a browser does.")
300
+ });
301
+ }
302
+ }
303
+ if (mode === "fs") {
304
+ var hostname = host === null ? null : hostnameOf(host);
305
+ if (hostname === null || !LOOPBACK_HOSTNAMES.has(hostname)) {
306
+ return jsonResponse(403, {
307
+ error: "Val: refusing an MCP request addressed to ".concat(JSON.stringify(host !== null && host !== void 0 ? host : "an unknown host"), ". In local filesystem mode this endpoint only answers on localhost, because a name that resolves to 127.0.0.1 is how a web page reaches a developer's own machine.")
308
+ });
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+ var LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
314
+
315
+ /**
316
+ * Which host the request was addressed to, as `hostname:port`.
317
+ *
318
+ * `Host` only, and deliberately **not** `X-Forwarded-Host`. The forwarded header
319
+ * is what a client asked for behind a proxy, but nothing stops a client sending
320
+ * it directly — so preferring it hands an attacker the value both checks below
321
+ * are decided on. `Host`, by contrast, a browser sets from the URL and page
322
+ * script cannot override, which is exactly the property the loopback check
323
+ * depends on.
324
+ *
325
+ * The cost is that behind a proxy that rewrites `Host`, a *browser* request
326
+ * whose `Origin` is the public name no longer matches. That is acceptable: such
327
+ * a request carries no personal access token, so proxy mode refuses it anyway,
328
+ * and a non-browser MCP client sends no `Origin` and never reaches the
329
+ * comparison. Trusting the forwarded header would need an explicit trusted-proxy
330
+ * configuration, which is a bigger thing than this needs.
331
+ */
332
+ function requestHost(request) {
333
+ var host = request.headers.get("host");
334
+ if (host) {
335
+ return host.trim().toLowerCase();
336
+ }
337
+ // Last resort: the URL the framework saw.
338
+ try {
339
+ return new URL(request.url).host.toLowerCase();
340
+ } catch (_unused) {
341
+ return null;
342
+ }
343
+ }
344
+ function hostOf(origin) {
345
+ try {
346
+ return new URL(origin).host.toLowerCase();
347
+ } catch (_unused2) {
348
+ return null;
349
+ }
350
+ }
351
+
352
+ /** Strips the port, keeping IPv6 brackets — `[::1]:3000` is hostname `[::1]`. */
353
+ function hostnameOf(host) {
354
+ if (host.startsWith("[")) {
355
+ var end = host.indexOf("]");
356
+ return end === -1 ? host : host.slice(0, end + 1);
357
+ }
358
+ var colon = host.indexOf(":");
359
+ return colon === -1 ? host : host.slice(0, colon);
360
+ }
361
+ function readBearerToken(request) {
362
+ var _match$;
363
+ var header = request.headers.get("authorization");
364
+ if (!header) {
365
+ return null;
366
+ }
367
+ var match = /^Bearer\s+(.+)$/i.exec(header.trim());
368
+ var token = match === null || match === void 0 || (_match$ = match[1]) === null || _match$ === void 0 ? void 0 : _match$.trim();
369
+ return token ? token : null;
370
+ }
371
+ function jsonResponse(status, body) {
372
+ return new Response(JSON.stringify(body), {
373
+ status: status,
374
+ headers: {
375
+ "Content-Type": "application/json"
376
+ }
377
+ });
378
+ }
379
+
380
+ exports.initValMcp = initValMcp;
123
381
  exports.initValServer = initValServer;
@@ -3,7 +3,7 @@ import { _ as _slicedToArray } from '../../dist/slicedToArray-aa291011.esm.js';
3
3
  import { _ as _asyncToGenerator, a as _regenerator, V as VERSION } from '../../dist/version-4d7b692c.esm.js';
4
4
  import { _ as _objectSpread2 } from '../../dist/objectSpread2-60d1bd93.esm.js';
5
5
  import { Internal } from '@valbuild/core';
6
- import { createValApiRouter, createValServer } from '@valbuild/server';
6
+ import { createValApiRouter, createValServer, createValTools, initHandlerOptions } from '@valbuild/server';
7
7
  import { NextResponse } from 'next/server';
8
8
  import '../../dist/unsupportedIterableToArray-5baabfdc.esm.js';
9
9
  import '../../dist/defineProperty-cca5affa.esm.js';
@@ -116,4 +116,261 @@ function initValServer(valModules, config, nextConfig) {
116
116
  };
117
117
  }
118
118
 
119
- export { initValServer };
119
+ /**
120
+ * Val's tools over MCP, and the two checks that have to happen before a request
121
+ * gets to them.
122
+ *
123
+ * Nothing here imports an MCP SDK. The app owns the transport — which SDK, which
124
+ * route, which framework — and this owns the parts that must not be re-decided
125
+ * per app: whether the request is allowed to reach the tools at all, and whose
126
+ * credential it carries. `docs/plans/mcp.md` Part A has the reasoning; the short
127
+ * version is that the SDK reorganised itself once already, and the security
128
+ * checks should not move when it does again.
129
+ */
130
+
131
+ function initValMcp(valModules, config, opts) {
132
+ var route = "/api/val"; // TODO: get from config, as initValServer does
133
+ var coreVersion = Internal.VERSION.core;
134
+ if (!coreVersion) {
135
+ throw new Error("Could not get @valbuild/core package version");
136
+ }
137
+ var nextVersion = VERSION;
138
+ if (!nextVersion) {
139
+ throw new Error("Could not get @valbuild/next package version");
140
+ }
141
+
142
+ // Resolved once at module-eval time, awaited per request. The no-op catch is
143
+ // load bearing for the same reason it is in createValApiRouter: a config error
144
+ // on a promise with no handler attached becomes an unhandledRejection and
145
+ // takes the dev server down, and the error is reported per request below
146
+ // anyway.
147
+ var setupPromise = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee() {
148
+ var options;
149
+ return _regenerator().w(function (_context) {
150
+ while (1) switch (_context.n) {
151
+ case 0:
152
+ _context.n = 1;
153
+ return initHandlerOptions(route, _objectSpread2(_objectSpread2({}, config), {}, {
154
+ versions: {
155
+ core: coreVersion,
156
+ next: nextVersion
157
+ }
158
+ }), config);
159
+ case 1:
160
+ options = _context.v;
161
+ return _context.a(2, {
162
+ mode: options.mode,
163
+ tools: createValTools(valModules, _objectSpread2(_objectSpread2({}, options), {}, {
164
+ formatter: opts === null || opts === void 0 ? void 0 : opts.formatter
165
+ }))
166
+ });
167
+ }
168
+ }, _callee);
169
+ }))();
170
+ setupPromise["catch"](function () {
171
+ // handled per request
172
+ });
173
+ return {
174
+ valMcpTools: function valMcpTools() {
175
+ return _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee2() {
176
+ return _regenerator().w(function (_context2) {
177
+ while (1) switch (_context2.n) {
178
+ case 0:
179
+ _context2.n = 1;
180
+ return setupPromise;
181
+ case 1:
182
+ return _context2.a(2, _context2.v.tools);
183
+ }
184
+ }, _callee2);
185
+ }))();
186
+ },
187
+ valMcpAuthorize: function valMcpAuthorize(request) {
188
+ return _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee3() {
189
+ var setup, refusal, pat, _t;
190
+ return _regenerator().w(function (_context3) {
191
+ while (1) switch (_context3.p = _context3.n) {
192
+ case 0:
193
+ _context3.p = 0;
194
+ _context3.n = 1;
195
+ return setupPromise;
196
+ case 1:
197
+ setup = _context3.v;
198
+ _context3.n = 3;
199
+ break;
200
+ case 2:
201
+ _context3.p = 2;
202
+ _t = _context3.v;
203
+ return _context3.a(2, {
204
+ status: "refused",
205
+ response: jsonResponse(500, {
206
+ error: "Val: could not start the Val MCP server",
207
+ details: _t instanceof Error ? _t.message : String(_t)
208
+ })
209
+ });
210
+ case 3:
211
+ if (!(request === undefined)) {
212
+ _context3.n = 4;
213
+ break;
214
+ }
215
+ return _context3.a(2, {
216
+ status: "refused",
217
+ response: jsonResponse(401, {
218
+ error: "Val: this MCP server needs the HTTP request to authorize a call, and none was available."
219
+ })
220
+ });
221
+ case 4:
222
+ refusal = refuseUnsafeRequest(request, setup.mode);
223
+ if (!refusal) {
224
+ _context3.n = 5;
225
+ break;
226
+ }
227
+ return _context3.a(2, {
228
+ status: "refused",
229
+ response: refusal
230
+ });
231
+ case 5:
232
+ pat = readBearerToken(request);
233
+ return _context3.a(2, {
234
+ status: "ok",
235
+ tools: setup.tools,
236
+ ctx: {
237
+ // Passed through unverified, deliberately: this app is not the
238
+ // authority on what a token may do, and the registry sends it to the
239
+ // backend that is. See `docs/plans/mcp.md` D.2.
240
+ auth: pat === null ? null : {
241
+ pat: pat
242
+ },
243
+ // Not the MCP session id. Val's patch `sessionId` names a Val AI
244
+ // session, and putting an unrelated id in it would claim a
245
+ // relationship that does not exist.
246
+ sessionId: null
247
+ }
248
+ });
249
+ }
250
+ }, _callee3, null, [[0, 2]]);
251
+ }))();
252
+ }
253
+ };
254
+ }
255
+
256
+ /**
257
+ * The two ways this route is dangerous, both refused here.
258
+ *
259
+ * 1. **Local filesystem mode outside development.** In fs mode there is no
260
+ * credential and no backend: the tools read and write the running process's
261
+ * own working tree, and every permission check Val has lives on the other
262
+ * side of a backend that is not in this path. Exposed on a deployed host,
263
+ * that is unauthenticated write access to the site's content for anyone who
264
+ * can reach the port. There is no configuration that makes it safe, so there
265
+ * is no flag to turn this off — a project that wants MCP in production wants
266
+ * proxy mode, where every call carries its caller's own token.
267
+ *
268
+ * 2. **A browser driving the local server.** A page on any origin can `fetch`
269
+ * `http://localhost:3000/api/mcp` while a developer has the app running, and
270
+ * with DNS rebinding it can do so with a `Host` of its own choosing. Neither
271
+ * needs a credential in fs mode. So a cross-origin `Origin` is refused, and
272
+ * in fs mode the request must actually be addressed to a loopback host.
273
+ *
274
+ * MCP clients are not browsers and send no `Origin`, so the check costs them
275
+ * nothing.
276
+ */
277
+ function refuseUnsafeRequest(request, mode) {
278
+ if (mode === "fs" && process.env.NODE_ENV !== "development") {
279
+ return jsonResponse(403, {
280
+ error: "Val: the MCP endpoint is disabled. This project is running in local filesystem mode, where MCP calls are unauthenticated and write directly to the working tree, so it is only served in development. Configure Val for proxy mode to use MCP on a deployed host."
281
+ });
282
+ }
283
+ var host = requestHost(request);
284
+ var origin = request.headers.get("origin");
285
+ if (origin !== null) {
286
+ // `Origin: null` is refused along with the rest. It is the *opaque* origin —
287
+ // a sandboxed iframe, a `file://` page, some redirects — so it cannot be
288
+ // compared to anything, and "cannot be compared" has to mean refuse: a page
289
+ // that would fail the check can otherwise pass it by arranging to have no
290
+ // origin at all. Absent entirely is the case that is allowed, and that is
291
+ // the one MCP clients produce.
292
+ var originHost = origin === "null" ? null : hostOf(origin);
293
+ if (originHost === null || host === null || originHost !== host) {
294
+ return jsonResponse(403, {
295
+ error: "Val: refusing a cross-origin MCP request from ".concat(JSON.stringify(origin), ". MCP clients do not send an Origin header; a browser does.")
296
+ });
297
+ }
298
+ }
299
+ if (mode === "fs") {
300
+ var hostname = host === null ? null : hostnameOf(host);
301
+ if (hostname === null || !LOOPBACK_HOSTNAMES.has(hostname)) {
302
+ return jsonResponse(403, {
303
+ error: "Val: refusing an MCP request addressed to ".concat(JSON.stringify(host !== null && host !== void 0 ? host : "an unknown host"), ". In local filesystem mode this endpoint only answers on localhost, because a name that resolves to 127.0.0.1 is how a web page reaches a developer's own machine.")
304
+ });
305
+ }
306
+ }
307
+ return null;
308
+ }
309
+ var LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
310
+
311
+ /**
312
+ * Which host the request was addressed to, as `hostname:port`.
313
+ *
314
+ * `Host` only, and deliberately **not** `X-Forwarded-Host`. The forwarded header
315
+ * is what a client asked for behind a proxy, but nothing stops a client sending
316
+ * it directly — so preferring it hands an attacker the value both checks below
317
+ * are decided on. `Host`, by contrast, a browser sets from the URL and page
318
+ * script cannot override, which is exactly the property the loopback check
319
+ * depends on.
320
+ *
321
+ * The cost is that behind a proxy that rewrites `Host`, a *browser* request
322
+ * whose `Origin` is the public name no longer matches. That is acceptable: such
323
+ * a request carries no personal access token, so proxy mode refuses it anyway,
324
+ * and a non-browser MCP client sends no `Origin` and never reaches the
325
+ * comparison. Trusting the forwarded header would need an explicit trusted-proxy
326
+ * configuration, which is a bigger thing than this needs.
327
+ */
328
+ function requestHost(request) {
329
+ var host = request.headers.get("host");
330
+ if (host) {
331
+ return host.trim().toLowerCase();
332
+ }
333
+ // Last resort: the URL the framework saw.
334
+ try {
335
+ return new URL(request.url).host.toLowerCase();
336
+ } catch (_unused) {
337
+ return null;
338
+ }
339
+ }
340
+ function hostOf(origin) {
341
+ try {
342
+ return new URL(origin).host.toLowerCase();
343
+ } catch (_unused2) {
344
+ return null;
345
+ }
346
+ }
347
+
348
+ /** Strips the port, keeping IPv6 brackets — `[::1]:3000` is hostname `[::1]`. */
349
+ function hostnameOf(host) {
350
+ if (host.startsWith("[")) {
351
+ var end = host.indexOf("]");
352
+ return end === -1 ? host : host.slice(0, end + 1);
353
+ }
354
+ var colon = host.indexOf(":");
355
+ return colon === -1 ? host : host.slice(0, colon);
356
+ }
357
+ function readBearerToken(request) {
358
+ var _match$;
359
+ var header = request.headers.get("authorization");
360
+ if (!header) {
361
+ return null;
362
+ }
363
+ var match = /^Bearer\s+(.+)$/i.exec(header.trim());
364
+ var token = match === null || match === void 0 || (_match$ = match[1]) === null || _match$ === void 0 ? void 0 : _match$.trim();
365
+ return token ? token : null;
366
+ }
367
+ function jsonResponse(status, body) {
368
+ return new Response(JSON.stringify(body), {
369
+ status: status,
370
+ headers: {
371
+ "Content-Type": "application/json"
372
+ }
373
+ });
374
+ }
375
+
376
+ export { initValMcp, initValServer };