Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

18 changes: 13 additions & 5 deletions backend/__tests__/unit/integrations/hostedMcpClientWiring.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@
* 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'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');
Expand All @@ -17,6 +16,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.
Expand Down Expand Up @@ -51,6 +52,13 @@ const wiringTable = preRegistered.length
? preRegistered.map((entry) => [entry.id])
: [['no-pre-registered-entry']];

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: 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 }}/);
});

describe.each(wiringTable)('%s client wiring', (entryId) => {
const { clientId, clientSecret } = hostedMcpClientConfigKeys(entryId);

Expand Down
14 changes: 14 additions & 0 deletions backend/__tests__/unit/routes/hostedMcpConnect.callback.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,20 @@ 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');
expect(Integration.findOneAndUpdate).toHaveBeenCalledTimes(3);
const [, update] = Integration.findOneAndUpdate.mock.calls[2];
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;
Expand Down
99 changes: 99 additions & 0 deletions backend/__tests__/unit/services/hostedMcpEntry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1023,6 +1075,53 @@ describe('the shipped catalogue cannot land half-wired', () => {
.toEqual({ page: bothURLs.page });
});

// TASK-290: GitHub's App user token reaches only the accounts and
// repositories the App is installed on, so the page where a person picks
// them is part of connecting. Each instance registers its own App, so the
// page comes from the instance's config, and a slug GitHub could not have
// issued offers no page at all rather than a link somewhere else.
test('the GitHub access page is the configured App\'s install page, or none', () => {
const { githubMcpAccessPage } = require('../../../integrations/hostedMcp/githubMcp');
expect(githubMcpAccessPage({ GITHUB_MCP_APP_SLUG: 'commonly-connect' }))
.toBe('https://github.com/apps/commonly-connect/installations/new');
expect(githubMcpAccessPage({ GITHUB_MCP_APP_SLUG: ' self-host-app ' }))
.toBe('https://github.com/apps/self-host-app/installations/new');
for (const slug of [undefined, '', 'Commonly', '../evil', 'a/b', 'app?x=1', '-lead', 'trail-', 'evil.example']) {
expect(githubMcpAccessPage({ GITHUB_MCP_APP_SLUG: slug })).toBeUndefined();
}
const before = process.env.GITHUB_MCP_APP_SLUG;
try {
process.env.GITHUB_MCP_APP_SLUG = 'commonly-connect';
jest.isolateModules(() => {
const { GITHUB_MCP_ENTRY } = require('../../../integrations/hostedMcp/githubMcp');
expect(GITHUB_MCP_ENTRY.accessPage).toBe('https://github.com/apps/commonly-connect/installations/new');
});
delete process.env.GITHUB_MCP_APP_SLUG;
jest.isolateModules(() => {
const { GITHUB_MCP_ENTRY } = require('../../../integrations/hostedMcp/githubMcp');
expect(GITHUB_MCP_ENTRY).not.toHaveProperty('accessPage');
});
} finally {
if (before === undefined) delete process.env.GITHUB_MCP_APP_SLUG;
else process.env.GITHUB_MCP_APP_SLUG = before;
}
});

test('an access page is an absolute https URL or absent', () => {
for (const accessPage of [
'github.com/apps/x/installations/new', 'http://github.com/apps/x', '', 'https://a b', 42,
// Pass the prefix check, but name no host.
'https://#fragment', 'https://?q',
]) {
expect(() => assertHostedMcpEntries([linear({ accessPage })]))
.toThrow(/access page is not an absolute https URL: linear/);
}
// Controls: an entry with no page, and one with an https page, both load.
expect(() => assertHostedMcpEntries([linear()])).not.toThrow();
expect(() => assertHostedMcpEntries([linear({ accessPage: 'https://github.com/apps/x/installations/new' })]))
.not.toThrow();
});

test('a namespaced name is the entry id and the pinned name', () => {
const entry = linear();
expect(hostedMcpToolName(entry, entry.tools[0])).toBe('linear.list_issues');
Expand Down
22 changes: 22 additions & 0 deletions backend/__tests__/unit/services/hostedMcpIntake.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ it("a hosted entry's tool row carries its connect descriptor and only its own co
title: 'Acme',
description: 'Tickets in Acme.',
connectNotice: 'Ask your admin to allow this connection first.',
accessPage: 'https://acme.example/install',
resource: 'https://mcp.acme.example/mcp',
issuer: 'https://mcp.acme.example',
client: 'cimd',
Expand Down Expand Up @@ -285,6 +286,8 @@ it("a hosted entry's tool row carries its connect descriptor and only its own co
expect(acmeRow).toMatchObject({ connectionType: 'hosted-mcp', entryId: 'acme' });
expect(acmeRow.connectNotice).toBe(acme.connectNotice);
expect(linear.connectNotice).toBeUndefined();
expect(acmeRow.accessPage).toBe(acme.accessPage);
expect(linear.accessPage).toBeUndefined();
// Each row sees its own vendor's connection and not the other's.
expect(linear.connections).toEqual([
{ connectionId: 'conn-linear', owner: '', repo: '' },
Expand Down
18 changes: 18 additions & 0 deletions backend/__tests__/unit/services/toolInstallables.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
17 changes: 17 additions & 0 deletions backend/integrations/hostedMcp/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +27,7 @@ export const HOSTED_MCP_ENTRIES: HostedMcpEntry[] = [
NOTION_ENTRY,
GITLAB_ENTRY,
SUPABASE_ENTRY,
GITHUB_MCP_ENTRY,
];

/**
Expand Down Expand Up @@ -182,6 +184,21 @@ export const assertHostedMcpEntries = (
);
}
}
// The catalogue hands this page to a person as a link. The prefix check
// alone accepts `https://#fragment`, which has no host to open.
if (entry.accessPage !== undefined) {
let page: URL | null = null;
try {
if (typeof entry.accessPage === 'string' && /^https:\/\/[^\s]+$/.test(entry.accessPage)) {
page = new URL(entry.accessPage);
}
} catch {
page = null;
}
if (!page || page.protocol !== 'https:' || !page.hostname) {
throw new Error(`hosted-mcp entry access page is not an absolute https URL: ${entry.id}`);
}
}
// What removal will do with this entry
// (services/connectionRemovalService.ts). A `revoke` that names no kind,
// both, or a URL that is not https is not a refusal at call time — the
Expand Down
62 changes: 62 additions & 0 deletions backend/integrations/hostedMcp/githubMcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* 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. 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';

const descriptions: Record<string, string> = {
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 },
}));

// Each instance registers its own App, so its install page comes from the
// instance's config, like its client id. A slug GitHub could not have issued
// offers no page rather than a link to somewhere else.
const GITHUB_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/;

export const githubMcpAccessPage = (env: NodeJS.ProcessEnv = process.env): string | undefined => {
const slug = String(env.GITHUB_MCP_APP_SLUG || '').trim();
return GITHUB_APP_SLUG.test(slug) ? `https://github.com/apps/${slug}/installations/new` : undefined;
};

const accessPage = githubMcpAccessPage();

export const GITHUB_MCP_ENTRY: HostedMcpEntry = {
id: 'github-mcp',
title: 'GitHub',
description: 'Read files, issues and pull requests in the repositories you install the GitHub App on.',
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' },
...(accessPage ? { accessPage } : {}),
tools,
};
Loading
Loading