@storyshelf/mcp 0.0.0-stage → 0.7.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.
- package/LICENSE +21 -0
- package/README.md +27 -2
- package/dist/capabilities.js +22 -0
- package/dist/client.js +51 -0
- package/dist/config.js +24 -0
- package/dist/http.js +113 -0
- package/dist/index.js +74 -0
- package/dist/server.js +75 -0
- package/dist/stdio.js +16 -0
- package/dist/tools/builds.js +108 -0
- package/dist/tools/comments.js +59 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/insights.js +35 -0
- package/dist/tools/projects.js +37 -0
- package/dist/tools/snapshots.js +47 -0
- package/dist/tools/types.js +18 -0
- package/package.json +68 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Siddhant Gupta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @storyshelf/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP server for [StoryShelf](https://github.com/GuptaSiddhant/StoryShelf): let AI agents (Claude Code, Cursor, …) read builds, snapshot diffs, capture logs and AI triage, and comment on reviews. Read-only plus comments; approving stays a human decision.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
STORYSHELF_URL=https://shelf.example.com STORYSHELF_TOKEN=... STORYSHELF_SLUG=web npx @storyshelf/mcp # stdio
|
|
9
|
+
STORYSHELF_URL=https://shelf.example.com npx @storyshelf/mcp --http --port 3333 # HTTP at /mcp
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The tools that read snapshots, capture logs or AI insights are advertised only when the instance has `testing` (read from `GET /api/v1/health`).
|
|
13
|
+
|
|
14
|
+
Over HTTP each client sends its own `Authorization: Bearer <token>`, which is forwarded to the StoryShelf API. Or scaffold with `storyshelf mcp init`.
|
|
15
|
+
|
|
16
|
+
## Tools
|
|
17
|
+
|
|
18
|
+
Read tools cover projects, builds, snapshots and AI insights. The write tools are the review comments, which are `review` routes of the API:
|
|
19
|
+
|
|
20
|
+
| Tool | What it does |
|
|
21
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
22
|
+
| `list_comments` | Build comments, story comments and per-snapshot threads of a build. |
|
|
23
|
+
| `add_comment` | Adds a comment to a build, optionally anchored to a story (`storyId`) or a snapshot (`snapshotId`), or a reply. Needs a token with `developer` or above. |
|
|
24
|
+
| `resolve_comment` | Marks a comment resolved. |
|
|
25
|
+
|
|
26
|
+
A project with [review](https://storyshelf.js.org/guides/review/#review-without-screenshots) off has no comments: these tools fail with 404 for it. Build verdicts (`POST …/reviews`) are deliberately not exposed.
|
|
27
|
+
|
|
28
|
+
Docs: https://storyshelf.js.org/ai/mcp/ · Design: `docs/adr/0017-mcp-server.md`
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
//#region src/capabilities.ts
|
|
6
|
+
/**
|
|
7
|
+
* Read the instance's capabilities. An instance that does not say (an older one) or cannot be
|
|
8
|
+
* reached counts as having `testing`, so no tool is hidden on a guess.
|
|
9
|
+
*/
|
|
10
|
+
async function probeCapabilities(client) {
|
|
11
|
+
try {
|
|
12
|
+
return { testing: (await client.get("/health")).testing !== false };
|
|
13
|
+
} catch {
|
|
14
|
+
return { testing: true };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** The tools an instance with these capabilities serves: `testing` tools only when it has `testing`. */
|
|
18
|
+
function toolsFor(tools, capabilities) {
|
|
19
|
+
return tools.filter((tool) => tool.routeClass !== "testing" || capabilities.testing);
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { probeCapabilities, toolsFor };
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { HttpError, httpJson } from "@storyshelf/core/utils";
|
|
6
|
+
//#region src/client.ts
|
|
7
|
+
/** Error whose message is safe to show an agent (no secrets, actionable). */
|
|
8
|
+
var ToolError = class extends Error {
|
|
9
|
+
name = "ToolError";
|
|
10
|
+
};
|
|
11
|
+
/** Build an {@link ApiClient} that sends the configured Bearer token on every call. */
|
|
12
|
+
function createApiClient(config) {
|
|
13
|
+
const headers = config.token ? { authorization: `Bearer ${config.token}` } : {};
|
|
14
|
+
const request = async (method, path, json, query) => {
|
|
15
|
+
try {
|
|
16
|
+
return await httpJson(`${config.url}/api/v1${path}${toQueryString(query)}`, {
|
|
17
|
+
method,
|
|
18
|
+
headers,
|
|
19
|
+
...json === void 0 ? {} : { json }
|
|
20
|
+
});
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw toToolError(error);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
defaultSlug: config.slug,
|
|
27
|
+
get: async (path, query) => await request("GET", path, void 0, query),
|
|
28
|
+
post: async (path, json) => await request("POST", path, json ?? {})
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function toQueryString(query) {
|
|
32
|
+
const params = new URLSearchParams();
|
|
33
|
+
for (const [key, value] of Object.entries(query ?? {})) if (value !== void 0) params.set(key, String(value));
|
|
34
|
+
const text = params.toString();
|
|
35
|
+
return text ? `?${text}` : "";
|
|
36
|
+
}
|
|
37
|
+
const STATUS_HINTS = {
|
|
38
|
+
401: "authentication failed: check STORYSHELF_TOKEN",
|
|
39
|
+
403: "forbidden: this token's role does not allow that (comments need developer or above)",
|
|
40
|
+
404: "not found: check the project slug and build id",
|
|
41
|
+
409: "conflict: AI insights are disabled for this project",
|
|
42
|
+
501: "AI is not configured on this StoryShelf server"
|
|
43
|
+
};
|
|
44
|
+
/** Map transport/HTTP failures to an agent-readable {@link ToolError}. */
|
|
45
|
+
function toToolError(error) {
|
|
46
|
+
if (error instanceof ToolError) return error;
|
|
47
|
+
if (error instanceof HttpError) return new ToolError(`StoryShelf API: ${STATUS_HINTS[error.status] ?? `request failed (${error.status})`}`);
|
|
48
|
+
return new ToolError(`StoryShelf API unreachable: ${error instanceof Error ? error.message : String(error)}`);
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { ToolError, createApiClient, toToolError };
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
//#region src/config.ts
|
|
6
|
+
/**
|
|
7
|
+
* Read the connection from the environment (same variables as the CLI:
|
|
8
|
+
* `STORYSHELF_URL`, `STORYSHELF_TOKEN`/`SHELF_TOKEN`, `STORYSHELF_SLUG`).
|
|
9
|
+
*
|
|
10
|
+
* @throws When no server URL is configured.
|
|
11
|
+
*/
|
|
12
|
+
function resolveConfig(env = process.env, overrides = {}) {
|
|
13
|
+
const url = overrides.url ?? env["STORYSHELF_URL"];
|
|
14
|
+
if (!url) throw new Error("STORYSHELF_URL is required (or pass --url)");
|
|
15
|
+
const token = overrides.token ?? env["STORYSHELF_TOKEN"] ?? env["SHELF_TOKEN"];
|
|
16
|
+
const slug = overrides.slug ?? env["STORYSHELF_SLUG"];
|
|
17
|
+
return {
|
|
18
|
+
url: url.replace(/\/+$/u, ""),
|
|
19
|
+
...token ? { token } : {},
|
|
20
|
+
...slug ? { slug } : {}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { resolveConfig };
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { probeCapabilities } from "./capabilities.js";
|
|
6
|
+
import { createApiClient } from "./client.js";
|
|
7
|
+
import { createStoryShelfMcpServer } from "./server.js";
|
|
8
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { Readable } from "node:stream";
|
|
11
|
+
//#region src/http.ts
|
|
12
|
+
/**
|
|
13
|
+
* Create a stateless Streamable HTTP MCP handler. Each request carries its own
|
|
14
|
+
* `Authorization: Bearer` token, which is forwarded to `/api/v1`, so StoryShelf's
|
|
15
|
+
* project scoping and roles apply per caller and no token is stored here.
|
|
16
|
+
*/
|
|
17
|
+
function createMcpHttpHandler(options) {
|
|
18
|
+
const capabilities = cachedCapabilities(options);
|
|
19
|
+
return async (request) => {
|
|
20
|
+
const rejected = rejectRequest(request, options);
|
|
21
|
+
if (rejected) return rejected;
|
|
22
|
+
const token = bearerToken(request);
|
|
23
|
+
const client = createApiClient({
|
|
24
|
+
url: options.url,
|
|
25
|
+
token,
|
|
26
|
+
...options.slug ? { slug: options.slug } : {}
|
|
27
|
+
});
|
|
28
|
+
const server = createStoryShelfMcpServer(client, await capabilities());
|
|
29
|
+
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
30
|
+
await server.connect(transport);
|
|
31
|
+
try {
|
|
32
|
+
return await transport.handleRequest(request);
|
|
33
|
+
} finally {
|
|
34
|
+
await server.close();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** How long the instance's capabilities are reused (they only change when it restarts). */
|
|
39
|
+
const CAPABILITIES_TTL_MS = 6e4;
|
|
40
|
+
/** The instance's capabilities, probed without credentials (liveness is open) at most once a minute. */
|
|
41
|
+
function cachedCapabilities(options) {
|
|
42
|
+
let cached;
|
|
43
|
+
let inflight;
|
|
44
|
+
return async () => {
|
|
45
|
+
if (cached && Date.now() - cached.at < CAPABILITIES_TTL_MS) return cached.value;
|
|
46
|
+
inflight ??= probeCapabilities(createApiClient({
|
|
47
|
+
url: options.url,
|
|
48
|
+
token: ""
|
|
49
|
+
})).then((value) => {
|
|
50
|
+
cached = {
|
|
51
|
+
at: Date.now(),
|
|
52
|
+
value
|
|
53
|
+
};
|
|
54
|
+
inflight = void 0;
|
|
55
|
+
return value;
|
|
56
|
+
}, (error) => {
|
|
57
|
+
inflight = void 0;
|
|
58
|
+
throw error;
|
|
59
|
+
});
|
|
60
|
+
return await inflight;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Start a Node HTTP server exposing the handler at `/mcp`. */
|
|
64
|
+
async function serveHttp(options) {
|
|
65
|
+
const handler = createMcpHttpHandler(options);
|
|
66
|
+
const server = createServer((incoming, outgoing) => {
|
|
67
|
+
handleNode(handler, incoming, outgoing).catch(() => outgoing.writeHead(500).end());
|
|
68
|
+
});
|
|
69
|
+
await new Promise((resolve) => {
|
|
70
|
+
server.listen(options.port, options.host ?? "127.0.0.1", resolve);
|
|
71
|
+
});
|
|
72
|
+
return server;
|
|
73
|
+
}
|
|
74
|
+
function rejectRequest(request, options) {
|
|
75
|
+
const origin = request.headers.get("origin");
|
|
76
|
+
if (origin && !options.allowedOrigins?.includes(origin)) return jsonError(403, "Origin not allowed");
|
|
77
|
+
if (!bearerToken(request)) return Response.json({ error: "Missing Bearer token" }, {
|
|
78
|
+
status: 401,
|
|
79
|
+
headers: {
|
|
80
|
+
"content-type": "application/json",
|
|
81
|
+
"www-authenticate": "Bearer realm=\"storyshelf\""
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function bearerToken(request) {
|
|
86
|
+
return /^Bearer\s+(?<token>.+)$/iu.exec(request.headers.get("authorization") ?? "")?.groups?.["token"] ?? "";
|
|
87
|
+
}
|
|
88
|
+
function jsonError(status, error) {
|
|
89
|
+
return Response.json({ error }, { status });
|
|
90
|
+
}
|
|
91
|
+
async function handleNode(handler, incoming, outgoing) {
|
|
92
|
+
const url = new URL(incoming.url ?? "/", `http://${incoming.headers.host ?? "localhost"}`);
|
|
93
|
+
if (url.pathname !== "/mcp") {
|
|
94
|
+
outgoing.writeHead(404).end();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const response = await handler(toRequest(incoming, url));
|
|
98
|
+
outgoing.writeHead(response.status, Object.fromEntries(response.headers));
|
|
99
|
+
outgoing.end(Buffer.from(await response.arrayBuffer()));
|
|
100
|
+
}
|
|
101
|
+
function toRequest(incoming, url) {
|
|
102
|
+
const hasBody = incoming.method !== "GET" && incoming.method !== "HEAD";
|
|
103
|
+
return new Request(url, {
|
|
104
|
+
method: incoming.method ?? "GET",
|
|
105
|
+
headers: incoming.headers,
|
|
106
|
+
...hasBody ? {
|
|
107
|
+
body: Readable.toWeb(incoming),
|
|
108
|
+
duplex: "half"
|
|
109
|
+
} : {}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
export { createMcpHttpHandler, serveHttp };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { resolveConfig } from "./config.js";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
//#region src/index.ts
|
|
8
|
+
const HELP = `storyshelf-mcp: StoryShelf MCP server
|
|
9
|
+
|
|
10
|
+
Usage: storyshelf-mcp [--http] [--port 3333] [--host 127.0.0.1] [--url URL] [--slug SLUG] [--token TOKEN] [--origin ORIGIN]
|
|
11
|
+
|
|
12
|
+
Env: STORYSHELF_URL, STORYSHELF_TOKEN (or SHELF_TOKEN), STORYSHELF_SLUG
|
|
13
|
+
Default transport is stdio. With --http, a Streamable HTTP endpoint is served at /mcp
|
|
14
|
+
and each client sends its own "Authorization: Bearer <token>".`;
|
|
15
|
+
function parseCli() {
|
|
16
|
+
return parseArgs({ options: {
|
|
17
|
+
http: { type: "boolean" },
|
|
18
|
+
port: { type: "string" },
|
|
19
|
+
host: { type: "string" },
|
|
20
|
+
url: { type: "string" },
|
|
21
|
+
slug: { type: "string" },
|
|
22
|
+
token: { type: "string" },
|
|
23
|
+
origin: {
|
|
24
|
+
type: "string",
|
|
25
|
+
multiple: true
|
|
26
|
+
},
|
|
27
|
+
help: {
|
|
28
|
+
type: "boolean",
|
|
29
|
+
short: "h"
|
|
30
|
+
}
|
|
31
|
+
} }).values;
|
|
32
|
+
}
|
|
33
|
+
function configFrom(values) {
|
|
34
|
+
return resolveConfig(process.env, {
|
|
35
|
+
...values.url ? { url: values.url } : {},
|
|
36
|
+
...values.slug ? { slug: values.slug } : {},
|
|
37
|
+
...values.token ? { token: values.token } : {}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async function runHttp(values, config) {
|
|
41
|
+
const { serveHttp } = await import("./http.js");
|
|
42
|
+
const port = Number(values.port ?? process.env["PORT"] ?? 3333);
|
|
43
|
+
const host = values.host ?? "127.0.0.1";
|
|
44
|
+
await serveHttp({
|
|
45
|
+
url: config.url,
|
|
46
|
+
...config.slug ? { slug: config.slug } : {},
|
|
47
|
+
...values.origin ? { allowedOrigins: values.origin } : {},
|
|
48
|
+
port,
|
|
49
|
+
host
|
|
50
|
+
});
|
|
51
|
+
process.stderr.write(`storyshelf-mcp listening on http://${host}:${port}/mcp\n`);
|
|
52
|
+
}
|
|
53
|
+
async function main() {
|
|
54
|
+
const values = parseCli();
|
|
55
|
+
if (values.help) {
|
|
56
|
+
process.stdout.write(`${HELP}\n`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const config = configFrom(values);
|
|
60
|
+
if (values.http) {
|
|
61
|
+
await runHttp(values, config);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const { runStdio } = await import("./stdio.js");
|
|
65
|
+
await runStdio(config);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
await main();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export {};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { toolsFor } from "./capabilities.js";
|
|
6
|
+
import { toToolError } from "./client.js";
|
|
7
|
+
import { ALL_TOOLS } from "./tools/index.js";
|
|
8
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
//#region src/server.ts
|
|
11
|
+
/** What a server assumes of an instance that was not asked: nothing is hidden. */
|
|
12
|
+
const ALL_CAPABILITIES = { testing: true };
|
|
13
|
+
/**
|
|
14
|
+
* Create the StoryShelf MCP server over an {@link ApiClient}. Transport-agnostic:
|
|
15
|
+
* connect it to stdio or Streamable HTTP.
|
|
16
|
+
*/
|
|
17
|
+
function createStoryShelfMcpServer(client, capabilities = ALL_CAPABILITIES) {
|
|
18
|
+
const server = new McpServer({
|
|
19
|
+
name: "storyshelf",
|
|
20
|
+
version: "0.7.0"
|
|
21
|
+
}, { instructions: "StoryShelf visual testing for Storybook. Inspect builds, snapshot diffs, capture logs and advisory AI triage; add review comments. Approving or rejecting snapshots is not available: leave that to a human in the StoryShelf UI." });
|
|
22
|
+
registerTools(server, client, capabilities);
|
|
23
|
+
registerPrompts(server, capabilities);
|
|
24
|
+
return server;
|
|
25
|
+
}
|
|
26
|
+
function registerTools(server, client, capabilities) {
|
|
27
|
+
for (const tool of toolsFor(ALL_TOOLS, capabilities)) server.registerTool(tool.name, {
|
|
28
|
+
title: tool.title,
|
|
29
|
+
description: tool.description,
|
|
30
|
+
inputSchema: tool.input,
|
|
31
|
+
annotations: {
|
|
32
|
+
readOnlyHint: tool.readOnly,
|
|
33
|
+
destructiveHint: false,
|
|
34
|
+
openWorldHint: false
|
|
35
|
+
}
|
|
36
|
+
}, async (args) => {
|
|
37
|
+
try {
|
|
38
|
+
const data = await tool.run(client, args);
|
|
39
|
+
return { content: [{
|
|
40
|
+
type: "text",
|
|
41
|
+
text: JSON.stringify(data, null, 2)
|
|
42
|
+
}] };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return {
|
|
45
|
+
isError: true,
|
|
46
|
+
content: [{
|
|
47
|
+
type: "text",
|
|
48
|
+
text: toToolError(error).message
|
|
49
|
+
}]
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** The steps of the review prompt: the capture tools are named only when the instance serves them. */
|
|
55
|
+
function reviewSteps(testing) {
|
|
56
|
+
return testing ? "Call get_build, then list_snapshots, then get_build_insight (if AI is enabled) and get_capture_logs if the build failed. Summarise: what changed, which snapshots look like regressions vs intended changes, and what a human reviewer should check first." : "Call get_build (this instance has no capture, so there are no snapshots, insight or capture logs). Summarise: what the build contains and what a human reviewer should check first.";
|
|
57
|
+
}
|
|
58
|
+
function registerPrompts(server, capabilities) {
|
|
59
|
+
server.registerPrompt("review-build", {
|
|
60
|
+
title: "Review a build",
|
|
61
|
+
description: "Summarise a build's visual changes and recommend what a human should look at.",
|
|
62
|
+
argsSchema: {
|
|
63
|
+
buildId: z.string(),
|
|
64
|
+
project: z.string().optional()
|
|
65
|
+
}
|
|
66
|
+
}, ({ buildId, project }) => ({ messages: [{
|
|
67
|
+
role: "user",
|
|
68
|
+
content: {
|
|
69
|
+
type: "text",
|
|
70
|
+
text: `Review StoryShelf build ${buildId}${project ? ` in project ${project}` : ""}. ${reviewSteps(capabilities.testing)} Do not approve anything; you may add_comment with findings if asked.`
|
|
71
|
+
}
|
|
72
|
+
}] }));
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
export { createStoryShelfMcpServer };
|
package/dist/stdio.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { probeCapabilities } from "./capabilities.js";
|
|
6
|
+
import { createApiClient } from "./client.js";
|
|
7
|
+
import { createStoryShelfMcpServer } from "./server.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
//#region src/stdio.ts
|
|
10
|
+
/** Serve MCP over stdio (local agents: Claude Code, Cursor). Logs go to stderr only. */
|
|
11
|
+
async function runStdio(config) {
|
|
12
|
+
const client = createApiClient(config);
|
|
13
|
+
await createStoryShelfMcpServer(client, await probeCapabilities(client)).connect(new StdioServerTransport());
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { runStdio };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { buildPath, projectSlug } from "./types.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
//#region src/tools/builds.ts
|
|
8
|
+
const project = z.string().optional().describe("Project slug (defaults to STORYSHELF_SLUG)");
|
|
9
|
+
const buildId = z.string().describe("Build id (ULID)");
|
|
10
|
+
const BUILD_FIELDS = [
|
|
11
|
+
"id",
|
|
12
|
+
"gitSha",
|
|
13
|
+
"gitBranch",
|
|
14
|
+
"isDefault",
|
|
15
|
+
"message",
|
|
16
|
+
"authorName",
|
|
17
|
+
"status",
|
|
18
|
+
"snapshotCount",
|
|
19
|
+
"changedCount",
|
|
20
|
+
"approvedCount",
|
|
21
|
+
"rejectedCount",
|
|
22
|
+
"createdAt"
|
|
23
|
+
];
|
|
24
|
+
const listBuilds = {
|
|
25
|
+
name: "list_builds",
|
|
26
|
+
title: "List builds",
|
|
27
|
+
description: "List recent builds of a project, newest first, with change counts. Filter by branch or status.",
|
|
28
|
+
readOnly: true,
|
|
29
|
+
input: {
|
|
30
|
+
project,
|
|
31
|
+
branch: z.string().optional().describe("Only builds on this git branch"),
|
|
32
|
+
status: z.enum([
|
|
33
|
+
"pending",
|
|
34
|
+
"capturing",
|
|
35
|
+
"comparing",
|
|
36
|
+
"reviewing",
|
|
37
|
+
"approved",
|
|
38
|
+
"rejected",
|
|
39
|
+
"failed",
|
|
40
|
+
"cancelled"
|
|
41
|
+
]).optional(),
|
|
42
|
+
limit: z.number().int().min(1).max(100).default(20).describe("Max builds to return")
|
|
43
|
+
},
|
|
44
|
+
run: async (client, args) => {
|
|
45
|
+
const builds = await client.get(`/projects/${projectSlug(client, args["project"])}/builds`, {
|
|
46
|
+
branch: args["branch"],
|
|
47
|
+
status: args["status"]
|
|
48
|
+
});
|
|
49
|
+
const limit = Number(args["limit"] ?? 20);
|
|
50
|
+
return builds.slice(0, limit).map((b) => pickFields(b, BUILD_FIELDS));
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const getBuild = {
|
|
54
|
+
name: "get_build",
|
|
55
|
+
title: "Get build",
|
|
56
|
+
description: "Get one build: status, git info, snapshot/changed/approved/rejected counts, baseline.",
|
|
57
|
+
readOnly: true,
|
|
58
|
+
input: {
|
|
59
|
+
project,
|
|
60
|
+
buildId
|
|
61
|
+
},
|
|
62
|
+
run: async (client, args) => await client.get(buildPath(client, args))
|
|
63
|
+
};
|
|
64
|
+
const getCaptureLogs = {
|
|
65
|
+
name: "get_capture_logs",
|
|
66
|
+
title: "Get capture logs",
|
|
67
|
+
description: "Get the capture attempts of a build and the log lines of one attempt (latest by default). Use it to debug failed or flaky captures.",
|
|
68
|
+
readOnly: true,
|
|
69
|
+
routeClass: "testing",
|
|
70
|
+
input: {
|
|
71
|
+
project,
|
|
72
|
+
buildId,
|
|
73
|
+
attemptNo: z.number().int().min(1).optional().describe("Attempt number (default: latest)"),
|
|
74
|
+
tail: z.number().int().min(1).max(500).default(100).describe("Last N log lines")
|
|
75
|
+
},
|
|
76
|
+
run: async (client, args) => {
|
|
77
|
+
const base = buildPath(client, args);
|
|
78
|
+
const attempts = await client.get(`${base}/attempts`);
|
|
79
|
+
const latest = attempts.at(-1)?.attemptNo;
|
|
80
|
+
const attemptNo = args["attemptNo"] ?? latest;
|
|
81
|
+
if (attemptNo === void 0) return {
|
|
82
|
+
attempts,
|
|
83
|
+
logs: []
|
|
84
|
+
};
|
|
85
|
+
const logs = await client.get(`${base}/attempts/${attemptNo}/logs`);
|
|
86
|
+
const tail = Number(args["tail"] ?? 100);
|
|
87
|
+
return {
|
|
88
|
+
attempts,
|
|
89
|
+
attemptNo,
|
|
90
|
+
logs: logs.slice(-tail).map((l) => pickFields(l, [
|
|
91
|
+
"seq",
|
|
92
|
+
"level",
|
|
93
|
+
"message",
|
|
94
|
+
"fields"
|
|
95
|
+
]))
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
function pickFields(source, keys) {
|
|
100
|
+
return Object.fromEntries(keys.map((key) => [key, source[key]]));
|
|
101
|
+
}
|
|
102
|
+
const buildTools = [
|
|
103
|
+
listBuilds,
|
|
104
|
+
getBuild,
|
|
105
|
+
getCaptureLogs
|
|
106
|
+
];
|
|
107
|
+
//#endregion
|
|
108
|
+
export { buildTools, getBuild, getCaptureLogs, listBuilds };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { buildPath } from "./types.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
//#region src/tools/comments.ts
|
|
8
|
+
const project = z.string().optional().describe("Project slug (defaults to STORYSHELF_SLUG)");
|
|
9
|
+
const buildId = z.string().describe("Build id (ULID)");
|
|
10
|
+
const listComments = {
|
|
11
|
+
name: "list_comments",
|
|
12
|
+
title: "List comments",
|
|
13
|
+
description: "List review comments on a build: build comments, story comments and per-snapshot threads. A project with review off has none (the call fails with 404).",
|
|
14
|
+
readOnly: true,
|
|
15
|
+
input: {
|
|
16
|
+
project,
|
|
17
|
+
buildId
|
|
18
|
+
},
|
|
19
|
+
run: async (client, args) => await client.get(`${buildPath(client, args)}/comments`)
|
|
20
|
+
};
|
|
21
|
+
const addComment = {
|
|
22
|
+
name: "add_comment",
|
|
23
|
+
title: "Add comment",
|
|
24
|
+
description: "Add a review comment to a build, optionally on one story or one snapshot (not both) or as a reply. Needs a token with developer role or above. Visible to everyone on the project. There is no tool to approve or reject a build: a verdict is for people. Fails with 404 when the project has review off.",
|
|
25
|
+
readOnly: false,
|
|
26
|
+
input: {
|
|
27
|
+
project,
|
|
28
|
+
buildId,
|
|
29
|
+
body: z.string().min(1).max(4e3),
|
|
30
|
+
storyId: z.string().optional().describe("Attach to this story id (not with snapshotId)"),
|
|
31
|
+
snapshotId: z.string().optional().describe("Attach to this snapshot (not with storyId)"),
|
|
32
|
+
parentId: z.string().optional().describe("Reply to this comment id")
|
|
33
|
+
},
|
|
34
|
+
run: async (client, args) => await client.post(`${buildPath(client, args)}/comments`, {
|
|
35
|
+
body: args["body"],
|
|
36
|
+
...args["storyId"] ? { storyId: args["storyId"] } : {},
|
|
37
|
+
...args["snapshotId"] ? { snapshotId: args["snapshotId"] } : {},
|
|
38
|
+
...args["parentId"] ? { parentId: args["parentId"] } : {}
|
|
39
|
+
})
|
|
40
|
+
};
|
|
41
|
+
const resolveComment = {
|
|
42
|
+
name: "resolve_comment",
|
|
43
|
+
title: "Resolve comment",
|
|
44
|
+
description: "Mark a review comment as resolved. Needs developer role or above.",
|
|
45
|
+
readOnly: false,
|
|
46
|
+
input: {
|
|
47
|
+
project,
|
|
48
|
+
buildId,
|
|
49
|
+
commentId: z.string()
|
|
50
|
+
},
|
|
51
|
+
run: async (client, args) => await client.post(`${buildPath(client, args)}/comments/${encodeURIComponent(String(args["commentId"]))}/resolve`)
|
|
52
|
+
};
|
|
53
|
+
const commentTools = [
|
|
54
|
+
listComments,
|
|
55
|
+
addComment,
|
|
56
|
+
resolveComment
|
|
57
|
+
];
|
|
58
|
+
//#endregion
|
|
59
|
+
export { addComment, commentTools, listComments, resolveComment };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { buildTools } from "./builds.js";
|
|
6
|
+
import { commentTools } from "./comments.js";
|
|
7
|
+
import { insightTools } from "./insights.js";
|
|
8
|
+
import { projectTools } from "./projects.js";
|
|
9
|
+
import { snapshotTools } from "./snapshots.js";
|
|
10
|
+
//#region src/tools/index.ts
|
|
11
|
+
/**
|
|
12
|
+
* Every tool exposed in v1: read-only review data plus comments. No approvals and no build
|
|
13
|
+
* verdicts: an agent must not be able to sign off a build (ADR 0013, decision 10).
|
|
14
|
+
*/
|
|
15
|
+
const ALL_TOOLS = [
|
|
16
|
+
...projectTools,
|
|
17
|
+
...buildTools,
|
|
18
|
+
...snapshotTools,
|
|
19
|
+
...insightTools,
|
|
20
|
+
...commentTools
|
|
21
|
+
];
|
|
22
|
+
//#endregion
|
|
23
|
+
export { ALL_TOOLS };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { buildPath, projectSlug } from "./types.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
//#region src/tools/insights.ts
|
|
8
|
+
const project = z.string().optional().describe("Project slug (defaults to STORYSHELF_SLUG)");
|
|
9
|
+
const getBuildInsight = {
|
|
10
|
+
name: "get_build_insight",
|
|
11
|
+
title: "Get build AI triage",
|
|
12
|
+
description: "Get the latest cached AI triage of a build: verdict (likely-intended, needs-review, likely-regression), summary and per-snapshot notes. Advisory only. Read-only: it never triggers generation (an approver does that in the UI).",
|
|
13
|
+
readOnly: true,
|
|
14
|
+
routeClass: "testing",
|
|
15
|
+
input: {
|
|
16
|
+
project,
|
|
17
|
+
buildId: z.string().describe("Build id (ULID)")
|
|
18
|
+
},
|
|
19
|
+
run: async (client, args) => await client.get(`${buildPath(client, args)}/insights/latest`)
|
|
20
|
+
};
|
|
21
|
+
const getProjectHealth = {
|
|
22
|
+
name: "get_project_health",
|
|
23
|
+
title: "Get project health",
|
|
24
|
+
description: "Get the latest cached AI project-health digest (verdict, 0-100 score, trends) for a project. Read-only.",
|
|
25
|
+
readOnly: true,
|
|
26
|
+
routeClass: "testing",
|
|
27
|
+
input: {
|
|
28
|
+
project,
|
|
29
|
+
window: z.string().optional().describe("Window, e.g. 30d")
|
|
30
|
+
},
|
|
31
|
+
run: async (client, args) => await client.get(`/projects/${projectSlug(client, args["project"])}/insights/health`, { window: args["window"] })
|
|
32
|
+
};
|
|
33
|
+
const insightTools = [getBuildInsight, getProjectHealth];
|
|
34
|
+
//#endregion
|
|
35
|
+
export { getBuildInsight, getProjectHealth, insightTools };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { projectSlug } from "./types.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
//#region src/tools/projects.ts
|
|
8
|
+
const project = z.string().optional().describe("Project slug (defaults to STORYSHELF_SLUG)");
|
|
9
|
+
const listProjects = {
|
|
10
|
+
name: "list_projects",
|
|
11
|
+
title: "List projects",
|
|
12
|
+
description: "List StoryShelf projects (one per Storybook). Needs a site-admin token; with a project token use get_project instead.",
|
|
13
|
+
readOnly: true,
|
|
14
|
+
input: {},
|
|
15
|
+
run: async (client) => {
|
|
16
|
+
return (await client.get("/projects")).map((p) => pick(p, [
|
|
17
|
+
"name",
|
|
18
|
+
"slug",
|
|
19
|
+
"gitRepository",
|
|
20
|
+
"gitDefaultBranch"
|
|
21
|
+
]));
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const getProject = {
|
|
25
|
+
name: "get_project",
|
|
26
|
+
title: "Get project",
|
|
27
|
+
description: "Get a project's settings: default branch, diff thresholds, browser, play-function setting.",
|
|
28
|
+
readOnly: true,
|
|
29
|
+
input: { project },
|
|
30
|
+
run: async (client, args) => await client.get(`/projects/${projectSlug(client, args["project"])}`)
|
|
31
|
+
};
|
|
32
|
+
function pick(source, keys) {
|
|
33
|
+
return Object.fromEntries(keys.map((key) => [key, source[key]]));
|
|
34
|
+
}
|
|
35
|
+
const projectTools = [listProjects, getProject];
|
|
36
|
+
//#endregion
|
|
37
|
+
export { getProject, listProjects, projectTools };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { buildPath } from "./types.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
//#region src/tools/snapshots.ts
|
|
8
|
+
const SNAPSHOT_FIELDS = [
|
|
9
|
+
"id",
|
|
10
|
+
"storyId",
|
|
11
|
+
"storyTitle",
|
|
12
|
+
"storyName",
|
|
13
|
+
"viewportName",
|
|
14
|
+
"mode",
|
|
15
|
+
"status",
|
|
16
|
+
"diffPixels",
|
|
17
|
+
"diffRatio",
|
|
18
|
+
"diffPassed",
|
|
19
|
+
"inherited",
|
|
20
|
+
"reviewedBy"
|
|
21
|
+
];
|
|
22
|
+
const listSnapshots = {
|
|
23
|
+
name: "list_snapshots",
|
|
24
|
+
title: "List snapshots",
|
|
25
|
+
description: "List the story snapshots of a build with diff metrics (pixels, ratio, pass/fail) and review status. By default only changed/new/pending/rejected snapshots are returned; set includeUnchanged for all. Approving is intentionally not available: a human approves in the StoryShelf UI.",
|
|
26
|
+
readOnly: true,
|
|
27
|
+
routeClass: "testing",
|
|
28
|
+
input: {
|
|
29
|
+
project: z.string().optional().describe("Project slug (defaults to STORYSHELF_SLUG)"),
|
|
30
|
+
buildId: z.string().describe("Build id (ULID)"),
|
|
31
|
+
includeUnchanged: z.boolean().default(false),
|
|
32
|
+
limit: z.number().int().min(1).max(500).default(100)
|
|
33
|
+
},
|
|
34
|
+
run: async (client, args) => {
|
|
35
|
+
const all = await client.get(`${buildPath(client, args)}/snapshots`);
|
|
36
|
+
const wanted = args["includeUnchanged"] ? all : all.filter((s) => s["status"] !== "unchanged" && s["status"] !== "approved");
|
|
37
|
+
const limit = Number(args["limit"] ?? 100);
|
|
38
|
+
return {
|
|
39
|
+
total: all.length,
|
|
40
|
+
returned: Math.min(wanted.length, limit),
|
|
41
|
+
snapshots: wanted.slice(0, limit).map((s) => Object.fromEntries(SNAPSHOT_FIELDS.map((key) => [key, s[key]])))
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const snapshotTools = [listSnapshots];
|
|
46
|
+
//#endregion
|
|
47
|
+
export { listSnapshots, snapshotTools };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "node:path";
|
|
3
|
+
import "node:url";
|
|
4
|
+
import.meta.url;
|
|
5
|
+
import { ToolError } from "../client.js";
|
|
6
|
+
//#region src/tools/types.ts
|
|
7
|
+
/** Resolve the project slug from the call argument or the configured default. */
|
|
8
|
+
function projectSlug(client, project) {
|
|
9
|
+
const slug = typeof project === "string" && project ? project : client.defaultSlug;
|
|
10
|
+
if (!slug) throw new ToolError("No project given: pass `project` or set STORYSHELF_SLUG");
|
|
11
|
+
return encodeURIComponent(slug);
|
|
12
|
+
}
|
|
13
|
+
/** Build the `/projects/{slug}/builds/{id}` path prefix. */
|
|
14
|
+
function buildPath(client, args) {
|
|
15
|
+
return `/projects/${projectSlug(client, args["project"])}/builds/${encodeURIComponent(String(args["buildId"]))}`;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { buildPath, projectSlug };
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@storyshelf/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "StoryShelf MCP server: let AI agents read builds, diffs and AI insights, and comment on reviews.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agents",
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"self-hosted",
|
|
10
|
+
"storybook",
|
|
11
|
+
"visual-testing"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/GuptaSiddhant/StoryShelf#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/GuptaSiddhant/StoryShelf/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Siddhant Gupta",
|
|
20
|
+
"url": "https://guptasiddhant.com"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/GuptaSiddhant/StoryShelf.git",
|
|
25
|
+
"directory": "packages/mcp"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"storyshelf-mcp": "dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"type": "module",
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"exports": {
|
|
36
|
+
".": "./dist/index.js",
|
|
37
|
+
"./http": "./dist/http.js",
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsdown",
|
|
45
|
+
"dev": "tsdown -w",
|
|
46
|
+
"fmt": "oxfmt ./src",
|
|
47
|
+
"lint": "oxlint --type-aware --type-check ./src",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"prepublishOnly": "nub run build"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.20.0",
|
|
53
|
+
"@storyshelf/core": "0.7.0",
|
|
54
|
+
"zod": "^4.2.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^26.0.0",
|
|
58
|
+
"@vitest/coverage-v8": "^4.0.0",
|
|
59
|
+
"oxfmt": "^0.65.0",
|
|
60
|
+
"oxlint": "^1.80.0",
|
|
61
|
+
"oxlint-tsgolint": "^7.0.2001",
|
|
62
|
+
"tsdown": "^0.22.14",
|
|
63
|
+
"typescript": "^7.0.2",
|
|
64
|
+
"vitest": "^5.0.0"
|
|
65
|
+
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=24"
|
|
68
|
+
},
|
|
69
|
+
"jsr": false
|
|
70
|
+
}
|