11import {
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' ;
611import { 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
1020const DEFAULT_GITHUB_MCP_URL = 'https://api.githubcopilot.com/mcp/' ;
1121const ROUTER_GITHUB_SERVER_ID : RouterMcpServerId = 'github' ;
1222
23+ const repositoryArgs = z . object ( {
24+ owner : z . string ( ) . regex ( / ^ [ a - z A - Z 0 - 9 ] [ a - z A - Z 0 - 9 - ] * $ / ) ,
25+ repo : z
26+ . string ( )
27+ . regex ( / ^ [ a - z A - Z 0 - 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+
1336function 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 } ) ;
0 commit comments