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
81 changes: 81 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,87 @@ export class GittensoryMcp {
async (input) => this.toolResult(await this.agentPreparePrPacket(input)),
);

// ── Miner planning prompts ───────────────────────────────────────────
server.registerPrompt(
"gittensory_select_contribution_issue",
{
title: "Select contribution issue",
description: "Identify the best open issue for a contributor to work on based on lane fit, issue quality, and queue signals. Advisory only — no GitHub writes.",
argsSchema: { ...ownerRepoShape, login: z.string().min(1) },
},
({ owner, repo, login }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Use gittensory_get_issue_quality and gittensory_explain_repo_decision for ${login} on ${owner}/${repo} to identify which open issues are the best fit. Rank candidates by actionability, lane alignment, and queue pressure. Present a short ranked list with a brief rationale for each. Do not create issues, file comments, or take any GitHub action — this is a planning aid for the contributor to decide from.`,
},
},
],
}),
);

server.registerPrompt(
"gittensory_draft_contribution_pr_packet",
{
title: "Draft contribution PR packet",
description: "Draft a public-safe PR submission packet for a planned contribution without uploading source code. Advisory only — no GitHub writes.",
argsSchema: { ...ownerRepoShape, login: z.string().min(1) },
},
({ owner, repo, login }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Use gittensory_get_repo_context and gittensory_get_decision_pack for ${login} to prepare a public-safe PR packet for work on ${owner}/${repo}. The packet should include lane fit, recommended next steps, and any preflight considerations the contributor should address before opening the PR. Do not open a PR, post any comment, or take any GitHub action — present the packet for the contributor to review and submit manually.`,
},
},
],
}),
);

server.registerPrompt(
"gittensory_preflight_contribution_branch",
{
title: "Preflight contribution branch",
description: "Assess branch readiness before opening a PR using cached lane and preflight signals. Advisory only — no GitHub writes.",
argsSchema: { ...ownerRepoShape, login: z.string().min(1) },
},
({ owner, repo, login }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Use gittensory_get_repo_context and gittensory_explain_repo_decision for ${login} on ${owner}/${repo} to assess whether the planned branch is ready to be submitted as a PR. Check lane fit, duplicate risk, linked issue coverage, and any signals that suggest the branch needs more work. Present a preflight summary the contributor can act on before opening the PR. Do not open a PR, push any branch, or take any GitHub action.`,
},
},
],
}),
);

server.registerPrompt(
"gittensory_plan_cleanup_first",
{
title: "Plan cleanup-first work",
description: "Identify open PRs to address before starting new work to reduce queue pressure and improve lane fit. Advisory only — no GitHub writes.",
argsSchema: { login: z.string().min(1) },
},
({ login }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Use gittensory_monitor_open_prs and gittensory_get_decision_pack for ${login} to identify which open PRs to address before starting new contribution work. Surface PRs with failing checks, pending review comments, stale queue pressure, or duplicate risk. Recommend an ordered cleanup list with a brief rationale for each item. Do not close PRs, post comments, or take any GitHub action — present the plan for the contributor to execute manually.`,
},
},
],
}),
);

return server;
}

Expand Down
177 changes: 177 additions & 0 deletions test/unit/mcp-miner-prompts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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";

// Forbidden terms that must never appear in miner planning prompt descriptions or content.
const FORBIDDEN_PROMPT_TERMS =
/wallet|hotkey|coldkey|mnemonic|seed phrase|payout|raw trust|trust score|reward estimate|farming|private reviewability|scoreability|private ranking/i;

// Explicit secret-request patterns that prompts must never contain.
const FORBIDDEN_REQUEST_PATTERNS = /enter your (wallet|hotkey|token|seed|key|mnemonic|password)|provide your (wallet|hotkey|token|seed|key)|paste your (hotkey|wallet|key)/i;

const MINER_PROMPT_NAMES = [
"gittensory_select_contribution_issue",
"gittensory_draft_contribution_pr_packet",
"gittensory_preflight_contribution_branch",
"gittensory_plan_cleanup_first",
];

async function connectTestClient() {
const mcpServer = new GittensoryMcp(createTestEnv()).createServer();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await mcpServer.connect(serverTransport);
const client = new Client({ name: "gittensory-miner-test", version: "0.1.0" }, { capabilities: {} });
await client.connect(clientTransport);
return { client, mcpServer };
}

// ── Discovery fixtures ────────────────────────────────────────────────────────

describe("MCP miner planning prompt discovery", () => {
it("lists all miner planning prompts via client discovery", async () => {
const { client } = await connectTestClient();
const { prompts } = await client.listPrompts();
const names = prompts.map((p) => p.name);

for (const expected of MINER_PROMPT_NAMES) {
expect(names, `expected miner prompt "${expected}" to be discoverable`).toContain(expected);
}
});

it("all miner prompt names are prefixed with gittensory_", async () => {
const { client } = await connectTestClient();
const { prompts } = await client.listPrompts();
for (const prompt of prompts) {
expect(prompt.name).toMatch(/^gittensory_/);
}
});

it("miner prompt descriptions do not expose forbidden terms", async () => {
const { client } = await connectTestClient();
const { prompts } = await client.listPrompts();
const minerPrompts = prompts.filter((p) => MINER_PROMPT_NAMES.includes(p.name));

expect(minerPrompts.length).toBe(MINER_PROMPT_NAMES.length);
for (const prompt of minerPrompts) {
expect(prompt.description ?? "", `prompt "${prompt.name}" description must not contain forbidden terms`).not.toMatch(FORBIDDEN_PROMPT_TERMS);
}
});

it("miner prompt inventory is stable — fails if any prompt is removed", async () => {
const { mcpServer } = await connectTestClient();
const registered = (mcpServer as unknown as { _registeredPrompts: Record<string, unknown> })._registeredPrompts;

for (const name of MINER_PROMPT_NAMES) {
expect(Object.keys(registered), `miner prompt "${name}" must remain registered`).toContain(name);
}
});

it("getting a non-existent miner prompt fails safely", async () => {
const { client } = await connectTestClient();
await expect(client.getPrompt({ name: "gittensory_nonexistent_miner_prompt" })).rejects.toThrow();
});
});

