Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ It exposes these read-only tools:

- `gittensory_miner_ping` (#5153) — a health check returning a static `{ "status": "ok", "tool": "gittensory_miner_ping" }` object. Reads no AMS state, takes no arguments.
- `gittensory_miner_get_portfolio_dashboard` (#5155) — the per-repo portfolio-queue backlog dashboard: status counts (queued / in_progress / done), totals, and the oldest-queued age. Wraps `collectPortfolioDashboard()` (no new logic) — the same data `gittensory-miner queue dashboard --json` prints locally. Read-only, takes no arguments.
- `gittensory_miner_status` (#5154) — read-only status/doctor diagnostics: state-dir path, engine-version skew, Docker/Claude/Codex CLI presence booleans, config validity, and the full doctor check list. Wraps `collectMinerDiagnostics()` (no new logic) — the same fields `gittensory-miner status --json` and `doctor --json` expose locally. Returns env-var names and booleans only, never secret values. Read-only, takes no arguments.

Further AMS-state-reading tools (claim-ledger listing, run-state, event/governor ledgers) land as follow-up PRs on top of this server. The resolved coding-agent driver section (`MINER_CODING_AGENT_PROVIDER`, model-env-var name, provider CLI presence) lands in #5164 and will be included here once that ships.
- `gittensory_miner_list_claims` (#5156) — lists the local claim ledger (repo, issue number, status, claimed-at, note) via `listClaims()`. Optional `repoFullName` / `status` filters pass through to the query. Read-only — exposes no claim/release mutation.

Further AMS-state-reading tools (status/doctor diagnostics, run-state, event/governor ledgers) land as follow-up PRs on top of this server.
Expand Down
10 changes: 9 additions & 1 deletion packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { MinerDiagnostics } from "../lib/status.js";

/** The static, non-secret payload the gittensory_miner_ping tool always returns, independent of input. */
export const MINER_PING_STATUS: { status: "ok"; tool: "gittensory_miner_ping" };
Expand All @@ -19,10 +20,17 @@ export interface MinerMcpServerOptions {
};
/** Override the clock used for the oldest-queued age (defaults to Date.now()); injection seam for tests. */
nowMs?: number;
/** Override the status/doctor snapshot builder (defaults to collectMinerDiagnostics); injection seam for tests. */
collectMinerDiagnostics?: (env?: Record<string, string | undefined>, cwd?: string) => MinerDiagnostics;
/** Env passed to collectMinerDiagnostics when the real builder runs (defaults to process.env). */
diagnosticsEnv?: Record<string, string | undefined>;
/** cwd passed to collectMinerDiagnostics when the real builder runs (defaults to process.cwd()). */
diagnosticsCwd?: string;
}

/**
* Build the miner MCP server with its tools registered (gittensory_miner_ping, gittensory_miner_get_portfolio_dashboard).
* Build the miner MCP server with its tools registered (gittensory_miner_ping,
* gittensory_miner_get_portfolio_dashboard, gittensory_miner_list_claims, gittensory_miner_status).
* `options` supplies test injection seams; production callers pass nothing.
*/
export function createMinerMcpServer(options?: MinerMcpServerOptions): McpServer;
23 changes: 23 additions & 0 deletions packages/gittensory-miner/bin/gittensory-miner-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { z } from "zod";
import { collectPortfolioDashboard } from "../lib/portfolio-dashboard.js";
import { initPortfolioQueueStore } from "../lib/portfolio-queue.js";
import { collectMinerDiagnostics } from "../lib/status.js";
import { CLAIM_STATUSES, openClaimLedger } from "../lib/claim-ledger.js";

// MCP stdio server for @jsonbored/gittensory-miner (scaffold #5153). Mirrors the packages/gittensory-mcp
// harness (MCP SDK server + stdio transport). Tools:
// - gittensory_miner_ping (#5153): trivial static health check, reads no AMS state.
// - gittensory_miner_get_portfolio_dashboard (#5155): read-only per-repo backlog dashboard, wrapping the
// existing collectPortfolioDashboard aggregator (no new logic; same data as `queue dashboard --json`).
// - gittensory_miner_status (#5154): read-only status/doctor diagnostics via collectMinerDiagnostics()
// (same fields as `status`/`doctor --json`; no secret values). Driver-info fields follow #5164.
// Remaining AMS-state-reading tools (claim-ledger listing, run-state, etc.) land as follow-ups.
// - gittensory_miner_list_claims (#5156): read-only listing of the local claim ledger (optional repo/status
// filter passed through to listClaims); exposes no claim/release mutation.
// Remaining AMS-state-reading tools (status/doctor, run-state, event/governor ledgers, etc.) land as follow-ups.
Expand All @@ -28,6 +32,7 @@ export const MINER_PING_STATUS = { status: "ok", tool: "gittensory_miner_ping" }
* Build the miner MCP server with its tools registered. `options.initPortfolioQueue`, `options.openClaimLedger`,
* and `options.nowMs` are injection seams for tests (default to the real stores and the wall clock); the ping tool
* needs none. Each store-backed tool opens its store only when invoked and closes any store it opened.
* `options.collectMinerDiagnostics` / `options.diagnosticsEnv` / `options.diagnosticsCwd` inject the status tool.
*/
export function createMinerMcpServer(options = {}) {
const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version });
Expand Down Expand Up @@ -86,6 +91,24 @@ export function createMinerMcpServer(options = {}) {
}
},
);
server.registerTool(
"gittensory_miner_status",
{
description:
"Read-only miner status/doctor diagnostics: state-dir path, engine-version skew, Docker/Claude/Codex CLI " +
"presence booleans, config validity, and the full doctor check list. Wraps collectMinerDiagnostics() " +
"(no new logic) — the same data `gittensory-miner status --json` and `doctor --json` expose locally. " +
"Returns env-var names and booleans only, never secret values. Takes no arguments; mutates nothing.",
inputSchema: {},
},
async () => {
const diagnostics = (options.collectMinerDiagnostics ?? collectMinerDiagnostics)(
options.diagnosticsEnv ?? process.env,
options.diagnosticsCwd ?? process.cwd(),
);
return { content: [{ type: "text", text: JSON.stringify(diagnostics) }] };
},
);
return server;
}

