From fff0f26d931b8a16d7048f202af182cd02cf1e85 Mon Sep 17 00:00:00 2001 From: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:00:40 -0400 Subject: [PATCH] feat(miner): scaffold the gittensory-miner MCP stdio server Add a second bin entry, gittensory-miner-mcp, mirroring the packages/gittensory-mcp harness (MCP SDK server + stdio transport). It ships exactly one trivial health-check tool, gittensory_miner_ping, returning a static {status,tool} object -- no AMS state read, no arguments -- so future AMS-state-reading tools (status/doctor, portfolio dashboard, claim-ledger listing) have a real server to be added to. - new bin/gittensory-miner-mcp.js (executable; exported factory for in-process tests) - register the bin + @modelcontextprotocol/sdk dependency in package.json (+ lockfile) - smoke test drives the server over an in-memory transport, asserting the ping response, the single-tool listing, and the static invariant with no on-disk AMS state - README pointer for the new entry point and its current single-tool scope Closes #5153 --- package-lock.json | 7 ++- packages/gittensory-miner/README.md | 13 +++++ .../bin/gittensory-miner-mcp.d.ts | 10 ++++ .../bin/gittensory-miner-mcp.js | 50 +++++++++++++++++ packages/gittensory-miner/package.json | 8 +-- scripts/check-miner-package.mjs | 2 +- test/unit/miner-mcp-scaffold.test.ts | 54 +++++++++++++++++++ test/unit/miner-package-skeleton.test.ts | 5 +- 8 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts create mode 100755 packages/gittensory-miner/bin/gittensory-miner-mcp.js create mode 100644 test/unit/miner-mcp-scaffold.test.ts diff --git a/package-lock.json b/package-lock.json index 670f52923..25119913e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@asteasolutions/zod-to-openapi": "^8.5.0", "@cloudflare/puppeteer": "^1.1.0", "@hono/node-server": "^2.0.8", + "@jsonbored/gittensory-engine": "*", "@modelcontextprotocol/sdk": "1.29.0", "@octokit/core": "^7.0.6", "@opentelemetry/api": "^1.9.1", @@ -15673,10 +15674,12 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { - "@jsonbored/gittensory-engine": "*" + "@jsonbored/gittensory-engine": "*", + "@modelcontextprotocol/sdk": "1.29.0" }, "bin": { - "gittensory-miner": "bin/gittensory-miner.js" + "gittensory-miner": "bin/gittensory-miner.js", + "gittensory-miner-mcp": "bin/gittensory-miner-mcp.js" }, "engines": { "node": ">=22.13.0" diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 8d290d817..a0ca58d01 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -123,6 +123,19 @@ gittensory-miner manage status [--json] gittensory-miner manage poll [--branch ] [--json] ``` +## MCP server + +The package ships a second bin entry, `gittensory-miner-mcp`, a minimal [Model Context Protocol](https://modelcontextprotocol.io) stdio server that any MCP-compatible client can connect to: + +```sh +gittensory-miner-mcp +``` + +It currently exposes a single tool, `gittensory_miner_ping` — a health check that returns a static +`{ "status": "ok", "tool": "gittensory_miner_ping" }` object, reads no AMS state, and takes no arguments. This is a +scaffold (#5153): real AMS-state-reading tools (status/doctor diagnostics, portfolio dashboard, claim-ledger +listing) land as follow-up PRs on top of it. + ## Version check On every invocation the CLI starts an async npm registry lookup (5s timeout). When the installed package is behind `@jsonbored/gittensory-miner@latest`, it prints a one-line upgrade command to stderr without blocking or failing the requested command. Set `GITTENSORY_NPM_REGISTRY_URL` to point at a mirror, same as `@jsonbored/gittensory-mcp`. diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts new file mode 100644 index 000000000..0a2c5d90b --- /dev/null +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.d.ts @@ -0,0 +1,10 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.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" }; + +/** + * Build the miner MCP server with its single gittensory_miner_ping health-check tool registered. No I/O + * and no AMS-state reads, so a test can drive it over an in-memory transport. + */ +export function createMinerMcpServer(): McpServer; diff --git a/packages/gittensory-miner/bin/gittensory-miner-mcp.js b/packages/gittensory-miner/bin/gittensory-miner-mcp.js new file mode 100755 index 000000000..e5ca311b5 --- /dev/null +++ b/packages/gittensory-miner/bin/gittensory-miner-mcp.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { readFileSync, realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +// Minimal MCP stdio-server scaffold for @jsonbored/gittensory-miner (#5153). Mirrors the +// packages/gittensory-mcp harness (MCP SDK server + stdio transport) but ships exactly ONE trivial +// health-check tool -- gittensory_miner_ping -- returning a static status object. It reads NO AMS +// state and takes no arguments. Future AMS-state-reading tools (status/doctor, portfolio dashboard, +// claim-ledger listing) land as follow-up PRs on top of this scaffold. + +// Read the version from this package's own package.json (always shipped) rather than a hand-synced +// literal, so a release bump never has a second place to forget -- same approach as the mcp harness. +const ownPackageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + +/** The static, non-secret payload the ping tool always returns, independent of any input or AMS state. */ +export const MINER_PING_STATUS = { status: "ok", tool: "gittensory_miner_ping" }; + +/** + * Build the miner MCP server with its single health-check tool registered. No I/O and no AMS-state + * reads, so a test can drive it over an in-memory transport without spawning a process or requiring + * any on-disk state to exist. + */ +export function createMinerMcpServer() { + const server = new McpServer({ name: "gittensory-miner", version: ownPackageJson.version }); + server.registerTool( + "gittensory_miner_ping", + { + description: + "Health check for the gittensory-miner MCP server. Returns a static status object confirming the " + + "server is reachable. Reads no AMS state and takes no arguments.", + inputSchema: {}, + }, + async () => ({ content: [{ type: "text", text: JSON.stringify(MINER_PING_STATUS) }] }), + ); + return server; +} + +// Start the stdio transport only when executed directly as the bin, not when imported by a test. +// realpathSync on both sides resolves the npm bin symlink so a global/npx install still matches. +const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : ""; +if (invokedPath && invokedPath === realpathSync(fileURLToPath(import.meta.url))) { + createMinerMcpServer() + .connect(new StdioServerTransport()) + .catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 29d876bf8..f95cbd0c1 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -24,7 +24,8 @@ "access": "public" }, "bin": { - "gittensory-miner": "bin/gittensory-miner.js" + "gittensory-miner": "bin/gittensory-miner.js", + "gittensory-miner-mcp": "bin/gittensory-miner-mcp.js" }, "files": [ "bin", @@ -32,10 +33,11 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { - "@jsonbored/gittensory-engine": "*" + "@jsonbored/gittensory-engine": "*", + "@modelcontextprotocol/sdk": "1.29.0" }, "engines": { "node": ">=22.13.0" diff --git a/scripts/check-miner-package.mjs b/scripts/check-miner-package.mjs index f88c823b3..f2e93b331 100644 --- a/scripts/check-miner-package.mjs +++ b/scripts/check-miner-package.mjs @@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const ALLOWED = [ - /^bin\/gittensory-miner\.js$/, + /^bin\/gittensory-miner(-[a-z0-9-]+)?\.(js|d\.ts)$/, /^lib\/[a-z0-9-]+\.(js|d\.ts)$/, /^package\.json$/, /^README\.md$/, diff --git a/test/unit/miner-mcp-scaffold.test.ts b/test/unit/miner-mcp-scaffold.test.ts new file mode 100644 index 000000000..11d208fa2 --- /dev/null +++ b/test/unit/miner-mcp-scaffold.test.ts @@ -0,0 +1,54 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { + createMinerMcpServer, + MINER_PING_STATUS, +} from "../../packages/gittensory-miner/bin/gittensory-miner-mcp.js"; + +// Smoke test for the gittensory-miner MCP scaffold (#5153). Drives the real server over an in-memory +// transport (no child process, no AMS state on disk) and exercises the single gittensory_miner_ping tool. + +async function connectedClient(): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "miner-mcp-test", version: "0.0.0" }); + await Promise.all([createMinerMcpServer().connect(serverTransport), client.connect(clientTransport)]); + return client; +} + +function pingText(result: { content: Array<{ type: string; text?: string }> }): 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 MCP scaffold (#5153)", () => { + it("exposes exactly the gittensory_miner_ping tool", async () => { + const client = await connectedClient(); + const { tools } = await client.listTools(); + expect(tools.map((tool) => tool.name)).toEqual(["gittensory_miner_ping"]); + }); + + it("gittensory_miner_ping returns the static, non-secret status object", async () => { + const client = await connectedClient(); + const result = (await client.callTool({ name: "gittensory_miner_ping", arguments: {} })) as { + content: Array<{ type: string; text?: string }>; + }; + expect(JSON.parse(pingText(result))).toEqual({ status: "ok", tool: "gittensory_miner_ping" }); + expect(JSON.parse(pingText(result))).toEqual(MINER_PING_STATUS); + }); + + it("returns the same object on every call, with no AMS state required on disk (invariant)", async () => { + const client = await connectedClient(); + const first = (await client.callTool({ name: "gittensory_miner_ping", arguments: {} })) as { + content: Array<{ type: string; text?: string }>; + }; + const second = (await client.callTool({ name: "gittensory_miner_ping", arguments: {} })) as { + content: Array<{ type: string; text?: string }>; + }; + expect(pingText(first)).toBe(pingText(second)); + expect(JSON.parse(pingText(first))).toEqual(MINER_PING_STATUS); + }); +}); diff --git a/test/unit/miner-package-skeleton.test.ts b/test/unit/miner-package-skeleton.test.ts index e3a1d001b..43973fa7f 100644 --- a/test/unit/miner-package-skeleton.test.ts +++ b/test/unit/miner-package-skeleton.test.ts @@ -33,7 +33,10 @@ describe("gittensory-miner package skeleton (#2287)", () => { expect(miner.name).toBe("@jsonbored/gittensory-miner"); expect(miner.license).toBe("AGPL-3.0-only"); expect(miner.type).toBe("module"); - expect(miner.bin).toEqual({ "gittensory-miner": "bin/gittensory-miner.js" }); + expect(miner.bin).toEqual({ + "gittensory-miner": "bin/gittensory-miner.js", + "gittensory-miner-mcp": "bin/gittensory-miner-mcp.js", + }); expect(miner.publishConfig).toEqual(mcp.publishConfig); expect(miner.dependencies["@jsonbored/gittensory-engine"]).toBeDefined(); expect(miner.engines.node).toMatch(/^>=22(?:\.\d+){0,2}$/);