Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

doctor.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

doctor.tsBlame285 lines · 2 contributors
2cecc08ccanty labs1// Doctor — one-shot public-readiness audit of a LIVE Gluecron instance.
2//
3// Certifies the surface an external AI platform needs: endpoint health,
4// the OAuth provider (discovery, DCR, token-endpoint error semantics),
5// and the MCP server (anonymous handshake, auth challenges per RFC 6750,
6// authenticated tool calls, tool-manifest drift vs local code).
7//
8// Run: bun run scripts/doctor.ts [--full] [--md]
9// Env: GLUECRON_HOST target instance (default https://gluecron.com)
10// GLUECRON_PAT optional PAT — enables the authenticated sections
11// (without it they report SKIP, not FAIL)
12// Exit: 0 iff every non-skipped check passes.
13//
14// Companion: scripts/agent-journey.ts exercises the full write path
15// (push → PR → review → merge). Keep doctor read-only + residue-free,
16// except one recognizably-named `doctor-*` OAuth client from the DCR
17// round-trip (RFC 7592 delete isn't implemented).
18
19import {
20 runChecks,
21 formatTable,
22 CHECKS,
23 type CheckResult,
24} from "../src/lib/post-deploy-smoke";
25import { defaultTools } from "../src/lib/mcp-tools";
26
27const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
28const PAT = process.env.GLUECRON_PAT?.trim() || "";
29const FULL = process.argv.includes("--full");
30const MD = process.argv.includes("--md");
31
32interface Row extends CheckResult {
33 skipped?: boolean;
34}
35
36function row(name: string, ok: boolean, opts: Partial<Row> = {}): Row {
37 return { name, url: opts.url ?? "", status: opts.status ?? 0, durationMs: opts.durationMs ?? 0, ok, ...(opts.error ? { error: opts.error } : {}), ...(opts.skipped ? { skipped: true } : {}) };
38}
39
40function skip(name: string, why: string): Row {
41 return { name, url: "", status: 0, durationMs: 0, ok: true, skipped: true, error: why };
42}
43
44async function timed<T>(fn: () => Promise<T>): Promise<[T, number]> {
45 const t0 = Date.now();
46 const v = await fn();
47 return [v, Date.now() - t0];
48}
49
50async function jget(path: string, headers: Record<string, string> = {}) {
51 const res = await fetch(HOST + path, { headers });
52 const text = await res.text();
53 let json: any = null;
54 try { json = JSON.parse(text); } catch { /* non-JSON */ }
55 return { status: res.status, headers: res.headers, text, json };
56}
57
58async function jpost(path: string, body: unknown, headers: Record<string, string> = {}) {
59 const res = await fetch(HOST + path, {
60 method: "POST",
61 headers: { "content-type": "application/json", ...headers },
62 body: JSON.stringify(body),
63 });
64 const text = await res.text();
65 let json: any = null;
66 try { json = JSON.parse(text); } catch { /* non-JSON */ }
67 return { status: res.status, headers: res.headers, text, json };
68}
69
70let rpcId = 0;
71async function mcpRpc(method: string, params?: unknown, bearer?: string) {
72 const headers: Record<string, string> = {};
73 if (bearer) headers.authorization = `Bearer ${bearer}`;
74 return jpost("/mcp", { jsonrpc: "2.0", id: ++rpcId, method, ...(params !== undefined ? { params } : {}) }, headers);
75}
76
77async function mcpCall(tool: string, args: Record<string, unknown>, bearer?: string) {
78 return mcpRpc("tools/call", { name: tool, arguments: args }, bearer);
79}
80
81// ─── Section (a): endpoint smoke — the deploy-gate 15 ───────────────
82
83async function sectionSmoke(): Promise<Row[]> {
84 const summary = await runChecks({ baseUrl: HOST, fetchImpl: fetch as any, checks: CHECKS, log: () => undefined });
85 return summary.results;
86}
87
88// ─── Section (b): OAuth provider surface ────────────────────────────
89
90async function sectionOauth(): Promise<Row[]> {
91 const out: Row[] = [];
92
93 const [as, asMs] = await timed(() => jget("/.well-known/oauth-authorization-server"));
94 const grants: string[] = as.json?.grant_types_supported ?? [];
95 out.push(row("as-metadata advertises refresh_token", as.status === 200 && grants.includes("refresh_token"), {
96 url: "/.well-known/oauth-authorization-server", status: as.status, durationMs: asMs,
97 error: as.status !== 200 ? "not 200" : grants.includes("refresh_token") ? undefined : `grants: ${grants.join(",")}`,
98 }));
99 out.push(row("as-metadata has registration_endpoint", Boolean(as.json?.registration_endpoint), { status: as.status }));
100
101 for (const p of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"]) {
102 const [pr, prMs] = await timed(() => jget(p));
103 out.push(row(`protected-resource ${p.endsWith("mcp") ? "(mcp)" : "(root)"}`, pr.status === 200 && typeof pr.json?.resource === "string", { url: p, status: pr.status, durationMs: prMs }));
104 }
105
106 // DCR round-trip. Residue: one client row named doctor-<ts>.
107 const [reg, regMs] = await timed(() =>
108 jpost("/oauth/register", {
109 client_name: `doctor-${new Date().toISOString().replace(/[:.]/g, "-")}`,
110 redirect_uris: ["https://localhost/doctor-callback"],
111 token_endpoint_auth_method: "none",
112 })
113 );
114 const clientId: string | undefined = reg.json?.client_id;
115 out.push(row("DCR round-trip returns client_id", (reg.status === 200 || reg.status === 201) && Boolean(clientId), {
116 url: "/oauth/register", status: reg.status, durationMs: regMs, error: clientId ? undefined : reg.text.slice(0, 120),
117 }));
118
119 // Token endpoint error semantics (the signals refresh clients key off).
120 if (clientId) {
121 const badCode = await jpost("/oauth/token", { grant_type: "authorization_code", code: "garbage", client_id: clientId, redirect_uri: "https://localhost/doctor-callback", code_verifier: "x".repeat(43) });
122 out.push(row("token: garbage code → invalid_grant", badCode.status === 400 && badCode.json?.error === "invalid_grant", { status: badCode.status, error: badCode.json?.error }));
123
124 const badRefresh = await jpost("/oauth/token", { grant_type: "refresh_token", refresh_token: "glcr_garbage", client_id: clientId });
125 out.push(row("token: garbage refresh → invalid_grant", badRefresh.status === 400 && badRefresh.json?.error === "invalid_grant", { status: badRefresh.status, error: badRefresh.json?.error }));
126
127 const badGrant = await jpost("/oauth/token", { grant_type: "password", client_id: clientId });
128 out.push(row("token: bogus grant_type → unsupported_grant_type", badGrant.status === 400 && badGrant.json?.error === "unsupported_grant_type", { status: badGrant.status, error: badGrant.json?.error }));
129 } else {
130 out.push(skip("token endpoint error semantics", "no client_id from DCR"));
131 }
132
133 return out;
134}
135
136// ─── Section (c): MCP anonymous surface + RFC 6750 challenges ───────
137
138async function sectionMcpAnon(): Promise<Row[]> {
139 const out: Row[] = [];
140
141 const [init, initMs] = await timed(() => mcpRpc("initialize"));
142 out.push(row("anonymous initialize → 200", init.status === 200 && Boolean(init.json?.result?.protocolVersion), { status: init.status, durationMs: initMs }));
143
144 const [list, listMs] = await timed(() => mcpRpc("tools/list"));
145 const liveNames: string[] = (list.json?.result?.tools ?? []).map((t: any) => t.name).sort();
146 const localNames = Object.keys(defaultTools()).sort();
147 const missing = localNames.filter((n) => !liveNames.includes(n));
148 const extra = liveNames.filter((n) => !localNames.includes(n));
149 out.push(row(`tools/list matches local manifest (${localNames.length} tools)`, list.status === 200 && missing.length === 0 && extra.length === 0, {
150 status: list.status, durationMs: listMs,
151 error: missing.length || extra.length ? `live missing: [${missing.join(",")}] live extra: [${extra.join(",")}]` : undefined,
152 }));
153
154 const anon = await mcpCall("gluecron_repo_search", { query: "x" });
155 const anonWww = anon.headers.get("www-authenticate") || "";
156 out.push(row("anonymous tools/call → 401 challenge (no error=)", anon.status === 401 && anonWww.includes("resource_metadata=") && !anonWww.includes("error="), {
157 status: anon.status, error: anonWww ? undefined : "missing WWW-Authenticate",
158 }));
159
160 // Live proof of the 2026-07-14 fix: dead bearer must signal invalid_token
161 // on the HANDSHAKE, not just tools/call — this is what breaks the
162 // reconnect loop.
163 for (const method of ["initialize", "tools/list"]) {
164 const dead = await mcpRpc(method, undefined, "glct_deadbeef");
165 const www = dead.headers.get("www-authenticate") || "";
166 out.push(row(`dead bearer on ${method} → 401 invalid_token`, dead.status === 401 && www.includes('error="invalid_token"'), {
167 status: dead.status, error: dead.status === 401 ? (www.includes('error="invalid_token"') ? undefined : "no invalid_token attr") : "not 401 (fix not deployed?)",
168 }));
169 }
170
171 return out;
172}
173
174// ─── Section (d): MCP authenticated reads (needs GLUECRON_PAT) ──────
175
176async function sectionMcpAuthed(): Promise<Row[]> {
177 if (!PAT) return [skip("authenticated MCP reads", "GLUECRON_PAT not set")];
178 const out: Row[] = [];
179
180 const probes: Array<[string, Record<string, unknown>]> = [
181 ["gluecron_search_repos", { query: "gluecron" }],
182 ["gluecron_repo_health", { owner: "ccantynz", repo: "Gluecron.com" }],
183 ["gluecron_list_prs", { owner: "ccantynz", repo: "Gluecron.com", state: "open" }],
184 ];
185 for (const [tool, args] of probes) {
186 const [res, ms] = await timed(() => mcpCall(tool, args, PAT));
187 const ok = res.status === 200 && !res.json?.error && !res.json?.result?.isError;
188 out.push(row(`authed ${tool}`, ok, { status: res.status, durationMs: ms, error: ok ? undefined : JSON.stringify(res.json?.error ?? res.json?.result)?.slice(0, 140) }));
189 }
190 return out;
191}
192
193// ─── Section (e): --full read-only tool matrix ──────────────────────
194
195// Canned args per read-only tool; write tools belong to agent-journey.ts.
196const READ_MATRIX: Record<string, Record<string, unknown>> = {
197 gluecron_repo_search: { query: "gluecron" },
198 gluecron_search_repos: { query: "gluecron" },
199 gluecron_repo_health: { owner: "ccantynz", repo: "Gluecron.com" },
200 gluecron_list_prs: { owner: "ccantynz", repo: "Gluecron.com", state: "open" },
201 gluecron_search_prs: { owner: "ccantynz", repo: "Gluecron.com", query: "fix" },
202 gluecron_repo_list_issues: { owner: "ccantynz", repo: "Gluecron.com", state: "open" },
203 gluecron_search_issues: { owner: "ccantynz", repo: "Gluecron.com", query: "deploy" },
6930df0ccanty labs204 gluecron_list_tree: { owner: "ccantynz", repo: "Gluecron.com", ref: "main", path: "" },
2cecc08ccanty labs205 gluecron_repo_read_file: { owner: "ccantynz", repo: "Gluecron.com", path: "README.md" },
206 gluecron_read_file: { owner: "ccantynz", repo: "Gluecron.com", path: "README.md" },
207 gluecron_clone_url: { owner: "ccantynz", repo: "Gluecron.com" },
208 gluecron_pr_status_summary: { owner: "ccantynz", repo: "Gluecron.com" },
209};
210
211async function sectionMatrix(): Promise<Row[]> {
212 if (!FULL) return [skip("read-only tool matrix", "pass --full to run")];
213 if (!PAT) return [skip("read-only tool matrix", "GLUECRON_PAT not set")];
214 const out: Row[] = [];
215 for (const [tool, args] of Object.entries(READ_MATRIX)) {
4fc7860Test User216 let probeArgs = args;
217 if (tool === "gluecron_pr_status_summary") {
218 const prsRes = await mcpCall("gluecron_list_prs", { owner: "ccantynz", repo: "Gluecron.com", state: "all" }, PAT);
219 const textPart = (prsRes.json?.result?.content ?? []).find((p: any) => p.type === "text")?.text;
220 let listed: any;
221 try { listed = JSON.parse(textPart); } catch { listed = textPart; }
222 const number = listed?.prs?.[0]?.number;
223 if (typeof number !== "number") {
224 out.push(skip(`matrix ${tool}`, "no PRs to summarize"));
225 continue;
226 }
227 probeArgs = { owner: "ccantynz", repo: "Gluecron.com", number };
228 }
229 const [res, ms] = await timed(() => mcpCall(tool, probeArgs, PAT));
2cecc08ccanty labs230 const ok = res.status === 200 && !res.json?.error && !res.json?.result?.isError;
231 out.push(row(`matrix ${tool}`, ok, { status: res.status, durationMs: ms, error: ok ? undefined : JSON.stringify(res.json?.error ?? res.json?.result)?.slice(0, 140) }));
232 }
233 return out;
234}
235
236// ─── Main ───────────────────────────────────────────────────────────
237
238async function main() {
239 console.log(`# Gluecron doctor — ${HOST} — ${new Date().toISOString()}`);
240 console.log(`# PAT: ${PAT ? "present" : "absent (authed sections will SKIP)"} mode: ${FULL ? "full" : "standard"}\n`);
241
242 const sections: Array<[string, () => Promise<Row[]>]> = [
243 ["a. Endpoint smoke (deploy-gate 15)", sectionSmoke],
244 ["b. OAuth provider surface", sectionOauth],
245 ["c. MCP anonymous surface + RFC 6750", sectionMcpAnon],
246 ["d. MCP authenticated reads", sectionMcpAuthed],
247 ["e. Read-only tool matrix", sectionMatrix],
248 ];
249
250 let failed = 0;
251 let skipped = 0;
252 const mdLines: string[] = [];
253
254 for (const [title, run] of sections) {
255 let rows: Row[];
256 try {
257 rows = await run();
258 } catch (err) {
259 rows = [row(title, false, { error: `section crashed: ${(err as Error).message}` })];
260 }
261 const sectionFailed = rows.filter((r) => !r.ok && !r.skipped).length;
262 const sectionSkipped = rows.filter((r) => r.skipped).length;
263 failed += sectionFailed;
264 skipped += sectionSkipped;
265
266 console.log(`\n## ${title} — ${sectionFailed === 0 ? "PASS" : `FAIL (${sectionFailed})`}${sectionSkipped ? ` (${sectionSkipped} skipped)` : ""}\n`);
267 console.log(formatTable(rows.map((r) => (r.skipped ? { ...r, ok: true, error: `SKIP: ${r.error}` } : r))));
268
269 if (MD) {
270 mdLines.push(`### ${title}`, "");
271 for (const r of rows) mdLines.push(`- ${r.skipped ? "⬜" : r.ok ? "✅" : "🚫"} ${r.name}${r.error ? ` — ${r.error}` : ""}`);
272 mdLines.push("");
273 }
274 }
275
276 if (MD) console.log(`\n<!-- markdown -->\n${mdLines.join("\n")}`);
277
278 console.log(`\n# doctor: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${skipped ? ` — ${skipped} skipped` : ""}`);
279 process.exit(failed === 0 ? 0 : 1);
280}
281
282main().catch((err) => {
283 console.error("doctor crashed:", err);
284 process.exit(2);
285});