Expand Down
12 changes: 12 additions & 0 deletions packages/gittensory-miner/lib/status.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ export function resolveMinerStateDir(env?: Record<string, string | undefined>):

export function collectStatus(env?: Record<string, string | undefined>, cwd?: string): MinerStatus;

export type MinerDiagnostics = MinerStatus & {
configValid: boolean;
engineVersionSkew: { ok: boolean; detail: string };
presence: { docker: boolean; claudeCli: boolean; codexCli: boolean };
doctor: { ok: boolean; checks: DoctorCheck[] };
};

export function collectMinerDiagnostics(
env?: Record<string, string | undefined>,
cwd?: string,
): MinerDiagnostics;

export function runStatus(args?: string[], env?: Record<string, string | undefined>, cwd?: string): number;

export function runDoctorChecks(env?: Record<string, string | undefined>): DoctorCheck[];
Expand Down
50 changes: 49 additions & 1 deletion packages/gittensory-miner/lib/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,54 @@ export function collectStatus(env = process.env, cwd = process.cwd()) {
};
}

function findDoctorCheck(checks, name) {
return checks.find((check) => check.name === name);
}

/** True when a docker/cli presence check found its binary on PATH (independent of doctor pass/fail). */
function binaryPresentFromDoctorCheck(check) {
return typeof check?.detail === "string" && check.detail.startsWith("found at");
}

/** Whether an optional on-disk config file exists and is readable. Full schema validation lands in #4873. */
function assessConfigValidity(configPath) {
if (!configPath) return true;
try {
readFileSync(configPath, "utf8");
return true;
} catch {
return false;
}
}

/** Read-only diagnostics snapshot shared by the CLI and the MCP status tool (#5154). */
export function collectMinerDiagnostics(env = process.env, cwd = process.cwd()) {
const status = collectStatus(env, cwd);
const checks = runDoctorChecks(env);
const failed = checks.filter((check) => !check.ok);
const engineVersionSkew = findDoctorCheck(checks, "engine-version-skew") ?? {
name: "engine-version-skew",
ok: true,
detail: "engine-version-skew check missing",
};
return {
package: status.package,
engine: status.engine,
node: status.node,
stateDir: status.stateDir,
configFile: status.configFile,
configValid: assessConfigValidity(status.configFile),
engineVersionSkew: { ok: engineVersionSkew.ok, detail: engineVersionSkew.detail },
presence: {
docker: binaryPresentFromDoctorCheck(findDoctorCheck(checks, "docker-present")),
claudeCli: binaryPresentFromDoctorCheck(findDoctorCheck(checks, "claude-cli-present")),
codexCli: binaryPresentFromDoctorCheck(findDoctorCheck(checks, "codex-cli-present")),
},
doctor: { ok: failed.length === 0, checks },
driver: status.driver,
};
}