// ── Prompt content safety ─────────────────────────────────────────────────────

describe("MCP miner planning prompt content safety", () => {
it("gittensory_select_contribution_issue message is free of forbidden terms", async () => {
const { client } = await connectTestClient();
const result = await client.getPrompt({
name: "gittensory_select_contribution_issue",
arguments: { owner: "test-owner", repo: "test-repo", login: "contributor" },
});
for (const message of result.messages) {
const text = typeof message.content === "object" && "text" in message.content ? (message.content.text as string) : "";
expect(text).not.toMatch(FORBIDDEN_PROMPT_TERMS);
expect(text).not.toMatch(FORBIDDEN_REQUEST_PATTERNS);
}
});

it("gittensory_draft_contribution_pr_packet message is free of forbidden terms", async () => {
const { client } = await connectTestClient();
const result = await client.getPrompt({
name: "gittensory_draft_contribution_pr_packet",
arguments: { owner: "test-owner", repo: "test-repo", login: "contributor" },
});
for (const message of result.messages) {
const text = typeof message.content === "object" && "text" in message.content ? (message.content.text as string) : "";
expect(text).not.toMatch(FORBIDDEN_PROMPT_TERMS);
expect(text).not.toMatch(FORBIDDEN_REQUEST_PATTERNS);
}
});

it("gittensory_preflight_contribution_branch message is free of forbidden terms", async () => {
const { client } = await connectTestClient();
const result = await client.getPrompt({
name: "gittensory_preflight_contribution_branch",
arguments: { owner: "test-owner", repo: "test-repo", login: "contributor" },
});
for (const message of result.messages) {
const text = typeof message.content === "object" && "text" in message.content ? (message.content.text as string) : "";
expect(text).not.toMatch(FORBIDDEN_PROMPT_TERMS);
expect(text).not.toMatch(FORBIDDEN_REQUEST_PATTERNS);
}
});

it("gittensory_plan_cleanup_first message is free of forbidden terms", async () => {
const { client } = await connectTestClient();
const result = await client.getPrompt({
name: "gittensory_plan_cleanup_first",
arguments: { login: "contributor" },
});
for (const message of result.messages) {
const text = typeof message.content === "object" && "text" in message.content ? (message.content.text as string) : "";
expect(text).not.toMatch(FORBIDDEN_PROMPT_TERMS);
expect(text).not.toMatch(FORBIDDEN_REQUEST_PATTERNS);
}
});

it("all miner prompts confirm advisory-only intent — no autonomous GitHub writes", async () => {
const { client } = await connectTestClient();
const promptArgs: Record<string, Record<string, string>> = {
gittensory_select_contribution_issue: { owner: "o", repo: "r", login: "dev" },
gittensory_draft_contribution_pr_packet: { owner: "o", repo: "r", login: "dev" },
gittensory_preflight_contribution_branch: { owner: "o", repo: "r", login: "dev" },
gittensory_plan_cleanup_first: { login: "dev" },
};

for (const name of MINER_PROMPT_NAMES) {
const result = await client.getPrompt({ name, arguments: promptArgs[name] });
const allText = result.messages
.map((m) => (typeof m.content === "object" && "text" in m.content ? (m.content.text as string) : ""))
.join(" ");

expect(allText, `prompt "${name}" must not claim to create issues or PRs`).not.toMatch(
/\bcreate\s+(?:an?\s+)?(?:issue|pr|pull request|comment|label)\b/i,
);
expect(allText, `prompt "${name}" must not claim to merge or close`).not.toMatch(/\b(?:merge|close|push|commit)\b.*\bautomatically\b/i);
expect(allText, `prompt "${name}" must clarify advisory-only intent`).toMatch(
/do not|requires.*approval|human.*approval|manually|not.*autonomous|not.*post|not.*open.*pr|not.*create|not.*take.*action/i,
);
}
});

it("miner prompts do not request secrets, tokens, wallets, or hotkeys from the user", async () => {
const { client } = await connectTestClient();
const promptArgs: Record<string, Record<string, string>> = {
gittensory_select_contribution_issue: { owner: "o", repo: "r", login: "dev" },
gittensory_draft_contribution_pr_packet: { owner: "o", repo: "r", login: "dev" },
gittensory_preflight_contribution_branch: { owner: "o", repo: "r", login: "dev" },
gittensory_plan_cleanup_first: { login: "dev" },
};

for (const name of MINER_PROMPT_NAMES) {
const result = await client.getPrompt({ name, arguments: promptArgs[name] });
const allText = result.messages
.map((m) => (typeof m.content === "object" && "text" in m.content ? (m.content.text as string) : ""))
.join(" ");

expect(allText, `prompt "${name}" must not request secrets or private credentials`).not.toMatch(
/\b(?:wallet|hotkey|coldkey|mnemonic|seed phrase|private key|token|api key|password)\b/i,
);
}
});
});