dungbeetle-mcp 0.1.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 ADDED
@@ -0,0 +1,110 @@
1
+ Business Source License 1.1
2
+
3
+ License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
4
+ "Business Source License" is a trademark of MariaDB Corporation Ab.
5
+
6
+ -----------------------------------------------------------------------------
7
+
8
+ Parameters
9
+
10
+ Licensor: DungbeetleDev
11
+
12
+ Licensed Work: Dungbeetle Server
13
+ The Licensed Work is the contents of the Dungbeetle Cloud
14
+ repository
15
+ (https://github.com/DungbeetleTech/cloud) — the
16
+ self-hostable cloud ingest, storage, review, and
17
+ analytics service. The Licensed Work is
18
+ (c) 2026 DungbeetleDev.
19
+
20
+ Additional Use Grant: You may make production use of the Licensed Work,
21
+ provided that you do not offer the Licensed Work to
22
+ third parties as a hosted or managed service whose value
23
+ derives substantially from the functionality of the
24
+ Licensed Work, where such a service competes with any
25
+ hosted or managed Dungbeetle offering provided by the
26
+ Licensor.
27
+
28
+ For clarity, internal use of the Licensed Work — for
29
+ example, running your own self-hosted Dungbeetle Server for
30
+ your own team, company, or projects — is permitted
31
+ production use under this grant.
32
+
33
+ Change Date: 2030-06-28
34
+
35
+ Change License: Apache License, Version 2.0
36
+
37
+ For information about alternative licensing arrangements for the Licensed Work,
38
+ please contact the Licensor (see SECURITY.md / the repository for contact
39
+ details).
40
+
41
+ -----------------------------------------------------------------------------
42
+
43
+ Notice
44
+
45
+ Business Source License 1.1
46
+
47
+ Terms
48
+
49
+ The Licensor hereby grants you the right to copy, modify, create derivative
50
+ works, redistribute, and make non-production use of the Licensed Work. The
51
+ Licensor may make an Additional Use Grant, above, permitting limited production
52
+ use.
53
+
54
+ Effective on the Change Date, or the fourth anniversary of the first publicly
55
+ available distribution of a specific version of the Licensed Work under this
56
+ License, whichever comes first, the Licensor hereby grants you rights under the
57
+ terms of the Change License, and the rights granted in the paragraph above
58
+ terminate.
59
+
60
+ If your use of the Licensed Work does not comply with the requirements currently
61
+ in effect as described in this License, you must purchase a commercial license
62
+ from the Licensor, its affiliated entities, or authorized resellers, or you must
63
+ refrain from using the Licensed Work.
64
+
65
+ All copies of the original and modified Licensed Work, and derivative works of
66
+ the Licensed Work, are subject to this License. This License applies separately
67
+ for each version of the Licensed Work and the Change Date may vary for each
68
+ version of the Licensed Work released by Licensor.
69
+
70
+ You must conspicuously display this License on each original or modified copy of
71
+ the Licensed Work. If you receive the Licensed Work in original or modified form
72
+ from a third party, the terms and conditions set forth in this License apply to
73
+ your use of that work.
74
+
75
+ Any use of the Licensed Work in violation of this License will automatically
76
+ terminate your rights under this License for the current and all other versions
77
+ of the Licensed Work.
78
+
79
+ This License does not grant you any right in any trademark or logo of Licensor or
80
+ its affiliates (provided that you may use a trademark or logo of Licensor to the
81
+ extent expressly required to comply with the notice requirements above).
82
+
83
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
84
+ "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS OR
85
+ IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY, FITNESS FOR
86
+ A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE.
87
+
88
+ MariaDB hereby grants you permission to use this License's text to license your
89
+ works, and to refer to it using the trademark "Business Source License", as long
90
+ as you comply with the Covenants of Licensor below.
91
+
92
+ Covenants of Licensor
93
+
94
+ In consideration of the right to use this License's text and the "Business Source
95
+ License" name and trademark, Licensor covenants to MariaDB, and to all other
96
+ recipients of the licensed work to be provided by Licensor:
97
+
98
+ 1. To specify as the Change License the GPL Version 2.0 or any later version, or
99
+ a license that is compatible with GPL Version 2.0 or a later version, where
100
+ "compatible" means that software provided under the Change License can be
101
+ included in a program with software provided under GPL Version 2.0 or a later
102
+ version. Licensor may specify additional Change Licenses without limitation.
103
+
104
+ 2. To either: (a) specify an additional grant of rights to use that does not
105
+ impose any additional restriction on the right granted in this License, as the
106
+ Additional Use Grant; or (b) insert the text "None".
107
+
108
+ 3. To specify a Change Date.
109
+
110
+ 4. Not to modify this License in any other way.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # dungbeetle-mcp
2
+
3
+ The MCP server for [Dungbeetle](https://dungbeetle.dev) — the snapshot and
4
+ visual regression testing tool built for AI agents and the humans they work
5
+ for. It lets a coding agent (Claude Code, Cursor, Codex, Gemini CLI, or any
6
+ MCP client) list runs, read **low-token semantic diffs**, check usage, and
7
+ record reviews — while **promoting a baseline stays a human decision** unless
8
+ a human explicitly grants the `baselines:write` scope.
9
+
10
+ ## Setup in 60 seconds
11
+
12
+ 1. Get an agent token (a human approves a one-time code in the browser — no
13
+ secret is ever copy-pasted):
14
+
15
+ ```sh
16
+ npx dungbeetle login --label "My agent"
17
+ ```
18
+
19
+ 2. Add the server to your MCP client:
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "dungbeetle": {
25
+ "command": "npx",
26
+ "args": ["-y", "dungbeetle-mcp"],
27
+ "env": { "DUNGBEETLE_AGENT_TOKEN": "dbat_…" }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ Claude Code one-liner:
34
+ `claude mcp add dungbeetle --env DUNGBEETLE_AGENT_TOKEN=… -- npx -y dungbeetle-mcp`
35
+
36
+ Per-client guides: [Claude Code](https://dungbeetle.dev/mcp/claude-code) ·
37
+ [Cursor](https://dungbeetle.dev/mcp/cursor) ·
38
+ [Codex](https://dungbeetle.dev/mcp/codex) ·
39
+ [Gemini CLI](https://dungbeetle.dev/mcp/gemini-cli)
40
+
41
+ ## Environment
42
+
43
+ | Variable | Meaning |
44
+ | --- | --- |
45
+ | `DUNGBEETLE_AGENT_TOKEN` | Scoped, revocable agent token from `dungbeetle login` (recommended) |
46
+ | `DUNGBEETLE_CLIENT_ID` / `DUNGBEETLE_CLIENT_SECRET` | Repository client credentials (CI-style alternative) |
47
+ | `DUNGBEETLE_SERVER_URL` | Only when self-hosting — defaults to `https://dungbeetle.dev` |
48
+
49
+ stdout is the JSON-RPC channel; diagnostics go to stderr. Missing credentials
50
+ exit non-zero with guidance.
51
+
52
+ ## Why the auth model is different
53
+
54
+ Agent tokens are minted via an RFC 8628 device flow and scoped: the default
55
+ **Triage** preset reads runs/baselines/analytics and writes reviews, but
56
+ promoting a baseline requires the separate `baselines:write` scope — so an
57
+ agent can triage its own visual regressions without ever being able to
58
+ approve them. Reviews are attributed as
59
+ `"<label> (agent, on behalf of <owner>)"`, and tokens are revocable from the
60
+ dashboard at any time. Details: <https://dungbeetle.dev/mcp/auth>
61
+
62
+ ## Machine-readable docs
63
+
64
+ Full docs index for agents: <https://dungbeetle.dev/llms.txt> — every docs
65
+ page also serves raw markdown at its URL + `.md`. See
66
+ <https://dungbeetle.dev/docs-for-agents>.
67
+
68
+ ## License
69
+
70
+ Source-available under the Business Source License 1.1 (converts to
71
+ Apache-2.0). The Dungbeetle CLI (`dungbeetle` on npm) is a separate,
72
+ FSL-licensed package.
package/dist/brand.js ADDED
@@ -0,0 +1,117 @@
1
+ // Brand assets, served inline so the server stays a single self-contained
2
+ // process with no static-file build step (consistent with the hand-rendered
3
+ // views). The same SVG sources live in server/docs/public for the site and README.
4
+ //
5
+ // Raster exports (og-card.jpg, apple-touch-icon.png, manifest icons) are
6
+ // generated from these SVGs by `npm run brand:rasters` and committed as
7
+ // src/brandRasters.ts (base64, served by routes in app.ts) plus raw files in
8
+ // docs/public. Rerun that script after editing any SVG below.
9
+ const GREEN_DARK = "#137a43";
10
+ // Display name, overridable via env so the app can be white-labelled when
11
+ // self-hosted without editing source. Structural identifiers (package/bin names,
12
+ // the Dungbeetle-Api-Version wire header) are intentionally NOT driven by this — they
13
+ // stay fixed.
14
+ export const BRAND_NAME = process.env.BRAND_NAME?.trim() || "Dungbeetle";
15
+ export const BRAND_SHORT_NAME = process.env.BRAND_SHORT_NAME?.trim() || BRAND_NAME;
16
+ // Shared brand copy — one source so every heading, tagline, and meta tag stays
17
+ // in sync. Docs/README mirror these strings (they build separately and can't
18
+ // import this module).
19
+ export const BRAND_SLOGAN = "Test anything, anywhere, instantly.";
20
+ export const BRAND_SUBTITLE = "The regression safety net for AI agents and the humans they work for — web, desktop, terminal, anything. Zero adoption cost, runs anywhere.";
21
+ // The canonical entity description — the one-line answer to "what is
22
+ // Dungbeetle", used verbatim across metadata surfaces (meta/OG descriptions,
23
+ // docs site description; mirrored in the npm package and registry listings) so
24
+ // search and AI engines ground the entity consistently. Deliberately
25
+ // keyword-bearing ("snapshot", "visual regression testing") where
26
+ // BRAND_SUBTITLE is the on-page brand voice for humans. Keep the two distinct:
27
+ // this line is for machines and SERP snippets, the subtitle is the hero.
28
+ export const BRAND_DESCRIPTION = `${BRAND_NAME} is a snapshot and visual regression testing tool — a free CLI plus self-hostable cloud — built for AI agents and the humans they work for.`;
29
+ // Escape XML-significant chars so a brand name containing & < > " stays valid
30
+ // inside the inline SVG/aria strings below. The manifest uses JSON.stringify,
31
+ // which escapes on its own. ponytail: covers an operator setting an odd name.
32
+ const xe = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
33
+ // Square app mark: a dung beetle pushing its ball (the snapshot it rolls
34
+ // everywhere) on a green tile. Used as the favicon and the header
35
+ // glyph. viewBox 0 0 64 64 so it scales cleanly to any icon size.
36
+ export const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="${xe(BRAND_NAME)}">
37
+ <defs>
38
+ <linearGradient id="ck-bg" x1="0" y1="0" x2="0" y2="1">
39
+ <stop offset="0" stop-color="#1ea65c"/>
40
+ <stop offset="1" stop-color="${GREEN_DARK}"/>
41
+ </linearGradient>
42
+ </defs>
43
+ <rect width="64" height="64" rx="15" fill="url(#ck-bg)"/>
44
+ <g fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
45
+ <circle cx="25" cy="33" r="11"/>
46
+ <path d="M38 46a6.5 6.5 0 0 1 13 0"/>
47
+ <path d="M12 48h40"/>
48
+ </g>
49
+ <circle cx="35.6" cy="44" r="2.6" fill="#ffffff"/>
50
+ </svg>`;
51
+ // Standalone mark without the tile background, for inlining next to text where
52
+ // the surrounding element already provides colour/contrast.
53
+ export const MARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="${xe(BRAND_NAME)}" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
54
+ <circle cx="25" cy="33" r="11"/>
55
+ <path d="M38 46a6.5 6.5 0 0 1 13 0"/>
56
+ <path d="M12 48h40"/>
57
+ <circle cx="35.6" cy="44" r="2.6" fill="currentColor" stroke="none"/>
58
+ </svg>`;
59
+ // Horizontal lockup: tile mark + wordmark. Dark text suits light backgrounds
60
+ // (README, docs); the app header inlines the mark with CSS-coloured text so it
61
+ // adapts to light/dark themes.
62
+ export const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 290 64" width="290" height="64" role="img" aria-label="${xe(BRAND_NAME)}">
63
+ <defs>
64
+ <linearGradient id="ck-logo-bg" x1="0" y1="0" x2="0" y2="1">
65
+ <stop offset="0" stop-color="#1ea65c"/>
66
+ <stop offset="1" stop-color="${GREEN_DARK}"/>
67
+ </linearGradient>
68
+ </defs>
69
+ <rect width="64" height="64" rx="15" fill="url(#ck-logo-bg)"/>
70
+ <g fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
71
+ <circle cx="25" cy="33" r="11"/>
72
+ <path d="M38 46a6.5 6.5 0 0 1 13 0"/>
73
+ <path d="M12 48h40"/>
74
+ </g>
75
+ <circle cx="35.6" cy="44" r="2.6" fill="#ffffff"/>
76
+ <text x="80" y="42" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif" font-size="30" font-weight="700" fill="#0f2e1c">${xe(BRAND_NAME)}</text>
77
+ </svg>`;
78
+ // OpenGraph / Twitter card (1200x630). SVG is fine for the docs/app reference;
79
+ // for maximal scraper support, export a PNG at release (brand/README.md).
80
+ export const OG_CARD_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img" aria-label="${xe(BRAND_NAME)} — snapshot testing">
81
+ <defs>
82
+ <linearGradient id="ck-og" x1="0" y1="0" x2="1" y2="1">
83
+ <stop offset="0" stop-color="#0f2e1c"/>
84
+ <stop offset="1" stop-color="${GREEN_DARK}"/>
85
+ </linearGradient>
86
+ </defs>
87
+ <rect width="1200" height="630" fill="url(#ck-og)"/>
88
+ <g transform="translate(96 150)">
89
+ <rect width="120" height="120" rx="28" fill="#ffffff"/>
90
+ <g transform="translate(28 28) scale(1.0)" fill="none" stroke="${GREEN_DARK}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
91
+ <circle cx="25" cy="33" r="11"/>
92
+ <path d="M38 46a6.5 6.5 0 0 1 13 0"/>
93
+ <path d="M12 48h40"/>
94
+ <circle cx="35.6" cy="44" r="2.6" fill="${GREEN_DARK}" stroke="none"/>
95
+ </g>
96
+ </g>
97
+ <text x="100" y="360" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif" font-size="92" font-weight="800" fill="#ffffff">${xe(BRAND_NAME)}</text>
98
+ <text x="100" y="430" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif" font-size="38" font-weight="400" fill="#cdeedd">${xe(BRAND_SLOGAN)}</text>
99
+ </svg>`;
100
+ // PWA manifest. The SVG icon covers modern browsers; the generated PNG sizes
101
+ // (served by app.ts from src/brandRasters.ts) cover installers that want
102
+ // raster icons.
103
+ export const WEBMANIFEST = JSON.stringify({
104
+ name: BRAND_NAME,
105
+ short_name: BRAND_SHORT_NAME,
106
+ description: BRAND_SUBTITLE,
107
+ start_url: "/",
108
+ display: "standalone",
109
+ background_color: "#ffffff",
110
+ theme_color: GREEN_DARK,
111
+ icons: [
112
+ { src: "/favicon.svg", type: "image/svg+xml", sizes: "any", purpose: "any maskable" },
113
+ { src: "/assets/icon-192.png", type: "image/png", sizes: "192x192" },
114
+ { src: "/assets/icon-512.png", type: "image/png", sizes: "512x512" }
115
+ ]
116
+ }, null, 2);
117
+ export const BRAND_THEME_COLOR = GREEN_DARK;
@@ -0,0 +1,11 @@
1
+ // Wire contract for run ingestion.
2
+ //
3
+ // Deliberately kept independent of the CLI's internal `RunnerReport` type: this
4
+ // is a network boundary, so the server validates structurally rather than
5
+ // trusting a shared compile-time type. The CLI builds this payload from the
6
+ // JSON report it already writes.
7
+ // The major version of the HTTP API this server speaks. Served at /health and on
8
+ // every /api response as the `Dungbeetle-Api-Version` header, so a client can detect
9
+ // an incompatible server (a future v2) with a clear message instead of opaque
10
+ // 404s. Bump when the API breaks compatibility (and mount the new path).
11
+ export const API_VERSION = 1;
@@ -0,0 +1,104 @@
1
+ // An error carrying the HTTP status and the server's `{ error }` message, so the
2
+ // MCP layer can surface an actionable message to the agent.
3
+ export class DungbeetleApiError extends Error {
4
+ status;
5
+ constructor(status, message) {
6
+ super(message);
7
+ this.status = status;
8
+ this.name = "DungbeetleApiError";
9
+ }
10
+ }
11
+ export class DungbeetleApi {
12
+ baseUrl;
13
+ authHeader;
14
+ fetchImpl;
15
+ repoCache;
16
+ constructor(opts) {
17
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
18
+ this.authHeader =
19
+ opts.agentToken !== undefined
20
+ ? `Bearer ${opts.agentToken}`
21
+ : `Basic ${Buffer.from(`${opts.clientId}:${opts.clientSecret}`, "utf8").toString("base64")}`;
22
+ this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
23
+ }
24
+ // Deep link to the web review UI for a run (humans approve there).
25
+ async reviewUrl(runId) {
26
+ const repo = await this.repository();
27
+ return `${this.baseUrl}/ui/repos/${repo.id}/runs/${encodeURIComponent(runId)}`;
28
+ }
29
+ async health() {
30
+ return this.request("GET", "/healthz", { auth: false });
31
+ }
32
+ async repository() {
33
+ if (!this.repoCache) {
34
+ const { repository } = await this.request("GET", "/api/v1/repository");
35
+ this.repoCache = repository;
36
+ }
37
+ return this.repoCache;
38
+ }
39
+ async listBaselines() {
40
+ return this.request("GET", "/api/v1/baselines");
41
+ }
42
+ async baselineHistory(target) {
43
+ return this.request("GET", `/api/v1/baselines/${encodeURIComponent(target)}`);
44
+ }
45
+ async getRun(id) {
46
+ return this.request("GET", `/api/v1/runs/${encodeURIComponent(id)}`);
47
+ }
48
+ // Recent runs for the repository, newest first. `limit` defaults to 50
49
+ // server-side (clamped to 200); `branch` is an exact filter.
50
+ async listRuns(params) {
51
+ const query = new URLSearchParams();
52
+ if (params?.limit !== undefined) {
53
+ query.set("limit", String(params.limit));
54
+ }
55
+ if (params?.branch !== undefined) {
56
+ query.set("branch", params.branch);
57
+ }
58
+ const qs = query.toString();
59
+ return this.request("GET", `/api/v1/runs${qs ? `?${qs}` : ""}`);
60
+ }
61
+ // Current-period usage (snapshots + storage) with the plan limits it is
62
+ // measured against.
63
+ async usage() {
64
+ return this.request("GET", "/api/v1/usage");
65
+ }
66
+ async analytics() {
67
+ return this.request("GET", "/api/v1/analytics");
68
+ }
69
+ async reviewRun(id, body) {
70
+ return this.request("POST", `/api/v1/runs/${encodeURIComponent(id)}/review`, { body });
71
+ }
72
+ async request(method, path, opts = {}) {
73
+ const headers = {};
74
+ if (opts.auth !== false) {
75
+ headers.authorization = this.authHeader;
76
+ }
77
+ let body;
78
+ if (opts.body !== undefined) {
79
+ headers["content-type"] = "application/json";
80
+ body = JSON.stringify(opts.body);
81
+ }
82
+ let res;
83
+ try {
84
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, { method, headers, body });
85
+ }
86
+ catch (err) {
87
+ const reason = err instanceof Error ? err.message : String(err);
88
+ throw new DungbeetleApiError(0, `Could not reach the Dungbeetle server at ${this.baseUrl}: ${reason}`);
89
+ }
90
+ const text = await res.text();
91
+ let parsed;
92
+ try {
93
+ parsed = text ? JSON.parse(text) : {};
94
+ }
95
+ catch {
96
+ parsed = { error: text };
97
+ }
98
+ if (!res.ok) {
99
+ const message = parsed.error ?? `Request failed with status ${res.status}.`;
100
+ throw new DungbeetleApiError(res.status, message);
101
+ }
102
+ return parsed;
103
+ }
104
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+ // Dungbeetle Server — Copyright 2026 DungbeetleDev. See LICENSE.
4
+ //
5
+ // stdio entrypoint for the Dungbeetle MCP server (`dungbeetle-mcp` bin). Point any MCP
6
+ // client at this command with an agent token (recommended — mint one with
7
+ // `dungbeetle login`, so the agent has its own identity and scoped permissions):
8
+ //
9
+ // DUNGBEETLE_AGENT_TOKEN=dbat_… dungbeetle-mcp
10
+ //
11
+ // or with the repository's client credentials, as used by `dungbeetle push`:
12
+ //
13
+ // DUNGBEETLE_CLIENT_ID=cid_… DUNGBEETLE_CLIENT_SECRET=csec_… dungbeetle-mcp
14
+ //
15
+ // The hosted cloud is the default target; set DUNGBEETLE_SERVER_URL only when
16
+ // self-hosting. stdout is the JSON-RPC channel — all diagnostics go to stderr.
17
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
+ import { DungbeetleApi } from "./api.js";
19
+ import { createMcpServer } from "./server.js";
20
+ // Keep in sync with the client CLI's DEFAULT_SERVER_URL (client/src/cloud.ts) —
21
+ // the two packages build separately and can't share the constant.
22
+ const DEFAULT_SERVER_URL = "https://dungbeetle.dev";
23
+ async function main() {
24
+ const baseUrl = process.env.DUNGBEETLE_SERVER_URL || DEFAULT_SERVER_URL;
25
+ const agentToken = process.env.DUNGBEETLE_AGENT_TOKEN;
26
+ const clientId = process.env.DUNGBEETLE_CLIENT_ID;
27
+ const clientSecret = process.env.DUNGBEETLE_CLIENT_SECRET;
28
+ if (!agentToken && (!clientId || !clientSecret)) {
29
+ console.error("dungbeetle-mcp: set DUNGBEETLE_AGENT_TOKEN (an agent token from `dungbeetle login`) " +
30
+ "or DUNGBEETLE_CLIENT_ID and DUNGBEETLE_CLIENT_SECRET (the repository's client " +
31
+ "credentials, as used by `dungbeetle push`). DUNGBEETLE_SERVER_URL is only " +
32
+ "needed when self-hosting.");
33
+ process.exit(1);
34
+ }
35
+ const api = agentToken
36
+ ? new DungbeetleApi({ baseUrl, agentToken })
37
+ : new DungbeetleApi({ baseUrl, clientId: clientId, clientSecret: clientSecret });
38
+ const server = createMcpServer(api, process.env.DUNGBEETLE_VERSION ?? "0.1.0");
39
+ await server.connect(new StdioServerTransport());
40
+ console.error(`dungbeetle-mcp ready → ${baseUrl}`);
41
+ }
42
+ main().catch((err) => {
43
+ console.error("dungbeetle-mcp failed to start:", err);
44
+ process.exit(1);
45
+ });
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Dungbeetle Server — Copyright 2026 DungbeetleDev. See LICENSE.
3
+ //
4
+ // Builds the Dungbeetle MCP server: a low-level @modelcontextprotocol/sdk Server that
5
+ // advertises the dungbeetle_* tools and dispatches calls through the tool layer. The
6
+ // low-level API (plain JSON Schema, no zod) keeps this independent of the SDK's
7
+ // internal validation library. Transport is attached by the caller (stdio in
8
+ // src/mcp/index.ts; an in-memory pair in tests).
9
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
11
+ import { callTool, TOOLS } from "./tools.js";
12
+ import { BRAND_NAME } from "../brand.js";
13
+ export const MCP_SERVER_NAME = "dungbeetle";
14
+ export function createMcpServer(api, version = "0.1.0") {
15
+ const server = new Server({
16
+ name: MCP_SERVER_NAME,
17
+ version,
18
+ title: `${BRAND_NAME} snapshot review`
19
+ }, {
20
+ capabilities: { tools: {} },
21
+ instructions: `${BRAND_NAME} exposes snapshot-test runs, diffs, baselines, and analytics for one repository. ` +
22
+ "Discovery loop: dungbeetle_list_runs to find a run id (newest first, filterable by branch), " +
23
+ "dungbeetle_get_run to triage it (a low-token structured diff summary; images are never " +
24
+ "inlined — open the review URL), then dungbeetle_approve_run / dungbeetle_reject_run to " +
25
+ "decide, or dungbeetle_reopen_run to set a decided run back to pending for re-review. " +
26
+ "dungbeetle_get_usage reports quota (snapshots/storage vs plan limits) before you push. " +
27
+ "Approving with promote=true mutates baselines, so confirm with a human before calling " +
28
+ "dungbeetle_approve_run. If authenticated with a scoped agent token, tools outside its " +
29
+ "granted scopes fail with HTTP 403 — surface the server's error message to the user."
30
+ });
31
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
32
+ tools: TOOLS.map((t) => ({
33
+ name: t.name,
34
+ title: t.title,
35
+ description: t.description,
36
+ inputSchema: t.inputSchema,
37
+ annotations: { title: t.title, ...t.annotations }
38
+ }))
39
+ }));
40
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
41
+ const { name, arguments: args } = request.params;
42
+ return callTool(name, args ?? {}, api);
43
+ });
44
+ return server;
45
+ }
@@ -0,0 +1,283 @@
1
+ import { DungbeetleApiError } from "./api.js";
2
+ function str(args, key) {
3
+ const v = args[key];
4
+ if (typeof v !== "string" || v.trim() === "") {
5
+ throw new DungbeetleApiError(400, `Missing required string argument "${key}".`);
6
+ }
7
+ return v;
8
+ }
9
+ // Concise, token-cheap summary of a run: counts + per-target status, with the
10
+ // changed targets called out. No diff bodies, no screenshots.
11
+ function summarizeRun(detail, reviewUrl) {
12
+ const { run, report } = detail;
13
+ const targets = report.results.map((r) => ({ name: r.name, kind: r.kind, status: r.status }));
14
+ const changed = targets.filter((t) => t.status !== "passed").map((t) => t.name);
15
+ return {
16
+ id: run.id,
17
+ status: run.status,
18
+ reviewState: run.reviewState ?? "pending",
19
+ branch: run.branch,
20
+ commit: run.commit,
21
+ createdAt: run.createdAt,
22
+ project: report.project,
23
+ counts: run.counts,
24
+ targets,
25
+ changed,
26
+ reviewUrl,
27
+ note: "Screenshots are not inlined — open reviewUrl to view before/after images."
28
+ };
29
+ }
30
+ // Detailed view: adds the structured text diff per target and flags whether an
31
+ // image / promotable snapshot exists, still without shipping any base64 payload.
32
+ function detailRun(detail, reviewUrl) {
33
+ const base = summarizeRun(detail, reviewUrl);
34
+ return {
35
+ ...base,
36
+ targets: detail.report.results.map((r) => ({
37
+ name: r.name,
38
+ kind: r.kind,
39
+ status: r.status,
40
+ diff: r.diff ?? null,
41
+ hasScreenshot: Boolean(r.screenshotRefs?.baseline ||
42
+ r.screenshotRefs?.candidate ||
43
+ r.screenshotRefs?.diff ||
44
+ r.screenshot?.baseline ||
45
+ r.screenshot?.candidate ||
46
+ r.screenshot?.diff),
47
+ promotable: typeof r.snapshot === "string" && r.snapshot.length > 0
48
+ }))
49
+ };
50
+ }
51
+ export const TOOLS = [
52
+ {
53
+ name: "dungbeetle_health",
54
+ title: "Server health",
55
+ description: "Liveness/version probe for the configured Dungbeetle server. Use to confirm connectivity before other calls.",
56
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
57
+ annotations: { readOnlyHint: true, idempotentHint: true },
58
+ handler: (_args, api) => api.health()
59
+ },
60
+ {
61
+ name: "dungbeetle_get_repository",
62
+ title: "Identify repository",
63
+ description: "Return the repository the configured credentials resolve to (id, name). Everything else is scoped to it.",
64
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
65
+ annotations: { readOnlyHint: true, idempotentHint: true },
66
+ handler: (_args, api) => api.repository()
67
+ },
68
+ {
69
+ name: "dungbeetle_list_baselines",
70
+ title: "List baseline targets",
71
+ description: "List the repository's baseline targets with their latest version and last-updated time. Start here to see what's under test.",
72
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
73
+ annotations: { readOnlyHint: true, idempotentHint: true },
74
+ handler: (_args, api) => api.listBaselines()
75
+ },
76
+ {
77
+ name: "dungbeetle_get_baseline_history",
78
+ title: "Baseline version history",
79
+ description: "Full version history for one baseline target (versions, digests, timestamps).",
80
+ inputSchema: {
81
+ type: "object",
82
+ properties: {
83
+ target: { type: "string", description: 'Target name, e.g. "home-page".' }
84
+ },
85
+ required: ["target"],
86
+ additionalProperties: false
87
+ },
88
+ annotations: { readOnlyHint: true, idempotentHint: true },
89
+ handler: (args, api) => api.baselineHistory(str(args, "target"))
90
+ },
91
+ {
92
+ name: "dungbeetle_list_runs",
93
+ title: "List recent runs",
94
+ description: "List recent runs for the repository, newest first — use this to discover a run id to triage with dungbeetle_get_run. Returns compact per-run summaries (id, status/passed, mode, branch, commit, createdAt, reviewState, failedCount, url); no diffs, no images.",
95
+ inputSchema: {
96
+ type: "object",
97
+ properties: {
98
+ limit: {
99
+ type: "number",
100
+ description: "Max runs to return (default 50, clamped to 200)."
101
+ },
102
+ branch: {
103
+ type: "string",
104
+ description: "Only runs pushed from this exact branch."
105
+ }
106
+ },
107
+ additionalProperties: false
108
+ },
109
+ annotations: { readOnlyHint: true, idempotentHint: true },
110
+ handler: async (args, api) => {
111
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
112
+ const branch = typeof args.branch === "string" && args.branch.trim() !== "" ? args.branch : undefined;
113
+ const { runs } = await api.listRuns({ limit, branch });
114
+ // Keep the per-run shape compact and explicit — enough to pick a run id
115
+ // for triage, nothing an agent has to page through.
116
+ return {
117
+ runs: runs.map((r) => ({
118
+ id: r.id,
119
+ status: r.status,
120
+ passed: r.passed,
121
+ mode: r.mode,
122
+ branch: r.branch,
123
+ commit: r.commit,
124
+ createdAt: r.createdAt,
125
+ reviewState: r.reviewState,
126
+ failedCount: r.failedCount,
127
+ url: r.url
128
+ }))
129
+ };
130
+ }
131
+ },
132
+ {
133
+ name: "dungbeetle_get_run",
134
+ title: "Get run / diff summary",
135
+ description: 'Structured summary of a run for triage: per-target status, counts, changed targets, current review state, and a review URL. Use detail="full" to include the text diff per target. Screenshots are never inlined — open the review URL for images.',
136
+ inputSchema: {
137
+ type: "object",
138
+ properties: {
139
+ run_id: { type: "string", description: "Run id (from an ingested run or analytics)." },
140
+ detail: {
141
+ type: "string",
142
+ enum: ["concise", "full"],
143
+ description: '"concise" (default) = status only; "full" = include text diffs.'
144
+ }
145
+ },
146
+ required: ["run_id"],
147
+ additionalProperties: false
148
+ },
149
+ annotations: { readOnlyHint: true, idempotentHint: true },
150
+ handler: async (args, api) => {
151
+ const id = str(args, "run_id");
152
+ const [detail, reviewUrl] = await Promise.all([api.getRun(id), api.reviewUrl(id)]);
153
+ return args.detail === "full"
154
+ ? detailRun(detail, reviewUrl)
155
+ : summarizeRun(detail, reviewUrl);
156
+ }
157
+ },
158
+ {
159
+ name: "dungbeetle_get_review_url",
160
+ title: "Get review URL",
161
+ description: "Deep link to the web review page for a run, where a human reviews diffs and approves baselines.",
162
+ inputSchema: {
163
+ type: "object",
164
+ properties: { run_id: { type: "string", description: "Run id." } },
165
+ required: ["run_id"],
166
+ additionalProperties: false
167
+ },
168
+ annotations: { readOnlyHint: true, idempotentHint: true },
169
+ handler: async (args, api) => {
170
+ const id = str(args, "run_id");
171
+ return { runId: id, url: await api.reviewUrl(id) };
172
+ }
173
+ },
174
+ {
175
+ name: "dungbeetle_get_analytics",
176
+ title: "Repository analytics",
177
+ description: "Pass rate, run trend, and per-target flakiness over the recent run window. Use to spot flaky targets before approving.",
178
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
179
+ annotations: { readOnlyHint: true, idempotentHint: true },
180
+ handler: (_args, api) => api.analytics()
181
+ },
182
+ {
183
+ name: "dungbeetle_get_usage",
184
+ title: "Usage & plan limits",
185
+ description: "Current-period usage (snapshots ingested, storage bytes, repositories) alongside the plan limits it is measured against, plus any active quota warnings. Use to check quota before pushing runs or baselines.",
186
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
187
+ annotations: { readOnlyHint: true, idempotentHint: true },
188
+ handler: (_args, api) => api.usage()
189
+ },
190
+ {
191
+ name: "dungbeetle_approve_run",
192
+ title: "Approve run (optionally promote baselines)",
193
+ description: "Approve a run. With promote=true, the run's candidate snapshots become new hosted baseline versions — this MUTATES baselines and should be confirmed by a human first. Returns the recorded review and any promoted targets.",
194
+ inputSchema: {
195
+ type: "object",
196
+ properties: {
197
+ run_id: { type: "string", description: "Run id to approve." },
198
+ note: { type: "string", description: "Optional reviewer note for the audit trail." },
199
+ promote: {
200
+ type: "boolean",
201
+ description: "Promote candidate snapshots to new baselines (default false)."
202
+ }
203
+ },
204
+ required: ["run_id"],
205
+ additionalProperties: false
206
+ },
207
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
208
+ handler: async (args, api) => {
209
+ const id = str(args, "run_id");
210
+ const note = typeof args.note === "string" ? args.note : undefined;
211
+ const promote = args.promote === true;
212
+ const result = await api.reviewRun(id, { state: "approved", note, promote });
213
+ return { ...result, reviewUrl: await api.reviewUrl(id) };
214
+ }
215
+ },
216
+ {
217
+ name: "dungbeetle_reject_run",
218
+ title: "Reject run",
219
+ description: "Reject a run's changes (records a rejected review in the append-only audit trail). Does not modify baselines.",
220
+ inputSchema: {
221
+ type: "object",
222
+ properties: {
223
+ run_id: { type: "string", description: "Run id to reject." },
224
+ note: { type: "string", description: "Optional reviewer note." }
225
+ },
226
+ required: ["run_id"],
227
+ additionalProperties: false
228
+ },
229
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
230
+ handler: async (args, api) => {
231
+ const id = str(args, "run_id");
232
+ const note = typeof args.note === "string" ? args.note : undefined;
233
+ return api.reviewRun(id, { state: "rejected", note });
234
+ }
235
+ },
236
+ {
237
+ name: "dungbeetle_reopen_run",
238
+ title: "Reopen run for re-review",
239
+ description: "Reopen a previously approved or rejected run by setting its review state back to pending, so it can be re-reviewed. Recorded in the append-only audit trail; does not modify baselines.",
240
+ inputSchema: {
241
+ type: "object",
242
+ properties: {
243
+ run_id: { type: "string", description: "Run id to reopen." },
244
+ note: { type: "string", description: "Optional reviewer note." }
245
+ },
246
+ required: ["run_id"],
247
+ additionalProperties: false
248
+ },
249
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
250
+ handler: async (args, api) => {
251
+ const id = str(args, "run_id");
252
+ const note = typeof args.note === "string" ? args.note : undefined;
253
+ return api.reviewRun(id, { state: "pending", note });
254
+ }
255
+ }
256
+ ];
257
+ // Dispatch a tool call to its handler, shaping the result into an MCP tool
258
+ // result. Both a human-readable JSON `text` block and `structuredContent` are
259
+ // returned so simple and structured clients are both served.
260
+ export async function callTool(name, args, api) {
261
+ const tool = TOOLS.find((t) => t.name === name);
262
+ if (!tool) {
263
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
264
+ }
265
+ try {
266
+ const result = await tool.handler(args ?? {}, api);
267
+ const structured = result && typeof result === "object" && !Array.isArray(result)
268
+ ? result
269
+ : { result };
270
+ return {
271
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
272
+ structuredContent: structured
273
+ };
274
+ }
275
+ catch (err) {
276
+ const message = err instanceof DungbeetleApiError
277
+ ? `${err.message}${err.status ? ` (HTTP ${err.status})` : ""}`
278
+ : err instanceof Error
279
+ ? err.message
280
+ : String(err);
281
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
282
+ }
283
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "dungbeetle-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Dungbeetle, the snapshot and visual regression testing tool built for AI agents: list runs, read low-token semantic diffs, and review — with human-gated baseline promotion.",
5
+ "type": "module",
6
+ "license": "BUSL-1.1",
7
+ "mcpName": "dev.dungbeetle/mcp",
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "bin": {
12
+ "dungbeetle-mcp": "dist/mcp/index.js"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json && node -e \"require('fs').chmodSync('dist/mcp/index.js', 0o755)\"",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "mcp-server",
26
+ "model-context-protocol",
27
+ "visual-regression-testing",
28
+ "snapshot-testing",
29
+ "visual-testing",
30
+ "dungbeetle",
31
+ "ai-agents",
32
+ "claude",
33
+ "cursor"
34
+ ],
35
+ "homepage": "https://dungbeetle.dev/mcp/",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/DungbeetleTech/cloud.git",
39
+ "directory": "packages/mcp"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/DungbeetleTech/cloud/issues"
43
+ },
44
+ "author": "DungbeetleDev <tech@dungbeetle.dev>",
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.29.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^26.1.0",
50
+ "typescript": "^6.0.3"
51
+ }
52
+ }