Skip to content

Commit 8be6097

Browse files
[Feat] Enable native GitHub PR updates, comments, and reactions in Fast (#2367)
* feat: enable bounded GitHub writes through existing MCP proxy * fix: keep bounded source inspection in Fast * fix: preserve native GitHub MCP write contracts * fix: omit caller-controlled keys from GitHub audit logs * refactor: simplify bounded GitHub write enforcement --------- Co-authored-by: @daniel-lxs <57051444+daniel-lxs@users.noreply.github.com>
1 parent 73e3124 commit 8be6097

11 files changed

Lines changed: 1048 additions & 21 deletions

File tree

‎apps/api/src/handlers/mcp/__tests__/github.test.ts‎

Lines changed: 678 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎apps/api/src/handlers/mcp/github.ts‎

Lines changed: 146 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,50 @@
11
import {
2+
getAllowedRouterMcpToolNames,
23
getRouterMcpUpstreamConstraints,
34
type RouterMcpServerId,
45
} from '@roomote/cloud-agents/router-mcp-policy';
5-
import { createGitHubToken } from '@roomote/auth';
6+
import {
7+
createGitHubToken,
8+
resolveRuntimeGitHubAppCredentials,
9+
} from '@roomote/auth';
10+
import { and, db, eq, isNull, repositories, users } from '@roomote/db/server';
611
import { Env } from '@roomote/env';
12+
import { z } from 'zod';
713

8-
import { createMcpProxy, McpProxyError } from './proxy-utils';
14+
import {
15+
createMcpProxy,
16+
McpProxyError,
17+
resolveActingUserId,
18+
} from './proxy-utils';
919

1020
const DEFAULT_GITHUB_MCP_URL = 'https://api.githubcopilot.com/mcp/';
1121
const ROUTER_GITHUB_SERVER_ID: RouterMcpServerId = 'github';
1222

23+
const repositoryArgs = z.object({
24+
owner: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9-]*$/),
25+
repo: z
26+
.string()
27+
.regex(/^[a-zA-Z0-9_.-]+$/)
28+
.refine((value) => value !== '.' && value !== '..'),
29+
});
30+
const writeToolNames = [
31+
'update_pull_request',
32+
'add_issue_comment',
33+
'add_reply_to_pull_request_comment',
34+
];
35+
1336
function isMissingGitHubInstallationError(error: unknown): boolean {
1437
const message = error instanceof Error ? error.message : String(error);
1538
return message.toLowerCase().includes('installation not found');
1639
}
1740

