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.
| 2cecc08 | 1 | // 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 | ||
| 19 | import { | |
| 20 | runChecks, | |
| 21 | formatTable, | |
| 22 | CHECKS, | |
| 23 | type CheckResult, | |
| 24 | } from "../src/lib/post-deploy-smoke"; | |
| 25 | import { defaultTools } from "../src/lib/mcp-tools"; | |
| 26 | ||
| 27 | const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, ""); | |
| 28 | const PAT = process.env.GLUECRON_PAT?.trim() || ""; | |
| 29 | const FULL = process.argv.includes("--full"); | |
| 30 | const MD = process.argv.includes("--md"); | |
| 31 | ||
| 32 | interface Row extends CheckResult { | |
| 33 | skipped?: boolean; | |
| 34 | } | |
| 35 | ||
| 36 | function 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 | ||
| 40 | function skip(name: string, why: string): Row { | |
| 41 | return { name, url: "", status: 0, durationMs: 0, ok: true, skipped: true, error: why }; | |
| 42 | } | |
| 43 | ||
| 44 | async 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 | ||
| 50 | async 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 | ||
| 58 | async 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 | ||
| 70 | let rpcId = 0; | |
| 71 | async 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 | ||
| 77 | async 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 | ||
| 83 | async 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 | ||
| 90 | async 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 | ||
| 138 | async 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 | ||
| 176 | async 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. | |
| 196 | const 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" }, | |
| 6930df0 | 204 | gluecron_list_tree: { owner: "ccantynz", repo: "Gluecron.com", ref: "main", path: "" }, |
| 2cecc08 | 205 | 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 | ||
| 211 | async 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)) { | |
| 216 | const [res, ms] = await timed(() => mcpCall(tool, args, PAT)); | |
| 217 | const ok = res.status === 200 && !res.json?.error && !res.json?.result?.isError; | |
| 218 | 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) })); | |
| 219 | } | |
| 220 | return out; | |
| 221 | } | |
| 222 | ||
| 223 | // ─── Main ─────────────────────────────────────────────────────────── | |
| 224 | ||
| 225 | async function main() { | |
| 226 | console.log(`# Gluecron doctor — ${HOST} — ${new Date().toISOString()}`); | |
| 227 | console.log(`# PAT: ${PAT ? "present" : "absent (authed sections will SKIP)"} mode: ${FULL ? "full" : "standard"}\n`); | |
| 228 | ||
| 229 | const sections: Array<[string, () => Promise<Row[]>]> = [ | |
| 230 | ["a. Endpoint smoke (deploy-gate 15)", sectionSmoke], | |
| 231 | ["b. OAuth provider surface", sectionOauth], | |
| 232 | ["c. MCP anonymous surface + RFC 6750", sectionMcpAnon], | |
| 233 | ["d. MCP authenticated reads", sectionMcpAuthed], | |
| 234 | ["e. Read-only tool matrix", sectionMatrix], | |
| 235 | ]; | |
| 236 | ||
| 237 | let failed = 0; | |
| 238 | let skipped = 0; | |
| 239 | const mdLines: string[] = []; | |
| 240 | ||
| 241 | for (const [title, run] of sections) { | |
| 242 | let rows: Row[]; | |
| 243 | try { | |
| 244 | rows = await run(); | |
| 245 | } catch (err) { | |
| 246 | rows = [row(title, false, { error: `section crashed: ${(err as Error).message}` })]; | |
| 247 | } | |
| 248 | const sectionFailed = rows.filter((r) => !r.ok && !r.skipped).length; | |
| 249 | const sectionSkipped = rows.filter((r) => r.skipped).length; | |
| 250 | failed += sectionFailed; | |
| 251 | skipped += sectionSkipped; | |
| 252 | ||
| 253 | console.log(`\n## ${title} — ${sectionFailed === 0 ? "PASS" : `FAIL (${sectionFailed})`}${sectionSkipped ? ` (${sectionSkipped} skipped)` : ""}\n`); | |
| 254 | console.log(formatTable(rows.map((r) => (r.skipped ? { ...r, ok: true, error: `SKIP: ${r.error}` } : r)))); | |
| 255 | ||
| 256 | if (MD) { | |
| 257 | mdLines.push(`### ${title}`, ""); | |
| 258 | for (const r of rows) mdLines.push(`- ${r.skipped ? "⬜" : r.ok ? "✅" : "🚫"} ${r.name}${r.error ? ` — ${r.error}` : ""}`); | |
| 259 | mdLines.push(""); | |
| 260 | } | |
| 261 | } | |
| 262 | ||
| 263 | if (MD) console.log(`\n<!-- markdown -->\n${mdLines.join("\n")}`); | |
| 264 | ||
| 265 | console.log(`\n# doctor: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${skipped ? ` — ${skipped} skipped` : ""}`); | |
| 266 | process.exit(failed === 0 ? 0 : 1); | |
| 267 | } | |
| 268 | ||
| 269 | main().catch((err) => { | |
| 270 | console.error("doctor crashed:", err); | |
| 271 | process.exit(2); | |
| 272 | }); |