function renderDriverLine(driver) {
if (!driver.provider) return "driver: none configured";
const cliText = driver.cliPresent === null ? "n/a" : driver.cliPresent ? "yes" : "no";
Expand Down Expand Up @@ -291,7 +339,7 @@ export function runDoctorChecks(env = process.env) {
checkEngineVersionSkew(),
checkStateDirWritable(resolveMinerStateDir(env)),
checkLaptopStateSqlite(env),
checkDockerPresent(),
checkDockerPresent({ env }),
checkClaudeCliPresent({ env }),
checkCodexCliPresent({ env }),
];
Expand Down
1 change: 1 addition & 0 deletions test/unit/miner-mcp-scaffold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe("gittensory-miner MCP server (#5153 scaffold)", () => {
"gittensory_miner_get_portfolio_dashboard",
"gittensory_miner_list_claims",
"gittensory_miner_ping",
"gittensory_miner_status",
]);
});

Expand Down
143 changes: 143 additions & 0 deletions test/unit/miner-mcp-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createMinerMcpServer } from "../../packages/gittensory-miner/bin/gittensory-miner-mcp.js";
import { initLaptopState } from "../../packages/gittensory-miner/lib/laptop-init.js";
import { collectMinerDiagnostics } from "../../packages/gittensory-miner/lib/status.js";
import { containsSecretLikeText } from "../../src/review/content-lane/registry-logic";

type Content = { content: Array<{ type: string; text?: string }> };

const roots: string[] = [];

function tempRoot() {
const root = mkdtempSync(join(tmpdir(), "gittensory-miner-mcp-status-"));
roots.push(root);
return root;
}

afterEach(() => {
vi.restoreAllMocks();
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});

async function connectedClient(options: Parameters<typeof createMinerMcpServer>[0] = {}): Promise<Client> {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "miner-mcp-status-test", version: "0.0.0" });
await Promise.all([createMinerMcpServer(options).connect(serverTransport), client.connect(clientTransport)]);
return client;
}

function toolText(result: Content): string {
const first = result.content[0];
if (!first || first.type !== "text" || typeof first.text !== "string") {
throw new Error("expected a single text content block");
}
return first.text;
}

