From 5708faddbccfd9fec1c74ce280836790687825c4 Mon Sep 17 00:00:00 2001 From: Martin Bens Date: Sat, 3 Oct 2026 11:14:04 +0200 Subject: [PATCH] fix(github-mcp): rank the requested tool first in pi's tool_search pi's tool_search ranks deferred tools with BM25 and adds each MCP server's description and instructions to every one of its tools' search text. The old texts listed the nouns the tools are named after (issues, pull requests, runs), so those terms stopped telling tools apart and an exact-name query such as issue_view ranked 5th. The server descriptions in pi/index.ts and the instructions in config-read.json and config-write.json now share no word with any tool name. The vendored SDK does not send the instructions yet, but a later version would otherwise undo the fix. host-pi.md now tells the model to call a loaded tool directly and otherwise search by exact name with limit: 1, searching again when a different tool loads, because tool_search skips loaded tools and the default limit loads up to eight tools per search. search, repo_file, and with the write server pr_comments, pr_reviews and pr_comment still rank 2nd to 5th, since their own descriptions share terms with another tool. tool-search-ranking.test.ts runs pi's own ranker over both tool catalogs, with and without the instructions, and requires every tool to rank first except those five, each with a worst allowed rank. A second test fails when a server text shares a word with a tool name. pi_e2e.bats checks that the real tool_search with limit: 1 loads exactly pr_view and, in a new case, issue_view. The two pi unit test files now share one fake pi in fake-pi.ts. The shared tool directive now lists release_list and the root README lists releases. Both were missing. Closes #15 Co-Authored-By: Claude --- README.md | 2 +- plugin-tests/github-mcp/fixtures/pi/driver.ts | 3 +- plugin-tests/github-mcp/pi/fake-pi.ts | 34 +++ plugin-tests/github-mcp/pi/index.test.ts | 28 +- .../github-mcp/pi/tool-search-ranking.test.ts | 285 ++++++++++++++++++ plugin-tests/github-mcp/pi_e2e.bats | 25 +- plugins/github-mcp/AGENTS.md | 9 + plugins/github-mcp/CHANGELOG.md | 10 + plugins/github-mcp/hooks/prompts/host-pi.md | 2 +- .../hooks/prompts/mcp-tool-directives.md | 1 + .../github-mcp/mcp-server-gh/config-read.json | 2 +- .../mcp-server-gh/config-write.json | 2 +- plugins/github-mcp/pi/index.ts | 5 +- 13 files changed, 370 insertions(+), 38 deletions(-) create mode 100644 plugin-tests/github-mcp/pi/fake-pi.ts create mode 100644 plugin-tests/github-mcp/pi/tool-search-ranking.test.ts diff --git a/README.md b/README.md index 401b7ce..84f24af 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > **Experimental Community Project**: Maintained by Shopware Labs, not an official Shopware product. Not affiliated with, endorsed by, or sponsored by Anthropic, OpenAI, or any other AI provider. Provider and product names are used only to describe compatibility. Provided as-is without warranty. -GitHub CLI tools for AI coding agents. Wraps the GitHub CLI (`gh`) behind MCP servers — pull requests, issues, CI runs, jobs, commits, search, labels, projects, and repository file browsing — as first-class MCP tools, with hook-based enforcement that keeps the agent on the tools instead of raw `gh` bash calls. +GitHub CLI tools for AI coding agents. Wraps the GitHub CLI (`gh`) behind MCP servers — pull requests, issues, CI runs, jobs, commits, search, labels, projects, releases, and repository file browsing — as first-class MCP tools, with hook-based enforcement that keeps the agent on the tools instead of raw `gh` bash calls. The MCP servers use the assistant-neutral [Model Context Protocol](https://modelcontextprotocol.io/) and can be integrated with MCP-capable coding agents. This repository provides tested `github-mcp` plugin packaging for [Claude Code](https://docs.claude.com/en/docs/claude-code/plugins), [Codex](https://learn.chatgpt.com/docs/build-plugins), and [pi](https://pi.dev). diff --git a/plugin-tests/github-mcp/fixtures/pi/driver.ts b/plugin-tests/github-mcp/fixtures/pi/driver.ts index 61f0200..8bcab2f 100644 --- a/plugin-tests/github-mcp/fixtures/pi/driver.ts +++ b/plugin-tests/github-mcp/fixtures/pi/driver.ts @@ -63,7 +63,8 @@ return JSON.stringify({ results, caught }); "done", ], deferred: [ - fauxToolCall("tool_search", { query: "pr_view" }, { id: "search-pr-view" }), + fauxToolCall("tool_search", { limit: 1, query: "pr_view" }, { id: "search-pr-view" }), + fauxToolCall("tool_search", { limit: 1, query: "issue_view" }, { id: "search-issue-view" }), prView(8), ghPrViewInBash, fauxToolCall("tool_search", { query: "api_read" }, { id: "search-api-read" }), diff --git a/plugin-tests/github-mcp/pi/fake-pi.ts b/plugin-tests/github-mcp/pi/fake-pi.ts new file mode 100644 index 0000000..0221ee9 --- /dev/null +++ b/plugin-tests/github-mcp/pi/fake-pi.ts @@ -0,0 +1,34 @@ +import type { ExtensionAPI, McpServerConfig } from "@earendil-works/pi-coding-agent"; + +export type Handler = (event: unknown, ctx: unknown) => unknown; + +export type FakeMcpServerConfig = McpServerConfig; + +export interface FakePi { + handlers: Map; + servers: Map; +} + +function noop(): void { + // The fake pi has nothing to unsubscribe and no server to start. +} + +/** + * Registers `extension` on a fake pi that records every event handler and MCP server + * registration instead of running them. + */ +export function registerOnFakePi(extension: (pi: ExtensionAPI) => void): FakePi { + const handlers = new Map(); + const servers = new Map(); + const pi = { + on: (name: string, handler: Handler) => { + handlers.set(name, handler); + return noop; + }, + registerMcpServer: (name: string, config: FakeMcpServerConfig) => { + servers.set(name, config); + }, + }; + extension(pi as unknown as ExtensionAPI); + return { handlers, servers }; +} diff --git a/plugin-tests/github-mcp/pi/index.test.ts b/plugin-tests/github-mcp/pi/index.test.ts index 02c86af..2f0dba1 100644 --- a/plugin-tests/github-mcp/pi/index.test.ts +++ b/plugin-tests/github-mcp/pi/index.test.ts @@ -4,10 +4,8 @@ import { tmpdir } from "node:os"; import path from "node:path"; import test, { type TestContext } from "node:test"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { type Handler, registerOnFakePi } from "./fake-pi.ts"; import type githubMcp from "../../../plugins/github-mcp/pi/index.ts"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -type Handler = (event: unknown, ctx: unknown) => unknown; const PLUGIN_DIR = fileURLToPath(new URL("../../../plugins/github-mcp", import.meta.url)); @@ -19,26 +17,6 @@ function makeTemporaryDirectory(t: TestContext): string { return dir; } -function noop(): void { - // The fake pi has nothing to unsubscribe and no server to start. -} - -/** - * Registers the extension on a fake pi that only records the event handlers. - */ -function register(extension: typeof githubMcp): Map { - const handlers = new Map(); - const pi = { - on: (name: string, handler: Handler) => { - handlers.set(name, handler); - return noop; - }, - registerMcpServer: noop, - }; - extension(pi as unknown as ExtensionAPI); - return handlers; -} - function handler(handlers: Map, name: string): Handler { const found = handlers.get(name); if (found === undefined) { @@ -59,7 +37,7 @@ async function sectionsAfterSessionStart(t: TestContext, scriptBody: string): Pr writeFileSync(path.join(dir, "plugin", "hooks", "scripts", "session-start.sh"), scriptBody); const indexUrl = pathToFileURL(path.join(dir, "plugin", "pi", "index.ts")).href; const { default: extension } = (await import(indexUrl)) as { default: typeof githubMcp }; - const handlers = register(extension); + const handlers = registerOnFakePi(extension).handlers; await handler(handlers, "session_start")({}, { cwd: dir }); const sections: Record = {}; @@ -69,7 +47,7 @@ async function sectionsAfterSessionStart(t: TestContext, scriptBody: string): Pr async function loadRealExtension(): Promise> { const { default: extension } = await import("../../../plugins/github-mcp/pi/index.ts"); - return register(extension); + return registerOnFakePi(extension).handlers; } test("the session-start directive becomes the github_mcp system prompt section", async (t) => { diff --git a/plugin-tests/github-mcp/pi/tool-search-ranking.test.ts b/plugin-tests/github-mcp/pi/tool-search-ranking.test.ts new file mode 100644 index 0000000..3e269b4 --- /dev/null +++ b/plugin-tests/github-mcp/pi/tool-search-ranking.test.ts @@ -0,0 +1,285 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { registerOnFakePi } from "./fake-pi.ts"; + +const PLUGIN_DIR = fileURLToPath(new URL("../../../plugins/github-mcp", import.meta.url)); + +interface ToolDefinition { + description: string; + inputSchema: Record; + name: string; +} + +interface ToolCatalog { + tools: ToolDefinition[]; +} + +interface SearchDocument { + name: string; + text: string; +} + +interface SearchNamespace { + description?: string; + instructions?: string; + name: string; +} + +interface ServerSpec { + catalog: ToolCatalog; + description: string; + instructions: string; + name: string; +} + +interface Bm25Match { + name: string; + score: number; +} + +interface Bm25RankerInstance { + rank(query: string, documents: SearchDocument[], limit: number): Bm25Match[]; +} + +type Bm25RankerConstructor = new (options?: Record) => Bm25RankerInstance; + +interface ToolSearchModule { + Bm25Ranker: Bm25RankerConstructor; + createToolSearchDocument: ( + tool: { description: string; name: string; parameters: unknown }, + namespace?: SearchNamespace, + ) => SearchDocument; + tokenize: (text: string) => string[]; +} + +interface McpServersModule { + mcpNamespace: (server: string) => string; +} + +interface McpToolsModule { + createMcpToolName: (server: string, tool: string, isTaken?: (name: string) => boolean) => string; +} + +/** + * These are pi internals loaded by file path because they are not in the package `exports` map. A + * pi upgrade that moves, renames, or removes any of these exports fails this test instead of + * silently losing the coverage. + */ +async function loadPiInternal(relativePath: string, exportNames: readonly string[]): Promise { + const piPackageDirectory = path.dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))); + if (path.basename(piPackageDirectory) !== "dist" || path.basename(path.dirname(piPackageDirectory)) !== "pi-coding-agent") { + throw new Error(`resolved pi package directory "${piPackageDirectory}" is not ".../pi-coding-agent/dist"`); + } + const absolute = path.join(piPackageDirectory, relativePath); + const loaded = (await import(pathToFileURL(absolute).href)) as Record; + for (const exportName of exportNames) { + if (typeof loaded[exportName] !== "function") { + throw new TypeError(`pi internal "${relativePath}" has no function export "${exportName}"`); + } + } + return loaded as unknown as T; +} + +/** + * Registers the real extension on a fake pi and returns both servers' descriptions, throwing if a + * server or its description is missing. + */ +async function registeredDescriptions(): Promise<{ read: string; write: string }> { + const { default: extension } = await import("../../../plugins/github-mcp/pi/index.ts"); + const { servers } = registerOnFakePi(extension); + const read = servers.get("gh-tooling"); + if (read === undefined) { + throw new Error('the extension registered no "gh-tooling" server'); + } + const write = servers.get("gh-tooling-write"); + if (write === undefined) { + throw new Error('the extension registered no "gh-tooling-write" server'); + } + if (read.description === undefined) { + throw new Error('gh-tooling server "gh-tooling" registered with no description'); + } + if (write.description === undefined) { + throw new Error('gh-tooling server "gh-tooling-write" registered with no description'); + } + return { read: read.description, write: write.description }; +} + +/** + * Reads a server config's `instructions`. The vendored SDK does not send this field to pi today + * (see plugins/github-mcp/shared/mcpserver_core.sh), so this test includes it anyway: a future SDK + * version that does send it must not be able to undo the ranking fix. + */ +function readInstructions(fileName: string): string { + const raw = readFileSync(path.join(PLUGIN_DIR, "mcp-server-gh", fileName), "utf8"); + const config = JSON.parse(raw) as { instructions?: unknown }; + if (typeof config.instructions !== "string" || config.instructions.trim() === "") { + throw new Error(`"${fileName}" has no non-empty "instructions" string`); + } + return config.instructions; +} + +function loadCatalog(fileName: string): ToolCatalog { + const raw = readFileSync(path.join(PLUGIN_DIR, "mcp-server-gh", fileName), "utf8"); + const catalog = JSON.parse(raw) as { tools: unknown }; + if (!Array.isArray(catalog.tools) || catalog.tools.length === 0) { + throw new Error(`catalog "${fileName}" has no tools`); + } + for (const [index, tool] of catalog.tools.entries()) { + const candidate = tool as Record; + const label = typeof candidate.name === "string" && candidate.name !== "" ? `"${candidate.name}"` : `index ${String(index)}`; + if (typeof candidate.name !== "string" || candidate.name === "") { + throw new TypeError(`catalog "${fileName}" tool at index ${String(index)} has no non-empty string "name"`); + } + if (typeof candidate.description !== "string") { + throw new TypeError(`catalog "${fileName}" tool ${label} has no string "description"`); + } + if (typeof candidate.inputSchema !== "object" || candidate.inputSchema === null || Array.isArray(candidate.inputSchema)) { + throw new TypeError(`catalog "${fileName}" tool ${label} has no object "inputSchema"`); + } + } + return catalog as ToolCatalog; +} + +/** + * Builds search documents the way pi does: one per tool, carrying its server's namespace. + */ +function buildDocuments( + servers: ServerSpec[], + hasInstructions: boolean, + createToolSearchDocument: ToolSearchModule["createToolSearchDocument"], + mcpNamespace: McpServersModule["mcpNamespace"], + createMcpToolName: McpToolsModule["createMcpToolName"], +): SearchDocument[] { + const documents: SearchDocument[] = []; + for (const server of servers) { + const namespace: SearchNamespace = { + description: server.description, + instructions: hasInstructions ? server.instructions : undefined, + name: mcpNamespace(server.name), + }; + for (const tool of server.catalog.tools) { + documents.push( + createToolSearchDocument( + { description: tool.description, name: createMcpToolName(server.name, tool.name), parameters: tool.inputSchema }, + namespace, + ), + ); + } + } + return documents; +} + +/** + * Worst rank allowed for a tool whose name shares terms with another tool. Every tool not listed + * here must rank first for its own bare name. + */ +const WORST_ALLOWED_RANK: Record = { + pr_comment: 3, + pr_comments: 5, + pr_reviews: 3, + repo_file: 3, + search: 3, +}; + +test("tool_search ranks every tool within its allowed worst rank, with and without instructions", async () => { + const { read: readDescription, write: writeDescription } = await registeredDescriptions(); + + const { Bm25Ranker: bmRanker, createToolSearchDocument } = await loadPiInternal( + "extensions/tool-search/tool.js", + ["Bm25Ranker", "createToolSearchDocument"], + ); + const { mcpNamespace } = await loadPiInternal("core/mcp-servers.js", ["mcpNamespace"]); + const { createMcpToolName } = await loadPiInternal("extensions/mcp/tools.js", ["createMcpToolName"]); + + const readServer: ServerSpec = { + catalog: loadCatalog("tools-read.json"), + description: readDescription, + instructions: readInstructions("config-read.json"), + name: "gh-tooling", + }; + const writeServer: ServerSpec = { + catalog: loadCatalog("tools-write.json"), + description: writeDescription, + instructions: readInstructions("config-write.json"), + name: "gh-tooling-write", + }; + + const scenarios: { hasInstructions: boolean; label: string; servers: ServerSpec[]; }[] = [ + { hasInstructions: false, label: "read server only, without instructions", servers: [readServer] }, + { hasInstructions: false, label: "read + write servers, without instructions", servers: [readServer, writeServer] }, + { hasInstructions: true, label: "read server only, with instructions", servers: [readServer] }, + { hasInstructions: true, label: "read + write servers, with instructions", servers: [readServer, writeServer] }, + ]; + + for (const scenario of scenarios) { + const documents = buildDocuments( + scenario.servers, + scenario.hasInstructions, + createToolSearchDocument, + mcpNamespace, + createMcpToolName, + ); + const ranker = new bmRanker(); + const violations: string[] = []; + + for (const server of scenario.servers) { + for (const tool of server.catalog.tools) { + const expected = createMcpToolName(server.name, tool.name); + const matches = ranker.rank(tool.name, documents, documents.length); + const index = matches.findIndex((match) => match.name === expected); + + if (index === -1) { + violations.push(`"${tool.name}" (expected "${expected}") was not found among the ranked results`); + continue; + } + + const allowed = WORST_ALLOWED_RANK[tool.name] ?? 1; + const rank = index + 1; + if (rank > allowed) { + violations.push( + `"${tool.name}" ranked ${String(rank)}, allowed ${String(allowed)}, first ranked was "${matches[0]?.name ?? "none"}"`, + ); + } + } + } + + assert.equal(violations.length, 0, `scenario "${scenario.label}": ${violations.join("; ")}`); + } +}); + +test("server descriptions and instructions share no terms with tool names", async () => { + const { read: readDescription, write: writeDescription } = await registeredDescriptions(); + + const { tokenize } = await loadPiInternal("extensions/tool-search/tool.js", ["tokenize"]); + + const sources: { label: string; text: string }[] = [ + { label: "gh-tooling description", text: readDescription }, + { label: "gh-tooling instructions", text: readInstructions("config-read.json") }, + { label: "gh-tooling-write description", text: writeDescription }, + { label: "gh-tooling-write instructions", text: readInstructions("config-write.json") }, + ]; + + const toolNameTerms = new Set(); + for (const fileName of ["tools-read.json", "tools-write.json"]) { + for (const tool of loadCatalog(fileName).tools) { + for (const term of tokenize(tool.name)) { + toolNameTerms.add(term); + } + } + } + + const overlaps: string[] = []; + for (const source of sources) { + const terms = new Set(tokenize(source.text)); + for (const term of terms) { + if (toolNameTerms.has(term)) { + overlaps.push(`"${term}" in ${source.label}`); + } + } + } + + assert.equal(overlaps.length, 0, `server text shares terms with tool names: ${overlaps.join(", ")}`); +}); diff --git a/plugin-tests/github-mcp/pi_e2e.bats b/plugin-tests/github-mcp/pi_e2e.bats index a4cedf2..5dc9418 100644 --- a/plugin-tests/github-mcp/pi_e2e.bats +++ b/plugin-tests/github-mcp/pi_e2e.bats @@ -183,13 +183,24 @@ assert_tools_deferred_until_searched() { assert_output "{\"all\":${expected_count},\"exposures\":[\"deferred\"]}" } -assert_tool_search_loads_pr_view() { - local result - result=$(tool_result "$1" search-pr-view) +assert_tool_search_loads_one_tool() { + local run_name="$1" tool_call_id="$2" tool_name="$3" result text + result=$(tool_result "${run_name}" "${tool_call_id}") run jq -r '.isError' <<<"${result}" assert_output "false" - run jq -r '.text' <<<"${result}" - assert_output --partial "mcp__gh_tooling__pr_view" + text=$(jq -r '.text' <<<"${result}") + run grep -c -E '^- [^[:space:]]+: ' <<<"${text}" + assert_output "1" + run grep -c -- "^- ${tool_name}: " <<<"${text}" + assert_output "1" +} + +assert_tool_search_loads_pr_view() { + assert_tool_search_loads_one_tool "$1" search-pr-view mcp__gh_tooling__pr_view +} + +assert_tool_search_loads_issue_view() { + assert_tool_search_loads_one_tool "$1" search-issue-view mcp__gh_tooling__issue_view } assert_pr_view_returns_stub() { @@ -247,6 +258,10 @@ assert_pi_exits_cleanly() { assert_tool_search_loads_pr_view git-deferred } +@test "git layout: tool_search for issue_view loads mcp__gh_tooling__issue_view" { + assert_tool_search_loads_issue_view git-deferred +} + @test "git layout: pr_view returns the stub's JSON" { assert_pr_view_returns_stub git-deferred } diff --git a/plugins/github-mcp/AGENTS.md b/plugins/github-mcp/AGENTS.md index fd49fdf..69e7f34 100644 --- a/plugins/github-mcp/AGENTS.md +++ b/plugins/github-mcp/AGENTS.md @@ -167,6 +167,10 @@ Where gh's stdout is a value the tool parses (an ID, a SHA, a JSON listing) rath 6. Add tool definition to `mcp-server-gh/tools-read.json` 7. If new file: source it in `mcp-server-gh/server-read.sh` 8. Update README.md and REFERENCE.md +9. Check that the tool name shares no word with the server descriptions in `pi/index.ts` or the + `instructions` in `config-read.json`/`config-write.json` — pi's `tool_search` adds those texts to + every tool's search text. `pi/tool-search-ranking.test.ts` checks this and that the new tool ranks + first for its own name. **Adding a new write tool:** 1. Choose or create appropriate `mcp-server-gh/lib/_write.sh` @@ -175,6 +179,10 @@ Where gh's stdout is a value the tool parses (an ID, a SHA, a JSON listing) rath 4. If new file: source it in `mcp-server-gh/server-write.sh` 5. Add bash command blocking in `hooks/scripts/check-gh-tools.sh` 6. Update README.md and REFERENCE.md +7. Check that the tool name shares no word with the server descriptions in `pi/index.ts` or the + `instructions` in `config-read.json`/`config-write.json` — pi's `tool_search` adds those texts to + every tool's search text. `pi/tool-search-ranking.test.ts` checks this and that the new tool ranks + first for its own name. **Modifying the pi extension:** 1. `pi/index.ts` registers the servers, runs the SessionStart directive, and wires the two gates — @@ -249,6 +257,7 @@ own unit tests are Node tests under `plugin-tests/github-mcp/pi/`: | `write_tools_review.bats` | `pr_review_submit`/`pr_comment`/`pr_review_reply` parameter handling and REST payloads | | `pi/gate.test.ts` | `runGate()`/`runScript()`: exit codes, stderr capture, stdin handling, and the timeout that kills a gate's background children | | `pi/index.test.ts` | The extension's event handlers on a fake pi: the `github_mcp` section from session-start output (malformed output and non-zero exits add none), and which `bash` and `api_read` calls the gates block or let through | +| `pi/tool-search-ranking.test.ts` | Exact-name ranking with pi's own ranker over the real tool catalogs, server descriptions, and server instructions; and that no server description or instructions term matches a tool-name term | The vendored SDK's own surface — argument validation and logging — is tested upstream in `shopwareLabs/bash-mcp-sdk`, not here. diff --git a/plugins/github-mcp/CHANGELOG.md b/plugins/github-mcp/CHANGELOG.md index 757707f..760a71e 100644 --- a/plugins/github-mcp/CHANGELOG.md +++ b/plugins/github-mcp/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- In pi, `tool_search` now ranks the requested gh-tooling tool first for most exact-name queries, + such as `issue_view` (before: 5th). Names that share terms with another tool can still rank 2nd to + 5th: `search`, `repo_file`, and with the write server `pr_comments`, `pr_reviews`, and + `pr_comment`. pi adds each server's description and instructions to every one of its tools' search + text, and the old texts repeated the words the tools are named after. The descriptions and the + server instructions now use none of those words (the instructions are not sent by the current SDK + yet). The pi directive now tells the model to search with `limit: 1` and to search again when a + different tool loads; most lookups now load one tool instead of up to eight, and each of the names + listed above takes one more search per place it ranks below first. +- The shared tool directive now lists `release_list`, which was missing (affects every host). - An invalid `grep_pattern` now fails the tool call with `Error: Invalid grep_pattern` before `gh` runs, so `run_logs` and `job_logs` no longer download a log and `search_code` no longer spends a search request first. Before, grep's exit status was discarded and the call succeeded, returning diff --git a/plugins/github-mcp/hooks/prompts/host-pi.md b/plugins/github-mcp/hooks/prompts/host-pi.md index dc6ebf2..ca55eb2 100644 --- a/plugins/github-mcp/hooks/prompts/host-pi.md +++ b/plugins/github-mcp/hooks/prompts/host-pi.md @@ -1 +1 @@ -In pi, these tools are named `mcp__gh_tooling__` (read) and `mcp__gh_tooling_write__` (write), for example `mcp__gh_tooling__pr_view`. A tool that `tool_search` loads is callable from your next call. +In pi, these tools are named `mcp__gh_tooling__` (read) and `mcp__gh_tooling_write__` (write), for example `mcp__gh_tooling__pr_view`. While they are deferred, which is the default, call a tool that is already loaded directly; otherwise query `tool_search` with the exact tool name and `limit: 1`, and if it loads a different tool, search again. A tool that `tool_search` loads is callable from your next call. diff --git a/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md b/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md index 1c43135..88a0d7e 100644 --- a/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md +++ b/plugins/github-mcp/hooks/prompts/mcp-tool-directives.md @@ -13,6 +13,7 @@ CI: run_view, run_list, run_logs, workflow_jobs, job_view, job_logs, job_annotat Commits: commit_pulls Search: search, search_code, search_repos, search_commits, search_discussions Repo: repo_tree, repo_file +Releases: release_list Labels: label_list Projects: project_list, project_view Escape hatch (last resort): api_read (GET only). Use only when no dedicated tool covers your endpoint — not as a fallback when a dedicated tool returns an error. diff --git a/plugins/github-mcp/mcp-server-gh/config-read.json b/plugins/github-mcp/mcp-server-gh/config-read.json index 871b9dd..73d7ab1 100644 --- a/plugins/github-mcp/mcp-server-gh/config-read.json +++ b/plugins/github-mcp/mcp-server-gh/config-read.json @@ -9,5 +9,5 @@ "listChanged": false } }, - "instructions": "GitHub CLI tools for operations against remote GitHub repositories via the GitHub API. All tools require network connectivity and the commit/branch to be pushed to GitHub. This is NOT a replacement for local git — for local git operations (git show, git log, git diff, git status) use Bash directly. Use these tools exclusively for GitHub-specific data: pull requests, issues, CI runs, jobs, PR associations for pushed commits, code/repo/commit/discussion search, and repository file browsing. Use repo_tree and repo_file instead of WebFetch for GitHub URLs — they return clean data via the API instead of downloading ~200KB of HTML. Requires the gh CLI to be installed and authenticated (gh auth login). Configure a default repository via .mcp-gh-tooling.json to avoid passing 'repo' to every tool call." + "instructions": "Remote GitHub data through the gh CLI. Needs network access; local work shows up only after it is pushed. Not a replacement for local git: use Bash for local history and uncommitted changes. Prefer these tools over WebFetch for GitHub URLs, which return about 200 KB of HTML. Needs the gh CLI installed and authenticated (gh auth login). A default repository in .mcp-gh-tooling.json saves passing a repository on every call." } diff --git a/plugins/github-mcp/mcp-server-gh/config-write.json b/plugins/github-mcp/mcp-server-gh/config-write.json index c259f4e..b6fa591 100644 --- a/plugins/github-mcp/mcp-server-gh/config-write.json +++ b/plugins/github-mcp/mcp-server-gh/config-write.json @@ -9,5 +9,5 @@ "listChanged": false } }, - "instructions": "GitHub write operations: create and edit PRs, issues, reviews, comments, labels, assignees, sub-issues, and projects. Requires enable_write_server: true in .mcp-gh-tooling.json. All tools require network connectivity and the gh CLI to be authenticated." + "instructions": "Changes to remote GitHub data through the gh CLI. Needs enable_write_server: true in .mcp-gh-tooling.json, network access, and an authenticated gh CLI." } diff --git a/plugins/github-mcp/pi/index.ts b/plugins/github-mcp/pi/index.ts index 292c97a..83f52b2 100644 --- a/plugins/github-mcp/pi/index.ts +++ b/plugins/github-mcp/pi/index.ts @@ -28,15 +28,14 @@ function readAdditionalContext(stdout: string): string { export default function githubMcp(pi: ExtensionAPI): void { pi.registerMcpServer("gh-tooling", { command: path.join(PLUGIN_ROOT, "mcp-server-gh", "server-read.sh"), - description: - "GitHub via the gh CLI: pull requests, issues, CI runs and logs, commits, search, labels, projects, repository files.", + description: "Remote GitHub data through the gh CLI, not local git.", env: { GITHUB_MCP_HOST: "pi" }, exposure: "deferred", }); pi.registerMcpServer("gh-tooling-write", { command: path.join(PLUGIN_ROOT, "mcp-server-gh", "server-write.sh"), description: - "GitHub writes via the gh CLI: create and edit pull requests, issues, reviews, comments, labels, projects. Empty unless enable_write_server is set.", + "Changes to remote GitHub data through the gh CLI, not local git. Empty unless enable_write_server is true.", env: { GITHUB_MCP_HOST: "pi" }, exposure: "deferred", });