From 68eeed070ba960d3f18a4cadc6daa5fc9d70cb6a Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:04:26 -0700 Subject: [PATCH] =?UTF-8?q?feat(agent):=20miner=20write-tools=20=E2=80=94?= =?UTF-8?q?=20local-execution=20action=20specs=20(#780)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2: new MCP write-tools that the miner's OWN local harness runs with its OWN GitHub creds. gittensory supplies the content; the OUTPUT is an action spec (a shell-safe command + structured inputs + the boundary note) — gittensory and this MCP package NEVER perform the write, so source and the write both stay on the miner's machine. - src/mcp/local-write-tools.ts: pure builders for open_pr, file_issue, apply_labels, post_eligibility_comment, create_branch, delete_branch. Each returns a LocalWriteActionSpec with a POSIX-single-quote-escaped command (safe to exec verbatim) + the structured inputs (so the harness can reconstruct its own invocation) + LOCAL_WRITE_BOUNDARY. - src/mcp/server.ts: register the six tools (each clearly labelled local-only) + a thin localWriteSpec wrapper. Tests: every builder (command shape, single-quote escaping, optional draft / labels / base / remote) + the MCP round-trip for all six tools. New code 100% covered; MCP discovery/output-schema meta-tests green; full suite green (2106). --- src/mcp/local-write-tools.ts | 70 +++++++++++++++++++++++ src/mcp/server.ts | 86 +++++++++++++++++++++++++++++ test/unit/local-write-tools.test.ts | 53 ++++++++++++++++++ test/unit/mcp-write-tools.test.ts | 48 ++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 src/mcp/local-write-tools.ts create mode 100644 test/unit/local-write-tools.test.ts create mode 100644 test/unit/mcp-write-tools.test.ts diff --git a/src/mcp/local-write-tools.ts b/src/mcp/local-write-tools.ts new file mode 100644 index 0000000000..eda41c9896 --- /dev/null +++ b/src/mcp/local-write-tools.ts @@ -0,0 +1,70 @@ +import type { JsonValue } from "../types"; + +// #780 miner write-tools. These build ACTION SPECS — gittensory supplies the content; the miner's OWN local +// harness runs the command with its OWN GitHub credentials. Gittensory (and this MCP package) NEVER perform +// the write, so source code and the write both stay on the miner's machine: the no-cloud-write boundary holds. +// Pure + deterministic: every builder returns a self-contained, shell-safe spec and touches nothing. + +export const LOCAL_WRITE_BOUNDARY = + "Run this locally with your OWN GitHub credentials (e.g. an authenticated `gh`/`git`). Gittensory supplies the content but never performs the write — your code and the action both stay on your machine."; + +export type LocalWriteActionSpec = { + action: string; + description: string; + // The structured parameters, so the harness can construct its own invocation instead of running `command` raw. + inputs: Record; + // A directly-runnable, shell-safe command (single-quoted) for harnesses that prefer to exec it as-is. + command: string; + boundary: string; +}; + +// POSIX single-quote escaping: wrap in single quotes and escape embedded single quotes. Safe against injection +// when the harness runs `command` verbatim. +function sq(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function spec(action: string, description: string, inputs: Record, command: string): LocalWriteActionSpec { + return { action, description, inputs, command, boundary: LOCAL_WRITE_BOUNDARY }; +} + +/** Open a PR from a local branch (content typically taken from gittensory's prepare_pr_packet). */ +export function buildOpenPrSpec(input: { repoFullName: string; base: string; head: string; title: string; body: string; draft?: boolean | undefined }): LocalWriteActionSpec { + const draft = input.draft === true; + const command = `gh pr create --repo ${sq(input.repoFullName)} --base ${sq(input.base)} --head ${sq(input.head)} --title ${sq(input.title)} --body ${sq(input.body)}${draft ? " --draft" : ""}`; + return spec("open_pr", "Open a pull request from your local branch.", { repoFullName: input.repoFullName, base: input.base, head: input.head, title: input.title, body: input.body, draft }, command); +} + +/** File an issue (e.g. an issue-discovery proposal). */ +export function buildFileIssueSpec(input: { repoFullName: string; title: string; body: string; labels?: string[] | undefined }): LocalWriteActionSpec { + const labels = input.labels ?? []; + const labelArgs = labels.map((label) => ` --label ${sq(label)}`).join(""); + const command = `gh issue create --repo ${sq(input.repoFullName)} --title ${sq(input.title)} --body ${sq(input.body)}${labelArgs}`; + return spec("file_issue", "File a new issue.", { repoFullName: input.repoFullName, title: input.title, body: input.body, labels }, command); +} + +/** Add labels to an issue or PR (gh issue edit also targets PRs). */ +export function buildApplyLabelsSpec(input: { repoFullName: string; number: number; labels: string[] }): LocalWriteActionSpec { + const labelArgs = input.labels.map((label) => ` --add-label ${sq(label)}`).join(""); + const command = `gh issue edit ${input.number} --repo ${sq(input.repoFullName)}${labelArgs}`; + return spec("apply_labels", "Add labels to an issue or pull request.", { repoFullName: input.repoFullName, number: input.number, labels: input.labels }, command); +} + +/** Post an eligibility/context comment on an issue or PR. */ +export function buildPostEligibilityCommentSpec(input: { repoFullName: string; number: number; body: string }): LocalWriteActionSpec { + const command = `gh issue comment ${input.number} --repo ${sq(input.repoFullName)} --body ${sq(input.body)}`; + return spec("post_eligibility_comment", "Post an eligibility/context comment on an issue or pull request.", { repoFullName: input.repoFullName, number: input.number, body: input.body }, command); +} + +/** Create a local branch off an optional base. */ +export function buildCreateBranchSpec(input: { branch: string; base?: string | undefined }): LocalWriteActionSpec { + const command = input.base ? `git switch -c ${sq(input.branch)} ${sq(input.base)}` : `git switch -c ${sq(input.branch)}`; + return spec("create_branch", "Create a local branch.", { branch: input.branch, ...(input.base ? { base: input.base } : {}) }, command); +} + +/** Delete a branch locally, and optionally on the remote. */ +export function buildDeleteBranchSpec(input: { branch: string; remote?: boolean | undefined }): LocalWriteActionSpec { + const local = `git branch -D ${sq(input.branch)}`; + const command = input.remote === true ? `${local} && git push origin --delete ${sq(input.branch)}` : local; + return spec("delete_branch", "Delete a branch (locally, and optionally on origin).", { branch: input.branch, remote: input.remote === true }, command); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4bffeb4a19..7cf718bfc7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -88,6 +88,15 @@ import { import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { computeLocalScorerTokens } from "../signals/local-scorer"; +import { + buildApplyLabelsSpec, + buildCreateBranchSpec, + buildDeleteBranchSpec, + buildFileIssueSpec, + buildOpenPrSpec, + buildPostEligibilityCommentSpec, + type LocalWriteActionSpec, +} from "./local-write-tools"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildPredictedGateVerdict } from "../rules/predicted-gate"; import { buildIssueSlopAssessment, buildSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN, SLOP_RUBRIC_MARKDOWN } from "../signals/slop"; @@ -227,6 +236,45 @@ const runLocalScorerOutputSchema = { usage: z.string().optional(), }; +// #780 miner write-tools. Inputs are content/targets; the OUTPUT is an action spec the LOCAL harness runs with +// its own creds — gittensory never performs the write. +const WRITE_TOOL_TITLE_MAX = 400; +const WRITE_TOOL_BODY_MAX = 60000; +const WRITE_TOOL_BRANCH_MAX = 255; +const openPrShape = { + repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), + base: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS), + head: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS), + title: z.string().min(1).max(WRITE_TOOL_TITLE_MAX), + body: z.string().max(WRITE_TOOL_BODY_MAX), + draft: z.boolean().optional(), +}; +const fileIssueShape = { + repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), + title: z.string().min(1).max(WRITE_TOOL_TITLE_MAX), + body: z.string().max(WRITE_TOOL_BODY_MAX), + labels: z.array(z.string().min(1).max(100)).max(20).optional(), +}; +const applyLabelsShape = { + repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), + number: z.number().int().positive(), + labels: z.array(z.string().min(1).max(100)).min(1).max(20), +}; +const postEligibilityCommentShape = { + repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), + number: z.number().int().positive(), + body: z.string().min(1).max(WRITE_TOOL_BODY_MAX), +}; +const createBranchShape = { branch: z.string().min(1).max(WRITE_TOOL_BRANCH_MAX), base: z.string().min(1).max(WRITE_TOOL_BRANCH_MAX).optional() }; +const deleteBranchShape = { branch: z.string().min(1).max(WRITE_TOOL_BRANCH_MAX), remote: z.boolean().optional() }; +const localWriteActionOutputSchema = { + action: z.string(), + description: z.string(), + inputs: z.record(z.string(), z.unknown()), + command: z.string(), + boundary: z.string(), +}; + const localBranchAnalysisShape = { login: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS), repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), @@ -1026,6 +1074,38 @@ export class GittensoryMcp { async (input) => this.toolResult(this.runLocalScorer(input)), ); + // #780 miner write-tools — each returns a LOCAL-execution action spec; gittensory never performs the write. + server.registerTool( + "gittensory_open_pr", + { description: "Build a LOCAL-execution spec to open a pull request from your branch (run it with your own gh creds; gittensory never performs the write).", inputSchema: openPrShape, outputSchema: localWriteActionOutputSchema }, + async (input) => this.toolResult(this.localWriteSpec(buildOpenPrSpec(input))), + ); + server.registerTool( + "gittensory_file_issue", + { description: "Build a LOCAL-execution spec to file an issue (run it with your own gh creds; gittensory never performs the write).", inputSchema: fileIssueShape, outputSchema: localWriteActionOutputSchema }, + async (input) => this.toolResult(this.localWriteSpec(buildFileIssueSpec(input))), + ); + server.registerTool( + "gittensory_apply_labels", + { description: "Build a LOCAL-execution spec to add labels to an issue or PR (run it with your own gh creds; gittensory never performs the write).", inputSchema: applyLabelsShape, outputSchema: localWriteActionOutputSchema }, + async (input) => this.toolResult(this.localWriteSpec(buildApplyLabelsSpec(input))), + ); + server.registerTool( + "gittensory_post_eligibility_comment", + { description: "Build a LOCAL-execution spec to post an eligibility/context comment on an issue or PR (run it with your own gh creds; gittensory never performs the write).", inputSchema: postEligibilityCommentShape, outputSchema: localWriteActionOutputSchema }, + async (input) => this.toolResult(this.localWriteSpec(buildPostEligibilityCommentSpec(input))), + ); + server.registerTool( + "gittensory_create_branch", + { description: "Build a LOCAL-execution spec to create a branch (run it locally; gittensory never performs the write).", inputSchema: createBranchShape, outputSchema: localWriteActionOutputSchema }, + async (input) => this.toolResult(this.localWriteSpec(buildCreateBranchSpec(input))), + ); + server.registerTool( + "gittensory_delete_branch", + { description: "Build a LOCAL-execution spec to delete a branch (run it locally; gittensory never performs the write).", inputSchema: deleteBranchShape, outputSchema: localWriteActionOutputSchema }, + async (input) => this.toolResult(this.localWriteSpec(buildDeleteBranchSpec(input))), + ); + server.registerTool( "gittensory_explain_score_breakdown", { @@ -1781,6 +1861,12 @@ export class GittensoryMcp { }; } + // #780 — wrap a local write-action spec for return. gittensory never executes it; the harness runs `command` + // (or reconstructs from `inputs`) with the miner's own credentials. + private localWriteSpec(spec: LocalWriteActionSpec): ToolPayload { + return { summary: `${spec.action}: ${spec.description} ${spec.boundary}`, data: spec as unknown as Record }; + } + private async explainScoreBreakdown(input: z.infer>): Promise { if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown."); this.requireContributorAccess(input.contributorLogin); diff --git a/test/unit/local-write-tools.test.ts b/test/unit/local-write-tools.test.ts new file mode 100644 index 0000000000..f42a90587a --- /dev/null +++ b/test/unit/local-write-tools.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + LOCAL_WRITE_BOUNDARY, + buildApplyLabelsSpec, + buildCreateBranchSpec, + buildDeleteBranchSpec, + buildFileIssueSpec, + buildOpenPrSpec, + buildPostEligibilityCommentSpec, +} from "../../src/mcp/local-write-tools"; + +describe("local write-tool specs (#780)", () => { + it("open_pr builds a shell-safe gh command and carries the local-execution boundary", () => { + const s = buildOpenPrSpec({ repoFullName: "o/r", base: "main", head: "feat/x", title: "Add thing", body: "Body", draft: false }); + expect(s.action).toBe("open_pr"); + expect(s.command).toBe("gh pr create --repo 'o/r' --base 'main' --head 'feat/x' --title 'Add thing' --body 'Body'"); + expect(s.boundary).toBe(LOCAL_WRITE_BOUNDARY); + expect(s.inputs).toMatchObject({ repoFullName: "o/r", draft: false }); + }); + + it("open_pr appends --draft and POSIX-escapes embedded single quotes", () => { + const s = buildOpenPrSpec({ repoFullName: "o/r", base: "main", head: "h", title: "it's a fix", body: "x", draft: true }); + expect(s.command).toContain("--title 'it'\\''s a fix'"); + expect(s.command.endsWith("--draft")).toBe(true); + }); + + it("file_issue includes each label as a --label arg, and omits them when none", () => { + expect(buildFileIssueSpec({ repoFullName: "o/r", title: "T", body: "B", labels: ["bug", "good first issue"] }).command).toBe( + "gh issue create --repo 'o/r' --title 'T' --body 'B' --label 'bug' --label 'good first issue'", + ); + expect(buildFileIssueSpec({ repoFullName: "o/r", title: "T", body: "B" }).command).toBe("gh issue create --repo 'o/r' --title 'T' --body 'B'"); + }); + + it("apply_labels targets the number with --add-label", () => { + expect(buildApplyLabelsSpec({ repoFullName: "o/r", number: 7, labels: ["x", "y"] }).command).toBe("gh issue edit 7 --repo 'o/r' --add-label 'x' --add-label 'y'"); + }); + + it("post_eligibility_comment posts on the target number", () => { + const s = buildPostEligibilityCommentSpec({ repoFullName: "o/r", number: 7, body: "context" }); + expect(s.action).toBe("post_eligibility_comment"); + expect(s.command).toBe("gh issue comment 7 --repo 'o/r' --body 'context'"); + }); + + it("create_branch works with and without a base", () => { + expect(buildCreateBranchSpec({ branch: "feat/x" }).command).toBe("git switch -c 'feat/x'"); + expect(buildCreateBranchSpec({ branch: "feat/x", base: "main" }).command).toBe("git switch -c 'feat/x' 'main'"); + }); + + it("delete_branch is local-only by default, remote-deleting when asked", () => { + expect(buildDeleteBranchSpec({ branch: "feat/x" }).command).toBe("git branch -D 'feat/x'"); + expect(buildDeleteBranchSpec({ branch: "feat/x", remote: true }).command).toBe("git branch -D 'feat/x' && git push origin --delete 'feat/x'"); + }); +}); diff --git a/test/unit/mcp-write-tools.test.ts b/test/unit/mcp-write-tools.test.ts new file mode 100644 index 0000000000..873478efbc --- /dev/null +++ b/test/unit/mcp-write-tools.test.ts @@ -0,0 +1,48 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +async function connect() { + const server = new GittensoryMcp(createTestEnv()).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-write-tools-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +type Spec = { action: string; command: string; boundary: string; inputs: Record }; + +describe("MCP miner write-tools (#780)", () => { + it("open_pr returns a local-execution spec; gittensory performs no write", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "gittensory_open_pr", + arguments: { repoFullName: "o/r", base: "main", head: "feat/x", title: "Add thing", body: "Body", draft: true }, + }); + expect(result.isError).toBeFalsy(); + const spec = result.structuredContent as Spec; + expect(spec.action).toBe("open_pr"); + expect(spec.command).toBe("gh pr create --repo 'o/r' --base 'main' --head 'feat/x' --title 'Add thing' --body 'Body' --draft"); + expect(spec.boundary).toMatch(/your OWN GitHub credentials/i); + expect(spec.boundary).toMatch(/never performs the write/i); + }); + + it("file_issue / apply_labels / post_eligibility_comment / branch helpers all return runnable specs", async () => { + const client = await connect(); + const cases: Array<{ name: string; args: Record; expect: string }> = [ + { name: "gittensory_file_issue", args: { repoFullName: "o/r", title: "T", body: "B", labels: ["bug"] }, expect: "gh issue create --repo 'o/r' --title 'T' --body 'B' --label 'bug'" }, + { name: "gittensory_apply_labels", args: { repoFullName: "o/r", number: 7, labels: ["x"] }, expect: "gh issue edit 7 --repo 'o/r' --add-label 'x'" }, + { name: "gittensory_post_eligibility_comment", args: { repoFullName: "o/r", number: 7, body: "hi" }, expect: "gh issue comment 7 --repo 'o/r' --body 'hi'" }, + { name: "gittensory_create_branch", args: { branch: "feat/x", base: "main" }, expect: "git switch -c 'feat/x' 'main'" }, + { name: "gittensory_delete_branch", args: { branch: "feat/x", remote: true }, expect: "git branch -D 'feat/x' && git push origin --delete 'feat/x'" }, + ]; + for (const testCase of cases) { + const result = await client.callTool({ name: testCase.name, arguments: testCase.args }); + expect(result.isError, testCase.name).toBeFalsy(); + expect((result.structuredContent as Spec).command, testCase.name).toBe(testCase.expect); + } + }); +});