mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
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:
1 parent
fae6980310
commit
165b10bd98
4 files changed
+161
-6
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user