From 76104918b5393dc50ae50ca7a658bcb88629bad2 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 1/6] Scaffold pinned read-only GitHub MCP entry Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: claude/claude-haiku-5-5/default --- .../github-mcp-tools-source-2026-10-15.json | 412 ++++++++++++++++++ .../routes/hostedMcpConnect.callback.test.js | 13 + .../unit/services/hostedMcpEntry.test.js | 53 ++- .../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(+), 3 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 f21cb1aa5c..bef5a3b28f 100644 --- a/backend/__tests__/unit/services/hostedMcpEntry.test.js +++ b/backend/__tests__/unit/services/hostedMcpEntry.test.js @@ -27,6 +27,8 @@ const AIRTABLE_AS_CAPTURE = require('../../fixtures/hostedMcp/airtable-authoriza const SENTRY_CAPTURE = require('../../fixtures/hostedMcp/sentry-tools-list-2026-10-19.json'); 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 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.'; @@ -582,7 +584,7 @@ describe('the shipped catalogue cannot land half-wired', () => { // The catalogue is the one place that says what an instance offers before // 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', + 'linear', 'google-calendar', 'atlassian', 'airtable', 'sentry', 'notion', 'gitlab', 'github-mcp', ]); const catalogue = [linear(), linear({ id: 'notion', title: 'Notion' })]; expect(findHostedMcpEntry(catalogue, 'notion').title).toBe('Notion'); @@ -830,6 +832,55 @@ describe('the shipped catalogue cannot land half-wired', () => { } }); + 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 589806e0e5..9efc054968 100644 --- a/backend/integrations/hostedMcp/entries.ts +++ b/backend/integrations/hostedMcp/entries.ts @@ -16,6 +16,7 @@ import { AIRTABLE_ENTRY } from './airtable'; import { ATLASSIAN_ENTRY } from './atlassian'; import { GOOGLE_CALENDAR_ENTRY } from './googleCalendar'; import { GITLAB_ENTRY } from './gitlab'; +import { GITHUB_MCP_ENTRY } from './githubMcp'; import { LINEAR_ENTRY } from './linear'; import { NOTION_ENTRY } from './notion'; import { SENTRY_ENTRY } from './sentry'; @@ -24,6 +25,7 @@ export const HOSTED_MCP_ENTRIES: HostedMcpEntry[] = [ LINEAR_ENTRY, GOOGLE_CALENDAR_ENTRY, ATLASSIAN_ENTRY, AIRTABLE_ENTRY, SENTRY_ENTRY, NOTION_ENTRY, GITLAB_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 02d4ac4d6e..e67ba1e755 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 e3d7ba83a1c8c5dbc30fb615f64a377de1fa4e25 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 2/6] test(hosted-mcp): hold GitHub entry until App client registration Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: claude/claude-haiku-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 8b54c66e73..15f389e08d 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 // (Vera, #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 95ef4346358edfdc08260ed3ec0b6e3e46d45d44 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 3/6] Map GitHub MCP catalogue entry to GitHub glyph Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: claude/claude-haiku-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 3ec4534b7a..9df12487a5 100644 --- a/frontend/src/v2/icons/platforms/index.tsx +++ b/frontend/src/v2/icons/platforms/index.tsx @@ -100,6 +100,7 @@ const GoogleCalendar = () => ( export const PLATFORM_GLYPHS: Record = { github: GitHub, + 'github-mcp': GitHub, linear: Linear, 'google-calendar': GoogleCalendar, atlassian: Atlassian, From 9cab272e10d2e8757f45d7b5cb763dae94d66632 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 4/6] chore(connectors): stage optional GitHub MCP client wiring Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: claude/claude-haiku-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 15f389e08d..0153d64aa9 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 // (Vera, #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 e11173a84514b8f347e2320df6cbbfefded77b11 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 5/6] chore(connectors): enable GitHub MCP client sync in dev Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: claude/claude-haiku-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 0153d64aa9..c48ad10088 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 5c9cdd975ff624cb1892d29dbe1af71afe346945 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 6/6] fix(hosted-mcp): scope GitHub client secret refs by release Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: claude/claude-haiku-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: