From 10f1ac25b9249c92d9fac426fa95333215209de1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:01:32 -0700 Subject: [PATCH 01/10] Scaffold pinned read-only GitHub MCP entry Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Co-authored-by: rivet Agent-Model: claude/claude-opus-5-5/default --- .../github-mcp-tools-source-2026-10-15.json | 412 ++++++++++++++++++ .../routes/hostedMcpConnect.callback.test.js | 13 + .../unit/services/hostedMcpEntry.test.js | 52 +++ .../unit/services/hostedMcpIntake.test.js | 22 + backend/integrations/hostedMcp/entries.ts | 2 + backend/integrations/hostedMcp/githubMcp.ts | 46 ++ .../hostedMcp/githubMcpSchemas.json | 394 +++++++++++++++++ backend/services/hostedMcpEntryService.ts | 2 + backend/services/hostedMcpIntakeService.ts | 7 +- 9 files changed, 948 insertions(+), 2 deletions(-) create mode 100644 backend/__tests__/fixtures/hostedMcp/github-mcp-tools-source-2026-10-15.json create mode 100644 backend/integrations/hostedMcp/githubMcp.ts create mode 100644 backend/integrations/hostedMcp/githubMcpSchemas.json diff --git a/backend/__tests__/fixtures/hostedMcp/github-mcp-tools-source-2026-10-15.json b/backend/__tests__/fixtures/hostedMcp/github-mcp-tools-source-2026-10-15.json new file mode 100644 index 0000000000..a2f0855169 --- /dev/null +++ b/backend/__tests__/fixtures/hostedMcp/github-mcp-tools-source-2026-10-15.json @@ -0,0 +1,412 @@ +{ + "sourceCommit": "796afb74711ddc856fe587debc49373e21c8d0f7", + "tools": [ + { + "name": "get_me", + "description": "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.", + "inputSchema": { + "properties": {}, + "type": "object" + }, + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get my user profile" + } + }, + { + "name": "list_issues", + "description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination. Use the cursor from the previous response.", + "type": "string" + }, + "direction": { + "description": "Order direction. If provided, the 'orderBy' also needs to be provided.", + "enum": [ + "ASC", + "DESC" + ], + "type": "string" + }, + "field_filters": { + "description": "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).", + "items": { + "properties": { + "field_name": { + "description": "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", + "type": "string" + }, + "value": { + "description": "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.", + "type": "string" + } + }, + "required": [ + "field_name", + "value" + ], + "type": "object" + }, + "type": "array" + }, + "fields": { + "description": "Subset of fields to return for each issue. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' and 'field_values' in particular drops the largest per-result data.", + "items": { + "enum": [ + "number", + "title", + "body", + "state", + "user", + "labels", + "assignees", + "comments", + "created_at", + "updated_at", + "field_values" + ], + "type": "string" + }, + "type": "array" + }, + "labels": { + "description": "Filter by labels", + "items": { + "type": "string" + }, + "type": "array" + }, + "orderBy": { + "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", + "enum": [ + "CREATED_AT", + "UPDATED_AT", + "COMMENTS" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "since": { + "description": "Filter by date (ISO 8601 timestamp)", + "type": "string" + }, + "state": { + "description": "Filter by state, by default both open and closed issues are returned when not provided", + "enum": [ + "OPEN", + "CLOSED" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "List issues" + } + }, + { + "name": "issue_read", + "description": "Get information about a specific issue in a GitHub repository.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The number of the issue", + "type": "number" + }, + "method": { + "description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries, and `closed_by_pull_requests` summarizes the pull requests configured to close the issue as `total_count` plus up to 5 `references`.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues (children) of the issue.\n4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n5. get_labels - Get labels assigned to the issue.\n", + "enum": [ + "get", + "get_comments", + "get_sub_issues", + "get_parent", + "get_labels" + ], + "type": "string" + }, + "owner": { + "description": "The owner of the repository", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "The name of the repository", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo", + "issue_number" + ], + "type": "object" + }, + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get issue details" + } + }, + { + "name": "list_pull_requests", + "description": "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.", + "inputSchema": { + "properties": { + "base": { + "description": "Filter by base branch", + "type": "string" + }, + "direction": { + "description": "Sort direction", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "fields": { + "description": "Subset of fields to return for each pull request. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-result data.", + "items": { + "enum": [ + "number", + "title", + "body", + "state", + "draft", + "merged", + "mergeable_state", + "html_url", + "user", + "labels", + "assignees", + "requested_reviewers", + "merged_by", + "head", + "base", + "additions", + "deletions", + "changed_files", + "commits", + "comments", + "created_at", + "updated_at", + "closed_at", + "merged_at", + "milestone" + ], + "type": "string" + }, + "type": "array" + }, + "head": { + "description": "Filter by head user/org and branch", + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "sort": { + "description": "Sort by", + "enum": [ + "created", + "updated", + "popularity", + "long-running" + ], + "type": "string" + }, + "state": { + "description": "Filter by state", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "List pull requests" + } + }, + { + "name": "pull_request_read", + "description": "Get information on a specific pull request in GitHub repository.", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page.", + "type": "string" + }, + "method": { + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.\n 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns thread metadata and comments with nullable current and original line-range coordinates (line, start_line, original_line, original_start_line). Current coordinates are omitted when unavailable, such as for outdated comments. Use cursor-based pagination (perPage, after) to control results.\n 7. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 8. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", + "enum": [ + "get", + "get_diff", + "get_status", + "get_files", + "get_commits", + "get_review_comments", + "get_reviews", + "get_comments", + "get_check_runs" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "pullNumber": { + "description": "Pull request number", + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo", + "pullNumber" + ], + "type": "object" + }, + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get details for a single pull request" + } + }, + { + "name": "get_file_contents", + "description": "Get the contents of a file or directory from a GitHub repository", + "inputSchema": { + "properties": { + "fields": { + "description": "Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'.", + "items": { + "enum": [ + "type", + "name", + "path", + "size", + "sha", + "url", + "git_url", + "html_url", + "download_url" + ], + "type": "string" + }, + "type": "array" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string", + "x-mcp-header": "owner" + }, + "path": { + "default": "/", + "description": "Path to file/directory", + "type": "string" + }, + "ref": { + "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string", + "x-mcp-header": "repo" + }, + "sha": { + "description": "Accepts optional commit SHA. If specified, it will be used instead of ref", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get file or directory contents" + } + } + ] +} diff --git a/backend/__tests__/unit/routes/hostedMcpConnect.callback.test.js b/backend/__tests__/unit/routes/hostedMcpConnect.callback.test.js index 00177c5810..5b5e520861 100644 --- a/backend/__tests__/unit/routes/hostedMcpConnect.callback.test.js +++ b/backend/__tests__/unit/routes/hostedMcpConnect.callback.test.js @@ -576,6 +576,19 @@ describe('hosted-mcp connect: callback', () => { expect(update.$set['config.grantedScope']).toBe('read'); }); + it('records an explicit empty scope response without inventing requested scopes', async () => { + // GitHub App user tokens can report scope="" even when offline_access + // was requested. The callback must preserve what the vendor returned. + global.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ access_token: 'access-1', scope: '' }), + }); + const res = await callback(); + expect(outcome(res).hostedMcp).toBe('connected'); + const [, update] = Integration.findOneAndUpdate.mock.calls[1]; + expect(update.$set['config.grantedScope']).toBe(''); + }); + it('exchanges a pre-registered callback with instance credentials while retaining PKCE', async () => { const originalEntry = { ...FIXTURE_ENTRY }; const originalId = process.env.GOOGLE_CALENDAR_CLIENT_ID; diff --git a/backend/__tests__/unit/services/hostedMcpEntry.test.js b/backend/__tests__/unit/services/hostedMcpEntry.test.js index 0215ef745d..05f5acd6de 100644 --- a/backend/__tests__/unit/services/hostedMcpEntry.test.js +++ b/backend/__tests__/unit/services/hostedMcpEntry.test.js @@ -28,6 +28,8 @@ const SENTRY_CAPTURE = require('../../fixtures/hostedMcp/sentry-tools-list-2026- const NOTION_CAPTURE = require('../../fixtures/hostedMcp/notion-tools-list-2026-10-19.json'); const GITLAB_CAPTURE = require('../../fixtures/hostedMcp/gitlab-tools-list-2026-10-19.json'); const SUPABASE_CAPTURE = require('../../fixtures/hostedMcp/supabase-tools-list-2026-10-19.json'); +const GITHUB_MCP_SOURCE = require('../../fixtures/hostedMcp/github-mcp-tools-source-2026-10-15.json'); +const GITHUB_MCP_PIN = require('../../../integrations/hostedMcp/githubMcpSchemas.json'); const ENTRY_TEXT = 'ENTRY TEXT the pin agreed to.'; const VENDOR_TEXT = 'VENDOR TEXT nobody reviewed.'; @@ -584,6 +586,7 @@ describe('the shipped catalogue cannot land half-wired', () => { // any member has connected anything. Its ordering follows the build plan. expect(HOSTED_MCP_ENTRIES.map((entry) => entry.id)).toEqual([ 'linear', 'google-calendar', 'atlassian', 'airtable', 'sentry', 'notion', 'gitlab', 'supabase', + 'github-mcp', ]); const catalogue = [linear(), linear({ id: 'notion', title: 'Notion' })]; expect(findHostedMcpEntry(catalogue, 'notion').title).toBe('Notion'); @@ -874,6 +877,55 @@ describe('the shipped catalogue cannot land half-wired', () => { .toContain('untracked data and schema changes'); }); + test('GitHub pins six read tools from one fixed upstream commit and exposes no writer', () => { + const entry = findHostedMcpEntry(HOSTED_MCP_ENTRIES, 'github-mcp'); + expect(GITHUB_MCP_SOURCE.sourceCommit).toBe('796afb74711ddc856fe587debc49373e21c8d0f7'); + expect(GITHUB_MCP_PIN.sourceCommit).toBe(GITHUB_MCP_SOURCE.sourceCommit); + expect(entry).toMatchObject({ + resource: 'https://api.githubcopilot.com/mcp/', + issuer: 'https://github.com/login/oauth', + client: 'pre-registered', + scopes: ['offline_access'], + tokenEndpointAuthMethod: 'client_secret_post', + revoke: { page: 'https://github.com/settings/applications' }, + }); + const expected = [ + 'get_me', 'list_issues', 'issue_read', 'list_pull_requests', + 'pull_request_read', 'get_file_contents', + ]; + expect(entry.tools.map((tool) => tool.name)).toEqual(expected); + expect(GITHUB_MCP_SOURCE.tools.map((tool) => tool.name)).toEqual(expected); + const source = new Map(GITHUB_MCP_SOURCE.tools.map((tool) => [tool.name, tool])); + for (const tool of entry.tools) { + const upstream = source.get(tool.name); + expect(tool.upstreamName).toBe(tool.name); + expect(tool.class).toBe('read'); + expect(tool.annotations.readOnlyHint).toBe(true); + expect(upstream.annotations.readOnlyHint).toBe(true); + expect(canonicalJson(tool.inputSchema)).toBe(canonicalJson(upstream.inputSchema)); + expect(tool.description).not.toBe(upstream.description); + const propertyNames = []; + const visit = (value) => { + if (!value || typeof value !== 'object') return; + if (value.properties && typeof value.properties === 'object') { + propertyNames.push(...Object.keys(value.properties)); + } + Object.values(value).forEach(visit); + }; + visit(tool.inputSchema); + for (const targetKey of ['url', 'uri', 'fetch_target', 'target_url', 'source_url']) { + expect(propertyNames).not.toContain(targetKey); + } + } + const offered = entryToolProjections(entry); + expect(offered.map((tool) => tool.name)).toEqual(expected.map((name) => `github-mcp.${name}`)); + expect(offered.every((tool) => tool.requiredWriteMode === 'read')).toBe(true); + for (const name of ['create_issue', 'add_issue_comment', 'create_pull_request', 'merge_pull_request']) { + expect(offered.some((tool) => tool.name === `github-mcp.${name}`)).toBe(false); + } + expect(assessEntryTools(entry, GITHUB_MCP_SOURCE.tools).every((row) => row.verdict === 'ok')).toBe(true); + }); + test('the Linear pin is the measured list minus caller-chosen fetch paths', () => { // The latest capture `connector-ops` took (2026-10-14) is committed beside // this suite, so the pin's schemas are compared with the measurement rather diff --git a/backend/__tests__/unit/services/hostedMcpIntake.test.js b/backend/__tests__/unit/services/hostedMcpIntake.test.js index 2f209e8a01..59f3ecd9be 100644 --- a/backend/__tests__/unit/services/hostedMcpIntake.test.js +++ b/backend/__tests__/unit/services/hostedMcpIntake.test.js @@ -426,6 +426,28 @@ describe('hosted-mcp intake: pre-registered client authentication', () => { ); }); + it('uses GitHub App client_secret_post for exchange and refresh when metadata omits methods', () => { + const { GITHUB_MCP_ENTRY } = require('../../../integrations/hostedMcp/githubMcp'); + const client = { clientId: 'github-client', clientSecret: 'github-secret' }; + const exchange = buildTokenExchangeRequest(GITHUB_MCP_ENTRY, {}, client, { + redirectUri: 'https://api.commonly.me/api/hosted-mcp/github-mcp/callback', + code: 'code', codeVerifier: 'pkce-verifier', + }); + const refresh = buildRefreshRequest(GITHUB_MCP_ENTRY, {}, client, { refreshToken: 'refresh' }); + for (const request of [exchange, refresh]) { + expect(request.headers.Authorization).toBeUndefined(); + expect(request.body.get('client_id')).toBe(client.clientId); + expect(request.body.get('client_secret')).toBe(client.clientSecret); + expect(request.body.get('resource')).toBe(GITHUB_MCP_ENTRY.resource); + } + expect(exchange.body.get('code_verifier')).toBe('pkce-verifier'); + expect(() => buildRefreshRequest(GITHUB_MCP_ENTRY, { + token_endpoint_auth_methods_supported: ['client_secret_basic'], + }, client, { refreshToken: 'refresh' })).toThrow(expect.objectContaining({ + code: 'client_auth_unsupported', + })); + }); + it('uses client_secret_post when both endpoints advertise it', () => { const staticEntry = entry({ id: 'google-calendar', client: 'pre-registered' }); const metadata = { diff --git a/backend/integrations/hostedMcp/entries.ts b/backend/integrations/hostedMcp/entries.ts index b969b774d3..d7a350f608 100644 --- a/backend/integrations/hostedMcp/entries.ts +++ b/backend/integrations/hostedMcp/entries.ts @@ -15,6 +15,7 @@ import type { HostedMcpEntry } from '../../services/hostedMcpEntryService'; import { AIRTABLE_ENTRY } from './airtable'; import { ATLASSIAN_ENTRY } from './atlassian'; import { GOOGLE_CALENDAR_ENTRY } from './googleCalendar'; +import { GITHUB_MCP_ENTRY } from './githubMcp'; import { GITLAB_ENTRY } from './gitlab'; import { LINEAR_ENTRY } from './linear'; import { NOTION_ENTRY } from './notion'; @@ -26,6 +27,7 @@ export const HOSTED_MCP_ENTRIES: HostedMcpEntry[] = [ NOTION_ENTRY, GITLAB_ENTRY, SUPABASE_ENTRY, + GITHUB_MCP_ENTRY, ]; /** diff --git a/backend/integrations/hostedMcp/githubMcp.ts b/backend/integrations/hostedMcp/githubMcp.ts new file mode 100644 index 0000000000..329d4fade8 --- /dev/null +++ b/backend/integrations/hostedMcp/githubMcp.ts @@ -0,0 +1,46 @@ +/** + * GitHub's remote MCP server. The six input schemas are pinned from + * github/github-mcp-server at 796afb74711ddc856fe587debc49373e21c8d0f7 + * (the upstream tool snapshots), not from a live account. A consenting + * account's first tools/list remains the drift check before this is offered. + * + * The credential is a separate, public GitHub App with read-only repository + * permissions. GitHub App user tokens use the app's permissions rather than + * OAuth scopes; offline_access asks GitHub for a refresh token only. + */ +import type { HostedMcpEntry, HostedMcpPinnedTool } from '../../services/hostedMcpEntryService'; +import pin from './githubMcpSchemas.json'; + +const descriptions: Record = { + get_me: 'Read the account profile for this GitHub connection.', + list_issues: 'List issues in a GitHub repository.', + issue_read: 'Read an issue and its comments or related issues.', + list_pull_requests: 'List pull requests in a GitHub repository.', + pull_request_read: 'Read a pull request, its files, reviews or comments.', + get_file_contents: 'Read a file or directory in a GitHub repository.', +}; + +const tools: HostedMcpPinnedTool[] = pin.tools.map((tool) => ({ + name: tool.name, + upstreamName: tool.name, + description: descriptions[tool.name], + class: 'read', + inputSchema: tool.inputSchema, + annotations: { readOnlyHint: true }, +})); + +export const GITHUB_MCP_ENTRY: HostedMcpEntry = { + id: 'github-mcp', + title: 'GitHub', + description: 'Read files, issues and pull requests in repositories this GitHub App can access.', + resource: 'https://api.githubcopilot.com/mcp/', + issuer: 'https://github.com/login/oauth', + client: 'pre-registered', + // GitHub's AS metadata omits auth methods. Its GitHub App token guide names + // client_id and client_secret as POST parameters, so pin that method instead + // of applying RFC 8414's Basic default. Confirm on the first live consent. + tokenEndpointAuthMethod: 'client_secret_post', + scopes: ['offline_access'], + revoke: { page: 'https://github.com/settings/applications' }, + tools, +}; diff --git a/backend/integrations/hostedMcp/githubMcpSchemas.json b/backend/integrations/hostedMcp/githubMcpSchemas.json new file mode 100644 index 0000000000..8a8393ba1e --- /dev/null +++ b/backend/integrations/hostedMcp/githubMcpSchemas.json @@ -0,0 +1,394 @@ +{ + "sourceCommit": "796afb74711ddc856fe587debc49373e21c8d0f7", + "tools": [ + { + "name": "get_me", + "inputSchema": { + "properties": {}, + "type": "object" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "list_issues", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination. Use the cursor from the previous response.", + "type": "string" + }, + "direction": { + "description": "Order direction. If provided, the 'orderBy' also needs to be provided.", + "enum": [ + "ASC", + "DESC" + ], + "type": "string" + }, + "field_filters": { + "description": "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).", + "items": { + "properties": { + "field_name": { + "description": "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", + "type": "string" + }, + "value": { + "description": "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.", + "type": "string" + } + }, + "required": [ + "field_name", + "value" + ], + "type": "object" + }, + "type": "array" + }, + "fields": { + "description": "Subset of fields to return for each issue. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' and 'field_values' in particular drops the largest per-result data.", + "items": { + "enum": [ + "number", + "title", + "body", + "state", + "user", + "labels", + "assignees", + "comments", + "created_at", + "updated_at", + "field_values" + ], + "type": "string" + }, + "type": "array" + }, + "labels": { + "description": "Filter by labels", + "items": { + "type": "string" + }, + "type": "array" + }, + "orderBy": { + "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", + "enum": [ + "CREATED_AT", + "UPDATED_AT", + "COMMENTS" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "since": { + "description": "Filter by date (ISO 8601 timestamp)", + "type": "string" + }, + "state": { + "description": "Filter by state, by default both open and closed issues are returned when not provided", + "enum": [ + "OPEN", + "CLOSED" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "issue_read", + "inputSchema": { + "properties": { + "issue_number": { + "description": "The number of the issue", + "type": "number" + }, + "method": { + "description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries, and `closed_by_pull_requests` summarizes the pull requests configured to close the issue as `total_count` plus up to 5 `references`.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues (children) of the issue.\n4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n5. get_labels - Get labels assigned to the issue.\n", + "enum": [ + "get", + "get_comments", + "get_sub_issues", + "get_parent", + "get_labels" + ], + "type": "string" + }, + "owner": { + "description": "The owner of the repository", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "The name of the repository", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo", + "issue_number" + ], + "type": "object" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "list_pull_requests", + "inputSchema": { + "properties": { + "base": { + "description": "Filter by base branch", + "type": "string" + }, + "direction": { + "description": "Sort direction", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "fields": { + "description": "Subset of fields to return for each pull request. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-result data.", + "items": { + "enum": [ + "number", + "title", + "body", + "state", + "draft", + "merged", + "mergeable_state", + "html_url", + "user", + "labels", + "assignees", + "requested_reviewers", + "merged_by", + "head", + "base", + "additions", + "deletions", + "changed_files", + "commits", + "comments", + "created_at", + "updated_at", + "closed_at", + "merged_at", + "milestone" + ], + "type": "string" + }, + "type": "array" + }, + "head": { + "description": "Filter by head user/org and branch", + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "sort": { + "description": "Sort by", + "enum": [ + "created", + "updated", + "popularity", + "long-running" + ], + "type": "string" + }, + "state": { + "description": "Filter by state", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "pull_request_read", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page.", + "type": "string" + }, + "method": { + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.\n 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns thread metadata and comments with nullable current and original line-range coordinates (line, start_line, original_line, original_start_line). Current coordinates are omitted when unavailable, such as for outdated comments. Use cursor-based pagination (perPage, after) to control results.\n 7. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 8. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", + "enum": [ + "get", + "get_diff", + "get_status", + "get_files", + "get_commits", + "get_review_comments", + "get_reviews", + "get_comments", + "get_check_runs" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "pullNumber": { + "description": "Pull request number", + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo", + "pullNumber" + ], + "type": "object" + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "get_file_contents", + "inputSchema": { + "properties": { + "fields": { + "description": "Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'.", + "items": { + "enum": [ + "type", + "name", + "path", + "size", + "sha", + "url", + "git_url", + "html_url", + "download_url" + ], + "type": "string" + }, + "type": "array" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string", + "x-mcp-header": "owner" + }, + "path": { + "default": "/", + "description": "Path to file/directory", + "type": "string" + }, + "ref": { + "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string", + "x-mcp-header": "repo" + }, + "sha": { + "description": "Accepts optional commit SHA. If specified, it will be used instead of ref", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "annotations": { + "readOnlyHint": true + } + } + ] +} diff --git a/backend/services/hostedMcpEntryService.ts b/backend/services/hostedMcpEntryService.ts index abd1c06c1e..9f4e04184e 100644 --- a/backend/services/hostedMcpEntryService.ts +++ b/backend/services/hostedMcpEntryService.ts @@ -80,6 +80,8 @@ export interface HostedMcpEntry { /** The authorization server the protected-resource metadata names. */ issuer: string; client: 'pre-registered' | 'cimd' | 'dcr'; + /** Entry-pinned exception when an AS omits its token auth-method list. */ + tokenEndpointAuthMethod?: 'client_secret_basic' | 'client_secret_post'; scopes: string[]; /** Only the vendor-specific authorization parameters supported by this code. */ authorizationParams?: { diff --git a/backend/services/hostedMcpIntakeService.ts b/backend/services/hostedMcpIntakeService.ts index 57d247db53..fac8e1ad69 100644 --- a/backend/services/hostedMcpIntakeService.ts +++ b/backend/services/hostedMcpIntakeService.ts @@ -455,10 +455,13 @@ const endpointAuthentication = ( const supported = endpoint === 'token' ? metadata.token_endpoint_auth_methods_supported : metadata.revocation_endpoint_auth_methods_supported; + const pinnedMethod = endpoint === 'token' ? entry.tokenEndpointAuthMethod : undefined; const methods = supported === undefined - ? ['client_secret_basic'] + ? [pinnedMethod ?? 'client_secret_basic'] : (Array.isArray(supported) ? supported : []); - const method = CONFIDENTIAL_CLIENT_METHODS.find((candidate) => methods.includes(candidate)); + const method = pinnedMethod + ? methods.find((candidate) => candidate === pinnedMethod) + : CONFIDENTIAL_CLIENT_METHODS.find((candidate) => methods.includes(candidate)); if (!method) { throw new HostedMcpClientError( 'client_auth_unsupported', From 3a65c877b753022ad37818e0cfab155ebbc3260d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:03:14 -0700 Subject: [PATCH 02/10] test(hosted-mcp): hold GitHub entry until App client registration Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Co-authored-by: rivet Agent-Model: claude/claude-opus-5-5/default --- .../hostedMcpClientWiring.test.js | 22 +++++++++++++------ .../unit/services/toolInstallables.test.js | 18 +++++++++++++++ backend/integrations/hostedMcp/githubMcp.ts | 4 +++- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js b/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js index 64ccd8e157..ddd5773b13 100644 --- a/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js +++ b/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js @@ -3,11 +3,11 @@ * names derived from its id (`hostedMcpClientConfigKeys`). Those names reach * the backend only if the helm chart maps them from `api-keys`, and `api-keys` * carries them only if the ExternalSecret references their Secret Manager keys. - * Nothing else holds the three layers together: an entry added without its - * wiring reads `not_configured` in production while every other test stays - * green (review-checklist rule 7, a phantom cross-layer contract). This pins - * the chain for every pre-registered entry, so the next one cannot ship - * half-wired. + * Nothing else holds the three layers together (review-checklist rule 7). + * GitHub is deliberately pending TASK-225's separate GitHub App registration: + * its two env values have no deployment mapping yet, so it must stay unavailable. + * Once that App and its Secret Manager values exist, remove the pending case + * and wire both values like the other pre-registered entries. */ const fs = require('fs'); const path = require('path'); @@ -47,10 +47,18 @@ test('api-keys defaults the SM key prefix to commonly-dev-', () => { // A non-empty table even when the list empties, so the guard above is what // fails, by name, rather than jest refusing an empty `.each` at load time // (#2111). The placeholder id is mapped nowhere, so its arms fail too. -const wiringTable = preRegistered.length - ? preRegistered.map((entry) => [entry.id]) +const wiredEntries = preRegistered.filter((entry) => entry.id !== 'github-mcp'); +const wiringTable = wiredEntries.length + ? wiredEntries.map((entry) => [entry.id]) : [['no-pre-registered-entry']]; +test('the pending GitHub App has no deployment mapping until its registration is complete', () => { + expect(preRegistered.map((entry) => entry.id)).toContain('github-mcp'); + const { clientId, clientSecret } = hostedMcpClientConfigKeys('github-mcp'); + expect(apiKeysKeyForEnv(clientId)).toBeNull(); + expect(apiKeysKeyForEnv(clientSecret)).toBeNull(); +}); + describe.each(wiringTable)('%s client wiring', (entryId) => { const { clientId, clientSecret } = hostedMcpClientConfigKeys(entryId); diff --git a/backend/__tests__/unit/services/toolInstallables.test.js b/backend/__tests__/unit/services/toolInstallables.test.js index e04acc86b6..8c546adc5e 100644 --- a/backend/__tests__/unit/services/toolInstallables.test.js +++ b/backend/__tests__/unit/services/toolInstallables.test.js @@ -229,6 +229,24 @@ describe('one tool Installable per catalogue entry', () => { expect(metas['google-calendar'].readiness()).toEqual({ available: false, reason: 'not_configured' }); }); + test('the pending GitHub MCP entry stays unavailable until both App client values exist', () => { + const noConfig = toolInstallableMetas(HOSTED_MCP_ENTRIES, {}); + expect(noConfig['github-mcp']).toMatchObject({ + connectionType: 'hosted-mcp', + entryId: 'github-mcp', + }); + expect(noConfig['github-mcp'].readiness()).toEqual({ available: false, reason: 'not_configured' }); + + const idOnly = toolInstallableMetas(HOSTED_MCP_ENTRIES, { GITHUB_MCP_CLIENT_ID: 'app-client' }); + expect(idOnly['github-mcp'].readiness()).toEqual({ available: false, reason: 'not_configured' }); + + const configured = toolInstallableMetas(HOSTED_MCP_ENTRIES, { + GITHUB_MCP_CLIENT_ID: 'app-client', + GITHUB_MCP_CLIENT_SECRET: 'app-secret', + }); + expect(configured['github-mcp'].readiness()).toEqual({ available: true }); + }); + test('a hosted entry cannot shadow the builtin GitHub Installable meta', () => { expect(() => toolInstallableMetas([{ ...LINEAR_ENTRY, id: 'github' }])) .toThrow(/collides with a builtin tool installable: github/); diff --git a/backend/integrations/hostedMcp/githubMcp.ts b/backend/integrations/hostedMcp/githubMcp.ts index 329d4fade8..e561d6e3a9 100644 --- a/backend/integrations/hostedMcp/githubMcp.ts +++ b/backend/integrations/hostedMcp/githubMcp.ts @@ -6,7 +6,9 @@ * * The credential is a separate, public GitHub App with read-only repository * permissions. GitHub App user tokens use the app's permissions rather than - * OAuth scopes; offline_access asks GitHub for a refresh token only. + * OAuth scopes; offline_access asks GitHub for a refresh token only. The App's + * client id and secret are optional instance configuration. Until registration + * supplies both, the catalogue reports not_configured and offers no Connect. */ import type { HostedMcpEntry, HostedMcpPinnedTool } from '../../services/hostedMcpEntryService'; import pin from './githubMcpSchemas.json'; From bb5cac97a1f2914de23f7da2b4a3281ec7388f8b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:20:47 -0700 Subject: [PATCH 03/10] Map GitHub MCP catalogue entry to GitHub glyph Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Co-authored-by: rivet Agent-Model: claude/claude-opus-5-5/default --- frontend/src/v2/icons/platforms/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/v2/icons/platforms/index.tsx b/frontend/src/v2/icons/platforms/index.tsx index af61d3ae8d..6542a2d0ee 100644 --- a/frontend/src/v2/icons/platforms/index.tsx +++ b/frontend/src/v2/icons/platforms/index.tsx @@ -104,6 +104,7 @@ const GoogleCalendar = () => ( export const PLATFORM_GLYPHS: Record = { github: GitHub, + 'github-mcp': GitHub, linear: Linear, 'google-calendar': GoogleCalendar, atlassian: Atlassian, From 00b168dc609ce2a1132390b2667423dd69fbbfcb Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:37:47 -0700 Subject: [PATCH 04/10] chore(connectors): stage optional GitHub MCP client wiring Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Co-authored-by: rivet Agent-Model: claude/claude-opus-5-5/default --- .../hostedMcpClientWiring.test.js | 21 +++++++++---------- backend/integrations/hostedMcp/githubMcp.ts | 11 +++++----- .../templates/core/backend-deployment.yaml | 15 +++++++++++++ .../commonly/templates/secrets/api-keys.yaml | 10 +++++++++ k8s/helm/commonly/values-dev.yaml | 3 +++ k8s/helm/commonly/values.yaml | 4 ++++ 6 files changed, 48 insertions(+), 16 deletions(-) diff --git a/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js b/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js index ddd5773b13..7929243474 100644 --- a/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js +++ b/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js @@ -4,10 +4,8 @@ * the backend only if the helm chart maps them from `api-keys`, and `api-keys` * carries them only if the ExternalSecret references their Secret Manager keys. * Nothing else holds the three layers together (review-checklist rule 7). - * GitHub is deliberately pending TASK-225's separate GitHub App registration: - * its two env values have no deployment mapping yet, so it must stay unavailable. - * Once that App and its Secret Manager values exist, remove the pending case - * and wire both values like the other pre-registered entries. + * GitHub's App is still pending registration. Its ExternalSecret entries are + * gated off until both remote values exist, and both backend refs are optional. */ const fs = require('fs'); const path = require('path'); @@ -17,6 +15,8 @@ const { hostedMcpClientConfigKeys } = require('../../../services/hostedMcpIntake const chart = path.join(__dirname, '../../../../k8s/helm/commonly'); const deployment = fs.readFileSync(path.join(chart, 'templates/core/backend-deployment.yaml'), 'utf8'); const apiKeys = fs.readFileSync(path.join(chart, 'templates/secrets/api-keys.yaml'), 'utf8'); +const values = fs.readFileSync(path.join(chart, 'values.yaml'), 'utf8'); +const devValues = fs.readFileSync(path.join(chart, 'values-dev.yaml'), 'utf8'); // `- name: ENV` mapped from api-keys, optional so an instance with the entry's // flag off still starts. Returns the api-keys key, or null when unmapped. @@ -47,16 +47,15 @@ test('api-keys defaults the SM key prefix to commonly-dev-', () => { // A non-empty table even when the list empties, so the guard above is what // fails, by name, rather than jest refusing an empty `.each` at load time // (#2111). The placeholder id is mapped nowhere, so its arms fail too. -const wiredEntries = preRegistered.filter((entry) => entry.id !== 'github-mcp'); -const wiringTable = wiredEntries.length - ? wiredEntries.map((entry) => [entry.id]) +const wiringTable = preRegistered.length + ? preRegistered.map((entry) => [entry.id]) : [['no-pre-registered-entry']]; -test('the pending GitHub App has no deployment mapping until its registration is complete', () => { +test('the pending GitHub App cannot request missing Secret Manager keys by default', () => { expect(preRegistered.map((entry) => entry.id)).toContain('github-mcp'); - const { clientId, clientSecret } = hostedMcpClientConfigKeys('github-mcp'); - expect(apiKeysKeyForEnv(clientId)).toBeNull(); - expect(apiKeysKeyForEnv(clientSecret)).toBeNull(); + expect(values).toMatch(/hostedMcp:\n(?:.|\n)*?githubMcp:\n\s+enabled: false/); + expect(devValues).toMatch(/hostedMcp:\n(?:.|\n)*?githubMcp:\n\s+enabled: false/); + expect(apiKeys).toMatch(/{{- if \.Values\.hostedMcp\.githubMcp\.enabled }}[\s\S]*?secretKey: github-mcp-client-id[\s\S]*?secretKey: github-mcp-client-secret[\s\S]*?{{- end }}/); }); describe.each(wiringTable)('%s client wiring', (entryId) => { diff --git a/backend/integrations/hostedMcp/githubMcp.ts b/backend/integrations/hostedMcp/githubMcp.ts index e561d6e3a9..302b5055cc 100644 --- a/backend/integrations/hostedMcp/githubMcp.ts +++ b/backend/integrations/hostedMcp/githubMcp.ts @@ -4,11 +4,12 @@ * (the upstream tool snapshots), not from a live account. A consenting * account's first tools/list remains the drift check before this is offered. * - * The credential is a separate, public GitHub App with read-only repository - * permissions. GitHub App user tokens use the app's permissions rather than - * OAuth scopes; offline_access asks GitHub for a refresh token only. The App's - * client id and secret are optional instance configuration. Until registration - * supplies both, the catalogue reports not_configured and offers no Connect. + * The credential is a separate, public GitHub App. GitHub App user tokens use + * the app's permissions rather than OAuth scopes; offline_access asks GitHub + * for a refresh token only. This entry pins only reads; any write tool must be + * pinned separately with owner confirmation. The App's client id and secret + * are optional instance configuration. Until registration supplies both, the + * catalogue reports not_configured and offers no Connect. */ import type { HostedMcpEntry, HostedMcpPinnedTool } from '../../services/hostedMcpEntryService'; import pin from './githubMcpSchemas.json'; diff --git a/k8s/helm/commonly/templates/core/backend-deployment.yaml b/k8s/helm/commonly/templates/core/backend-deployment.yaml index 09aadf4afd..a4b917c6ee 100644 --- a/k8s/helm/commonly/templates/core/backend-deployment.yaml +++ b/k8s/helm/commonly/templates/core/backend-deployment.yaml @@ -446,6 +446,21 @@ spec: key: google-calendar-client-secret optional: true + # GitHub hosted-MCP uses its own pre-registered App, separate from the + # sign-in app and room-grant broker. Missing keys leave it unavailable. + - name: GITHUB_MCP_CLIENT_ID + valueFrom: + secretKeyRef: + name: api-keys + key: github-mcp-client-id + optional: true + - name: GITHUB_MCP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: api-keys + key: github-mcp-client-secret + optional: true + # Slack installable OAuth and per-workspace token encryption. Every # reference is optional so instances that have not enabled Slack's # OAuth ExternalSecret entries continue to start normally. diff --git a/k8s/helm/commonly/templates/secrets/api-keys.yaml b/k8s/helm/commonly/templates/secrets/api-keys.yaml index af28c220d0..9af3098250 100644 --- a/k8s/helm/commonly/templates/secrets/api-keys.yaml +++ b/k8s/helm/commonly/templates/secrets/api-keys.yaml @@ -223,6 +223,16 @@ spec: remoteRef: key: {{ $keyPrefix }}google-calendar-client-secret {{- end }} + {{- if .Values.hostedMcp.githubMcp.enabled }} + # Pre-registered GitHub hosted-MCP client. Create both remote keys with an + # enabled version before enabling; ESO cannot skip a missing remoteRef. + - secretKey: github-mcp-client-id + remoteRef: + key: commonly-dev-github-mcp-client-id + - secretKey: github-mcp-client-secret + remoteRef: + key: commonly-dev-github-mcp-client-secret + {{- end }} - secretKey: groupme-bot-id remoteRef: key: {{ $keyPrefix }}groupme-bot-id diff --git a/k8s/helm/commonly/values-dev.yaml b/k8s/helm/commonly/values-dev.yaml index 92aa993591..f4e41e0b69 100644 --- a/k8s/helm/commonly/values-dev.yaml +++ b/k8s/helm/commonly/values-dev.yaml @@ -460,6 +460,9 @@ hostedMcp: - "6a47891ee025ce2e0ca1d30b" googleCalendar: enabled: false + # Commonly Connect App registration and Secret Manager values are pending. + githubMcp: + enabled: false # The team's own accounts, left out of the "typed and got no reply" alert, come # from the secret store (INTERNAL_ACCOUNTS, #2373). The key exists, and on diff --git a/k8s/helm/commonly/values.yaml b/k8s/helm/commonly/values.yaml index a2b6cf18b6..2f475b30e9 100644 --- a/k8s/helm/commonly/values.yaml +++ b/k8s/helm/commonly/values.yaml @@ -78,6 +78,10 @@ hostedMcp: hiddenEntryUserAllowlist: {} googleCalendar: enabled: false + # Enable only after both pre-registered GitHub App client secrets exist with + # enabled versions in Secret Manager. A missing remoteRef stalls api-keys. + githubMcp: + enabled: false # Agent provisioning (RBAC + PVCs) agentProvisioning: From 151d03910f7379e9bbeb0a6f2d58ddb6fa738079 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:51:06 -0700 Subject: [PATCH 05/10] chore(connectors): enable GitHub MCP client sync in dev Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Co-authored-by: rivet Agent-Model: claude/claude-opus-5-5/default --- .../unit/integrations/hostedMcpClientWiring.test.js | 9 +++++---- k8s/helm/commonly/values-dev.yaml | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js b/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js index 7929243474..ad42f5cff1 100644 --- a/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js +++ b/backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js @@ -4,8 +4,9 @@ * the backend only if the helm chart maps them from `api-keys`, and `api-keys` * carries them only if the ExternalSecret references their Secret Manager keys. * Nothing else holds the three layers together (review-checklist rule 7). - * GitHub's App is still pending registration. Its ExternalSecret entries are - * gated off until both remote values exist, and both backend refs are optional. + * GitHub's ExternalSecret entries are off by default for self-hosted instances. + * Dev enables them only after both App client values exist with versions, and + * both backend refs remain optional. */ const fs = require('fs'); const path = require('path'); @@ -51,10 +52,10 @@ const wiringTable = preRegistered.length ? preRegistered.map((entry) => [entry.id]) : [['no-pre-registered-entry']]; -test('the pending GitHub App cannot request missing Secret Manager keys by default', () => { +test('GitHub client mapping is off by default and enabled only in configured dev', () => { expect(preRegistered.map((entry) => entry.id)).toContain('github-mcp'); expect(values).toMatch(/hostedMcp:\n(?:.|\n)*?githubMcp:\n\s+enabled: false/); - expect(devValues).toMatch(/hostedMcp:\n(?:.|\n)*?githubMcp:\n\s+enabled: false/); + expect(devValues).toMatch(/hostedMcp:\n(?:.|\n)*?githubMcp:\n\s+enabled: true/); expect(apiKeys).toMatch(/{{- if \.Values\.hostedMcp\.githubMcp\.enabled }}[\s\S]*?secretKey: github-mcp-client-id[\s\S]*?secretKey: github-mcp-client-secret[\s\S]*?{{- end }}/); }); diff --git a/k8s/helm/commonly/values-dev.yaml b/k8s/helm/commonly/values-dev.yaml index f4e41e0b69..2bb1471f0e 100644 --- a/k8s/helm/commonly/values-dev.yaml +++ b/k8s/helm/commonly/values-dev.yaml @@ -460,9 +460,10 @@ hostedMcp: - "6a47891ee025ce2e0ca1d30b" googleCalendar: enabled: false - # Commonly Connect App registration and Secret Manager values are pending. + # Commonly Connect App's client ID and secret each have an enabled version + # in Secret Manager (verified 2026-10-19). The backend refs remain optional. githubMcp: - enabled: false + enabled: true # The team's own accounts, left out of the "typed and got no reply" alert, come # from the secret store (INTERNAL_ACCOUNTS, #2373). The key exists, and on From a0243ab1884ec0ba4f1f47408b63bba44310e22c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:44:40 -0700 Subject: [PATCH 06/10] fix(hosted-mcp): scope GitHub client secret refs by release Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Co-authored-by: rivet Agent-Model: claude/claude-opus-5-5/default --- k8s/helm/commonly/templates/secrets/api-keys.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/helm/commonly/templates/secrets/api-keys.yaml b/k8s/helm/commonly/templates/secrets/api-keys.yaml index 9af3098250..cc44620817 100644 --- a/k8s/helm/commonly/templates/secrets/api-keys.yaml +++ b/k8s/helm/commonly/templates/secrets/api-keys.yaml @@ -228,10 +228,10 @@ spec: # enabled version before enabling; ESO cannot skip a missing remoteRef. - secretKey: github-mcp-client-id remoteRef: - key: commonly-dev-github-mcp-client-id + key: {{ $keyPrefix }}github-mcp-client-id - secretKey: github-mcp-client-secret remoteRef: - key: commonly-dev-github-mcp-client-secret + key: {{ $keyPrefix }}github-mcp-client-secret {{- end }} - secretKey: groupme-bot-id remoteRef: From 6640d2e9a043e8c7a6070fc6c88dc96106ecf05d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:17:29 -0700 Subject: [PATCH 07/10] fix(hosted-mcp): mirror GitHub client env to ops-runner; ship github-mcp hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ops-runner env mirror guard (#2426) failed because the GitHub MCP client refs reached backend-deployment only. Mirror both optional refs. Hide github-mcp in dev until a fresh non-admin account walks connect, grant, call and refusal end to end (TASK-290); that account joins the allowlist. Written by Rivet, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015 Co-Authored-By: Claude Opus 5.5 Co-authored-by: rivet Agent-Model: claude/claude-opus-5-5/default --- .../templates/ops/ops-runner-deployment.yaml | 15 +++++++++++++++ k8s/helm/commonly/values-dev.yaml | 3 +++ 2 files changed, 18 insertions(+) diff --git a/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml b/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml index 1e8c169d99..957bf514f9 100644 --- a/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml +++ b/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml @@ -413,6 +413,21 @@ spec: key: google-calendar-client-secret optional: true + # GitHub hosted-MCP uses its own pre-registered App, separate from the + # sign-in app and room-grant broker. Missing keys leave it unavailable. + - name: GITHUB_MCP_CLIENT_ID + valueFrom: + secretKeyRef: + name: api-keys + key: github-mcp-client-id + optional: true + - name: GITHUB_MCP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: api-keys + key: github-mcp-client-secret + optional: true + # Slack installable OAuth and per-workspace token encryption. Every # reference is optional so instances that have not enabled Slack's # OAuth ExternalSecret entries continue to start normally. diff --git a/k8s/helm/commonly/values-dev.yaml b/k8s/helm/commonly/values-dev.yaml index 2bb1471f0e..543dd3a155 100644 --- a/k8s/helm/commonly/values-dev.yaml +++ b/k8s/helm/commonly/values-dev.yaml @@ -450,6 +450,9 @@ hostedMcp: # The 10-27 prod walk failed before Airtable's consent screen ("invalid client_id # or mismatched redirect_uri"). Hidden until TASK-318 lands and a walk passes. - airtable + # Hidden until a fresh non-admin account walks connect, grant, call and + # refusal end to end (TASK-290). That account joins the allowlist below. + - github-mcp # Mongo User _id(s) permitted to walk each hidden hosted-MCP entry (#2649). hiddenEntryUserAllowlist: atlassian: From 228962a08483f551443b52ff4f1e38160fe9283f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:41:00 -0700 Subject: [PATCH 08/10] feat(github-mcp): link the App install page so a person picks their repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written by Rivet, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015 GitHub's App user token reaches only the repositories the App is installed on. A person who connects github-mcp needs a place to choose those repositories, and today nothing points them there. Hosted entries gain an optional accessPage. It is validated as an absolute https URL when entries are asserted, projected onto the catalogue row, and rendered as "Choose what