@wervt/nuxt 0.2.11 → 0.2.13

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 CHANGED
@@ -19,7 +19,9 @@ export default defineNuxtConfig({
19
19
  - `proxyShape()` (server) and `useShape()` (app): live rows from Postgres via Electric.
20
20
  - `useWervtUser()`: the signed-in wervt user, on the server and in the app.
21
21
  - `useValidatedBody()` / `useValidatedQuery()` / `useValidatedParams()`: valibot validation.
22
- - `defineTool()` (server): tools in `server/tools/` that browser agents call through WebMCP.
22
+ - `useWervtTrack()`: custom events for wervt's cookieless analytics (page views are counted automatically).
23
+ - `defineTool()` (server): tools in `server/tools/` that browser agents call through WebMCP, and
24
+ MCP clients (Claude Code, Cursor, …) at `/mcp`, signed in through wervt's OAuth login.
23
25
  - `nuxt dev` starts against the local wervt stack (Postgres, Electric) and a dev user.
24
26
 
25
27
  Deploy with [`@wervt/cli`](https://www.npmjs.com/package/@wervt/cli): `wervt deploy`.
package/dist/module.d.ts CHANGED
@@ -18,6 +18,26 @@ export interface ModuleOptions {
18
18
  * Defaults to true; `false` leaves them out of the build.
19
19
  */
20
20
  webmcp?: boolean;
21
+ /**
22
+ * Serves the tools in `server/tools/` to MCP clients (Claude Code, Cursor, …) at `/mcp`,
23
+ * signed in through wervt's login. Defaults to true; `false` turns it off. An object names
24
+ * the server and tells clients how to use the tools (`instructions`).
25
+ */
26
+ mcp?: boolean | McpOptions;
27
+ /**
28
+ * Reports client-side navigations and `useWervtTrack()` events to wervt's usage analytics, so
29
+ * page views count more than the first load. Defaults to true. Cookieless; page loads are
30
+ * counted by the runtime either way.
31
+ */
32
+ analytics?: boolean;
33
+ }
34
+ export interface McpOptions {
35
+ /** Server name clients show. Defaults to the app name. */
36
+ name?: string;
37
+ title?: string;
38
+ description?: string;
39
+ /** How to use the tools together, e.g. which one to call first. Clients pass it to the model. */
40
+ instructions?: string;
21
41
  }
22
42
  /** `server/wervt-tasks.json` in the build: the app's tasks and schedules, for the control plane. */
23
43
  export interface TasksManifest {
package/dist/module.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { resolveAppName, setupDev } from "./dev.js";
2
2
  import { addImports, addPlugin, addServerHandler, addServerPlugin, addServerTemplate, createResolver, defineNuxtModule, getNuxtVersion, hasNuxtModule, installModule, useLogger } from "nuxt/kit";
3
3
  import { existsSync } from "node:fs";
4
- import { mkdir, readdir, writeFile } from "node:fs/promises";
4
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
5
  import { builtinModules, createRequire } from "node:module";
6
6
  import { dirname, extname, join, relative, resolve, sep } from "node:path";
7
7
  import evlog from "evlog/nuxt";
@@ -163,12 +163,18 @@ var module_default = defineNuxtModule({
163
163
  });
164
164
  }
165
165
  const toolsDir = join(nuxt.options.serverDir, "tools");
166
- const tools = options.webmcp === false ? [] : await findTools(toolsDir);
167
- if (tools.length > 0) {
168
- addServerTemplate({
169
- filename: "#wervt/tools",
170
- getContents: () => `export const tools = {\n${tools.map(({ name, path }) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(path)}),`).join("\n")}\n}\n`
171
- });
166
+ const webmcp = options.webmcp !== false;
167
+ let mcp = options.mcp !== false;
168
+ const tools = webmcp || mcp ? await findTools(toolsDir) : [];
169
+ if (tools.length > 0 && mcp && nitroMajor < 3) {
170
+ if (options.mcp) logger.warn("`/mcp` needs Nuxt 5; this app keeps its tools for WebMCP only.");
171
+ mcp = false;
172
+ }
173
+ if (tools.length > 0) addServerTemplate({
174
+ filename: "#wervt/tools",
175
+ getContents: () => `export const tools = {\n${tools.map(({ name, path }) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(path)}),`).join("\n")}\n}\n`
176
+ });
177
+ if (tools.length > 0 && webmcp) {
172
178
  addServerHandler({
173
179
  route: "/_wervt/tools",
174
180
  method: "get",
@@ -184,7 +190,34 @@ var module_default = defineNuxtModule({
184
190
  mode: "client"
185
191
  });
186
192
  }
187
- if (nuxt.options.dev && options.webmcp !== false) nuxt.hook("builder:watch", async (event, path) => {
193
+ if (tools.length > 0 && mcp) {
194
+ const config = typeof options.mcp === "object" ? options.mcp : {};
195
+ const app = await resolveAppName(nuxt.options.rootDir, options.app);
196
+ const server = {
197
+ name: config.name ?? app,
198
+ version: await appVersion(nuxt.options.rootDir),
199
+ title: config.title,
200
+ description: config.description,
201
+ instructions: config.instructions
202
+ };
203
+ addServerTemplate({
204
+ filename: "#wervt/mcp",
205
+ getContents: () => `export const server = ${JSON.stringify(server)}\n`
206
+ });
207
+ addServerHandler({
208
+ route: "/mcp",
209
+ handler: resolver.resolve("./runtime/server/mcp")
210
+ });
211
+ addServerHandler({
212
+ route: "/.well-known/oauth-protected-resource/mcp",
213
+ handler: resolver.resolve("./runtime/server/mcp-metadata")
214
+ });
215
+ addServerHandler({
216
+ route: "/.well-known/oauth-authorization-server",
217
+ handler: resolver.resolve("./runtime/server/mcp-auth-server")
218
+ });
219
+ }
220
+ if (nuxt.options.dev && (webmcp || mcp)) nuxt.hook("builder:watch", async (event, path) => {
188
221
  const file = relative(toolsDir, resolve(nuxt.options.srcDir, path));
189
222
  if ((event === "add" || event === "unlink") && !file.startsWith("..")) await nuxt.callHook("restart");
190
223
  });
@@ -221,6 +254,17 @@ var module_default = defineNuxtModule({
221
254
  mode: "client"
222
255
  });
223
256
  }
257
+ if (options.analytics !== false && !nuxt.options.dev) {
258
+ const config = nuxt.options.runtimeConfig.public;
259
+ config.wervt = {
260
+ ...config.wervt,
261
+ analytics: true
262
+ };
263
+ addPlugin({
264
+ src: resolver.resolve("./runtime/app/plugins/analytics.client"),
265
+ mode: "client"
266
+ });
267
+ }
224
268
  nuxt.options.build.transpile.push(resolver.resolve("./runtime"));
225
269
  addImports([
226
270
  {
@@ -231,6 +275,10 @@ var module_default = defineNuxtModule({
231
275
  name: "useWervtUser",
232
276
  from: resolver.resolve("./runtime/app/useWervtUser")
233
277
  },
278
+ {
279
+ name: "useWervtTrack",
280
+ from: resolver.resolve("./runtime/app/useWervtTrack")
281
+ },
234
282
  {
235
283
  name: "wervtLoginUrl",
236
284
  from: resolver.resolve("./runtime/app/useWervtUser")
@@ -273,6 +321,11 @@ async function findTools(dir) {
273
321
  return tools;
274
322
  }
275
323
  const logger = useLogger("wervt");
324
+ /** The app's package.json version, which MCP clients see as the server version. */
325
+ async function appVersion(rootDir) {
326
+ const pkg = JSON.parse(await readFile(join(rootDir, "package.json"), "utf8").catch(() => "{}"));
327
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
328
+ }
276
329
  /** Whether `name` is in a `node_modules` folder Node would look in from `dir`. */
277
330
  function hasPackage(dir, name) {
278
331
  for (let current = dir;; current = dirname(current)) {
@@ -1,5 +1,5 @@
1
- import { HTTPError } from "../../../../../../h3@2.0.2_crossws@0.4.15_srvx@1.0.5__ocache@0.3.0/node_modules/h3/dist/response.js";
2
1
  import { tasks } from "#nitro/virtual/tasks";
2
+ import { HTTPError } from "h3";
3
3
  //#region ../../node_modules/.pnpm/nitro@3.0.260903-beta/node_modules/nitro/dist/runtime/internal/task.mjs
4
4
  const __runningTasks__ = {};
5
5
  async function runTask(name, { payload = {}, context = {} } = {}) {
@@ -0,0 +1,4 @@
1
+ //#region src/runtime/app/plugins/analytics.client.d.ts
2
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
3
+ //#endregion
4
+ export { _default as default };
@@ -0,0 +1,16 @@
1
+ import { defineNuxtPlugin, useRouter, useRuntimeConfig } from "#app";
2
+ //#region src/runtime/app/plugins/analytics.client.ts
3
+ var analytics_client_default = defineNuxtPlugin({
4
+ name: "wervt:analytics",
5
+ setup() {
6
+ const router = useRouter();
7
+ const base = useRuntimeConfig().app.baseURL.replace(/\/$/, "");
8
+ router.afterEach((to, from, failure) => {
9
+ if (failure || !from.matched.length || to.path === from.path) return;
10
+ const body = new Blob([JSON.stringify({ path: `${base}${to.path}` })], { type: "application/json" });
11
+ navigator.sendBeacon?.(`${base}/_wervt/view`, body);
12
+ });
13
+ }
14
+ });
15
+ //#endregion
16
+ export { analytics_client_default as default };
@@ -0,0 +1,9 @@
1
+ //#region src/runtime/app/useWervtTrack.d.ts
2
+ /**
3
+ * Custom events for wervt's analytics, e.g. `track('signup')` or `track('click', url)`. They
4
+ * show up with their counts and visitors in the dashboard and `wervt stats`. Like page views
5
+ * they are cookieless: only the event, its label and the current path are sent. Does nothing
6
+ * in `nuxt dev`, during SSR and with `wervt.analytics: false`.
7
+ */
8
+ export declare function useWervtTrack(): (name: string, label?: string) => void;
9
+ //#endregion
@@ -0,0 +1,24 @@
1
+ import { useRuntimeConfig } from "#app";
2
+ //#region src/runtime/app/useWervtTrack.ts
3
+ /**
4
+ * Custom events for wervt's analytics, e.g. `track('signup')` or `track('click', url)`. They
5
+ * show up with their counts and visitors in the dashboard and `wervt stats`. Like page views
6
+ * they are cookieless: only the event, its label and the current path are sent. Does nothing
7
+ * in `nuxt dev`, during SSR and with `wervt.analytics: false`.
8
+ */
9
+ function useWervtTrack() {
10
+ const config = useRuntimeConfig();
11
+ const enabled = config.public.wervt?.analytics;
12
+ const base = config.app.baseURL.replace(/\/$/, "");
13
+ return (name, label) => {
14
+ if (!enabled || import.meta.server || typeof navigator === "undefined") return;
15
+ const body = JSON.stringify({
16
+ path: location.pathname,
17
+ name,
18
+ label
19
+ });
20
+ navigator.sendBeacon?.(`${base}/_wervt/view`, new Blob([body], { type: "application/json" }));
21
+ };
22
+ }
23
+ //#endregion
24
+ export { useWervtTrack };
@@ -3,8 +3,8 @@ declare const _default: import("nuxt/server").EventHandler<{
3
3
  error: string;
4
4
  ok?: undefined;
5
5
  } | {
6
- error?: undefined;
7
6
  ok: boolean;
7
+ error?: undefined;
8
8
  }>;
9
9
  //#endregion
10
10
  export { _default as default };
@@ -1,9 +1,10 @@
1
1
  import { WervtUser } from "../shared/user.js";
2
2
  import { useDb } from "./db.js";
3
3
  import { createEvlogError, log, useLogger } from "./logger.js";
4
+ import { McpTokenContext } from "./mcp-auth.js";
4
5
  import { ShapeOptions, proxyShape } from "./shape.js";
5
6
  import { Task, TaskEvent, TaskResult, defineTask } from "./tasks.js";
6
7
  import { SafeResult, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, vh } from "./validate.js";
7
8
  import { Tool, defineTool } from "./tools.js";
8
9
  import { useWervtUser } from "./user.js";
9
- export { type SafeResult, type ShapeOptions, type Task, type TaskEvent, type TaskResult, type Tool, type WervtUser, createEvlogError, defineTask, defineTool, log, proxyShape, useDb, useLogger, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
10
+ export { type McpTokenContext, type SafeResult, type ShapeOptions, type Task, type TaskEvent, type TaskResult, type Tool, type WervtUser, createEvlogError, defineTask, defineTool, log, proxyShape, useDb, useLogger, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
@@ -0,0 +1,4 @@
1
+ //#region src/runtime/server/mcp-auth-server.d.ts
2
+ declare const _default: import("nuxt/server").EventHandler<Promise<Response>>;
3
+ //#endregion
4
+ export { _default as default };
@@ -0,0 +1,21 @@
1
+ import { CORS, fetchAuth, mcpEndpoint } from "./mcp-auth.js";
2
+ import { defineEventHandler } from "nuxt/server";
3
+ //#region src/runtime/server/mcp-auth-server.ts
4
+ var mcp_auth_server_default = defineEventHandler(async (event) => {
5
+ const endpoint = mcpEndpoint(event);
6
+ if (event.req.method === "OPTIONS") return new Response(null, {
7
+ status: 204,
8
+ headers: CORS
9
+ });
10
+ if (!endpoint) return Response.json({ error: "No login configured." }, { status: 404 });
11
+ const issuer = new URL(endpoint.issuer);
12
+ const url = new URL(`/.well-known/oauth-authorization-server${issuer.pathname}`, issuer);
13
+ const res = await fetchAuth(url.href);
14
+ if (!res.ok) return Response.json({ error: "authorization_server_unavailable" }, {
15
+ status: 502,
16
+ headers: CORS
17
+ });
18
+ return Response.json(await res.json(), { headers: CORS });
19
+ });
20
+ //#endregion
21
+ export { mcp_auth_server_default as default };
@@ -0,0 +1,59 @@
1
+ import { WervtUser } from "../shared/user.js";
2
+ import { RequestEvent } from "nuxt/server";
3
+ //#region src/runtime/server/mcp-auth.d.ts
4
+ /** What `wervt:mcp:token` handlers get: set `user` to accept the token. */
5
+ export interface McpTokenContext {
6
+ token: string;
7
+ event: RequestEvent;
8
+ user?: WervtUser;
9
+ }
10
+ declare module 'nuxt/schema' {
11
+ interface NuxtServerHooks {
12
+ /**
13
+ * A bearer token on `/mcp` that isn't a wervt access token, e.g. the app's own API token
14
+ * for scripts. Set `ctx.user` to accept it; tools then see that user in `useWervtUser`.
15
+ */
16
+ 'wervt:mcp:token': (ctx: McpTokenContext) => void | Promise<void>;
17
+ }
18
+ }
19
+ export interface McpEndpoint {
20
+ /** This app's `/mcp`, the audience its access tokens must carry. */
21
+ resource: string;
22
+ /** wervt's login as OAuth issuer, e.g. `https://auth.wervt.app/api/auth`. */
23
+ issuer: string;
24
+ /** RFC 9728 metadata of `resource`, where the 401 points MCP clients. */
25
+ metadataUrl: string;
26
+ }
27
+ /**
28
+ * Where this app's `/mcp` and wervt's login live, from the runtime's `WERVT_APP_URL` and
29
+ * `WERVT_AUTH_URL`. Undefined without a login: `nuxt dev` then serves `/mcp` as the dev user.
30
+ */
31
+ export declare function mcpEndpoint(event: RequestEvent): McpEndpoint | undefined;
32
+ /** Discovery documents are public; browser-based MCP clients read them cross-origin. */
33
+ export declare const CORS: {
34
+ 'access-control-allow-origin': string;
35
+ 'access-control-allow-methods': string;
36
+ 'access-control-allow-headers': string;
37
+ 'access-control-max-age': string;
38
+ };
39
+ /** RFC 9728: tells MCP clients which login issues tokens for `/mcp`. */
40
+ export declare function protectedResourceMetadata(endpoint: McpEndpoint): {
41
+ resource: string;
42
+ authorization_servers: string[];
43
+ bearer_methods_supported: string[];
44
+ scopes_supported: string[];
45
+ resource_name: string;
46
+ };
47
+ /**
48
+ * Fetches from wervt's login. Inside wervt it's another isolate on the same runtime, so the
49
+ * request goes to the runtime directly (named by `x-wervt-host`): no round trip through the
50
+ * internet, and `*.localhost` of the local stack doesn't resolve inside the runtime anyway.
51
+ */
52
+ export declare function fetchAuth(issuerUrl: string, init?: RequestInit): Promise<Response>;
53
+ /** Verifies a wervt access token for `endpoint` and returns its user. */
54
+ export declare function verifyAccessToken(token: string, endpoint: McpEndpoint): Promise<WervtUser | undefined>;
55
+ /** The user a bearer token on `/mcp` stands for: a wervt access token, or the app's own. */
56
+ export declare function authenticateMcp(token: string, event: RequestEvent, endpoint: McpEndpoint): Promise<WervtUser | undefined>;
57
+ /** Makes `useWervtUser(event)` return the token's user while a tool runs over MCP. */
58
+ export declare function setMcpUser(event: RequestEvent, user: WervtUser): void;
59
+ //#endregion
@@ -0,0 +1,98 @@
1
+ import { userFromClaims } from "./mcp-claims.js";
2
+ import { getRequestURL, useServerHooks } from "nuxt/server";
3
+ import { createRemoteJWKSet, customFetch, errors, jwtVerify } from "jose";
4
+ //#region src/runtime/server/mcp-auth.ts
5
+ const trimSlash = (url) => url.replace(/\/+$/, "");
6
+ /**
7
+ * Where this app's `/mcp` and wervt's login live, from the runtime's `WERVT_APP_URL` and
8
+ * `WERVT_AUTH_URL`. Undefined without a login: `nuxt dev` then serves `/mcp` as the dev user.
9
+ */
10
+ function mcpEndpoint(event) {
11
+ const authUrl = process.env.WERVT_AUTH_URL;
12
+ if (!authUrl) return void 0;
13
+ const appUrl = trimSlash(process.env.WERVT_APP_URL ?? getRequestURL(event).origin);
14
+ return {
15
+ resource: `${appUrl}/mcp`,
16
+ issuer: `${trimSlash(authUrl)}/api/auth`,
17
+ metadataUrl: `${appUrl}/.well-known/oauth-protected-resource/mcp`
18
+ };
19
+ }
20
+ /** Discovery documents are public; browser-based MCP clients read them cross-origin. */
21
+ const CORS = {
22
+ "access-control-allow-origin": "*",
23
+ "access-control-allow-methods": "GET, OPTIONS",
24
+ "access-control-allow-headers": "*",
25
+ "access-control-max-age": "86400"
26
+ };
27
+ /** RFC 9728: tells MCP clients which login issues tokens for `/mcp`. */
28
+ function protectedResourceMetadata(endpoint) {
29
+ return {
30
+ resource: endpoint.resource,
31
+ authorization_servers: [endpoint.issuer],
32
+ bearer_methods_supported: ["header"],
33
+ scopes_supported: [
34
+ "openid",
35
+ "profile",
36
+ "email",
37
+ "offline_access"
38
+ ],
39
+ resource_name: new URL(endpoint.resource).hostname.split(".")[0]
40
+ };
41
+ }
42
+ /**
43
+ * Fetches from wervt's login. Inside wervt it's another isolate on the same runtime, so the
44
+ * request goes to the runtime directly (named by `x-wervt-host`): no round trip through the
45
+ * internet, and `*.localhost` of the local stack doesn't resolve inside the runtime anyway.
46
+ */
47
+ function fetchAuth(issuerUrl, init = {}) {
48
+ const runtime = process.env.WERVT_RUNTIME_URL;
49
+ if (!runtime) return fetch(issuerUrl, init);
50
+ const url = new URL(issuerUrl);
51
+ const headers = new Headers(init.headers);
52
+ headers.set("x-wervt-host", url.host);
53
+ return fetch(new URL(url.pathname + url.search, runtime), {
54
+ ...init,
55
+ headers
56
+ });
57
+ }
58
+ const keySets = /* @__PURE__ */ new Map();
59
+ function keySet(issuer) {
60
+ let keys = keySets.get(issuer);
61
+ if (!keys) {
62
+ keys = createRemoteJWKSet(new URL(`${issuer}/jwks`), { [customFetch]: (url, init) => fetchAuth(url, init) });
63
+ keySets.set(issuer, keys);
64
+ }
65
+ return keys;
66
+ }
67
+ /** Verifies a wervt access token for `endpoint` and returns its user. */
68
+ async function verifyAccessToken(token, endpoint) {
69
+ try {
70
+ const { payload } = await jwtVerify(token, keySet(endpoint.issuer), {
71
+ issuer: endpoint.issuer,
72
+ audience: endpoint.resource,
73
+ requiredClaims: ["exp", "sub"],
74
+ clockTolerance: 5
75
+ });
76
+ return userFromClaims(payload);
77
+ } catch (error) {
78
+ if (error instanceof errors.JOSEError && !(error instanceof errors.JWKSTimeout)) return void 0;
79
+ throw error;
80
+ }
81
+ }
82
+ /** The user a bearer token on `/mcp` stands for: a wervt access token, or the app's own. */
83
+ async function authenticateMcp(token, event, endpoint) {
84
+ const user = await verifyAccessToken(token, endpoint);
85
+ if (user) return user;
86
+ const ctx = {
87
+ token,
88
+ event
89
+ };
90
+ await useServerHooks().callHook("wervt:mcp:token", ctx);
91
+ return ctx.user;
92
+ }
93
+ /** Makes `useWervtUser(event)` return the token's user while a tool runs over MCP. */
94
+ function setMcpUser(event, user) {
95
+ event.context.wervtMcpUser = user;
96
+ }
97
+ //#endregion
98
+ export { CORS, authenticateMcp, fetchAuth, mcpEndpoint, protectedResourceMetadata, setMcpUser, verifyAccessToken };
@@ -0,0 +1,5 @@
1
+ import { WervtUser } from "../shared/user.js";
2
+ //#region src/runtime/server/mcp-claims.d.ts
3
+ /** The wervt user an access token's claims name, or undefined when they're incomplete. */
4
+ export declare function userFromClaims(claims: Record<string, unknown>): WervtUser | undefined;
5
+ //#endregion
@@ -0,0 +1,18 @@
1
+ //#region src/runtime/server/mcp-claims.ts
2
+ /** The wervt user an access token's claims name, or undefined when they're incomplete. */
3
+ function userFromClaims(claims) {
4
+ const { sub, email, name, picture, accounts } = claims;
5
+ if (typeof sub !== "string" || !sub || typeof email !== "string") return void 0;
6
+ return {
7
+ id: sub,
8
+ email: email.toLowerCase(),
9
+ name: typeof name === "string" ? name : void 0,
10
+ image: typeof picture === "string" ? picture : void 0,
11
+ accounts: isAccounts(accounts) ? accounts : void 0
12
+ };
13
+ }
14
+ function isAccounts(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((id) => typeof id === "string");
16
+ }
17
+ //#endregion
18
+ export { userFromClaims };
@@ -0,0 +1,4 @@
1
+ //#region src/runtime/server/mcp-metadata.d.ts
2
+ declare const _default: import("nuxt/server").EventHandler<Response>;
3
+ //#endregion
4
+ export { _default as default };
@@ -0,0 +1,14 @@
1
+ import { CORS, mcpEndpoint, protectedResourceMetadata } from "./mcp-auth.js";
2
+ import { defineEventHandler } from "nuxt/server";
3
+ //#region src/runtime/server/mcp-metadata.ts
4
+ var mcp_metadata_default = defineEventHandler((event) => {
5
+ const endpoint = mcpEndpoint(event);
6
+ if (event.req.method === "OPTIONS") return new Response(null, {
7
+ status: 204,
8
+ headers: CORS
9
+ });
10
+ if (!endpoint) return Response.json({ error: "No login configured." }, { status: 404 });
11
+ return Response.json(protectedResourceMetadata(endpoint), { headers: CORS });
12
+ });
13
+ //#endregion
14
+ export { mcp_metadata_default as default };
@@ -0,0 +1,4 @@
1
+ //#region src/runtime/server/mcp.d.ts
2
+ declare const _default: import("nuxt/server").EventHandler<Promise<unknown>>;
3
+ //#endregion
4
+ export { _default as default };
@@ -0,0 +1,112 @@
1
+ import { devUser } from "../shared/user.js";
2
+ import { useLogger } from "./logger.js";
3
+ import { safeValidate } from "./validate.js";
4
+ import { authenticateMcp, mcpEndpoint, setMcpUser } from "./mcp-auth.js";
5
+ import { loadTool, toolInputSchema } from "./tool-handlers.js";
6
+ import { defineEventHandler } from "nuxt/server";
7
+ import { server } from "#wervt/mcp";
8
+ import { tools } from "#wervt/tools";
9
+ import { defineMcpHandler } from "h3-mcp";
10
+ //#region src/runtime/server/mcp.ts
11
+ const MAX_BODY = 4194304;
12
+ const text = (value) => [{
13
+ type: "text",
14
+ text: value
15
+ }];
16
+ /** What the client sees of a thrown error: its status and message, like the WebMCP route. */
17
+ function errorResult(error) {
18
+ const { status, message, data } = error ?? {};
19
+ let body = message || "Tool failed.";
20
+ if (typeof status === "number") body = `[${status}] ${body}`;
21
+ if (data !== void 0) body += `\n${JSON.stringify(data)}`;
22
+ return {
23
+ content: text(body),
24
+ isError: true
25
+ };
26
+ }
27
+ function toMcpTool(name, tool) {
28
+ return {
29
+ name,
30
+ description: tool.description,
31
+ inputSchema: toolInputSchema(tool),
32
+ annotations: { readOnlyHint: tool.readOnly === true },
33
+ async handler(args, event) {
34
+ const requestEvent = event;
35
+ useLogger(requestEvent).set({
36
+ tool: name,
37
+ mcp: true
38
+ });
39
+ let input;
40
+ if (tool.input) {
41
+ const result = await safeValidate(tool.input, args ?? {});
42
+ if (!result.success) return {
43
+ content: text(`Invalid arguments.\n${JSON.stringify(result.issues)}`),
44
+ isError: true
45
+ };
46
+ input = result.data;
47
+ }
48
+ try {
49
+ const result = await tool.handler(requestEvent, input);
50
+ return { content: result === void 0 ? [] : text(JSON.stringify(result)) };
51
+ } catch (error) {
52
+ return errorResult(error);
53
+ }
54
+ }
55
+ };
56
+ }
57
+ let handler;
58
+ async function createHandler() {
59
+ const definitions = (await Promise.all(Object.keys(tools).map(async (name) => {
60
+ const tool = await loadTool(name);
61
+ return tool ? toMcpTool(name, tool) : void 0;
62
+ }))).filter((tool) => tool !== void 0);
63
+ return defineMcpHandler((event) => ({
64
+ ...server,
65
+ tools: definitions,
66
+ origin: mcpEndpoint(event) ? false : {}
67
+ }));
68
+ }
69
+ function unauthorized(metadataUrl, invalid) {
70
+ const params = [`resource_metadata="${metadataUrl}"`];
71
+ if (invalid) params.push("error=\"invalid_token\"");
72
+ return new Response(JSON.stringify({ error: "Sign in with wervt to use this app over MCP." }), {
73
+ status: 401,
74
+ headers: {
75
+ "content-type": "application/json",
76
+ "www-authenticate": `Bearer ${params.join(", ")}`,
77
+ "cache-control": "no-store"
78
+ }
79
+ });
80
+ }
81
+ var mcp_default = defineEventHandler(async (event) => {
82
+ const endpoint = mcpEndpoint(event);
83
+ if (endpoint) {
84
+ const authorization = event.req.headers.get("authorization") ?? "";
85
+ const token = /^bearer\s+(\S+)$/i.exec(authorization)?.[1];
86
+ const user = token ? await authenticateMcp(token, event, endpoint) : void 0;
87
+ if (!user) return unauthorized(endpoint.metadataUrl, Boolean(token));
88
+ setMcpUser(event, user);
89
+ } else if (import.meta.dev) {
90
+ const user = devUser(true);
91
+ if (user) setMcpUser(event, user);
92
+ } else return Response.json({ error: "MCP needs WERVT_AUTH_URL, which the wervt runtime sets." }, { status: 503 });
93
+ if (event.req.method === "POST") {
94
+ if (Number(event.req.headers.get("content-length") ?? 0) > MAX_BODY) return Response.json({ error: "Body too large." }, { status: 413 });
95
+ const body = await event.req.arrayBuffer();
96
+ if (body.byteLength > MAX_BODY) return Response.json({ error: "Body too large." }, { status: 413 });
97
+ const h3Event = event;
98
+ h3Event.req = new Request(event.req.url, {
99
+ method: "POST",
100
+ headers: event.req.headers,
101
+ body,
102
+ signal: event.req.signal
103
+ });
104
+ }
105
+ handler ??= createHandler().catch((error) => {
106
+ handler = void 0;
107
+ throw error;
108
+ });
109
+ return (await handler)(event);
110
+ });
111
+ //#endregion
112
+ export { mcp_default as default };
@@ -1,4 +1,11 @@
1
+ import { Input } from "./validate.js";
2
+ import { Tool } from "./tools.js";
1
3
  //#region src/runtime/server/tool-handlers.d.ts
4
+ export declare function loadTool(name: string): Promise<Tool<Input | undefined, unknown> | undefined>;
5
+ export declare function toolInputSchema(tool: Tool): import("@valibot/to-json-schema").JsonSchema | {
6
+ type: string;
7
+ properties: {};
8
+ };
2
9
  /** `GET /_wervt/tools`: name, description and JSON Schema of each tool, for the client plugin. */
3
10
  export declare const listTools: import("nuxt/server").EventHandler<Promise<{
4
11
  name: string;
@@ -1,13 +1,13 @@
1
1
  import { useLogger } from "./logger.js";
2
2
  import { safeValidate, toSchema } from "./validate.js";
3
3
  import { defineEventHandler } from "nuxt/server";
4
- import { toJsonSchema } from "@valibot/to-json-schema";
5
4
  import { tools } from "#wervt/tools";
5
+ import { toJsonSchema } from "@valibot/to-json-schema";
6
6
  //#region src/runtime/server/tool-handlers.ts
7
- async function load(name) {
7
+ async function loadTool(name) {
8
8
  return (await (Object.hasOwn(tools, name) ? tools[name] : void 0)?.())?.default;
9
9
  }
10
- function inputSchema(tool) {
10
+ function toolInputSchema(tool) {
11
11
  if (!tool.input) return {
12
12
  type: "object",
13
13
  properties: {}
@@ -23,12 +23,12 @@ function inputSchema(tool) {
23
23
  const listTools = defineEventHandler(async (event) => {
24
24
  event.res.headers.set("cache-control", "no-store");
25
25
  return (await Promise.all(Object.keys(tools).map(async (name) => {
26
- const tool = await load(name);
26
+ const tool = await loadTool(name);
27
27
  if (!tool) return void 0;
28
28
  return {
29
29
  name,
30
30
  description: tool.description,
31
- inputSchema: inputSchema(tool),
31
+ inputSchema: toolInputSchema(tool),
32
32
  readOnly: tool.readOnly === true
33
33
  };
34
34
  }))).filter((tool) => tool !== void 0);
@@ -45,7 +45,7 @@ const runTool = defineEventHandler(async (event) => {
45
45
  return { error: "Tools can only be called from the app itself." };
46
46
  }
47
47
  const name = decodeURIComponent(new URL(event.req.url).pathname.split("/").pop() ?? "");
48
- const tool = await load(name);
48
+ const tool = await loadTool(name);
49
49
  if (!tool) {
50
50
  event.res.status = 404;
51
51
  return { error: `No tool named ${name}.` };
@@ -67,4 +67,4 @@ const runTool = defineEventHandler(async (event) => {
67
67
  return { result: await tool.handler(event, input) ?? null };
68
68
  });
69
69
  //#endregion
70
- export { listTools, runTool };
70
+ export { listTools, loadTool, runTool, toolInputSchema };
@@ -1,11 +1,11 @@
1
1
  import { DEV_LOGIN_COOKIE, WervtUser, parseWervtUser } from "../shared/user.js";
2
- import "./user.js";
3
2
  import { RequestEvent } from "nuxt/server";
4
3
  //#region src/runtime/server/user.d.ts
5
4
  /**
6
5
  * The signed-in wervt user, or `undefined` when nobody is signed in.
7
6
  * Private apps always have one (the runtime checked the login and the allow-list); public
8
7
  * apps get one when the visitor is signed in. Send people to `/_wervt/login` to sign in.
8
+ * In a tool called over MCP, it's the user the client signed in as.
9
9
  */
10
10
  export declare const useWervtUser: (event: RequestEvent) => WervtUser | undefined;
11
11
  //#endregion
@@ -1,11 +1,13 @@
1
1
  import { DEV_LOGIN_COOKIE, devUser, parseWervtUser } from "../shared/user.js";
2
2
  import { getCookie } from "nuxt/server";
3
3
  //#region src/runtime/server/user.ts
4
+ const MCP_USER = "wervtMcpUser";
4
5
  /**
5
6
  * The signed-in wervt user, or `undefined` when nobody is signed in.
6
7
  * Private apps always have one (the runtime checked the login and the allow-list); public
7
8
  * apps get one when the visitor is signed in. Send people to `/_wervt/login` to sign in.
9
+ * In a tool called over MCP, it's the user the client signed in as.
8
10
  */
9
- const useWervtUser = (event) => parseWervtUser(event.req.headers.get("x-wervt-user")) ?? devUser(getCookie(event, "wervt_dev_login") === "1");
11
+ const useWervtUser = (event) => event.context[MCP_USER] ?? parseWervtUser(event.req.headers.get("x-wervt-user")) ?? devUser(getCookie(event, "wervt_dev_login") === "1");
10
12
  //#endregion
11
13
  export { DEV_LOGIN_COOKIE, parseWervtUser, useWervtUser };
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@wervt/nuxt",
3
- "version": "0.2.11",
4
- "description": "Nuxt module for apps hosted on wervt: database, live data and runtime setup.",
3
+ "version": "0.2.13",
4
+ "description": "Nuxt module for apps hosted on wervt: database, live data, MCP and runtime setup.",
5
5
  "keywords": [
6
6
  "drizzle",
7
7
  "electric",
8
+ "mcp",
8
9
  "nuxt",
9
10
  "nuxt-module",
10
11
  "postgres",
@@ -42,19 +43,28 @@
42
43
  "@valibot/to-json-schema": "^1.8.0",
43
44
  "aws4fetch": "^1.0.20",
44
45
  "evlog": "^2.30.1",
46
+ "h3-mcp": "0.2.0",
47
+ "jose": "^6.2.12",
45
48
  "postgres": "^3.4.9",
46
49
  "valibot": "^1.5.0"
47
50
  },
48
51
  "devDependencies": {
49
52
  "drizzle-orm": "1.0.0-rc.4",
53
+ "h3": "2.0.2",
50
54
  "nitro": "3.0.260903-beta",
51
55
  "nuxt": "npm:nuxt-nightly@5x",
52
56
  "vue": "^3.5.43"
53
57
  },
54
58
  "peerDependencies": {
55
59
  "drizzle-orm": "1.0.0-rc.4",
60
+ "h3": "^2.0.1",
56
61
  "nuxt": ">=4.2"
57
62
  },
63
+ "peerDependenciesMeta": {
64
+ "h3": {
65
+ "optional": true
66
+ }
67
+ },
58
68
  "scripts": {
59
69
  "build": "vp pack"
60
70
  }
@@ -1,96 +0,0 @@
1
- String.raw`%(?:2[146-9A-E]|3[0-9ABD]|4[0-9A-F]|5[0-9ABDF]|6[1-9A-F]|7[0-9ACE])`;
2
- const DISALLOWED_STATUS_CHARS = /[^\u0009\u0020-\u007E]/g;
3
- function sanitizeStatusMessage(statusMessage = "") {
4
- return statusMessage.replace(DISALLOWED_STATUS_CHARS, "");
5
- }
6
- function sanitizeStatusCode(statusCode, defaultStatusCode = 200) {
7
- if (!statusCode) return defaultStatusCode;
8
- if (typeof statusCode === "string") statusCode = +statusCode;
9
- if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 599) return defaultStatusCode;
10
- return statusCode;
11
- }
12
- var HTTPError = class HTTPError extends Error {
13
- get name() {
14
- return "HTTPError";
15
- }
16
- status;
17
- statusText;
18
- headers;
19
- cause;
20
- data;
21
- body;
22
- unhandled;
23
- static isError(input) {
24
- return input instanceof Error && input?.name === "HTTPError" && input.status > 99;
25
- }
26
- static status(status, statusText, details) {
27
- return new HTTPError({
28
- ...details,
29
- statusText,
30
- status
31
- });
32
- }
33
- constructor(arg1, arg2) {
34
- let messageInput;
35
- let details;
36
- if (typeof arg1 === "string") {
37
- messageInput = arg1;
38
- details = arg2;
39
- } else details = arg1;
40
- const status = sanitizeStatusCode(details?.status || details?.statusCode || (details?.cause)?.status || (details?.cause)?.statusCode, 500);
41
- const statusText = sanitizeStatusMessage(details?.statusText || details?.statusMessage || (details?.cause)?.statusText || (details?.cause)?.statusMessage);
42
- const message = messageInput || details?.message || (details?.cause)?.message || details?.statusText || details?.statusMessage || [
43
- "HTTPError",
44
- status,
45
- statusText
46
- ].filter(Boolean).join(" ");
47
- super(message, { cause: details });
48
- this.cause = details;
49
- this.status = status;
50
- this.statusText = statusText || void 0;
51
- const rawHeaders = details?.headers || (details?.cause)?.headers;
52
- this.headers = rawHeaders ? new Headers(rawHeaders) : void 0;
53
- this.unhandled = details?.unhandled ?? (details?.cause)?.unhandled ?? void 0;
54
- this.data = details?.data;
55
- this.body = details?.body;
56
- }
57
- get statusCode() {
58
- return this.status;
59
- }
60
- get statusMessage() {
61
- return this.statusText;
62
- }
63
- toJSON() {
64
- const unhandled = this.unhandled;
65
- return {
66
- status: this.status,
67
- statusText: this.statusText,
68
- unhandled,
69
- message: unhandled ? "HTTPError" : this.message,
70
- data: unhandled ? void 0 : this.data,
71
- ...unhandled ? void 0 : this.body
72
- };
73
- }
74
- };
75
- const kHTTPResponse = /* @__PURE__ */ Symbol.for("h3.HTTPResponse");
76
- var HTTPResponse = class {
77
- #headers;
78
- #init;
79
- body;
80
- constructor(body, init) {
81
- this.body = body;
82
- this.#init = init;
83
- }
84
- get status() {
85
- return this.#init?.status;
86
- }
87
- get statusText() {
88
- return this.#init?.statusText;
89
- }
90
- get headers() {
91
- return this.#headers ||= new Headers(this.#init?.headers);
92
- }
93
- };
94
- HTTPResponse.prototype[kHTTPResponse] = true;
95
- //#endregion
96
- export { HTTPError, HTTPResponse, sanitizeStatusCode, sanitizeStatusMessage };