From 165b10bd98f842d5a3f1b1f8bd1cf731271b4a4e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 16 Sep 2026 17:10:34 -0700 Subject: [PATCH] fix: enable GitHub Actions MCP toolset (#13553) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The GitHub connector lets agents use repository tools through MCP. > - GitHub excludes Actions from its default MCP toolsets. > - Approval of Actions permissions therefore does not make workflow tools appear in Paperclip. > - This pull request adds Actions to the requested toolsets for discovery and execution. > - Users can refresh existing connections and use workflow tools under the existing access rules. ## Linked Issues or Issue Description **What happened?** GitHub Actions tools remain absent after the GitHub App receives Actions read/write access and the user refreshes actions in Paperclip. Paperclip does not request the Actions MCP toolset. **Expected behavior** Authorized GitHub connections expose workflow tools, including `actions_run_trigger` with `method: "run_workflow"`, so agents can dispatch an existing release workflow. **Steps to reproduce** 1. Connect GitHub to Paperclip with access to a repository that has a dispatchable workflow. 2. Grant the GitHub App Actions read/write permission and approve the installation update. 3. Refresh the connection's actions in Paperclip. 4. Observe that the workflow tools are absent. **Paperclip version or commit** Base commit: `fae698031`. **Deployment mode** Hosted instance with a managed GitHub connection. The same missing header affects PAT connections. No matching public issue or pull request was found in the duplicate search. ## What Changed - Send `X-MCP-Toolsets: default,actions` through the shared GitHub MCP header helper. This covers discovery, refresh, and execution for existing and new managed or PAT connections, including legacy rows identified through `transportConfig`. - Test catalog refresh, tool risk classification, and workflow dispatch through a mock MCP server. - Document tool names, workflow arguments, required GitHub permissions, and the refresh step. ## Verification - Passed both affected test suites: `pnpm exec vitest run server/src/__tests__/tool-access-service.test.ts server/src/__tests__/tool-gateway.test.ts` (391 tests). - Passed `pnpm check:token-gates` and `git diff --check`. - Live provider check: the default catalog returned 45 tools. `default,actions` returned 49 tools, with no tools removed. The four added tools were `actions_get`, `actions_list`, `actions_run_trigger`, and `get_job_logs`. - Live `actions_get` / `get_workflow` call succeeded. No workflow was dispatched during live verification. - Passed `pnpm -r typecheck` and `pnpm build` with the existing Rust toolchain added to PATH. - Rechecked server typecheck and build after the legacy-connection fix; both passed. - The full local test run has reported three skills-cache failures in `company-skills-service.test.ts`. All three reproduce on the untouched base commit (`fae698031`) on this macOS host: runtime-cache directory renames fail with `EACCES`. The full run remains in progress. - Greptile: 5/5 on `7d391e3c7`, with no unresolved review threads. - After deployment, use **Refresh actions** on an existing GitHub connection and verify the workflow tools appear. ## Risks - Refreshed GitHub catalogs expose more tools. Existing access, approval, and quarantine rules still apply. `actions_run_trigger` keeps GitHub's destructive classification because it also supports cancellation and log deletion. - GitHub still enforces token and installation permissions. Dispatch requires Actions write permission and a workflow with `workflow_dispatch`. - No database migration or saved connection edit is required. ## Model Used OpenAI GPT-6 through Codex, with code execution and tool use. The exact serving model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/GITHUB.md | 23 ++++++ .../src/__tests__/tool-access-service.test.ts | 73 +++++++++++++++++++ server/src/__tests__/tool-gateway.test.ts | 50 +++++++++++++ server/src/services/tool-access.ts | 21 ++++-- 4 files changed, 161 insertions(+), 6 deletions(-) diff --git a/doc/connections/GITHUB.md b/doc/connections/GITHUB.md index f6b47653436..78ecb4619ef 100644 --- a/doc/connections/GITHUB.md +++ b/doc/connections/GITHUB.md @@ -100,6 +100,29 @@ OAuth. Production must advertise the `github.code` profile (see Cloud's preserves the sign-in intent and offers a retry instead of silently switching to a personal access token. A successful retry preserves the chosen audience. +## GitHub Actions tools + +Managed and PAT connections request `X-MCP-Toolsets: default,actions` for MCP +discovery and invocation. GitHub's default catalog excludes Actions; granting +Actions permissions alone does not expose workflow tools. Existing connections +can use **Refresh actions** after upgrading to discover the added tools. +The normal catalog, access, approval, and quarantine rules still apply. + +To dispatch an existing workflow, use `actions_run_trigger` with +`method: "run_workflow"`, the repository owner and name, `workflow_id`, `ref`, +and any workflow `inputs`. The workflow must declare `workflow_dispatch`. +The GitHub App installation or fine-grained PAT needs **Actions: Read and +write** for the repository. App owners set that permission on the GitHub App +registration; installation owners must approve an increase before it takes +effect. Paperclip's action controls do not grant GitHub permissions. + +The tool also supports rerunning and cancelling runs and deleting run logs. +It retains GitHub's destructive classification. Read tools include +`actions_list`, `actions_get`, and `get_job_logs`. + +Provider references: [MCP toolset configuration](https://github.com/github/github-mcp-server/blob/main/docs/server-configuration.md) +and [workflow dispatch permissions](https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event). + ## Webhooks Paperclip Cloud verifies `X-Hub-Signature-256` against the exact bounded request diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 91203224d61..6aa801701aa 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -66,6 +66,7 @@ import { import { classifyRisk, normalizeConnectionMethodConfig, + projectedConnectionHeaders, projectConnectionMethodToolInputSchema, projectConnectionMethodToolArguments, toolAccessService, @@ -14287,6 +14288,42 @@ describeEmbeddedPostgres("tool access service", () => { }); }); + it("discovers GitHub Actions tools during PAT setup and legacy catalog refresh", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const fetchMock = mockToolsList([ + { name: "actions_list", annotations: { readOnlyHint: true } }, + { + name: "actions_run_trigger", + annotations: { readOnlyHint: false, destructiveHint: true }, + }, + ]); + const actor = { actorType: "user" as const, actorId: "board" }; + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "github", + connectionMethodKey: "mcp-key", + credentialValues: { "credentials.authorization": "github-actions-test-token" }, + }, actor); + expect(connected.catalog).toEqual(expect.arrayContaining([ + expect.objectContaining({ toolName: "actions_list", riskLevel: "read" }), + expect.objectContaining({ toolName: "actions_run_trigger", riskLevel: "destructive" }), + ])); + + const setupRequestCount = fetchMock.mock.calls.length; + expect(setupRequestCount).toBeGreaterThan(0); + await db.update(toolConnections).set({ + config: sql`${toolConnections.config} - 'sourceTemplateKey'`, + }).where(eq(toolConnections.id, connected.connectionId)); + await service.refreshCatalog(connected.connectionId, actor); + expect(fetchMock.mock.calls.length).toBeGreaterThan(setupRequestCount); + for (const [url, init] of fetchMock.mock.calls) { + expect(url).toBe("https://api.githubcopilot.com/mcp/"); + expect(new Headers(init?.headers).get("X-MCP-Toolsets")).toBe("default,actions"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer github-actions-test-token"); + } + expect(JSON.stringify(connected)).not.toContain("github-actions-test-token"); + }); + it("connects gallery apps and finishes access profiles, bindings, and ask-first policies", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); @@ -17836,6 +17873,42 @@ describe("classifyRisk", () => { }); }); +describe("projectedConnectionHeaders", () => { + it.each([undefined, "managed", "mcp-key"])( + "adds Actions to the default GitHub toolsets for method %s without saved configuration", + (connectionMethodKey) => { + const connection = { + transport: "mcp_remote", + config: { sourceTemplateKey: "github", connectionMethodKey }, + } as typeof toolConnections.$inferSelect; + expect(projectedConnectionHeaders(connection)).toEqual({ + "X-MCP-Toolsets": "default,actions", + }); + expect(connection.config).not.toHaveProperty("headers"); + }, + ); + + it("recognizes GitHub in legacy transport configuration", () => { + const connection = { + transport: "mcp_remote", + config: {}, + transportConfig: { sourceTemplateKey: "github" }, + } as typeof toolConnections.$inferSelect; + expect(projectedConnectionHeaders(connection)).toEqual({ + "X-MCP-Toolsets": "default,actions", + }); + }); + + it("keeps unrelated MCP connections unchanged", () => { + for (const connection of [ + { transport: "mcp_remote", config: { url: "https://fixture.example/mcp" } }, + { transport: "mcp_remote", config: { sourceTemplateKey: "notion" } }, + ]) { + expect(projectedConnectionHeaders(connection as typeof toolConnections.$inferSelect)).toEqual({}); + } + }); +}); + describe("normalizeConnectionMethodConfig", () => { const posthog = getConnectableAppDefinition("posthog")!; const apiKeyMethod = posthog.methods.find( diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index a71bd968966..9c200f79b22 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -2115,6 +2115,56 @@ rl.on("line", (line) => { } }); + it.each([ + { connectionMethodKey: "managed", transportConfigOnly: false }, + { connectionMethodKey: "mcp-key", transportConfigOnly: false }, + { connectionMethodKey: "managed", transportConfigOnly: true }, + { connectionMethodKey: "mcp-key", transportConfigOnly: true }, + ])("sends the GitHub Actions toolset for $connectionMethodKey (legacy config: $transportConfigOnly)", async ({ connectionMethodKey, transportConfigOnly }) => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const parameters = { method: "run_workflow", owner: "example", repo: "release", workflow_id: "nightly.yml", ref: "main" }; + const fake = await startFakeRemoteMcpServer((fakeRequest) => { + expect(fakeRequest.headers["x-mcp-toolsets"]).toBe("default,actions"); + expect(fakeRequest.body).toMatchObject({ + method: "tools/call", + params: { name: "actions_run_trigger", arguments: parameters }, + }); + return { body: { jsonrpc: "2.0", id: fakeRequest.body?.id, result: { content: [{ type: "text", text: "Workflow dispatched" }] } } }; + }); + try { + const { connection, catalogEntry } = await createRemoteMcpTool(db, company.id, { + applicationKey: "github", + url: fake.url, + toolName: "actions_run_trigger", + riskLevel: "destructive", + connectionConfig: { sourceTemplateKey: "github", connectionMethodKey }, + }); + if (transportConfigOnly) { + await db.update(toolConnections).set({ config: { url: fake.url } }).where(eq(toolConnections.id, connection.id)); + } + await db.update(toolCatalogEntries).set({ + inputSchema: { + type: "object", + properties: Object.fromEntries(Object.keys(parameters).map((key) => [key, { type: "string" }])), + required: Object.keys(parameters), + additionalProperties: false, + }, + }).where(eq(toolCatalogEntries.id, catalogEntry.id)); + await allowAllToolsForAgent(db, company.id, agent.id); + const gateway = createTestToolGatewayService(db); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)).find((entry) => entry.catalogEntryId === catalogEntry.id); + expect(tool).toBeTruthy(); + const result = await gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters }); + expect(result.status).toBe("completed"); + expect(fake.requests).toHaveLength(1); + } finally { + await fake.close(); + } + }); + it("executes a connected remote HTTP MCP tool with stored credentials and redacted audit state", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 9ff39a5531d..e48dbec1cde 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1188,14 +1188,23 @@ export function projectedConnectionHeaders( const app = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; - if (!app) return {}; - const method = connectionMethodForConnection(app, connection); - return ( - normalizeConnectionMethodConfig( + const headers: Record = {}; + if (app) { + const method = connectionMethodForConnection(app, connection); + Object.assign(headers, normalizeConnectionMethodConfig( method, asRecord(connection.config.methodConfig), - ).headers ?? {} - ); + ).headers); + } + if ( + connection.transport === "mcp_remote" && + (sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github") + ) { + // GitHub excludes Actions from its default catalog. Project this on every + // discovery and invocation so existing connections gain the toolset too. + headers["X-MCP-Toolsets"] = "default,actions"; + } + return headers; } function mergeManagedToolArguments(