@wervt/nuxt 0.2.12 → 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 +2 -1
- package/dist/module.d.ts +14 -0
- package/dist/module.js +46 -8
- package/dist/node_modules/.pnpm/nitro@3.0.260903-beta/node_modules/nitro/dist/runtime/internal/task.js +1 -1
- package/dist/runtime/server/index.d.ts +2 -1
- package/dist/runtime/server/mcp-auth-server.d.ts +4 -0
- package/dist/runtime/server/mcp-auth-server.js +21 -0
- package/dist/runtime/server/mcp-auth.d.ts +59 -0
- package/dist/runtime/server/mcp-auth.js +98 -0
- package/dist/runtime/server/mcp-claims.d.ts +5 -0
- package/dist/runtime/server/mcp-claims.js +18 -0
- package/dist/runtime/server/mcp-metadata.d.ts +4 -0
- package/dist/runtime/server/mcp-metadata.js +14 -0
- package/dist/runtime/server/mcp.d.ts +4 -0
- package/dist/runtime/server/mcp.js +112 -0
- package/dist/runtime/server/task-run-nitro2.d.ts +1 -1
- package/dist/runtime/server/tool-handlers.d.ts +7 -0
- package/dist/runtime/server/tool-handlers.js +7 -7
- package/dist/runtime/server/user.d.ts +1 -1
- package/dist/runtime/server/user.js +3 -1
- package/package.json +12 -2
- package/dist/node_modules/.pnpm/h3@2.0.2_crossws@0.4.15_srvx@1.0.5__ocache@0.3.0/node_modules/h3/dist/response.js +0 -96
package/README.md
CHANGED
|
@@ -20,7 +20,8 @@ export default defineNuxtConfig({
|
|
|
20
20
|
- `useWervtUser()`: the signed-in wervt user, on the server and in the app.
|
|
21
21
|
- `useValidatedBody()` / `useValidatedQuery()` / `useValidatedParams()`: valibot validation.
|
|
22
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
|
|
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.
|
|
24
25
|
- `nuxt dev` starts against the local wervt stack (Postgres, Electric) and a dev user.
|
|
25
26
|
|
|
26
27
|
Deploy with [`@wervt/cli`](https://www.npmjs.com/package/@wervt/cli): `wervt deploy`.
|
package/dist/module.d.ts
CHANGED
|
@@ -18,6 +18,12 @@ 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;
|
|
21
27
|
/**
|
|
22
28
|
* Reports client-side navigations and `useWervtTrack()` events to wervt's usage analytics, so
|
|
23
29
|
* page views count more than the first load. Defaults to true. Cookieless; page loads are
|
|
@@ -25,6 +31,14 @@ export interface ModuleOptions {
|
|
|
25
31
|
*/
|
|
26
32
|
analytics?: boolean;
|
|
27
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;
|
|
41
|
+
}
|
|
28
42
|
/** `server/wervt-tasks.json` in the build: the app's tasks and schedules, for the control plane. */
|
|
29
43
|
export interface TasksManifest {
|
|
30
44
|
tasks: {
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 (
|
|
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
|
});
|
|
@@ -288,6 +321,11 @@ async function findTools(dir) {
|
|
|
288
321
|
return tools;
|
|
289
322
|
}
|
|
290
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
|
+
}
|
|
291
329
|
/** Whether `name` is in a `node_modules` folder Node would look in from `dir`. */
|
|
292
330
|
function hasPackage(dir, name) {
|
|
293
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 = {} } = {}) {
|
|
@@ -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,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,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,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
|
|
7
|
+
async function loadTool(name) {
|
|
8
8
|
return (await (Object.hasOwn(tools, name) ? tools[name] : void 0)?.())?.default;
|
|
9
9
|
}
|
|
10
|
-
function
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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 };
|