18-
function buildRouterGitHubHeaders(): Record<string, string> {
41+
function buildRouterGitHubHeaders(readonly: boolean): Record<string, string> {
1942
const constraints = getRouterMcpUpstreamConstraints(ROUTER_GITHUB_SERVER_ID);
2043
const headers: Record<string, string> = {};
2144

22-
if (constraints?.readonly) {
23-
headers['X-MCP-Readonly'] = 'true';
24-
}
45+
headers['X-MCP-Readonly'] = String(
46+
readonly || constraints?.readonly === true,
47+
);
2548

2649
if (constraints?.toolsets?.length) {
2750
headers['X-MCP-Toolsets'] = constraints.toolsets.join(',');
@@ -34,12 +57,122 @@ export function createGithubMcp(options?: {
3457
allowAuthTokens?: boolean;
3558
allowedToolNames?: readonly string[];
3659
}) {
60+
const allowedToolNames = getAllowedRouterMcpToolNames('github').filter(
61+
(name) =>
62+
!options?.allowedToolNames || options.allowedToolNames.includes(name),
63+
);
3764
return createMcpProxy({
3865
name: 'GitHub',
3966
upstream: Env.GITHUB_MCP_SERVER_URL ?? DEFAULT_GITHUB_MCP_URL,
4067
allowAuthTokens: options?.allowAuthTokens,
41-
allowedToolNames: options?.allowedToolNames,
42-
resolveCredentials: async () => {
68+
allowedToolNames,
69+
resolveCredentials: async (auth, _params, request) => {
70+
if (Array.isArray(request))
71+
throw new McpProxyError(
72+
400,
73+
'GitHub MCP batch requests are not allowed',
74+
);
75+
const rpc = request as
76+
| { method?: string; params?: { name?: string; arguments?: unknown } }
77+
| undefined;
78+
const name = rpc?.method === 'tools/call' ? rpc.params?.name : undefined;
79+
if (
80+
rpc?.method === 'tools/call' &&
81+
(typeof name !== 'string' || !allowedToolNames.includes(name))
82+
) {
83+
throw new McpProxyError(
84+
403,
85+
'GitHub MCP tool is not allowed on this endpoint',
86+
);
87+
}
88+
if (name && writeToolNames.includes(name)) {
89+
// Fast uses user tokens. Keep run tokens read-only here rather than
90+
// creating a second write path around coding-task repository scope.
91+
if (auth.tokenType !== 'auth') {
92+
throw new McpProxyError(
93+
403,
94+
'GitHub MCP writes require a user-scoped auth token',
95+
);
96+
}
97+
const parsed = repositoryArgs.safeParse(rpc?.params?.arguments);
98+
if (!parsed.success)
99+
throw new McpProxyError(
100+
400,
101+
'Invalid GitHub write repository arguments',
102+
);
103+
const args = parsed.data;
104+
const userId = await resolveActingUserId(auth);
105+
const actor = await db.query.users.findFirst({
106+
where: and(eq(users.id, userId), isNull(users.deletedAt)),
107+
columns: { id: true },
108+
});
109+
if (!actor)
110+
throw new McpProxyError(
111+
403,
112+
'GitHub writes require an active Roomote member',
113+
);
114+
// Repository access is deployment-scoped, matching the existing repository
115+
// helpers. Do not mistake a stored installation owner for a per-user ACL.
116+
const repository = await db.query.repositories.findFirst({
117+
where: and(
118+
eq(repositories.sourceControlProvider, 'github'),
119+
eq(repositories.fullName, `${args.owner}/${args.repo}`),
120+
eq(repositories.isActive, true),
121+
),
122+
with: { githubInstallation: true },
123+
});
124+
const installation = repository?.githubInstallation;
125+
if (
126+
!repository ||
127+
!installation ||
128+
installation.suspendedAt ||
129+
!repository.githubRepoId ||
130+
(repository.host && repository.host !== 'github.com')
131+
) {
132+
throw new McpProxyError(
133+
403,
134+
'GitHub write target must be an active connected repository',
135+
);
136+
}
137+
const appCredentials = await resolveRuntimeGitHubAppCredentials();
138+
if (String(installation.appId) !== appCredentials.appId)
139+
throw new McpProxyError(
140+
403,
141+
'GitHub repository installation does not belong to the configured app',
142+
);
143+
const token = await createGitHubToken(
144+
{
145+
type: 'installationId',
146+
installationId: installation.id,
147+
repositoryIds: [repository.githubRepoId],
148+
},
149+
appCredentials,
150+
);
151+
const originalArgs = rpc?.params?.arguments as Record<string, unknown>;
152+
const targetNumber =
153+
originalArgs.pullNumber ?? originalArgs.issue_number;
154+
console.info(
155+
JSON.stringify({
156+
event: 'github_mcp_write_authorized',
157+
userId,
158+
runId: auth.runId,
159+
tool: name,
160+
repositoryId: repository.id,
161+
repositoryFullName: repository.fullName,
162+
installationId: installation.installationId,
163+
targetNumber:
164+
typeof targetNumber === 'number' ? targetNumber : undefined,
165+
commentId:
166+
typeof originalArgs.commentId === 'number'
167+
? originalArgs.commentId
168+
: undefined,
169+
}),
170+
);
171+
return {
172+
authHeader: token,
173+
extraHeaders: buildRouterGitHubHeaders(false),
174+
};
175+
}
43176
let githubToken: string;
44177
try {
45178
githubToken = await createGitHubToken({ type: 'activeInstallation' });
@@ -55,7 +188,11 @@ export function createGithubMcp(options?: {
55188

56189
return {
57190
authHeader: githubToken,
58-
extraHeaders: buildRouterGitHubHeaders(),
191+
disabledToolNames:
192+
auth.tokenType === 'run' ? writeToolNames : undefined,
193+
extraHeaders: buildRouterGitHubHeaders(
194+
auth.tokenType === 'run' || rpc?.method !== 'tools/list',
195+
),
59196
};
60197
},
61198
});

‎apps/api/src/handlers/mcp/proxy-utils.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ interface McpProxyConfig {
367367
resolveCredentials: (
368368
auth: McpAuthContext,
369369
routeParams: Record<string, string>,
370+
request: unknown,
370371
) => Promise<ResolvedCredentials>;
371372
allowAuthTokens?: boolean;
372373
validateTaskRunToken?: (auth: RunTokenContext) => Promise<Response | null>;
@@ -911,7 +912,7 @@ export function createMcpProxy(config: McpProxyConfig) {
911912
let credentials: ResolvedCredentials;
912913

913914
try {
914-
credentials = await resolveCredentials(auth, c.req.param());
915+
credentials = await resolveCredentials(auth, c.req.param(), parsedBody);
915916
} catch (error) {
916917
console.warn(
917918
formatSingleLineLog(`${logPrefix} Failed to resolve credentials`, {

‎apps/docs/fast-sessions.mdx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,26 @@ dashboard page.
4343
You do not need to choose a separate conversation mode. The Session grows from
4444
conversation to execution to review without changing identity.
4545

46+
## Source-control work without a workspace
47+
48+
Fast uses existing integration APIs for bounded repository inspection when the
49+
available tools can answer the question. Reading files, searching a repository,
50+
or inspecting commits, pull requests, and reported CI results does not require
51+
cloning a repository or provisioning a workspace. Available reads depend on the
52+
connected integration's tools and permissions. Reading CI results is not the
53+
same as running tests: local checkout, code edits, command execution, and testing
54+
are delegated to a task, as are investigations that need a workspace.
55+
56+
The existing [GitHub integration](/providers/source-control/github#daily-github-management)
57+
also supports updates to existing pull requests, issue or PR comments,
58+
review-comment replies, and reactions using the deployment's GitHub App.
59+
See that guide for supported actions and access requirements.
60+
61+
[GitLab MCP](https://docs.gitlab.com/user/model_context_protocol/mcp_server/)
62+
and [Bitbucket Cloud MCP](https://support.atlassian.com/atlassian-ai-gateway/docs/supported-tools/#Bitbucket-Cloud-tools)
63+
are not enabled for this direct Fast path. Their compatibility with Roomote's
64+
existing credentials has not been verified; existing task workflows are unchanged.
65+
4666
## The session view
4767

4868
A Session header shows the other people currently viewing that Session. Your

‎apps/docs/providers/source-control/github.mdx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,35 @@ items.
190190

191191
## Daily GitHub management
192192

193+
Fast can use the native `update_pull_request`, `add_issue_comment`, and
194+
`add_reply_to_pull_request_comment` tools without starting a coding task.
195+
Their discovered descriptions and schemas define the supported arguments,
196+
including reviewer requests, draft status, base retargeting, maintainer edit
197+
permission, comments, replies, and reactions. These actions use the deployment
198+
GitHub App, require an active Roomote member and an active connected repository,
199+
and remain subject to the installation's issue or pull request write permission.
200+
No personal GitHub OAuth connection is required. Ask for the specific change and
201+
identify the repository and target issue, pull request, or comment. Authorization
202+
is repository-scoped, not restricted to pull requests. A reaction-only call on an
203+
existing review comment can use `commentId` without `pullNumber` when allowed by
204+
the discovered native schema.
205+
206+
For example, ask Roomote to:
207+
208+
- mark PR #42 in `example/repo` ready for review
209+
- request a review from `octocat` on that PR
210+
- add a thumbs-up reaction to an existing comment, providing its GitHub URL
211+
212+
Retargeting a pull request's base to an existing branch does not write a branch
213+
or change files. Merging, creating or deleting pull requests, writing branches
214+
or files, editing or deleting comments, and repository administration are
215+
not available through this bounded Fast path; those requests still require a
216+
coding task. Other source-control providers are unchanged.
217+
218+
Native updates that combine several actions are not guaranteed to be atomic.
219+
After an error, Roomote inspects the resulting state before retrying so an action
220+
that already succeeded is not blindly repeated.
221+
193222
Roomote tasks can manage routine repository work through the GitHub App
194223
installation. Requests default to the repository mapped to the task:
195224

‎packages/auth/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export {
4444
createGitHubToken,
4545
createGitHubTokenWithMetadata,
4646
resolveGitHubAppCredentials,
47+
resolveRuntimeGitHubAppCredentials,
4748
} from './github-token';
4849

4950
export {

‎packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-integration-broker.test.ts‎

Lines changed: 73 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)