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 +110 -0
- package/README.md +72 -0
- package/dist/brand.js +117 -0
- package/dist/contract.js +11 -0
- package/dist/mcp/api.js +104 -0
- package/dist/mcp/index.js +45 -0
- package/dist/mcp/server.js +45 -0
- package/dist/mcp/tools.js +283 -0
- package/package.json +52 -0
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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;
|
package/dist/contract.js
ADDED
|
@@ -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;
|
package/dist/mcp/api.js
ADDED
|
@@ -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
|
+
}
|