glitchless-mcp 0.0.0-stage → 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -2
  3. package/package.json +45 -4
  4. package/src/index.js +229 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Glitchless authors
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,76 @@
1
- # Temporary Holding Version
1
+ # glitchless-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The [Glitchless](https://github.com/glitchless-gg/glitchless) MCP server. It lets any coding agent
4
+ (Claude Code, Codex, Cursor or another MCP client) read how your game runs for real players, from a
5
+ Glitchless server: ranked findings, frame-time breakdowns, errors, level funnels, death hotspots and
6
+ version comparisons. It can also run playtest bots before and after a change, to check a fix.
7
+
8
+ It's a stdio server. Run it with `npx`, no install needed:
9
+
10
+ ```bash
11
+ npx -y glitchless-mcp
12
+ ```
13
+
14
+ ## Settings
15
+
16
+ | Variable | What |
17
+ | --- | --- |
18
+ | `GLITCHLESS_URL` | Your Glitchless server. Default `http://localhost:7777` |
19
+ | `GLITCHLESS_API_KEY` | A secret read key (`gl_sk_…`), if the server needs one. Keep it out of git and out of the game |
20
+ | `GLITCHLESS_SERVICE` | The game to use when a tool call doesn't name one. Optional |
21
+
22
+ ## Add it to your agent
23
+
24
+ Claude Code:
25
+
26
+ ```bash
27
+ claude mcp add glitchless -e GLITCHLESS_URL=http://localhost:7777 -e GLITCHLESS_API_KEY=gl_sk_… -- npx -y glitchless-mcp
28
+ ```
29
+
30
+ Codex, in `~/.codex/config.toml`:
31
+
32
+ ```toml
33
+ [mcp_servers.glitchless]
34
+ command = "npx"
35
+ args = ["-y", "glitchless-mcp"]
36
+ env = { GLITCHLESS_URL = "http://localhost:7777", GLITCHLESS_API_KEY = "gl_sk_…" }
37
+ ```
38
+
39
+ Cursor, in `.cursor/mcp.json` (add the file to `.gitignore` if it holds the key):
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "glitchless": {
45
+ "command": "npx",
46
+ "args": ["-y", "glitchless-mcp"],
47
+ "env": { "GLITCHLESS_URL": "http://localhost:7777", "GLITCHLESS_API_KEY": "gl_sk_…" }
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ Any other MCP client: the command is `npx`, the arguments are `-y glitchless-mcp`, and the settings
54
+ above go in its environment.
55
+
56
+ ## Tools
57
+
58
+ | Tool | What it answers |
59
+ | --- | --- |
60
+ | `glitchless_list_games` | Which games report to the server, with their versions and environments |
61
+ | `glitchless_triage` | Ranked findings: stutter per level and what it correlates with, leaks, top errors, difficulty spikes. Start here |
62
+ | `glitchless_overview` | Headline numbers: sessions, players, crash-free sessions, frame time against the budget, rage quits |
63
+ | `glitchless_performance` | Frame time per level, with draw calls, geometry counts and game gauges |
64
+ | `glitchless_get_error` | One error issue, with its stack and the gameplay before it |
65
+ | `glitchless_player_funnel` | Per level: starts, completions, deaths and their causes, rage quits |
66
+ | `glitchless_death_hotspots` | Where players die on a level, clustered in world coordinates |
67
+ | `glitchless_compare_versions` | Two or more versions side by side, to confirm a fix with real players |
68
+ | `glitchless_session` | One play session in detail, with its whole event timeline |
69
+ | `glitchless_playtest` | Autoplay bots play the game and compare with a saved baseline ([glitchless-playtest](https://www.npmjs.com/package/glitchless-playtest); needs Playwright in the game project) |
70
+
71
+ To let the agent drive the running game as well (measure frame times, inspect the scene, load a
72
+ level), add the WebMCP server beside this one: `npx -y @jason.today/webmcp@latest --mcp`.
73
+
74
+ ## License
75
+
76
+ MIT
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "glitchless-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Glitchless MCP server: lets any coding agent (Claude Code, Codex, Cursor and others) read your game's player telemetry and ranked findings, and run playtest bots.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "gamedev",
9
+ "observability",
10
+ "coding-agent",
11
+ "claude",
12
+ "codex",
13
+ "cursor"
14
+ ],
15
+ "homepage": "https://github.com/glitchless-gg/glitchless/tree/main/packages/mcp#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/glitchless-gg/glitchless/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/glitchless-gg/glitchless.git",
22
+ "directory": "packages/mcp"
23
+ },
24
+ "license": "MIT",
25
+ "type": "module",
26
+ "main": "./src/index.js",
27
+ "bin": {
28
+ "glitchless-mcp": "src/index.js"
29
+ },
30
+ "files": [
31
+ "src"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "scripts": {
37
+ "bundle": "esbuild src/index.js --bundle --platform=node --format=esm --target=node20 --external:playwright --outfile=../../plugin/glitchless/mcp/glitchless-mcp.mjs --banner:js=\"import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);\""
38
+ },
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.32.1",
41
+ "zod": "^4.6.7",
42
+ "glitchless-playtest": "^0.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "esbuild": "^0.28.2"
46
+ }
47
+ }
package/src/index.js ADDED
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ // Glitchless MCP server — gives coding agents (Claude Code, Codex, Cursor…) read access to a game's
3
+ // production telemetry: ranked findings, frame-time breakdowns, errors, player funnels, death
4
+ // hotspots and version comparisons. Pair it with WebMCP (live tools exposed by the running game)
5
+ // and the agent can go from "players are rage quitting" to a verified fix.
6
+ //
7
+ // claude mcp add glitchless -e GLITCHLESS_URL=http://localhost:7777 -- node packages/mcp/src/index.js
8
+
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
+ import { z } from 'zod';
12
+ import { playtest } from 'glitchless-playtest';
13
+
14
+ const BASE = (process.env.GLITCHLESS_URL || 'http://localhost:7777').replace(/\/$/, '');
15
+ const API_KEY = process.env.GLITCHLESS_API_KEY || '';
16
+ const DEFAULT_SERVICE = process.env.GLITCHLESS_SERVICE || '';
17
+
18
+ const server = new McpServer(
19
+ { name: 'glitchless', version: '0.1.0' },
20
+ {
21
+ instructions:
22
+ 'Glitchless is observability for web games. Start with glitchless_triage to get ranked, evidence-backed findings for a game, then drill in with glitchless_performance, glitchless_get_error, glitchless_death_hotspots or glitchless_session. Before changing game code, run glitchless_playtest with save_as "baseline"; after the change, run glitchless_playtest again to compare, and treat a FAIL as unfinished work. After shipping a fix (bump the version passed to Glitchless.init), use glitchless_compare_versions to confirm it with real players. If the game is running in a browser with WebMCP connected, its live tools (glitchless_live_performance, glitchless_measure, glitchless_inspect_scene, and game tools such as zoomies_load_level) let you reproduce and measure issues directly.',
23
+ },
24
+ );
25
+
26
+ async function query(name, params = {}) {
27
+ const qs = new URLSearchParams();
28
+ for (const [k, v] of Object.entries(params)) if (v != null && v !== '') qs.set(k, String(v));
29
+ const client = server.server.getClientVersion?.()?.name || 'mcp';
30
+ const res = await fetch(`${BASE}/v1/query/${name}?${qs}`, {
31
+ headers: { 'x-glitchless-client': client, ...(API_KEY ? { 'x-glitchless-api-key': API_KEY } : {}) },
32
+ });
33
+ if (!res.ok) throw new Error(`Glitchless API ${name} → HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
34
+ return res.json();
35
+ }
36
+
37
+ async function resolveService(service) {
38
+ if (service) return service;
39
+ if (DEFAULT_SERVICE) return DEFAULT_SERVICE;
40
+ const { services } = await query('services');
41
+ if (!services?.length) throw new Error('No games are reporting to Glitchless yet. Add the SDK and play a level first.');
42
+ return services.sort((a, b) => b.last_seen - a.last_seen)[0].service;
43
+ }
44
+
45
+ function range(hours = 24) {
46
+ const to = Date.now();
47
+ return { from: to - hours * 3600e3, to };
48
+ }
49
+
50
+ function reply(title, data) {
51
+ return { content: [{ type: 'text', text: `${title}\n\n${JSON.stringify(data, null, 2)}` }] };
52
+ }
53
+
54
+ function fail(err) {
55
+ return { content: [{ type: 'text', text: `Glitchless error: ${err.message}` }], isError: true };
56
+ }
57
+
58
+ const common = {
59
+ service: z.string().optional().describe('Game service name (defaults to the most recently active game)'),
60
+ hours: z.number().min(0.25).max(168).optional().describe('Look-back window in hours (default 24)'),
61
+ version: z.string().optional().describe('Only include sessions from this game version'),
62
+ };
63
+
64
+ function tool(name, description, schema, handler) {
65
+ server.registerTool(name, { description, inputSchema: schema, annotations: { readOnlyHint: true } }, async (args) => {
66
+ try {
67
+ return await handler(args || {});
68
+ } catch (err) {
69
+ return fail(err);
70
+ }
71
+ });
72
+ }
73
+
74
+ tool('glitchless_list_games', 'List the games reporting to Glitchless, with their versions, environments and when they were last seen.', {}, async () => {
75
+ const { services } = await query('services');
76
+ return reply(`${services.length} game(s) reporting to ${BASE}`, services.map((s) => ({ ...s, last_seen: new Date(s.last_seen).toISOString() })));
77
+ });
78
+
79
+ tool(
80
+ 'glitchless_triage',
81
+ "Ranked findings for a game: performance spikes per level (with the gauges and events they correlate with), memory leaks, top errors, difficulty spikes / rage-quit hotspots and version regressions — ranked by severity and player impact, each with evidence and a suggested fix. Start here when asked 'what's wrong with my game?'.",
82
+ { ...common },
83
+ async ({ service, hours, version }) => {
84
+ const svc = await resolveService(service);
85
+ const { findings, meta } = await query('findings', { service: svc, version, ...range(hours) });
86
+ const brief = findings.map((f, i) => `${i + 1}. [${f.severity.toUpperCase()}] ${f.title}`).join('\n');
87
+ return reply(`Findings for ${svc} (${hours || 24}h${version ? `, ${version}` : ''}):\n${brief || 'Nothing unusual.'}\n\nFull findings with evidence (window ${new Date(meta?.from).toISOString()} → now):`, findings);
88
+ },
89
+ );
90
+
91
+ tool('glitchless_overview', 'Headline KPIs for a game: sessions, players, crash-free and error-free sessions, frame-time p50/p95/p99 vs budget, hitches per minute, rage quits, top errors and the level funnel.', { ...common }, async ({ service, hours, version }) => {
92
+ const svc = await resolveService(service);
93
+ const d = await query('overview', { service: svc, version, ...range(hours) });
94
+ return reply(`Overview for ${svc}`, { kpis: d.kpis, deployments: d.deployments, top_errors: d.top_errors.map(slimIssue), funnel: d.funnel });
95
+ });
96
+
97
+ tool(
98
+ 'glitchless_performance',
99
+ 'Frame-time breakdown for a game: global percentiles vs the frame budget, per-level p50/p95/p99 with draw calls / triangles / geometry counts / game-context gauges, hitch causes (script, shader compile, GC, asset load), what is on screen during hitches, which events precede hitches, per GPU tier / browser / version, memory growth per level load and the slowest recent hitches with script attribution.',
100
+ { ...common, level: z.string().optional().describe('Focus on one level id, e.g. "level-2"') },
101
+ async ({ service, hours, version, level }) => {
102
+ const svc = await resolveService(service);
103
+ const d = await query('performance', { service: svc, version, ...range(hours) });
104
+ const hitches = (level ? d.hitches.filter((h) => h.level === level) : d.hitches).slice(0, 12);
105
+ return reply(`Frame performance for ${svc}${level ? ` (${level})` : ''}`, {
106
+ kpis: d.kpis,
107
+ by_level: level ? d.by_level.filter((l) => l.key === level) : d.by_level,
108
+ hitch_causes: d.hitch_causes,
109
+ hitch_context: d.hitch_context,
110
+ by_gpu_tier: d.by_gpu_tier.map(slimGroup),
111
+ by_browser: d.by_browser.map(slimGroup),
112
+ by_version: d.by_version.map(slimGroup),
113
+ memory: d.memory,
114
+ load: d.load,
115
+ slowest_recent_hitches: hitches,
116
+ });
117
+ },
118
+ );
119
+
120
+ tool(
121
+ 'glitchless_get_error',
122
+ 'Error Tracking for a game. Without a fingerprint: the list of error issues ranked by sessions affected. With a fingerprint: the full issue — message, location, stack trace, affected levels/versions, occurrence counts and breadcrumbs (the game events right before it fired).',
123
+ { ...common, fingerprint: z.string().optional().describe('Issue fingerprint from the list or from glitchless_triage') },
124
+ async ({ service, hours, version, fingerprint }) => {
125
+ const svc = await resolveService(service);
126
+ const { issues } = await query('errors', { service: svc, version, ...range(hours) });
127
+ if (!fingerprint) return reply(`${issues.length} error issue(s) in ${svc}`, issues.map(slimIssue));
128
+ const issue = issues.find((i) => i.fingerprint === fingerprint || i.fingerprint.startsWith(fingerprint));
129
+ if (!issue) return reply(`No issue ${fingerprint} in ${svc} in this window. Known issues:`, issues.map(slimIssue));
130
+ const { series, ...rest } = issue;
131
+ return reply(`Error issue ${issue.fingerprint} in ${svc}`, rest);
132
+ },
133
+ );
134
+
135
+ tool('glitchless_player_funnel', 'Player experience per level: sessions that started vs completed each level, attempts, deaths per session and their causes, average clear time, rage quits (quit ≤20s after dying), session length distribution and recent rage quits with positions.', { ...common }, async ({ service, hours, version }) => {
136
+ const svc = await resolveService(service);
137
+ const d = await query('players', { service: svc, version, ...range(hours) });
138
+ return reply(`Player funnel for ${svc}`, { kpis: d.kpis, funnel: d.funnel, session_length: d.session_length, recent_rage_quits: d.rage_quits.slice(0, 15) });
139
+ });
140
+
141
+ tool(
142
+ 'glitchless_death_hotspots',
143
+ 'Where players die on a level: clustered death positions in world coordinates (x range, share of the level’s deaths, top cause, rage quits at that spot), plus the level bounds and completion stats. Use it to find difficulty spikes, then look up the level data around those coordinates.',
144
+ { ...common, level: z.string().describe('Level id, e.g. "level-3"') },
145
+ async ({ service, hours, version, level }) => {
146
+ const svc = await resolveService(service);
147
+ const d = await query('heatmap', { service: svc, version, level, ...range(hours) });
148
+ return reply(`Death hotspots on ${level} in ${svc}`, { level: d.level, axis: d.axis, bounds: d.bounds, hotspots: d.hotspots, level_stats: d.stats, total_deaths: d.deaths?.length ?? 0, position_samples: d.density?.samples ?? 0 });
149
+ },
150
+ );
151
+
152
+ tool(
153
+ 'glitchless_compare_versions',
154
+ 'Compare game versions side by side: p50/p95/p99 frame time (overall and per level), hitches/min, crash-free and error-free sessions, errors per session, level completion, rage-quit rate, deaths per session and geometry growth per level load. Use it to confirm a fix after shipping a new version.',
155
+ { service: common.service, hours: common.hours, a: z.string().optional().describe('Baseline version (default: second newest)'), b: z.string().optional().describe('New version (default: newest)') },
156
+ async ({ service, hours, a, b }) => {
157
+ const svc = await resolveService(service);
158
+ const { versions } = await query('versions', { service: svc, ...range(hours || 72) });
159
+ if (versions.length < 2 && !(a && b)) return reply(`Only one version of ${svc} has reported in this window`, versions);
160
+ const va = versions.find((v) => v.version === a) || versions.at(-2);
161
+ const vb = versions.find((v) => v.version === b) || versions.at(-1);
162
+ const keys = ['frame_p50', 'frame_p95', 'frame_p99', 'hitches_per_min', 'crash_free_pct', 'error_free_pct', 'errors_per_session', 'level_completion_pct', 'rage_quit_pct', 'deaths_per_session', 'geometries_growth_per_level_load'];
163
+ const delta = Object.fromEntries(keys.map((k) => [k, { [va.version]: va[k], [vb.version]: vb[k], change: va[k] != null && vb[k] != null ? Math.round((vb[k] - va[k]) * 100) / 100 : null }]));
164
+ const levels = [...new Set([...Object.keys(va.level_p95 || {}), ...Object.keys(vb.level_p95 || {})])];
165
+ const level_p95 = Object.fromEntries(levels.map((l) => [l, { [va.version]: va.level_p95?.[l] ?? null, [vb.version]: vb.level_p95?.[l] ?? null }]));
166
+ return reply(`${svc}: ${va.version} (${va.sessions} sessions) vs ${vb.version} (${vb.sessions} sessions)`, { delta, level_p95, all_versions: versions.map((v) => ({ version: v.version, sessions: v.sessions, first_seen: new Date(v.first_seen).toISOString() })) });
167
+ },
168
+ );
169
+
170
+ tool(
171
+ 'glitchless_session',
172
+ 'One play session in detail: device/GPU, outcome (crashed, rage quit), and the full event timeline (levels, deaths with positions, errors, hitches with causes, agent annotations) plus per-5s frame stats. Use with session ids from errors, hitches or rage quits.',
173
+ { service: common.service, session_id: z.string().describe('Session id (s_…)') },
174
+ async ({ service, session_id }) => {
175
+ const svc = await resolveService(service);
176
+ const d = await query('session', { service: svc, id: session_id });
177
+ if (d.error) return reply(`Session ${session_id} not found in ${svc}`, {});
178
+ return reply(`Session ${session_id}`, { session: d.session, timeline: d.timeline.slice(0, 150), frames: d.frames.map(({ ctx, ...f }) => ({ ...f, ...ctx })).slice(0, 120) });
179
+ },
180
+ );
181
+
182
+ // Unlike the tools above, this one acts: it launches browsers and writes .glitchless/playtest/<name>.json
183
+ // in the project, so it is registered without the read-only hint.
184
+ server.registerTool(
185
+ 'glitchless_playtest',
186
+ {
187
+ description:
188
+ "Playtest the game with its autoplay bots and compare against a saved baseline. Bots play fixed-seed scenarios in real Chrome, one at a time, against the running game; Glitchless then compares frame time (overall and per level), render CPU, draw calls, errors, crashes, completion and deaths with the baseline. Regressions fail; warnings and improvements are listed. Call with save_as \"baseline\" BEFORE changing code, then without arguments AFTER the change. Takes 1–5 minutes (quick: about 1). Needs the game and the Glitchless server running and Playwright installed in the project.",
189
+ inputSchema: {
190
+ save_as: z.string().optional().describe('Save this run under a name, e.g. "baseline" before a change'),
191
+ compare_to: z.string().optional().describe('Saved run to compare with (default "baseline" when saved)'),
192
+ quick: z.boolean().optional().describe('Play only the first scenario (about a minute)'),
193
+ game_url: z.string().optional().describe('Game URL to play (default http://localhost:5173/ or glitchless.playtest.json)'),
194
+ max_seconds: z.number().min(10).max(600).optional().describe('Cap each run at this many seconds'),
195
+ },
196
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
197
+ },
198
+ async ({ save_as, compare_to, quick, game_url, max_seconds } = {}, extra) => {
199
+ const token = extra?._meta?.progressToken;
200
+ let step = 0;
201
+ const log = (message) => {
202
+ if (token != null) extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress: ++step, message } }).catch(() => {});
203
+ };
204
+ try {
205
+ const r = await playtest({
206
+ saveAs: save_as,
207
+ compareTo: compare_to,
208
+ quick,
209
+ maxSeconds: max_seconds,
210
+ config: { server: BASE, api_key: API_KEY || undefined, service: DEFAULT_SERVICE || undefined, game: game_url },
211
+ log,
212
+ });
213
+ const files = Object.entries(r.files || {}).map(([k, f]) => `${k}: ${f}`).join('\n');
214
+ return { content: [{ type: 'text', text: `${r.report}\n\nVerdict: ${r.verdict.toUpperCase()}\nSaved runs:\n${files}` }] };
215
+ } catch (err) {
216
+ return fail(err);
217
+ }
218
+ },
219
+ );
220
+
221
+ function slimIssue(i) {
222
+ return { fingerprint: i.fingerprint, kind: i.kind, message: i.message, location: i.location, count: i.count, sessions: i.sessions, sessions_pct: i.sessions_pct, levels: i.levels, versions: i.versions, first_version: i.first_version, last_seen: new Date(i.last_seen).toISOString() };
223
+ }
224
+
225
+ function slimGroup(g) {
226
+ return { key: g.key, sessions: g.sessions, p50: g.p50, p95: g.p95, p99: g.p99, pct_over_budget: g.pct_over_budget, hitches_per_min: g.hitches_per_min, draw_calls: g.draw_calls };
227
+ }
228
+
229
+ await server.connect(new StdioServerTransport());