describe("gittensory_miner_status (#5154)", () => {
it("is registered on the miner MCP server", async () => {
const client = await connectedClient();
const { tools } = await client.listTools();
expect(tools.map((tool) => tool.name)).toContain("gittensory_miner_status");
});

it("returns state-dir, engine skew, CLI presence booleans, config validity, and doctor checks", async () => {
const root = tempRoot();
writeFileSync(join(root, ".gittensory-miner.yml"), "minerEnabled: true\n");
const env = {
GITTENSORY_MINER_CONFIG_DIR: join(root, "state"),
PATH: "",
};
initLaptopState(env);
const client = await connectedClient({ diagnosticsEnv: env, diagnosticsCwd: root });
const result = (await client.callTool({ name: "gittensory_miner_status", arguments: {} })) as Content;
const payload = JSON.parse(toolText(result));
expect(payload.stateDir).toBe(join(root, "state"));
expect(payload.configFile).toBe(join(root, ".gittensory-miner.yml"));
expect(payload.configValid).toBe(true);
expect(payload.engineVersionSkew).toEqual(expect.objectContaining({ ok: expect.any(Boolean), detail: expect.any(String) }));
expect(payload.presence).toEqual({
docker: false,
claudeCli: false,
codexCli: false,
});
expect(payload.doctor.checks.map((check: { name: string }) => check.name)).toEqual([
"node-version",
"engine-resolves",
"engine-version-skew",
"state-dir-writable",
"laptop-state-sqlite",
"docker-present",
"claude-cli-present",
"codex-cli-present",
]);
});

it("is structurally identical to collectMinerDiagnostics() — the wrapper adds no drift (invariant)", async () => {
const root = tempRoot();
const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state"), PATH: "" };
initLaptopState(env);
const client = await connectedClient({ diagnosticsEnv: env, diagnosticsCwd: root });
const result = (await client.callTool({ name: "gittensory_miner_status", arguments: {} })) as Content;
expect(JSON.parse(toolText(result))).toEqual(collectMinerDiagnostics(env, root));
});

it("never returns secret-shaped values or configured env-var contents (invariant)", async () => {
const root = tempRoot();
// Assembled at runtime so this test file never contains a contiguous gate-flagged secret literal.
const githubToken = "ghp_" + "a".repeat(36);
const oauthSecret = "oauth-secret-" + "value";
const env = {
GITTENSORY_MINER_CONFIG_DIR: join(root, "state"),
GITHUB_TOKEN: githubToken,
CLAUDE_CODE_OAUTH_TOKEN: oauthSecret,
PATH: "",
};
initLaptopState(env);
const client = await connectedClient({ diagnosticsEnv: env, diagnosticsCwd: root });
const result = (await client.callTool({ name: "gittensory_miner_status", arguments: {} })) as Content;
const serialized = toolText(result);
expect(serialized).not.toContain(githubToken);
expect(serialized).not.toContain(oauthSecret);
expect(containsSecretLikeText(serialized)).toBe(false);
});

it("reports configValid false when the discovered config file is unreadable", async () => {
const root = tempRoot();
mkdirSync(join(root, ".gittensory-miner.yml"));
const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state"), PATH: "" };
initLaptopState(env);
const client = await connectedClient({ diagnosticsEnv: env, diagnosticsCwd: root });
const result = (await client.callTool({ name: "gittensory_miner_status", arguments: {} })) as Content;
expect(JSON.parse(toolText(result)).configValid).toBe(false);
});

it("supports a collectMinerDiagnostics injection seam for failure-path tests", async () => {
const client = await connectedClient({
collectMinerDiagnostics: () => ({
package: { name: "@jsonbored/gittensory-miner", version: "0.0.0-test" },
engine: { name: "@jsonbored/gittensory-engine", version: "0.0.0" },
node: "v22.0.0",
stateDir: "/tmp/missing-state",
configFile: null,
driver: { provider: null, modelEnvVar: null, cliPresent: null },
configValid: true,
engineVersionSkew: { ok: false, detail: "installed 0.1.0 is behind expected 0.2.0" },
presence: { docker: false, claudeCli: false, codexCli: false },
doctor: {
ok: false,
checks: [{ name: "state-dir-writable", ok: false, detail: "/tmp/missing-state: not writable" }],
},
}),
});
const result = (await client.callTool({ name: "gittensory_miner_status", arguments: {} })) as Content;
const payload = JSON.parse(toolText(result));
expect(payload.doctor.ok).toBe(false);
expect(payload.engineVersionSkew.ok).toBe(false);
});
});
30 changes: 30 additions & 0 deletions test/unit/miner-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildEngineVersionSkewCheck,
collectMinerDiagnostics,
collectStatus,
compareInstalledEngineVersion,
readExpectedEnginePackageVersion,
Expand Down Expand Up @@ -68,6 +69,35 @@ describe("gittensory-miner status/doctor (#2288)", () => {
expect(status.package.version).toBe("gittensory-miner-fleet@deadbeef");
});

it("collectMinerDiagnostics merges status and doctor checks without drift (#5154)", () => {
const root = tempRoot();
writeFileSync(join(root, ".gittensory-miner.yml"), "minerEnabled: true\n");
const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state"), PATH: "" };
initLaptopState(env);
const diagnostics = collectMinerDiagnostics(env, root);
expect(diagnostics.stateDir).toBe(join(root, "state"));
expect(diagnostics.configFile).toBe(join(root, ".gittensory-miner.yml"));
expect(diagnostics.configValid).toBe(true);
expect(diagnostics.presence).toEqual({ docker: false, claudeCli: false, codexCli: false });
expect(diagnostics.doctor.checks).toEqual(runDoctorChecks(env));
expect(diagnostics.engineVersionSkew).toEqual({
ok: runDoctorChecks(env).find((check) => check.name === "engine-version-skew")!.ok,
detail: runDoctorChecks(env).find((check) => check.name === "engine-version-skew")!.detail,
});
});

it("collectMinerDiagnostics treats a missing config file as valid defaults", () => {
const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state"), PATH: "" };
expect(collectMinerDiagnostics(env, tempRoot()).configValid).toBe(true);
});

it("collectMinerDiagnostics marks configValid false when the config path is unreadable", () => {
const root = tempRoot();
mkdirSync(join(root, ".gittensory-miner.yml"));
const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state"), PATH: "" };
expect(collectMinerDiagnostics(env, root).configValid).toBe(false);
});

it("runStatus prints human-readable text (0) and machine JSON with --json", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
expect(runStatus([], { GITTENSORY_MINER_CONFIG_DIR: "/s" }, tempRoot())).toBe(0);
Expand Down