Skip to content
Merged
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
70 changes: 70 additions & 0 deletions src/mcp/local-write-tools.ts
Original file line number Diff line number Diff line change
@@ -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<string, JsonValue>;
// 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<string, JsonValue>, 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);
}
86 changes: 86 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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<string, unknown> };
}

private async explainScoreBreakdown(input: z.infer<z.ZodObject<typeof scorePreviewShape>>): Promise<ToolPayload> {
if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown.");
this.requireContributorAccess(input.contributorLogin);
Expand Down
53 changes: 53 additions & 0 deletions test/unit/local-write-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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'");
});
});
48 changes: 48 additions & 0 deletions test/unit/mcp-write-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };

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<string, unknown>; 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);
}
});
});
Loading