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 <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-16 17:10:34 -07:00
1 parent fae6980310
commit 165b10bd98
4 files changed
+161 -6

No files matched your search

+23
View File
@@ -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
@@ -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(
+50
View File
@@ -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);
+15 -6
View File
@@ -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<string, string> = {};
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(