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(