From 7c64f30968112abccb4bfb26d6da5d00a5634f7f Mon Sep 17 00:00:00 2001
From: convoy
Date: Mon, 20 Jul 2026 21:01:07 +0100
Subject: [PATCH 01/10] =?UTF-8?q?convoy(implementer):=20Implementer=20Repo?=
=?UTF-8?q?rt=20=E2=80=94=20GitHub=20Repositories=20as=20Knowledge=20Base?=
=?UTF-8?q?=20Sources?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
ARCHITECTURE.md | 2 +-
.../[id]/test/__tests__/route.test.ts | 19 ++
.../u/[slug]/connectors/[id]/test/route.ts | 20 ++-
.../connectors/add-connector-config.ts | 11 ++
.../connectors/add-connector-modal.tsx | 14 ++
.../add-connector/__tests__/sections.test.tsx | 24 +++
.../connectors/add-connector/github/config.ts | 42 +++++
.../add-connector/github/section.tsx | 170 ++++++++++++++++++
.../connectors/add-connector/shared.ts | 7 +
.../connectors/connector-type-icon.tsx | 4 +-
.../lib/__tests__/agent-capabilities.test.ts | 4 +
.../agent-connector-capabilities.test.ts | 9 +
.../__tests__/workspace-tool-display.test.ts | 5 +
apps/web/src/lib/agent-capabilities.ts | 1 +
.../src/lib/agent-connector-capabilities.ts | 1 +
.../__tests__/github-config.test.ts | 82 +++++++++
.../__tests__/response-config.test.ts | 12 ++
.../__tests__/test-connection.test.ts | 32 ++++
.../__tests__/tool-inventory.test.ts | 36 ++++
.../lib/connectors/__tests__/types.test.ts | 2 +
apps/web/src/lib/connectors/github.ts | 107 +++++++++++
.../mcp/__tests__/server-url.test.ts | 7 +
apps/web/src/lib/connectors/mcp/server-url.ts | 8 +
.../web/src/lib/connectors/response-config.ts | 11 ++
.../web/src/lib/connectors/test-connection.ts | 30 ++++
apps/web/src/lib/connectors/tool-inventory.ts | 13 +-
apps/web/src/lib/connectors/types.ts | 4 +-
apps/web/src/lib/connectors/validators.ts | 8 +
.../lib/services/__tests__/connector.test.ts | 13 ++
apps/web/src/lib/services/connector.ts | 12 ++
.../lib/spawner/__tests__/mcp-config.test.ts | 34 ++++
.../__tests__/runtime-artifacts.test.ts | 37 ++++
.../__tests__/runtime-config-utils.test.ts | 17 ++
.../src/lib/spawner/mcp-connector-config.ts | 24 +++
apps/web/src/lib/spawner/runtime-artifacts.ts | 30 +++-
.../src/lib/spawner/runtime-config-utils.ts | 14 ++
apps/web/src/lib/workspace-tool-display.ts | 1 +
apps/web/tests/connectors.test.ts | 2 +-
38 files changed, 860 insertions(+), 9 deletions(-)
create mode 100644 apps/web/src/components/connectors/add-connector/github/config.ts
create mode 100644 apps/web/src/components/connectors/add-connector/github/section.tsx
create mode 100644 apps/web/src/lib/connectors/__tests__/github-config.test.ts
create mode 100644 apps/web/src/lib/connectors/github.ts
create mode 100644 apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index fb447ce8..b6cdec3d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -64,7 +64,7 @@ arche/
| `User` | Accounts (email, slug, role, Argon2 hash, TOTP fields) |
| `Session` | Sessions with token hash, expiration, IP, and user agent |
| `Instance` | Containerized workspace (status, containerId, encrypted password, configSha) |
-| `Connector` | Per-user connector records with encrypted config. Supported connector types are defined in `apps/web/src/lib/connectors/types.ts`: Linear, Notion, Zendesk, Ahrefs, Umami, custom MCP, Meta Ads, and Google Workspace products (Gmail, Drive, Calendar, Chat, People) |
+| `Connector` | Per-user connector records with encrypted config. Supported connector types are defined in `apps/web/src/lib/connectors/types.ts`: Linear, Notion, Zendesk, Ahrefs, Umami, GitHub repositories, custom MCP, Meta Ads, and Google Workspace products (Gmail, Drive, Calendar, Chat, People) |
| `ProviderCredential` | Per-user model provider credentials for providers such as OpenAI, Anthropic, Fireworks, OpenRouter, and OpenCode |
| `ExternalIntegration` | Admin-managed integrations stored once for the deployment, such as Slack |
| `SlackThreadBinding` | Mapping between Slack channel threads and OpenCode sessions |
diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/test/__tests__/route.test.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/test/__tests__/route.test.ts
index 11be2ef0..628ed94b 100644
--- a/apps/web/src/app/api/u/[slug]/connectors/[id]/test/__tests__/route.test.ts
+++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/test/__tests__/route.test.ts
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
validateDesktopToken: vi.fn(() => true),
requireCapability: vi.fn(() => null),
decryptConfig: vi.fn(),
+ getGithubConnectionTestEndpoint: vi.fn(),
getConnectorAuthType: vi.fn(() => 'manual'),
refreshConnectorOAuthConfigIfNeeded: vi.fn(),
getCustomConnectorTestEndpoint: vi.fn(),
@@ -29,6 +30,9 @@ vi.mock('@/lib/runtime/desktop/token', () => ({
}))
vi.mock('@/lib/runtime/require-capability', () => ({ requireCapability: mocks.requireCapability }))
vi.mock('@/lib/connectors/crypto', () => ({ decryptConfig: mocks.decryptConfig }))
+vi.mock('@/lib/connectors/github', () => ({
+ getGithubConnectionTestEndpoint: mocks.getGithubConnectionTestEndpoint,
+}))
vi.mock('@/lib/connectors/oauth-config', () => ({ getConnectorAuthType: mocks.getConnectorAuthType }))
vi.mock('@/lib/connectors/oauth-refresh', () => ({
refreshConnectorOAuthConfigIfNeeded: mocks.refreshConnectorOAuthConfigIfNeeded,
@@ -161,6 +165,21 @@ describe('POST /api/u/[slug]/connectors/[id]/test', () => {
expect(body.error).toBe('ssrf_blocked')
})
+ it('validates SSRF for GitHub Enterprise test endpoints', async () => {
+ mocks.connectorService.findByIdAndUserId.mockResolvedValue({ ...CONNECTOR, type: 'github' })
+ mocks.getGithubConnectionTestEndpoint.mockReturnValue('https://internal.example/api/v3/user')
+ mocks.validateConnectorTestEndpoint.mockResolvedValue({
+ ok: false,
+ error: 'blocked_endpoint',
+ })
+
+ const res = await POST(makeRequest(), params('admin', 'c1'))
+
+ expect(res.status).toBe(400)
+ expect(await res.json()).toEqual({ error: 'blocked_endpoint' })
+ expect(mocks.testConnectorConnection).not.toHaveBeenCalled()
+ })
+
it('returns 401 when not authenticated', async () => {
mocks.getSession.mockResolvedValue(null)
const res = await POST(makeRequest(), params('admin', 'c1'))
diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts
index e2cf8e80..dcaae563 100644
--- a/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts
+++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { decryptConfig } from '@/lib/connectors/crypto'
+import { getGithubConnectionTestEndpoint } from '@/lib/connectors/github'
import { getConnectorAuthType } from '@/lib/connectors/oauth-config'
import { refreshConnectorOAuthConfigIfNeeded } from '@/lib/connectors/oauth-refresh'
import { getCustomConnectorTestEndpoint, testConnectorConnection, type TestConnectionResult } from '@/lib/connectors/test-connection'
@@ -84,7 +85,24 @@ export const POST = withAuth(null)
const umamiRef = useRef(null)
const metaAdsRef = useRef(null)
+ const githubRef = useRef(null)
const googleGmailRef = useRef(null)
const googleDriveRef = useRef(null)
const googleCalendarRef = useRef(null)
@@ -83,6 +85,7 @@ export function AddConnectorModal({
ahrefs: ahrefsRef,
umami: umamiRef,
'meta-ads': metaAdsRef,
+ github: githubRef,
google_gmail: googleGmailRef,
google_drive: googleDriveRef,
google_calendar: googleCalendarRef,
@@ -211,6 +214,17 @@ export function AddConnectorModal({
/>
),
},
+ {
+ type: 'github',
+ node: (
+
+ ),
+ },
{
type: 'google_gmail',
node: (
diff --git a/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx b/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx
index 01b6a584..39fad941 100644
--- a/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx
+++ b/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AddConnectorSectionHandle } from '@/components/connectors/add-connector/section-types'
import { CustomSection } from '@/components/connectors/add-connector/custom/section'
import { GoogleWorkspaceSection } from '@/components/connectors/add-connector/google-workspace/section'
+import { GithubSection } from '@/components/connectors/add-connector/github/section'
import { LinearSection } from '@/components/connectors/add-connector/linear/section'
import { MetaAdsSection } from '@/components/connectors/add-connector/meta-ads/section'
import { NotionSection } from '@/components/connectors/add-connector/notion/section'
@@ -187,6 +188,29 @@ describe('add connector sections', () => {
expect(result.config).toEqual({ authType: 'oauth' })
})
+ it('submits a GitHub PAT and pinned repositories', () => {
+ const ref = createRef()
+ render()
+
+ fireEvent.change(screen.getByLabelText('Personal access token'), {
+ target: { value: 'github_pat_example' },
+ })
+ fireEvent.change(screen.getByLabelText('Pinned repositories'), {
+ target: { value: 'acme/api' },
+ })
+ fireEvent.keyDown(screen.getByLabelText('Pinned repositories'), { key: 'Enter' })
+
+ expect(getHandle(ref).isComplete()).toBe(true)
+ expect(expectSuccessfulSubmission(ref)).toMatchObject({
+ name: 'GitHub',
+ config: {
+ pat: 'github_pat_example',
+ pinnedRepos: ['acme/api'],
+ toolsets: ['repos', 'git'],
+ },
+ })
+ })
+
it('submits Umami API-key and login configurations', () => {
const ref = createRef()
render()
diff --git a/apps/web/src/components/connectors/add-connector/github/config.ts b/apps/web/src/components/connectors/add-connector/github/config.ts
new file mode 100644
index 00000000..41b7e3b1
--- /dev/null
+++ b/apps/web/src/components/connectors/add-connector/github/config.ts
@@ -0,0 +1,42 @@
+import { parseGithubConnectorConfig } from '@/lib/connectors/github'
+
+import type { ConnectorConfigResult } from '@/components/connectors/add-connector/types'
+
+export type GithubConnectorFormState = {
+ selectedType: 'github'
+ pat: string
+ host: string
+ pinnedRepos: string[]
+}
+
+export function buildGithubConnectorConfig(
+ state: GithubConnectorFormState
+): ConnectorConfigResult {
+ if (!state.pat.trim()) {
+ return { ok: false, message: 'GitHub personal access token is required.' }
+ }
+
+ if (state.pinnedRepos.length === 0) {
+ return { ok: false, message: 'Add at least one pinned repository.' }
+ }
+
+ const parsed = parseGithubConnectorConfig({
+ pat: state.pat,
+ ...(state.host.trim() ? { host: state.host } : {}),
+ pinnedRepos: state.pinnedRepos,
+ })
+ if (!parsed.ok) {
+ return {
+ ok: false,
+ message: 'Enter a valid GitHub personal access token, host, and pinned repositories.',
+ }
+ }
+
+ return { ok: true, value: parsed.config }
+}
+
+export function isGithubConnectorConfigurationComplete(
+ state: GithubConnectorFormState
+): boolean {
+ return buildGithubConnectorConfig(state).ok
+}
diff --git a/apps/web/src/components/connectors/add-connector/github/section.tsx b/apps/web/src/components/connectors/add-connector/github/section.tsx
new file mode 100644
index 00000000..9719225b
--- /dev/null
+++ b/apps/web/src/components/connectors/add-connector/github/section.tsx
@@ -0,0 +1,170 @@
+'use client'
+
+import { forwardRef, useImperativeHandle, useState } from 'react'
+
+import { ManualApiKeyField } from '@/components/connectors/add-connector/manual-api-key-field'
+import { buildDefaultName } from '@/components/connectors/add-connector/shared'
+import {
+ type AddConnectorSectionHandle,
+ type AddConnectorSectionProps,
+ type AddConnectorSubmissionResult,
+ useNotifyStateChange,
+} from '@/components/connectors/add-connector/section-types'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { isGithubPinnedRepo } from '@/lib/connectors/github'
+
+import {
+ buildGithubConnectorConfig,
+ isGithubConnectorConfigurationComplete,
+} from './config'
+
+export const GithubSection = forwardRef<
+ AddConnectorSectionHandle,
+ AddConnectorSectionProps
+>(function GithubSection({ onStateChange, isActive }, ref) {
+ const [pat, setPat] = useState('')
+ const [host, setHost] = useState('')
+ const [pinnedRepos, setPinnedRepos] = useState([])
+ const [repoInput, setRepoInput] = useState('')
+ const [repoError, setRepoError] = useState(null)
+ const [showAdvanced, setShowAdvanced] = useState(false)
+
+ const state = {
+ selectedType: 'github' as const,
+ pat,
+ host,
+ pinnedRepos,
+ }
+
+ useNotifyStateChange(onStateChange, state)
+
+ function addPinnedRepo() {
+ const repo = repoInput.trim()
+ if (!isGithubPinnedRepo(repo)) {
+ setRepoError('Enter a repository as owner/repository.')
+ return
+ }
+
+ setPinnedRepos((current) =>
+ current.includes(repo) ? current : [...current, repo]
+ )
+ setRepoInput('')
+ setRepoError(null)
+ }
+
+ useImperativeHandle(ref, () => ({
+ isComplete: () => isGithubConnectorConfigurationComplete(state),
+ getSubmission: (): AddConnectorSubmissionResult => {
+ const configResult = buildGithubConnectorConfig(state)
+ if (!configResult.ok) {
+ return { ok: false, message: configResult.message }
+ }
+
+ return {
+ ok: true,
+ name: buildDefaultName('github'),
+ config: configResult.value,
+ }
+ },
+ }))
+
+ if (!isActive) return null
+
+ return (
+
+
+
+
+ {buildDefaultName('github')}
+
+
+
+
+
+
+
+
+ {
+ setRepoInput(event.target.value)
+ setRepoError(null)
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ addPinnedRepo()
+ }
+ }}
+ placeholder="owner/repository"
+ />
+
+
+
+ Press Enter or Add after each repository. These repositories guide agents to the most relevant source code.
+
+ {repoError ?
{repoError}
: null}
+ {pinnedRepos.length > 0 ? (
+
+ {pinnedRepos.map((repo) => (
+ -
+
{repo}
+
+
+ ))}
+
+ ) : null}
+
+
+
+
+ {showAdvanced ? (
+
+
+
setHost(event.target.value)}
+ placeholder="https://github.example.com"
+ />
+
+ Leave blank for GitHub.com. GitHub Enterprise Server requires a compatible MCP endpoint and is not supported by the hosted GitHub.com MCP server.
+
+
+ ) : null}
+
+
+ )
+})
diff --git a/apps/web/src/components/connectors/add-connector/shared.ts b/apps/web/src/components/connectors/add-connector/shared.ts
index 585f312e..769a1296 100644
--- a/apps/web/src/components/connectors/add-connector/shared.ts
+++ b/apps/web/src/components/connectors/add-connector/shared.ts
@@ -43,6 +43,11 @@ export const CONNECTOR_TYPE_OPTIONS: {
label: 'Meta Ads',
description: 'Meta Marketing API insights via Arche MCP.',
},
+ {
+ type: 'github',
+ label: 'GitHub',
+ description: 'Read-only repository history and source code via GitHub MCP.',
+ },
{
type: 'google_gmail',
label: 'Gmail',
@@ -92,6 +97,8 @@ export function buildDefaultName(type: ConnectorType): string {
return 'Umami'
case 'meta-ads':
return 'Meta Ads'
+ case 'github':
+ return 'GitHub'
case 'google_gmail':
return 'Gmail'
case 'google_drive':
diff --git a/apps/web/src/components/connectors/connector-type-icon.tsx b/apps/web/src/components/connectors/connector-type-icon.tsx
index 590ba9aa..7c957cd9 100644
--- a/apps/web/src/components/connectors/connector-type-icon.tsx
+++ b/apps/web/src/components/connectors/connector-type-icon.tsx
@@ -1,4 +1,4 @@
-import { BarChart3, BookText, Boxes, Globe, Search, Ticket } from 'lucide-react'
+import { BarChart3, BookText, Boxes, Github, Globe, Search, Ticket } from 'lucide-react'
import type { ConnectorType } from '@/lib/connectors/types'
@@ -19,6 +19,8 @@ export function ConnectorTypeIcon({ type, className }: ConnectorTypeIconProps) {
return
case 'umami':
return
+ case 'github':
+ return
case 'custom':
return
default:
diff --git a/apps/web/src/lib/__tests__/agent-capabilities.test.ts b/apps/web/src/lib/__tests__/agent-capabilities.test.ts
index 3071f6e0..4cf90652 100644
--- a/apps/web/src/lib/__tests__/agent-capabilities.test.ts
+++ b/apps/web/src/lib/__tests__/agent-capabilities.test.ts
@@ -649,6 +649,10 @@ describe('getConnectorCapabilityId', () => {
expect(getConnectorCapabilityId('ahrefs', 'any-id')).toBe('globalahrefs')
})
+ it('returns global id for GitHub type', () => {
+ expect(getConnectorCapabilityId('github', 'any-id')).toBe('globalgithub')
+ })
+
it('returns global id for umami type', () => {
expect(getConnectorCapabilityId('umami', 'any-id')).toBe('globalumami')
})
diff --git a/apps/web/src/lib/__tests__/agent-connector-capabilities.test.ts b/apps/web/src/lib/__tests__/agent-connector-capabilities.test.ts
index 009aa92f..d0396e78 100644
--- a/apps/web/src/lib/__tests__/agent-connector-capabilities.test.ts
+++ b/apps/web/src/lib/__tests__/agent-connector-capabilities.test.ts
@@ -38,6 +38,15 @@ describe('agent connector capabilities', () => {
ownerKind: null,
ownerSlug: null,
},
+ {
+ id: 'globalgithub',
+ type: 'github',
+ name: 'GitHub',
+ enabled: false,
+ scope: 'type',
+ ownerKind: null,
+ ownerSlug: null,
+ },
{
id: 'globalgooglecalendar',
type: 'google_calendar',
diff --git a/apps/web/src/lib/__tests__/workspace-tool-display.test.ts b/apps/web/src/lib/__tests__/workspace-tool-display.test.ts
index 3edc372c..586bcb18 100644
--- a/apps/web/src/lib/__tests__/workspace-tool-display.test.ts
+++ b/apps/web/src/lib/__tests__/workspace-tool-display.test.ts
@@ -50,6 +50,11 @@ describe('workspace-tool-display', () => {
groupLabel: 'Using Linear',
commandLabel: 'get issue',
})
+ expect(getWorkspaceToolDisplay('arche_github_conn123_list_commits')).toEqual({
+ isConnectorTool: true,
+ groupLabel: 'Using GitHub',
+ commandLabel: 'list commits',
+ })
})
it('leaves built-in tool names untouched', () => {
diff --git a/apps/web/src/lib/agent-capabilities.ts b/apps/web/src/lib/agent-capabilities.ts
index 399be77e..c9e1953e 100644
--- a/apps/web/src/lib/agent-capabilities.ts
+++ b/apps/web/src/lib/agent-capabilities.ts
@@ -76,6 +76,7 @@ const SINGLE_INSTANCE_AGENT_CONNECTOR_CAPABILITY_IDS = {
notion: 'globalnotion',
zendesk: 'globalzendesk',
ahrefs: 'globalahrefs',
+ github: 'globalgithub',
umami: 'globalumami',
google_gmail: 'globalgooglegmail',
google_drive: 'globalgoogledrive',
diff --git a/apps/web/src/lib/agent-connector-capabilities.ts b/apps/web/src/lib/agent-connector-capabilities.ts
index 3b6fa86e..c7db5249 100644
--- a/apps/web/src/lib/agent-connector-capabilities.ts
+++ b/apps/web/src/lib/agent-connector-capabilities.ts
@@ -26,6 +26,7 @@ const SINGLE_INSTANCE_CONNECTOR_LABELS = {
notion: 'Notion',
zendesk: 'Zendesk',
ahrefs: 'Ahrefs',
+ github: 'GitHub',
umami: 'Umami',
google_gmail: 'Gmail',
google_drive: 'Google Drive',
diff --git a/apps/web/src/lib/connectors/__tests__/github-config.test.ts b/apps/web/src/lib/connectors/__tests__/github-config.test.ts
new file mode 100644
index 00000000..ef76bb0d
--- /dev/null
+++ b/apps/web/src/lib/connectors/__tests__/github-config.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ getGithubConnectionTestEndpoint,
+ getGithubMcpServerUrl,
+ parseGithubConnectorConfig,
+} from '@/lib/connectors/github'
+
+describe('GitHub connector config', () => {
+ it('parses a valid GitHub.com config with restricted default toolsets', () => {
+ expect(parseGithubConnectorConfig({
+ pat: ' github_pat_example ',
+ pinnedRepos: ['owner/repo', 'owner/repo'],
+ })).toEqual({
+ ok: true,
+ config: {
+ pat: 'github_pat_example',
+ pinnedRepos: ['owner/repo'],
+ toolsets: ['repos', 'git'],
+ },
+ })
+ })
+
+ it('parses a GitHub Enterprise host and allowed toolset subset', () => {
+ const parsed = parseGithubConnectorConfig({
+ pat: 'ghp_example',
+ host: 'https://github.example.com/',
+ pinnedRepos: ['acme/platform'],
+ toolsets: ['repos'],
+ })
+
+ expect(parsed).toEqual({
+ ok: true,
+ config: {
+ pat: 'ghp_example',
+ host: 'https://github.example.com',
+ pinnedRepos: ['acme/platform'],
+ toolsets: ['repos'],
+ },
+ })
+ })
+
+ it.each([
+ { pat: 'token', pinnedRepos: ['owner/repo'] },
+ { pat: 'ghp_example', pinnedRepos: 'owner/repo' },
+ { pat: 'ghp_example', pinnedRepos: ['owner/repo/path'] },
+ { pat: 'ghp_example', host: 'http://github.example.com', pinnedRepos: ['owner/repo'] },
+ { pat: 'ghp_example', host: 'https://github.example.com/api', pinnedRepos: ['owner/repo'] },
+ { pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: ['all'] },
+ { pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: [] },
+ ])('rejects invalid config %#', (config) => {
+ expect(parseGithubConnectorConfig(config)).toEqual({ ok: false })
+ })
+
+ it('builds MCP and REST test URLs from parsed configuration', () => {
+ const githubCom = parseGithubConnectorConfig({
+ pat: 'ghp_example',
+ pinnedRepos: ['owner/repo'],
+ })
+ const enterprise = parseGithubConnectorConfig({
+ pat: 'ghp_example',
+ host: 'https://github.example.com',
+ pinnedRepos: ['owner/repo'],
+ })
+
+ if (!githubCom.ok || !enterprise.ok) {
+ throw new Error('expected valid GitHub connector configurations')
+ }
+
+ expect(getGithubMcpServerUrl(githubCom.config)).toBe('https://api.githubcopilot.com/mcp/')
+ expect(getGithubMcpServerUrl(enterprise.config)).toBe('https://github.example.com/mcp/')
+ expect(getGithubConnectionTestEndpoint({
+ pat: 'ghp_example',
+ pinnedRepos: ['owner/repo'],
+ })).toBe('https://api.github.com/user')
+ expect(getGithubConnectionTestEndpoint({
+ pat: 'ghp_example',
+ host: 'https://github.example.com',
+ pinnedRepos: ['owner/repo'],
+ })).toBe('https://github.example.com/api/v3/user')
+ })
+})
diff --git a/apps/web/src/lib/connectors/__tests__/response-config.test.ts b/apps/web/src/lib/connectors/__tests__/response-config.test.ts
index 8582b499..9751d5a1 100644
--- a/apps/web/src/lib/connectors/__tests__/response-config.test.ts
+++ b/apps/web/src/lib/connectors/__tests__/response-config.test.ts
@@ -30,6 +30,18 @@ describe('sanitizeConnectorConfigForResponse', () => {
expect(sanitizeConnectorConfigForResponse('custom', config)).toBe(config)
})
+ it('redacts GitHub personal access tokens from responses', () => {
+ expect(sanitizeConnectorConfigForResponse('github', {
+ pat: 'github_pat_secret',
+ pinnedRepos: ['acme/api'],
+ toolsets: ['repos', 'git'],
+ })).toEqual({
+ hasPat: true,
+ pinnedRepos: ['acme/api'],
+ toolsets: ['repos', 'git'],
+ })
+ })
+
it('redacts OAuth credentials while preserving connection metadata', () => {
expect(sanitizeConnectorConfigForResponse('custom', {
authType: 'oauth',
diff --git a/apps/web/src/lib/connectors/__tests__/test-connection.test.ts b/apps/web/src/lib/connectors/__tests__/test-connection.test.ts
index db6bfd7d..0e64954c 100644
--- a/apps/web/src/lib/connectors/__tests__/test-connection.test.ts
+++ b/apps/web/src/lib/connectors/__tests__/test-connection.test.ts
@@ -256,6 +256,38 @@ describe('testConnectorConnection', () => {
})
})
+ describe('github', () => {
+ it('calls the GitHub user endpoint with the PAT', async () => {
+ fetchSpy = mockFetch({ status: 200 })
+
+ const result = await testConnectorConnection('github', {
+ pat: 'github_pat_example',
+ pinnedRepos: ['acme/api'],
+ })
+
+ expect(result).toEqual({ ok: true, tested: true, message: 'GitHub connection verified.' })
+ const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
+ expect(url).toBe('https://api.github.com/user')
+ expect((init.headers as Record).Authorization).toBe('Bearer github_pat_example')
+ })
+
+ it('does not test invalid GitHub configuration', async () => {
+ fetchSpy = vi.spyOn(globalThis, 'fetch')
+
+ const result = await testConnectorConnection('github', {
+ pat: 'invalid',
+ pinnedRepos: ['acme/api'],
+ })
+
+ expect(result).toEqual({
+ ok: false,
+ tested: false,
+ message: 'Invalid GitHub connector configuration',
+ })
+ expect(fetchSpy).not.toHaveBeenCalled()
+ })
+ })
+
describe('notion', () => {
it('calls Notion REST API with API key in manual mode', async () => {
fetchSpy = mockFetch({ status: 200 })
diff --git a/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts b/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts
index f61ed740..d240ad73 100644
--- a/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts
+++ b/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts
@@ -176,6 +176,42 @@ describe('loadConnectorToolInventory', () => {
)
})
+ it('loads GitHub tools with the read-only and toolset restrictions', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ result: {
+ tools: [{ name: 'list_commits', description: 'List commits' }],
+ },
+ }),
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ mocks.getConnectorMcpServerUrl.mockReturnValue('https://api.githubcopilot.com/mcp/')
+
+ const result = await loadConnectorToolInventory({
+ type: 'github',
+ config: {
+ pat: 'github_pat_example',
+ pinnedRepos: ['acme/api'],
+ },
+ })
+
+ expect(result).toEqual({
+ ok: true,
+ tools: [{ name: 'list_commits', title: 'List commits', description: 'List commits' }],
+ })
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://validated.example/rpc',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: 'Bearer github_pat_example',
+ 'X-MCP-Readonly': 'true',
+ 'X-MCP-Toolsets': 'repos,git',
+ }),
+ }),
+ )
+ })
+
it('uses API key auth for any remote connector inventory', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
diff --git a/apps/web/src/lib/connectors/__tests__/types.test.ts b/apps/web/src/lib/connectors/__tests__/types.test.ts
index e8a429a5..21b07af0 100644
--- a/apps/web/src/lib/connectors/__tests__/types.test.ts
+++ b/apps/web/src/lib/connectors/__tests__/types.test.ts
@@ -6,6 +6,7 @@ describe('connector types', () => {
it('isConnectorType returns true for known types', () => {
expect(isConnectorType('linear')).toBe(true)
expect(isConnectorType('notion')).toBe(true)
+ expect(isConnectorType('github')).toBe(true)
expect(isConnectorType('custom')).toBe(true)
})
@@ -17,6 +18,7 @@ describe('connector types', () => {
it('isSingleInstanceConnectorType returns true for single-instance types', () => {
expect(isSingleInstanceConnectorType('linear')).toBe(true)
expect(isSingleInstanceConnectorType('google_drive')).toBe(true)
+ expect(isSingleInstanceConnectorType('github')).toBe(true)
})
it('isSingleInstanceConnectorType returns false for multi-instance types', () => {
diff --git a/apps/web/src/lib/connectors/github.ts b/apps/web/src/lib/connectors/github.ts
new file mode 100644
index 00000000..8a26725b
--- /dev/null
+++ b/apps/web/src/lib/connectors/github.ts
@@ -0,0 +1,107 @@
+const GITHUB_PAT_PATTERN = /^(?:ghp_|github_pat_)\S+$/
+const GITHUB_PINNED_REPO_PATTERN = /^[\w.-]+\/[\w.-]+$/
+const GITHUB_MCP_DEFAULT_HOST = 'https://api.githubcopilot.com'
+const GITHUB_TOOLSETS = new Set(['repos', 'git'])
+
+export const DEFAULT_GITHUB_TOOLSETS = ['repos', 'git'] as const
+
+export type GithubConnectorConfig = {
+ pat: string
+ host?: string
+ pinnedRepos: string[]
+ toolsets: string[]
+}
+
+export type GithubConnectorConfigParseResult =
+ | { ok: true; config: GithubConnectorConfig }
+ | { ok: false }
+
+export function isGithubPat(value: string): boolean {
+ return GITHUB_PAT_PATTERN.test(value.trim())
+}
+
+export function isGithubPinnedRepo(value: string): boolean {
+ return GITHUB_PINNED_REPO_PATTERN.test(value.trim())
+}
+
+function parseGithubHost(value: unknown): string | undefined {
+ if (value === undefined) return undefined
+ if (typeof value !== 'string' || !value.trim()) return undefined
+
+ try {
+ const url = new URL(value.trim())
+ if (
+ url.protocol !== 'https:' ||
+ url.username ||
+ url.password ||
+ url.pathname !== '/' ||
+ url.search ||
+ url.hash
+ ) {
+ return undefined
+ }
+
+ return url.origin
+ } catch {
+ return undefined
+ }
+}
+
+function parseToolsets(value: unknown): string[] | null {
+ if (value === undefined) return [...DEFAULT_GITHUB_TOOLSETS]
+ if (!Array.isArray(value) || value.length === 0) return null
+
+ const toolsets = value.map((toolset) =>
+ typeof toolset === 'string' ? toolset.trim() : ''
+ )
+ if (
+ toolsets.some((toolset) => !GITHUB_TOOLSETS.has(toolset)) ||
+ new Set(toolsets).size !== toolsets.length
+ ) {
+ return null
+ }
+
+ return toolsets
+}
+
+export function parseGithubConnectorConfig(
+ config: Record
+): GithubConnectorConfigParseResult {
+ const pat = typeof config.pat === 'string' ? config.pat.trim() : ''
+ if (!isGithubPat(pat)) return { ok: false }
+
+ if (!Array.isArray(config.pinnedRepos)) return { ok: false }
+ const pinnedRepos = config.pinnedRepos.map((repo) =>
+ typeof repo === 'string' ? repo.trim() : ''
+ )
+ if (pinnedRepos.some((repo) => !isGithubPinnedRepo(repo))) return { ok: false }
+
+ const host = parseGithubHost(config.host)
+ if (config.host !== undefined && !host) return { ok: false }
+
+ const toolsets = parseToolsets(config.toolsets)
+ if (!toolsets) return { ok: false }
+
+ return {
+ ok: true,
+ config: {
+ pat,
+ ...(host ? { host } : {}),
+ pinnedRepos: Array.from(new Set(pinnedRepos)),
+ toolsets,
+ },
+ }
+}
+
+export function getGithubMcpServerUrl(config: GithubConnectorConfig): string {
+ return `${config.host ?? GITHUB_MCP_DEFAULT_HOST}/mcp/`
+}
+
+export function getGithubConnectionTestEndpoint(config: Record): string | null {
+ const parsed = parseGithubConnectorConfig(config)
+ if (!parsed.ok) return null
+
+ return parsed.config.host
+ ? `${parsed.config.host}/api/v3/user`
+ : 'https://api.github.com/user'
+}
diff --git a/apps/web/src/lib/connectors/mcp/__tests__/server-url.test.ts b/apps/web/src/lib/connectors/mcp/__tests__/server-url.test.ts
index 8592c4f2..4ef25e3a 100644
--- a/apps/web/src/lib/connectors/mcp/__tests__/server-url.test.ts
+++ b/apps/web/src/lib/connectors/mcp/__tests__/server-url.test.ts
@@ -86,6 +86,13 @@ describe('getConnectorMcpServerUrl', () => {
expect(getConnectorMcpServerUrl('umami', { baseUrl: 'https://api.umami.is/v1' })).toBeNull()
})
+ it('returns the hosted GitHub MCP URL for valid GitHub connector config', () => {
+ expect(getConnectorMcpServerUrl('github', {
+ pat: 'github_pat_example',
+ pinnedRepos: ['acme/api'],
+ })).toBe('https://api.githubcopilot.com/mcp/')
+ })
+
it('does not let stored OAuth MCP URLs influence hosted connector URLs except Linear and Notion', () => {
expect(getConnectorMcpServerUrl('google_gmail', {
authType: 'oauth',
diff --git a/apps/web/src/lib/connectors/mcp/server-url.ts b/apps/web/src/lib/connectors/mcp/server-url.ts
index b82c26c2..69b79d8c 100644
--- a/apps/web/src/lib/connectors/mcp/server-url.ts
+++ b/apps/web/src/lib/connectors/mcp/server-url.ts
@@ -1,3 +1,4 @@
+import { getGithubMcpServerUrl, parseGithubConnectorConfig } from '@/lib/connectors/github'
import { getGoogleWorkspaceMcpServerUrl, isGoogleWorkspaceConnectorType } from '@/lib/connectors/google-workspace'
import { getConnectorOAuthConfig } from '@/lib/connectors/oauth-config'
import type { ConnectorType } from '@/lib/connectors/types'
@@ -9,6 +10,7 @@ const MCP_SERVER_URLS = {
export function getConnectorMcpServerUrl(type: 'linear' | 'notion', config: Record): string
export function getConnectorMcpServerUrl(type: 'custom', config: Record): string | null
+export function getConnectorMcpServerUrl(type: 'github', config: Record): string | null
export function getConnectorMcpServerUrl(type: 'google_gmail' | 'google_drive' | 'google_calendar' | 'google_chat' | 'google_people', config: Record): string
export function getConnectorMcpServerUrl(type: ConnectorType, config: Record): string | null
export function getConnectorMcpServerUrl(type: ConnectorType, config: Record): string | null {
@@ -30,10 +32,16 @@ export function getConnectorMcpServerUrl(type: ConnectorType, config: Record): Reco
}
}
+function sanitizeGithubConfigForResponse(config: Record): Record {
+ const sanitizedConfig = { ...config }
+ delete sanitizedConfig.pat
+
+ return {
+ ...sanitizedConfig,
+ hasPat: typeof config.pat === 'string' && config.pat.length > 0,
+ }
+}
+
const CONNECTOR_CONFIG_SANITIZERS: Partial> = {
+ github: sanitizeGithubConfigForResponse,
'meta-ads': sanitizeMetaAdsConfigForResponse,
}
diff --git a/apps/web/src/lib/connectors/test-connection.ts b/apps/web/src/lib/connectors/test-connection.ts
index 0f17adac..628cf752 100644
--- a/apps/web/src/lib/connectors/test-connection.ts
+++ b/apps/web/src/lib/connectors/test-connection.ts
@@ -1,4 +1,5 @@
import { parseAhrefsConnectorConfig, testAhrefsConnection } from '@/lib/connectors/ahrefs'
+import { getGithubConnectionTestEndpoint, parseGithubConnectorConfig } from '@/lib/connectors/github'
import { getConnectorMcpServerUrl } from '@/lib/connectors/mcp/server-url'
import { testMetaAdsConnection } from '@/lib/connectors/meta-ads'
import { getConnectorAuthType, getConnectorOAuthConfig } from '@/lib/connectors/oauth-config'
@@ -14,6 +15,7 @@ export type TestConnectionResult = {
type TestConnectionOptions = {
customEndpointUrl?: URL
+ githubEndpointUrl?: URL
}
type TestConnectionHandler = (
@@ -46,6 +48,7 @@ function getAccessToken(type: ConnectorType, config: Record): s
case 'ahrefs':
case 'umami':
case 'custom':
+ case 'github':
return null
case 'google_gmail':
case 'google_drive':
@@ -205,6 +208,33 @@ const CONNECTOR_TEST_HANDLERS: Record = {
}
},
+ github: async (config, options) => {
+ const parsed = parseGithubConnectorConfig(config)
+ if (!parsed.ok) {
+ return { ok: false, tested: false, message: 'Invalid GitHub connector configuration' }
+ }
+
+ const endpoint = options.githubEndpointUrl?.toString() ?? getGithubConnectionTestEndpoint(config)
+ if (!endpoint) {
+ return { ok: false, tested: false, message: 'Invalid GitHub connector configuration' }
+ }
+
+ const response = await fetchWithTimeout(endpoint, {
+ method: 'GET',
+ headers: {
+ Accept: 'application/vnd.github+json',
+ Authorization: `Bearer ${parsed.config.pat}`,
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ })
+
+ if (!response.ok) {
+ return { ok: false, tested: true, message: `GitHub test failed (${response.status})` }
+ }
+
+ return { ok: true, tested: true, message: 'GitHub connection verified.' }
+ },
+
notion: async (config) => {
const pending = getPendingOAuthMessage('notion', config)
if (pending) return pending
diff --git a/apps/web/src/lib/connectors/tool-inventory.ts b/apps/web/src/lib/connectors/tool-inventory.ts
index 91df7861..859aade0 100644
--- a/apps/web/src/lib/connectors/tool-inventory.ts
+++ b/apps/web/src/lib/connectors/tool-inventory.ts
@@ -1,4 +1,5 @@
import { getAhrefsMcpTools, parseAhrefsConnectorConfig } from '@/lib/connectors/ahrefs'
+import { parseGithubConnectorConfig } from '@/lib/connectors/github'
import { getMetaAdsMcpTools, parseMetaAdsConnectorConfig } from '@/lib/connectors/meta-ads'
import { getConnectorMcpServerUrl } from '@/lib/connectors/mcp/server-url'
import { getConnectorAuthType, getConnectorOAuthConfig } from '@/lib/connectors/oauth-config'
@@ -65,6 +66,16 @@ function buildRemoteHeaders(
...(toStringRecord(config.headers) ?? {}),
}
+ if (type === 'github') {
+ const parsed = parseGithubConnectorConfig(config)
+ if (!parsed.ok) return null
+
+ headers.Authorization = `Bearer ${parsed.config.pat}`
+ headers['X-MCP-Readonly'] = 'true'
+ headers['X-MCP-Toolsets'] = parsed.config.toolsets.join(',')
+ return headers
+ }
+
if (getConnectorAuthType(config) === 'oauth') {
const oauth = getConnectorOAuthConfig(type, config)
if (!oauth?.accessToken) return null
@@ -206,7 +217,7 @@ async function loadRemoteConnectorToolInventory(
}
let url: URL
- if (type === 'custom') {
+ if (type === 'custom' || type === 'github') {
const endpointValidation = await validateConnectorTestEndpoint(upstreamUrl)
if (!endpointValidation.ok) {
return { ok: false, tools: [], message: 'Connector endpoint is not allowed.' }
diff --git a/apps/web/src/lib/connectors/types.ts b/apps/web/src/lib/connectors/types.ts
index 1ee19782..7dcb34dd 100644
--- a/apps/web/src/lib/connectors/types.ts
+++ b/apps/web/src/lib/connectors/types.ts
@@ -1,11 +1,11 @@
-export const CONNECTOR_TYPES = ['linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'custom', 'meta-ads', 'google_gmail', 'google_drive', 'google_calendar', 'google_chat', 'google_people'] as const
+export const CONNECTOR_TYPES = ['linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'custom', 'meta-ads', 'github', 'google_gmail', 'google_drive', 'google_calendar', 'google_chat', 'google_people'] as const
export type ConnectorType = (typeof CONNECTOR_TYPES)[number]
export function isConnectorType(value: string): value is ConnectorType {
return CONNECTOR_TYPES.includes(value as ConnectorType)
}
-export const SINGLE_INSTANCE_CONNECTOR_TYPES = ['linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'meta-ads', 'google_gmail', 'google_drive', 'google_calendar', 'google_chat', 'google_people'] as const satisfies readonly ConnectorType[]
+export const SINGLE_INSTANCE_CONNECTOR_TYPES = ['linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'meta-ads', 'github', 'google_gmail', 'google_drive', 'google_calendar', 'google_chat', 'google_people'] as const satisfies readonly ConnectorType[]
export function isSingleInstanceConnectorType(type: ConnectorType): boolean {
return SINGLE_INSTANCE_CONNECTOR_TYPES.includes(type as (typeof SINGLE_INSTANCE_CONNECTOR_TYPES)[number])
diff --git a/apps/web/src/lib/connectors/validators.ts b/apps/web/src/lib/connectors/validators.ts
index 7d4f0929..8cbf5020 100644
--- a/apps/web/src/lib/connectors/validators.ts
+++ b/apps/web/src/lib/connectors/validators.ts
@@ -1,4 +1,5 @@
import { getConnectorAuthType } from '@/lib/connectors/oauth-config'
+import { parseGithubConnectorConfig } from '@/lib/connectors/github'
import { getLinearOAuthActor, getLinearOAuthScopeValidationError, isLinearOAuthActor } from '@/lib/connectors/linear'
import { isOAuthConnectorType } from '@/lib/connectors/oauth'
import { validateMetaAdsConnectorConfig } from '@/lib/connectors/meta-ads-config'
@@ -32,6 +33,7 @@ export const CONNECTOR_SCHEMAS: Record = {
required: ['authType', 'appId', 'appSecret'],
optional: ['permissions', 'selectedAdAccountIds', 'defaultAdAccountId', 'oauth'],
},
+ github: { required: ['pat', 'pinnedRepos'], optional: ['host', 'toolsets'] },
custom: {
required: ['endpoint'],
optional: [
@@ -94,6 +96,12 @@ export function validateConnectorConfig(
return validateMetaAdsConnectorConfig(config)
}
+ if (type === 'github') {
+ return parseGithubConnectorConfig(config).ok
+ ? { valid: true }
+ : { valid: false, message: 'Invalid GitHub connector configuration' }
+ }
+
if (getConnectorAuthType(config) === 'oauth' && isOAuthConnectorType(type)) {
if (type === 'linear' && config.oauthActor !== undefined && !isLinearOAuthActor(config.oauthActor)) {
return { valid: false, message: 'Linear OAuth actor must be user or app' }
diff --git a/apps/web/src/lib/services/__tests__/connector.test.ts b/apps/web/src/lib/services/__tests__/connector.test.ts
index 5174969d..3b651340 100644
--- a/apps/web/src/lib/services/__tests__/connector.test.ts
+++ b/apps/web/src/lib/services/__tests__/connector.test.ts
@@ -18,6 +18,7 @@ import {
findManyByUserId,
findEnabledByUserId,
findEnabledMcpByUserId,
+ findEnabledGithubConnectorsForUser,
findHashEntriesByUserId,
findCapabilityInventoryEntries,
findManyByIds,
@@ -72,6 +73,18 @@ describe('connectorService', () => {
})
})
+ describe('findEnabledGithubConnectorsForUser', () => {
+ it('selects enabled GitHub connector configs deterministically', async () => {
+ mockPrisma.connector.findMany.mockResolvedValue([])
+ await findEnabledGithubConnectorsForUser('u1')
+ expect(mockPrisma.connector.findMany).toHaveBeenCalledWith({
+ where: { userId: 'u1', type: 'github', enabled: true },
+ select: { config: true },
+ orderBy: { id: 'asc' },
+ })
+ })
+ })
+
describe('findHashEntriesByUserId', () => {
it('orders by id asc', async () => {
mockPrisma.connector.findMany.mockResolvedValue([])
diff --git a/apps/web/src/lib/services/connector.ts b/apps/web/src/lib/services/connector.ts
index 4d6effeb..b495df76 100644
--- a/apps/web/src/lib/services/connector.ts
+++ b/apps/web/src/lib/services/connector.ts
@@ -36,6 +36,10 @@ export type ConnectorMcpEntry = {
enabled: boolean
}
+export type ConnectorGithubEntry = {
+ config: string
+}
+
export type ConnectorHashEntry = {
id: string
type: string
@@ -91,6 +95,14 @@ export function findEnabledMcpByUserId(userId: string): Promise {
+ return prisma.connector.findMany({
+ where: { userId, type: 'github', enabled: true },
+ select: { config: true },
+ orderBy: { id: 'asc' },
+ })
+}
+
export function findHashEntriesByUserId(userId: string): Promise {
return prisma.connector.findMany({
where: { userId },
diff --git a/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts b/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts
index 66dc764c..a9ff3c69 100644
--- a/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts
+++ b/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { ConnectorRecord } from '../mcp-config'
+import { shouldExposeConnectorViaGateway } from '../mcp-connector-config'
// ---------------------------------------------------------------------------
// Hoisted mocks
@@ -362,6 +363,39 @@ describe('mcp-config', () => {
})
})
+ // -----------------------------------------------------------------------
+ // GitHub connector
+ // -----------------------------------------------------------------------
+
+ describe('github', () => {
+ it('builds a direct read-only MCP config with restricted toolsets', () => {
+ passGates({ pat: 'github_pat_example', pinnedRepos: ['acme/api'] })
+ const connector = makeConnector({ type: 'github', id: 'gh1' })
+
+ const result = buildMcpConfigFromConnectors([connector], {
+ gatewayTargets: {
+ gh1: { url: 'http://gateway/gh1/mcp', token: 'gateway-token' },
+ },
+ })
+
+ expect(result.mcpConfig.mcp['arche_github_gh1']).toEqual({
+ type: 'remote',
+ url: 'https://api.githubcopilot.com/mcp/',
+ enabled: true,
+ headers: {
+ Authorization: 'Bearer github_pat_example',
+ 'X-MCP-Readonly': 'true',
+ 'X-MCP-Toolsets': 'repos,git',
+ },
+ oauth: false,
+ })
+ expect(shouldExposeConnectorViaGateway('github', {
+ pat: 'github_pat_example',
+ pinnedRepos: ['acme/api'],
+ })).toBe(false)
+ })
+ })
+
// -----------------------------------------------------------------------
// Custom connector
// -----------------------------------------------------------------------
diff --git a/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts b/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts
index c69b4e00..21914aca 100644
--- a/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts
+++ b/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts
@@ -10,6 +10,8 @@ const findIdentityBySlugMock = vi.fn()
const getEnabledProviderCredentialsForUserMock = vi.fn()
const getEffectiveCredentialForUserMock = vi.fn()
const decryptProviderSecretMock = vi.fn()
+const decryptConfigMock = vi.fn()
+const findEnabledGithubConnectorsForUserMock = vi.fn()
let repoDir: string | null = null
@@ -50,6 +52,12 @@ async function loadRuntimeArtifactsModule() {
userService: {
findIdentityBySlug: (...args: unknown[]) => findIdentityBySlugMock(...args),
},
+ connectorService: {
+ findEnabledGithubConnectorsForUser: (...args: unknown[]) => findEnabledGithubConnectorsForUserMock(...args),
+ },
+ }))
+ vi.doMock('@/lib/connectors/crypto', () => ({
+ decryptConfig: (...args: unknown[]) => decryptConfigMock(...args),
}))
vi.doMock('@/lib/providers/store', () => ({
getEnabledProviderCredentialsForUser: (...args: unknown[]) => getEnabledProviderCredentialsForUserMock(...args),
@@ -72,6 +80,8 @@ describe('runtime artifacts', () => {
getEnabledProviderCredentialsForUserMock.mockResolvedValue(new Map())
getEffectiveCredentialForUserMock.mockResolvedValue(null)
decryptProviderSecretMock.mockReturnValue({ apiKey: 'unused' })
+ decryptConfigMock.mockReturnValue({})
+ findEnabledGithubConnectorsForUserMock.mockResolvedValue([])
findIdentityBySlugMock.mockResolvedValue({
id: 'user-1',
slug: 'alice',
@@ -102,6 +112,7 @@ describe('runtime artifacts', () => {
}
vi.unmock('@/lib/config-repo-store')
+ vi.unmock('@/lib/connectors/crypto')
vi.unmock('@/lib/providers/crypto')
vi.unmock('@/lib/providers/store')
vi.unmock('@/lib/services')
@@ -251,6 +262,32 @@ describe('runtime artifacts', () => {
)
})
+ it('appends enabled GitHub connector repositories to AGENTS instructions', async () => {
+ await createRuntimeRepo({
+ 'AGENTS.md': '# Base instructions\n',
+ 'CommonWorkspaceConfig.json': createWorkspaceConfig(),
+ })
+ findEnabledGithubConnectorsForUserMock.mockResolvedValue([
+ { config: 'encrypted-github-config' },
+ ])
+ decryptConfigMock.mockReturnValue({
+ pat: 'github_pat_example',
+ pinnedRepos: ['acme/web', 'acme/api'],
+ })
+
+ const {
+ buildWorkspaceRuntimeArtifacts,
+ getWebProviderGatewayConfig,
+ } = await loadRuntimeArtifactsModule()
+
+ const artifacts = await buildWorkspaceRuntimeArtifacts('alice', getWebProviderGatewayConfig())
+
+ expect(findEnabledGithubConnectorsForUserMock).toHaveBeenCalledWith('user-1')
+ expect(artifacts.agentsMd).toContain('## Linked Repositories')
+ expect(artifacts.agentsMd).toContain('- `acme/api`')
+ expect(artifacts.agentsMd).toContain('- `acme/web`')
+ })
+
it('adds configured Ollama models to the runtime provider config', async () => {
await createRuntimeRepo({
'CommonWorkspaceConfig.json': createWorkspaceConfig(),
diff --git a/apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts b/apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts
new file mode 100644
index 00000000..a47cd23d
--- /dev/null
+++ b/apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+
+import { withLinkedRepositories } from '@/lib/spawner/runtime-config-utils'
+
+describe('withLinkedRepositories', () => {
+ it('returns the original instructions when no repositories are linked', () => {
+ expect(withLinkedRepositories('# Base instructions', [])).toBe('# Base instructions')
+ })
+
+ it('appends linked repository guidance and the pinned repositories', () => {
+ expect(withLinkedRepositories('# Base instructions', ['acme/api', 'acme/web'])).toBe(
+ '# Base instructions\n\n## Linked Repositories\n\n' +
+ 'You have GitHub MCP tools (`get_file_contents`, `search_code`, `list_commits`, `get_commit`, `compare_commits`) to query these repositories. Use `main` unless correlating across branches.\n\n' +
+ '- `acme/api`\n- `acme/web`\n'
+ )
+ })
+})
diff --git a/apps/web/src/lib/spawner/mcp-connector-config.ts b/apps/web/src/lib/spawner/mcp-connector-config.ts
index be520310..d2d3b3aa 100644
--- a/apps/web/src/lib/spawner/mcp-connector-config.ts
+++ b/apps/web/src/lib/spawner/mcp-connector-config.ts
@@ -1,4 +1,5 @@
import { parseAhrefsConnectorConfig } from '@/lib/connectors/ahrefs'
+import { getGithubMcpServerUrl, parseGithubConnectorConfig } from '@/lib/connectors/github'
import { getGoogleWorkspaceMcpServerUrl, isGoogleWorkspaceConnectorType } from '@/lib/connectors/google-workspace'
import { isMetaAdsConnectorReady, parseMetaAdsConnectorConfig } from '@/lib/connectors/meta-ads'
import { getConnectorAuthType, getConnectorOAuthConfig } from '@/lib/connectors/oauth-config'
@@ -121,6 +122,25 @@ function buildLinearMcpServerConfig(input: {
}
}
+function buildGithubMcpServerConfig(
+ config: Record
+): McpServerConfig | undefined {
+ const parsed = parseGithubConnectorConfig(config)
+ if (!parsed.ok) return undefined
+
+ return {
+ type: 'remote',
+ url: getGithubMcpServerUrl(parsed.config),
+ enabled: true,
+ headers: {
+ Authorization: `Bearer ${parsed.config.pat}`,
+ 'X-MCP-Readonly': 'true',
+ 'X-MCP-Toolsets': parsed.config.toolsets.join(','),
+ },
+ oauth: false,
+ }
+}
+
function buildCustomMcpServerConfig(input: {
connectorId: string
config: Record
@@ -228,6 +248,8 @@ export function buildConnectorMcpServerConfig(input: {
return buildNotionMcpServerConfig(input)
case 'linear':
return buildLinearMcpServerConfig(input)
+ case 'github':
+ return buildGithubMcpServerConfig(input.config)
case 'custom':
return buildCustomMcpServerConfig(input)
case 'zendesk':
@@ -246,6 +268,8 @@ export function buildConnectorMcpServerConfig(input: {
}
export function shouldExposeConnectorViaGateway(type: ConnectorType, config: Record): boolean {
+ if (type === 'github') return false
+
const parser = EMBEDDED_CONNECTOR_PARSERS[type]
if (parser) return parser(config).ok
diff --git a/apps/web/src/lib/spawner/runtime-artifacts.ts b/apps/web/src/lib/spawner/runtime-artifacts.ts
index 041cb2e0..da93cadb 100644
--- a/apps/web/src/lib/spawner/runtime-artifacts.ts
+++ b/apps/web/src/lib/spawner/runtime-artifacts.ts
@@ -4,7 +4,9 @@ import * as path from 'node:path'
import { readConfigRepoSnapshot } from '@/lib/config-repo-store'
import { readCommonWorkspaceConfig, readConfigRepoFile } from '@/lib/common-workspace-config-store'
+import { decryptConfig } from '@/lib/connectors/crypto'
import { getConnectorGatewayBaseUrl } from '@/lib/connectors/gateway-config'
+import { parseGithubConnectorConfig } from '@/lib/connectors/github'
import { decryptProviderSecret } from '@/lib/providers/crypto'
import { isOllamaSecret } from '@/lib/providers/ollama'
import { getEffectiveCredentialForUser, getEnabledProviderCredentialsForUser } from '@/lib/providers/store'
@@ -15,7 +17,7 @@ import {
withSystemSkillBundles,
} from '@/lib/skills/system-skills'
import type { SkillBundle } from '@/lib/skills/types'
-import { userService } from '@/lib/services'
+import { connectorService, userService } from '@/lib/services'
import {
applyDefaultAgentModel,
injectAlwaysOnAgentTools,
@@ -27,6 +29,7 @@ import {
import { buildMcpConfigForSlug } from '@/lib/spawner/mcp-config'
import {
withWorkspaceIdentity,
+ withLinkedRepositories,
withWorkspacePermissionGuards,
} from '@/lib/spawner/runtime-config-utils'
import {
@@ -114,6 +117,28 @@ async function getWorkspaceOwner(slug: string): Promise {
return userService.findIdentityBySlug(slug).catch(() => null)
}
+async function getLinkedRepositoriesForOwner(owner: WorkspaceOwner): Promise {
+ if (!owner) return []
+
+ try {
+ const connectors = await connectorService.findEnabledGithubConnectorsForUser(owner.id)
+ const repos: string[] = []
+
+ for (const connector of connectors) {
+ try {
+ const parsed = parseGithubConnectorConfig(decryptConfig(connector.config))
+ if (parsed.ok) repos.push(...parsed.config.pinnedRepos)
+ } catch {
+ continue
+ }
+ }
+
+ return Array.from(new Set(repos)).sort((left, right) => left.localeCompare(right))
+ } catch {
+ return []
+ }
+}
+
async function readOptionalRepoTextFile(repoDir: string, filePath: string): Promise {
return fs.readFile(path.join(repoDir, filePath), 'utf-8').catch((error: unknown) => {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
@@ -313,10 +338,11 @@ export async function buildWorkspaceAgentsMd(
}
const resolvedOwner = owner ?? (await getWorkspaceOwner(slug))
- return withWorkspaceIdentity(agentsResult.content, {
+ const agentsMd = withWorkspaceIdentity(agentsResult.content, {
slug: resolvedOwner?.slug ?? slug,
email: resolvedOwner?.email,
})
+ return withLinkedRepositories(agentsMd, await getLinkedRepositoriesForOwner(resolvedOwner))
}
export async function buildWorkspaceRuntimeArtifacts(
diff --git a/apps/web/src/lib/spawner/runtime-config-utils.ts b/apps/web/src/lib/spawner/runtime-config-utils.ts
index 48137d75..134f9ff2 100644
--- a/apps/web/src/lib/spawner/runtime-config-utils.ts
+++ b/apps/web/src/lib/spawner/runtime-config-utils.ts
@@ -77,3 +77,17 @@ export function withWorkspaceIdentity(
return agentsMd + block
}
+
+export function withLinkedRepositories(agentsMd: string, repos: string[]): string {
+ if (repos.length === 0) return agentsMd
+
+ const list = repos.map((repo) => `- \`${repo}\``).join('\n')
+ const block =
+ `\n\n## Linked Repositories\n\n` +
+ `You have GitHub MCP tools (\`get_file_contents\`, \`search_code\`, ` +
+ `\`list_commits\`, \`get_commit\`, \`compare_commits\`) to query these ` +
+ `repositories. Use \`main\` unless correlating across branches.\n\n` +
+ `${list}\n`
+
+ return agentsMd + block
+}
diff --git a/apps/web/src/lib/workspace-tool-display.ts b/apps/web/src/lib/workspace-tool-display.ts
index 821a2ca0..eb7a6d6d 100644
--- a/apps/web/src/lib/workspace-tool-display.ts
+++ b/apps/web/src/lib/workspace-tool-display.ts
@@ -6,6 +6,7 @@ const CONNECTOR_TYPE_LABELS: Record = {
zendesk: 'Zendesk',
ahrefs: 'Ahrefs',
umami: 'Umami',
+ github: 'GitHub',
'meta-ads': 'Meta Ads',
custom: 'Custom Connector',
google_gmail: 'Gmail',
diff --git a/apps/web/tests/connectors.test.ts b/apps/web/tests/connectors.test.ts
index 5a7f1071..fed75f8e 100644
--- a/apps/web/tests/connectors.test.ts
+++ b/apps/web/tests/connectors.test.ts
@@ -66,7 +66,7 @@ describe('connectors/crypto', () => {
describe('connectors/types', () => {
it('CONNECTOR_TYPES contains expected values', () => {
expect(CONNECTOR_TYPES).toEqual([
- 'linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'custom', 'meta-ads',
+ 'linear', 'notion', 'zendesk', 'ahrefs', 'umami', 'custom', 'meta-ads', 'github',
'google_gmail', 'google_drive', 'google_calendar', 'google_chat', 'google_people',
])
})
From 64c9f2607d204312028673e3e59abf0b9ccb868d Mon Sep 17 00:00:00 2001
From: convoy
Date: Mon, 20 Jul 2026 21:07:33 +0100
Subject: [PATCH 02/10] =?UTF-8?q?convoy(patterns):=20Pattern=20Audit=20?=
=?UTF-8?q?=E2=80=94=20GitHub=20Repositories=20as=20Knowledge=20Base=20Sou?=
=?UTF-8?q?rces?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../lib/connectors/__tests__/github-config.test.ts | 6 ++++++
apps/web/src/lib/connectors/github.ts | 11 ++++++++++-
apps/web/src/lib/connectors/tool-inventory.ts | 7 ++-----
apps/web/src/lib/spawner/mcp-connector-config.ts | 12 ++++++------
4 files changed, 24 insertions(+), 12 deletions(-)
diff --git a/apps/web/src/lib/connectors/__tests__/github-config.test.ts b/apps/web/src/lib/connectors/__tests__/github-config.test.ts
index ef76bb0d..2a34aac6 100644
--- a/apps/web/src/lib/connectors/__tests__/github-config.test.ts
+++ b/apps/web/src/lib/connectors/__tests__/github-config.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
getGithubConnectionTestEndpoint,
+ getGithubMcpHeaders,
getGithubMcpServerUrl,
parseGithubConnectorConfig,
} from '@/lib/connectors/github'
@@ -69,6 +70,11 @@ describe('GitHub connector config', () => {
expect(getGithubMcpServerUrl(githubCom.config)).toBe('https://api.githubcopilot.com/mcp/')
expect(getGithubMcpServerUrl(enterprise.config)).toBe('https://github.example.com/mcp/')
+ expect(getGithubMcpHeaders(githubCom.config)).toEqual({
+ Authorization: 'Bearer ghp_example',
+ 'X-MCP-Readonly': 'true',
+ 'X-MCP-Toolsets': 'repos,git',
+ })
expect(getGithubConnectionTestEndpoint({
pat: 'ghp_example',
pinnedRepos: ['owner/repo'],
diff --git a/apps/web/src/lib/connectors/github.ts b/apps/web/src/lib/connectors/github.ts
index 8a26725b..a98c534d 100644
--- a/apps/web/src/lib/connectors/github.ts
+++ b/apps/web/src/lib/connectors/github.ts
@@ -1,10 +1,11 @@
const GITHUB_PAT_PATTERN = /^(?:ghp_|github_pat_)\S+$/
const GITHUB_PINNED_REPO_PATTERN = /^[\w.-]+\/[\w.-]+$/
const GITHUB_MCP_DEFAULT_HOST = 'https://api.githubcopilot.com'
-const GITHUB_TOOLSETS = new Set(['repos', 'git'])
export const DEFAULT_GITHUB_TOOLSETS = ['repos', 'git'] as const
+const GITHUB_TOOLSETS = new Set(DEFAULT_GITHUB_TOOLSETS)
+
export type GithubConnectorConfig = {
pat: string
host?: string
@@ -97,6 +98,14 @@ export function getGithubMcpServerUrl(config: GithubConnectorConfig): string {
return `${config.host ?? GITHUB_MCP_DEFAULT_HOST}/mcp/`
}
+export function getGithubMcpHeaders(config: GithubConnectorConfig): Record {
+ return {
+ Authorization: `Bearer ${config.pat}`,
+ 'X-MCP-Readonly': 'true',
+ 'X-MCP-Toolsets': config.toolsets.join(','),
+ }
+}
+
export function getGithubConnectionTestEndpoint(config: Record): string | null {
const parsed = parseGithubConnectorConfig(config)
if (!parsed.ok) return null
diff --git a/apps/web/src/lib/connectors/tool-inventory.ts b/apps/web/src/lib/connectors/tool-inventory.ts
index 859aade0..f931ea66 100644
--- a/apps/web/src/lib/connectors/tool-inventory.ts
+++ b/apps/web/src/lib/connectors/tool-inventory.ts
@@ -1,5 +1,5 @@
import { getAhrefsMcpTools, parseAhrefsConnectorConfig } from '@/lib/connectors/ahrefs'
-import { parseGithubConnectorConfig } from '@/lib/connectors/github'
+import { getGithubMcpHeaders, parseGithubConnectorConfig } from '@/lib/connectors/github'
import { getMetaAdsMcpTools, parseMetaAdsConnectorConfig } from '@/lib/connectors/meta-ads'
import { getConnectorMcpServerUrl } from '@/lib/connectors/mcp/server-url'
import { getConnectorAuthType, getConnectorOAuthConfig } from '@/lib/connectors/oauth-config'
@@ -70,10 +70,7 @@ function buildRemoteHeaders(
const parsed = parseGithubConnectorConfig(config)
if (!parsed.ok) return null
- headers.Authorization = `Bearer ${parsed.config.pat}`
- headers['X-MCP-Readonly'] = 'true'
- headers['X-MCP-Toolsets'] = parsed.config.toolsets.join(',')
- return headers
+ return { ...headers, ...getGithubMcpHeaders(parsed.config) }
}
if (getConnectorAuthType(config) === 'oauth') {
diff --git a/apps/web/src/lib/spawner/mcp-connector-config.ts b/apps/web/src/lib/spawner/mcp-connector-config.ts
index d2d3b3aa..10e3a9b5 100644
--- a/apps/web/src/lib/spawner/mcp-connector-config.ts
+++ b/apps/web/src/lib/spawner/mcp-connector-config.ts
@@ -1,5 +1,9 @@
import { parseAhrefsConnectorConfig } from '@/lib/connectors/ahrefs'
-import { getGithubMcpServerUrl, parseGithubConnectorConfig } from '@/lib/connectors/github'
+import {
+ getGithubMcpHeaders,
+ getGithubMcpServerUrl,
+ parseGithubConnectorConfig,
+} from '@/lib/connectors/github'
import { getGoogleWorkspaceMcpServerUrl, isGoogleWorkspaceConnectorType } from '@/lib/connectors/google-workspace'
import { isMetaAdsConnectorReady, parseMetaAdsConnectorConfig } from '@/lib/connectors/meta-ads'
import { getConnectorAuthType, getConnectorOAuthConfig } from '@/lib/connectors/oauth-config'
@@ -132,11 +136,7 @@ function buildGithubMcpServerConfig(
type: 'remote',
url: getGithubMcpServerUrl(parsed.config),
enabled: true,
- headers: {
- Authorization: `Bearer ${parsed.config.pat}`,
- 'X-MCP-Readonly': 'true',
- 'X-MCP-Toolsets': parsed.config.toolsets.join(','),
- },
+ headers: getGithubMcpHeaders(parsed.config),
oauth: false,
}
}
From be0a825e802cc9c8b6884296761925bbd6368055 Mon Sep 17 00:00:00 2001
From: convoy
Date: Mon, 20 Jul 2026 21:15:55 +0100
Subject: [PATCH 03/10] =?UTF-8?q?convoy(security):=20Security=20Audit=20?=
=?UTF-8?q?=E2=80=94=20GitHub=20Repositories=20as=20Knowledge=20Base=20Sou?=
=?UTF-8?q?rces?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../[id]/test/__tests__/route.test.ts | 19 --------
.../u/[slug]/connectors/[id]/test/route.ts | 16 -------
.../connectors/add-connector/github/config.ts | 4 +-
.../add-connector/github/section.tsx | 29 ------------
.../__tests__/github-config.test.ts | 29 +++---------
.../__tests__/tool-inventory.test.ts | 3 +-
apps/web/src/lib/connectors/github.ts | 44 +++----------------
apps/web/src/lib/connectors/mcp/server-url.ts | 2 +-
.../web/src/lib/connectors/test-connection.ts | 10 +----
apps/web/src/lib/connectors/tool-inventory.ts | 2 +-
apps/web/src/lib/connectors/validators.ts | 2 +-
.../src/lib/spawner/mcp-connector-config.ts | 2 +-
12 files changed, 22 insertions(+), 140 deletions(-)
diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/test/__tests__/route.test.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/test/__tests__/route.test.ts
index 628ed94b..11be2ef0 100644
--- a/apps/web/src/app/api/u/[slug]/connectors/[id]/test/__tests__/route.test.ts
+++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/test/__tests__/route.test.ts
@@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({
validateDesktopToken: vi.fn(() => true),
requireCapability: vi.fn(() => null),
decryptConfig: vi.fn(),
- getGithubConnectionTestEndpoint: vi.fn(),
getConnectorAuthType: vi.fn(() => 'manual'),
refreshConnectorOAuthConfigIfNeeded: vi.fn(),
getCustomConnectorTestEndpoint: vi.fn(),
@@ -30,9 +29,6 @@ vi.mock('@/lib/runtime/desktop/token', () => ({
}))
vi.mock('@/lib/runtime/require-capability', () => ({ requireCapability: mocks.requireCapability }))
vi.mock('@/lib/connectors/crypto', () => ({ decryptConfig: mocks.decryptConfig }))
-vi.mock('@/lib/connectors/github', () => ({
- getGithubConnectionTestEndpoint: mocks.getGithubConnectionTestEndpoint,
-}))
vi.mock('@/lib/connectors/oauth-config', () => ({ getConnectorAuthType: mocks.getConnectorAuthType }))
vi.mock('@/lib/connectors/oauth-refresh', () => ({
refreshConnectorOAuthConfigIfNeeded: mocks.refreshConnectorOAuthConfigIfNeeded,
@@ -165,21 +161,6 @@ describe('POST /api/u/[slug]/connectors/[id]/test', () => {
expect(body.error).toBe('ssrf_blocked')
})
- it('validates SSRF for GitHub Enterprise test endpoints', async () => {
- mocks.connectorService.findByIdAndUserId.mockResolvedValue({ ...CONNECTOR, type: 'github' })
- mocks.getGithubConnectionTestEndpoint.mockReturnValue('https://internal.example/api/v3/user')
- mocks.validateConnectorTestEndpoint.mockResolvedValue({
- ok: false,
- error: 'blocked_endpoint',
- })
-
- const res = await POST(makeRequest(), params('admin', 'c1'))
-
- expect(res.status).toBe(400)
- expect(await res.json()).toEqual({ error: 'blocked_endpoint' })
- expect(mocks.testConnectorConnection).not.toHaveBeenCalled()
- })
-
it('returns 401 when not authenticated', async () => {
mocks.getSession.mockResolvedValue(null)
const res = await POST(makeRequest(), params('admin', 'c1'))
diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts
index dcaae563..64244188 100644
--- a/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts
+++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/test/route.ts
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { decryptConfig } from '@/lib/connectors/crypto'
-import { getGithubConnectionTestEndpoint } from '@/lib/connectors/github'
import { getConnectorAuthType } from '@/lib/connectors/oauth-config'
import { refreshConnectorOAuthConfigIfNeeded } from '@/lib/connectors/oauth-refresh'
import { getCustomConnectorTestEndpoint, testConnectorConnection, type TestConnectionResult } from '@/lib/connectors/test-connection'
@@ -85,23 +84,8 @@ export const POST = withAuth(function GithubSection({ onStateChange, isActive }, ref) {
const [pat, setPat] = useState('')
- const [host, setHost] = useState('')
const [pinnedRepos, setPinnedRepos] = useState([])
const [repoInput, setRepoInput] = useState('')
const [repoError, setRepoError] = useState(null)
- const [showAdvanced, setShowAdvanced] = useState(false)
const state = {
selectedType: 'github' as const,
pat,
- host,
pinnedRepos,
}
@@ -139,32 +136,6 @@ export const GithubSection = forwardRef<
) : null}
-
-
-
- {showAdvanced ? (
-
-
-
setHost(event.target.value)}
- placeholder="https://github.example.com"
- />
-
- Leave blank for GitHub.com. GitHub Enterprise Server requires a compatible MCP endpoint and is not supported by the hosted GitHub.com MCP server.
-
-
- ) : null}
-
)
})
diff --git a/apps/web/src/lib/connectors/__tests__/github-config.test.ts b/apps/web/src/lib/connectors/__tests__/github-config.test.ts
index 2a34aac6..31245a15 100644
--- a/apps/web/src/lib/connectors/__tests__/github-config.test.ts
+++ b/apps/web/src/lib/connectors/__tests__/github-config.test.ts
@@ -22,10 +22,9 @@ describe('GitHub connector config', () => {
})
})
- it('parses a GitHub Enterprise host and allowed toolset subset', () => {
+ it('parses an allowed toolset subset', () => {
const parsed = parseGithubConnectorConfig({
pat: 'ghp_example',
- host: 'https://github.example.com/',
pinnedRepos: ['acme/platform'],
toolsets: ['repos'],
})
@@ -34,7 +33,6 @@ describe('GitHub connector config', () => {
ok: true,
config: {
pat: 'ghp_example',
- host: 'https://github.example.com',
pinnedRepos: ['acme/platform'],
toolsets: ['repos'],
},
@@ -45,44 +43,29 @@ describe('GitHub connector config', () => {
{ pat: 'token', pinnedRepos: ['owner/repo'] },
{ pat: 'ghp_example', pinnedRepos: 'owner/repo' },
{ pat: 'ghp_example', pinnedRepos: ['owner/repo/path'] },
- { pat: 'ghp_example', host: 'http://github.example.com', pinnedRepos: ['owner/repo'] },
- { pat: 'ghp_example', host: 'https://github.example.com/api', pinnedRepos: ['owner/repo'] },
+ { pat: 'ghp_example', host: 'https://github.example.com', pinnedRepos: ['owner/repo'] },
{ pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: ['all'] },
{ pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: [] },
])('rejects invalid config %#', (config) => {
expect(parseGithubConnectorConfig(config)).toEqual({ ok: false })
})
- it('builds MCP and REST test URLs from parsed configuration', () => {
+ it('builds GitHub.com MCP and REST test URLs', () => {
const githubCom = parseGithubConnectorConfig({
pat: 'ghp_example',
pinnedRepos: ['owner/repo'],
})
- const enterprise = parseGithubConnectorConfig({
- pat: 'ghp_example',
- host: 'https://github.example.com',
- pinnedRepos: ['owner/repo'],
- })
- if (!githubCom.ok || !enterprise.ok) {
+ if (!githubCom.ok) {
throw new Error('expected valid GitHub connector configurations')
}
- expect(getGithubMcpServerUrl(githubCom.config)).toBe('https://api.githubcopilot.com/mcp/')
- expect(getGithubMcpServerUrl(enterprise.config)).toBe('https://github.example.com/mcp/')
+ expect(getGithubMcpServerUrl()).toBe('https://api.githubcopilot.com/mcp/')
expect(getGithubMcpHeaders(githubCom.config)).toEqual({
Authorization: 'Bearer ghp_example',
'X-MCP-Readonly': 'true',
'X-MCP-Toolsets': 'repos,git',
})
- expect(getGithubConnectionTestEndpoint({
- pat: 'ghp_example',
- pinnedRepos: ['owner/repo'],
- })).toBe('https://api.github.com/user')
- expect(getGithubConnectionTestEndpoint({
- pat: 'ghp_example',
- host: 'https://github.example.com',
- pinnedRepos: ['owner/repo'],
- })).toBe('https://github.example.com/api/v3/user')
+ expect(getGithubConnectionTestEndpoint()).toBe('https://api.github.com/user')
})
})
diff --git a/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts b/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts
index d240ad73..bc1f5bb0 100644
--- a/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts
+++ b/apps/web/src/lib/connectors/__tests__/tool-inventory.test.ts
@@ -201,7 +201,7 @@ describe('loadConnectorToolInventory', () => {
tools: [{ name: 'list_commits', title: 'List commits', description: 'List commits' }],
})
expect(fetchMock).toHaveBeenCalledWith(
- 'https://validated.example/rpc',
+ 'https://api.githubcopilot.com/mcp/',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer github_pat_example',
@@ -210,6 +210,7 @@ describe('loadConnectorToolInventory', () => {
}),
}),
)
+ expect(mocks.validateConnectorTestEndpoint).not.toHaveBeenCalled()
})
it('uses API key auth for any remote connector inventory', async () => {
diff --git a/apps/web/src/lib/connectors/github.ts b/apps/web/src/lib/connectors/github.ts
index a98c534d..dd7ee6d6 100644
--- a/apps/web/src/lib/connectors/github.ts
+++ b/apps/web/src/lib/connectors/github.ts
@@ -1,6 +1,7 @@
const GITHUB_PAT_PATTERN = /^(?:ghp_|github_pat_)\S+$/
const GITHUB_PINNED_REPO_PATTERN = /^[\w.-]+\/[\w.-]+$/
-const GITHUB_MCP_DEFAULT_HOST = 'https://api.githubcopilot.com'
+const GITHUB_MCP_SERVER_URL = 'https://api.githubcopilot.com/mcp/'
+const GITHUB_CONNECTION_TEST_ENDPOINT = 'https://api.github.com/user'
export const DEFAULT_GITHUB_TOOLSETS = ['repos', 'git'] as const
@@ -8,7 +9,6 @@ const GITHUB_TOOLSETS = new Set(DEFAULT_GITHUB_TOOLSETS)
export type GithubConnectorConfig = {
pat: string
- host?: string
pinnedRepos: string[]
toolsets: string[]
}
@@ -25,29 +25,6 @@ export function isGithubPinnedRepo(value: string): boolean {
return GITHUB_PINNED_REPO_PATTERN.test(value.trim())
}
-function parseGithubHost(value: unknown): string | undefined {
- if (value === undefined) return undefined
- if (typeof value !== 'string' || !value.trim()) return undefined
-
- try {
- const url = new URL(value.trim())
- if (
- url.protocol !== 'https:' ||
- url.username ||
- url.password ||
- url.pathname !== '/' ||
- url.search ||
- url.hash
- ) {
- return undefined
- }
-
- return url.origin
- } catch {
- return undefined
- }
-}
-
function parseToolsets(value: unknown): string[] | null {
if (value === undefined) return [...DEFAULT_GITHUB_TOOLSETS]
if (!Array.isArray(value) || value.length === 0) return null
@@ -77,8 +54,7 @@ export function parseGithubConnectorConfig(
)
if (pinnedRepos.some((repo) => !isGithubPinnedRepo(repo))) return { ok: false }
- const host = parseGithubHost(config.host)
- if (config.host !== undefined && !host) return { ok: false }
+ if (config.host !== undefined) return { ok: false }
const toolsets = parseToolsets(config.toolsets)
if (!toolsets) return { ok: false }
@@ -87,15 +63,14 @@ export function parseGithubConnectorConfig(
ok: true,
config: {
pat,
- ...(host ? { host } : {}),
pinnedRepos: Array.from(new Set(pinnedRepos)),
toolsets,
},
}
}
-export function getGithubMcpServerUrl(config: GithubConnectorConfig): string {
- return `${config.host ?? GITHUB_MCP_DEFAULT_HOST}/mcp/`
+export function getGithubMcpServerUrl(): string {
+ return GITHUB_MCP_SERVER_URL
}
export function getGithubMcpHeaders(config: GithubConnectorConfig): Record {
@@ -106,11 +81,6 @@ export function getGithubMcpHeaders(config: GithubConnectorConfig): Record): string | null {
- const parsed = parseGithubConnectorConfig(config)
- if (!parsed.ok) return null
-
- return parsed.config.host
- ? `${parsed.config.host}/api/v3/user`
- : 'https://api.github.com/user'
+export function getGithubConnectionTestEndpoint(): string {
+ return GITHUB_CONNECTION_TEST_ENDPOINT
}
diff --git a/apps/web/src/lib/connectors/mcp/server-url.ts b/apps/web/src/lib/connectors/mcp/server-url.ts
index 69b79d8c..811f66af 100644
--- a/apps/web/src/lib/connectors/mcp/server-url.ts
+++ b/apps/web/src/lib/connectors/mcp/server-url.ts
@@ -34,7 +34,7 @@ export function getConnectorMcpServerUrl(type: ConnectorType, config: Record = {
}
},
- github: async (config, options) => {
+ github: async (config) => {
const parsed = parseGithubConnectorConfig(config)
if (!parsed.ok) {
return { ok: false, tested: false, message: 'Invalid GitHub connector configuration' }
}
- const endpoint = options.githubEndpointUrl?.toString() ?? getGithubConnectionTestEndpoint(config)
- if (!endpoint) {
- return { ok: false, tested: false, message: 'Invalid GitHub connector configuration' }
- }
-
- const response = await fetchWithTimeout(endpoint, {
+ const response = await fetchWithTimeout(getGithubConnectionTestEndpoint(), {
method: 'GET',
headers: {
Accept: 'application/vnd.github+json',
diff --git a/apps/web/src/lib/connectors/tool-inventory.ts b/apps/web/src/lib/connectors/tool-inventory.ts
index f931ea66..067ca9d6 100644
--- a/apps/web/src/lib/connectors/tool-inventory.ts
+++ b/apps/web/src/lib/connectors/tool-inventory.ts
@@ -214,7 +214,7 @@ async function loadRemoteConnectorToolInventory(
}
let url: URL
- if (type === 'custom' || type === 'github') {
+ if (type === 'custom') {
const endpointValidation = await validateConnectorTestEndpoint(upstreamUrl)
if (!endpointValidation.ok) {
return { ok: false, tools: [], message: 'Connector endpoint is not allowed.' }
diff --git a/apps/web/src/lib/connectors/validators.ts b/apps/web/src/lib/connectors/validators.ts
index 8cbf5020..3dd42978 100644
--- a/apps/web/src/lib/connectors/validators.ts
+++ b/apps/web/src/lib/connectors/validators.ts
@@ -33,7 +33,7 @@ export const CONNECTOR_SCHEMAS: Record = {
required: ['authType', 'appId', 'appSecret'],
optional: ['permissions', 'selectedAdAccountIds', 'defaultAdAccountId', 'oauth'],
},
- github: { required: ['pat', 'pinnedRepos'], optional: ['host', 'toolsets'] },
+ github: { required: ['pat', 'pinnedRepos'], optional: ['toolsets'] },
custom: {
required: ['endpoint'],
optional: [
diff --git a/apps/web/src/lib/spawner/mcp-connector-config.ts b/apps/web/src/lib/spawner/mcp-connector-config.ts
index 10e3a9b5..985e6ba3 100644
--- a/apps/web/src/lib/spawner/mcp-connector-config.ts
+++ b/apps/web/src/lib/spawner/mcp-connector-config.ts
@@ -134,7 +134,7 @@ function buildGithubMcpServerConfig(
return {
type: 'remote',
- url: getGithubMcpServerUrl(parsed.config),
+ url: getGithubMcpServerUrl(),
enabled: true,
headers: getGithubMcpHeaders(parsed.config),
oauth: false,
From 8e7e676c8e3669d4dfe858b934085403618354ea Mon Sep 17 00:00:00 2001
From: convoy
Date: Mon, 20 Jul 2026 21:23:14 +0100
Subject: [PATCH 04/10] =?UTF-8?q?convoy(design):=20Design=20Polish=20?=
=?UTF-8?q?=E2=80=94=20GitHub=20Repositories=20as=20Knowledge=20Base=20Sou?=
=?UTF-8?q?rces?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../connectors/add-connector/github/section.tsx | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/components/connectors/add-connector/github/section.tsx b/apps/web/src/components/connectors/add-connector/github/section.tsx
index fd32a677..1357a17a 100644
--- a/apps/web/src/components/connectors/add-connector/github/section.tsx
+++ b/apps/web/src/components/connectors/add-connector/github/section.tsx
@@ -1,6 +1,7 @@
'use client'
import { forwardRef, useImperativeHandle, useState } from 'react'
+import { X } from 'lucide-react'
import { ManualApiKeyField } from '@/components/connectors/add-connector/manual-api-key-field'
import { buildDefaultName } from '@/components/connectors/add-connector/shared'
@@ -120,17 +121,19 @@ export const GithubSection = forwardRef<
{pinnedRepos.map((repo) => (
{repo}
-
+
+
))}
From 2af528bb8e8f8d838b1920ec80fe6896232b9039 Mon Sep 17 00:00:00 2001
From: convoy
Date: Mon, 20 Jul 2026 21:36:04 +0100
Subject: [PATCH 05/10] =?UTF-8?q?convoy(tests):=20Test=20Report=20?=
=?UTF-8?q?=E2=80=94=20GitHub=20Repositories=20as=20Knowledge=20Base=20Sou?=
=?UTF-8?q?rces?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/web/e2e/connectors-github.spec.ts | 75 +++++++++++++++++++
.../__tests__/add-connector-modal.test.tsx | 58 ++++++++++++++
.../add-connector/__tests__/sections.test.tsx | 33 ++++++++
.../__tests__/github-config.test.ts | 3 +
.../__tests__/test-connection.test.ts | 22 ++++++
.../lib/spawner/__tests__/mcp-config.test.ts | 26 +++++++
.../__tests__/runtime-artifacts.test.ts | 31 ++++++++
7 files changed, 248 insertions(+)
create mode 100644 apps/web/e2e/connectors-github.spec.ts
diff --git a/apps/web/e2e/connectors-github.spec.ts b/apps/web/e2e/connectors-github.spec.ts
new file mode 100644
index 00000000..7b81d691
--- /dev/null
+++ b/apps/web/e2e/connectors-github.spec.ts
@@ -0,0 +1,75 @@
+import { expect, test, type Page } from '@playwright/test'
+
+import { adminSlug } from './support/test-data'
+
+const APP_ORIGIN = 'http://127.0.0.1:3000'
+
+function isObjectRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+}
+
+function getGithubConnectorIds(value: unknown): string[] {
+ if (!isObjectRecord(value) || !Array.isArray(value.connectors)) {
+ throw new Error('Invalid connectors list response')
+ }
+
+ return value.connectors.flatMap((connector) => {
+ if (!isObjectRecord(connector) || connector.type !== 'github' || typeof connector.id !== 'string') {
+ return []
+ }
+
+ return [connector.id]
+ })
+}
+
+async function removeGithubConnectors(page: Page): Promise {
+ const response = await page.request.get(`/api/u/${adminSlug}/connectors`)
+ expect(response.ok()).toBeTruthy()
+
+ for (const connectorId of getGithubConnectorIds(await response.json())) {
+ const deleteResponse = await page.request.delete(`/api/u/${adminSlug}/connectors/${connectorId}`, {
+ headers: { origin: APP_ORIGIN },
+ })
+ expect(deleteResponse.ok()).toBeTruthy()
+ }
+}
+
+async function openGithubConnectorDialog(page: Page) {
+ await page.goto(`/u/${adminSlug}/connectors`)
+ await expect(page.getByRole('heading', { name: 'Connectors' })).toBeVisible()
+
+ const addFirstButton = page.getByRole('button', { name: 'Add your first connector' })
+ if (await addFirstButton.isVisible()) {
+ await addFirstButton.click()
+ } else {
+ await page.getByRole('button', { name: 'Add connector' }).click()
+ }
+
+ const dialog = page.getByRole('dialog')
+ await expect(dialog).toBeVisible()
+ await dialog.getByRole('button', { name: 'GitHub' }).click()
+ return dialog
+}
+
+test.beforeEach(async ({ page }) => {
+ await removeGithubConnectors(page)
+})
+
+test.afterEach(async ({ page }) => {
+ await removeGithubConnectors(page)
+})
+
+test('links a GitHub repository with a PAT', async ({ page }) => {
+ const dialog = await openGithubConnectorDialog(page)
+
+ await dialog.getByLabel('Personal access token').fill('github_pat_e2e_example')
+ await dialog.getByLabel('Pinned repositories').fill('acme/api')
+ await dialog.getByLabel('Pinned repositories').press('Enter')
+ await dialog.getByRole('button', { name: 'Save connector' }).click()
+
+ await expect(dialog).not.toBeVisible()
+ await expect(page.getByText('GitHub', { exact: true })).toBeVisible()
+
+ await page.reload()
+ await expect(page.getByText('GitHub', { exact: true })).toBeVisible()
+})
diff --git a/apps/web/src/components/connectors/__tests__/add-connector-modal.test.tsx b/apps/web/src/components/connectors/__tests__/add-connector-modal.test.tsx
index 37b1538f..3bfa2ed8 100644
--- a/apps/web/src/components/connectors/__tests__/add-connector-modal.test.tsx
+++ b/apps/web/src/components/connectors/__tests__/add-connector-modal.test.tsx
@@ -121,6 +121,64 @@ describe('AddConnectorModal', () => {
expect(onOpenChange).toHaveBeenCalledWith(false)
})
+ it('saves a GitHub PAT and pinned repository through the connector API', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ id: 'conn-github' }), {
+ status: 201,
+ headers: { 'content-type': 'application/json' },
+ })
+ )
+ vi.stubGlobal('fetch', fetchMock)
+
+ const onOpenChange = vi.fn()
+ const onSaved = vi.fn()
+
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'GitHub' }))
+ expect(screen.getByRole('button', { name: 'Save connector' }).hasAttribute('disabled')).toBe(true)
+
+ fireEvent.change(screen.getByLabelText('Personal access token'), {
+ target: { value: 'github_pat_example' },
+ })
+ fireEvent.change(screen.getByLabelText('Pinned repositories'), {
+ target: { value: 'acme/api' },
+ })
+ fireEvent.keyDown(screen.getByLabelText('Pinned repositories'), { key: 'Enter' })
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Save connector' }).hasAttribute('disabled')).toBe(false)
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Save connector' }))
+
+ await waitFor(() => {
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
+ expect(url).toBe('/api/u/alice/connectors')
+ expect(init.method).toBe('POST')
+ expect(JSON.parse(String(init.body))).toEqual({
+ type: 'github',
+ name: 'GitHub',
+ config: {
+ pat: 'github_pat_example',
+ pinnedRepos: ['acme/api'],
+ toolsets: ['repos', 'git'],
+ },
+ })
+ expect(onSaved).toHaveBeenCalledTimes(1)
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+
it('filters connectors by search query on the selection step', () => {
const onOpenChange = vi.fn()
const onSaved = vi.fn()
diff --git a/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx b/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx
index 39fad941..7dbece98 100644
--- a/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx
+++ b/apps/web/src/components/connectors/add-connector/__tests__/sections.test.tsx
@@ -211,6 +211,39 @@ describe('add connector sections', () => {
})
})
+ it('validates GitHub repositories and becomes incomplete when the last repository is removed', () => {
+ const ref = createRef()
+ render()
+
+ fireEvent.change(screen.getByLabelText('Personal access token'), {
+ target: { value: 'github_pat_example' },
+ })
+ fireEvent.change(screen.getByLabelText('Pinned repositories'), {
+ target: { value: 'acme/api/path' },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }))
+
+ expect(screen.getByText('Enter a repository as owner/repository.')).toBeTruthy()
+ expect(getHandle(ref).isComplete()).toBe(false)
+
+ fireEvent.change(screen.getByLabelText('Pinned repositories'), {
+ target: { value: 'acme/api' },
+ })
+ fireEvent.keyDown(screen.getByLabelText('Pinned repositories'), { key: 'Enter' })
+ fireEvent.keyDown(screen.getByLabelText('Pinned repositories'), { key: 'Enter' })
+
+ expect(screen.getAllByRole('button', { name: 'Remove acme/api' })).toHaveLength(1)
+ expect(getHandle(ref).isComplete()).toBe(true)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Remove acme/api' }))
+
+ expect(getHandle(ref).isComplete()).toBe(false)
+ expect(getHandle(ref).getSubmission()).toEqual({
+ ok: false,
+ message: 'Add at least one pinned repository.',
+ })
+ })
+
it('submits Umami API-key and login configurations', () => {
const ref = createRef()
render()
diff --git a/apps/web/src/lib/connectors/__tests__/github-config.test.ts b/apps/web/src/lib/connectors/__tests__/github-config.test.ts
index 31245a15..df1169de 100644
--- a/apps/web/src/lib/connectors/__tests__/github-config.test.ts
+++ b/apps/web/src/lib/connectors/__tests__/github-config.test.ts
@@ -43,9 +43,12 @@ describe('GitHub connector config', () => {
{ pat: 'token', pinnedRepos: ['owner/repo'] },
{ pat: 'ghp_example', pinnedRepos: 'owner/repo' },
{ pat: 'ghp_example', pinnedRepos: ['owner/repo/path'] },
+ { pat: 'ghp_example', pinnedRepos: ['owner/ repo'] },
{ pat: 'ghp_example', host: 'https://github.example.com', pinnedRepos: ['owner/repo'] },
{ pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: ['all'] },
{ pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: [] },
+ { pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: ['repos', 'repos'] },
+ { pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: [' '] },
])('rejects invalid config %#', (config) => {
expect(parseGithubConnectorConfig(config)).toEqual({ ok: false })
})
diff --git a/apps/web/src/lib/connectors/__tests__/test-connection.test.ts b/apps/web/src/lib/connectors/__tests__/test-connection.test.ts
index 0e64954c..c4040175 100644
--- a/apps/web/src/lib/connectors/__tests__/test-connection.test.ts
+++ b/apps/web/src/lib/connectors/__tests__/test-connection.test.ts
@@ -286,6 +286,28 @@ describe('testConnectorConnection', () => {
})
expect(fetchSpy).not.toHaveBeenCalled()
})
+
+ it.each([401, 403, 429])('returns a test failure for GitHub status %i', async (status) => {
+ fetchSpy = mockFetch({ status })
+
+ const result = await testConnectorConnection('github', {
+ pat: 'github_pat_example',
+ pinnedRepos: ['acme/api'],
+ })
+
+ expect(result).toEqual({ ok: false, tested: true, message: `GitHub test failed (${status})` })
+ expect(fetchSpy).toHaveBeenCalledWith(
+ 'https://api.github.com/user',
+ expect.objectContaining({
+ method: 'GET',
+ headers: {
+ Accept: 'application/vnd.github+json',
+ Authorization: 'Bearer github_pat_example',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ }),
+ )
+ })
})
describe('notion', () => {
diff --git a/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts b/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts
index a9ff3c69..29296d54 100644
--- a/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts
+++ b/apps/web/src/lib/spawner/__tests__/mcp-config.test.ts
@@ -960,5 +960,31 @@ describe('mcp-config', () => {
expect(result).not.toBeNull()
expect(result!.mcpConfig.mcp['arche_notion_n1']!.type).toBe('local')
})
+
+ it('injects GitHub directly without issuing a connector gateway token', async () => {
+ serviceMocks.userService.findIdBySlug.mockResolvedValue({ id: 'user-1' })
+ serviceMocks.connectorService.findEnabledMcpByUserId.mockResolvedValue([
+ makeConnector({ type: 'github', id: 'gh1', config: 'enc-github' }),
+ ])
+ connectorMocks.decryptConfig.mockReturnValue({
+ pat: 'github_pat_example',
+ pinnedRepos: ['acme/api'],
+ })
+
+ const result = await buildMcpConfigForSlug('my-slug')
+
+ expect(connectorMocks.issueConnectorGatewayToken).not.toHaveBeenCalled()
+ expect(result?.mcpConfig.mcp['arche_github_gh1']).toEqual({
+ type: 'remote',
+ url: 'https://api.githubcopilot.com/mcp/',
+ enabled: true,
+ headers: {
+ Authorization: 'Bearer github_pat_example',
+ 'X-MCP-Readonly': 'true',
+ 'X-MCP-Toolsets': 'repos,git',
+ },
+ oauth: false,
+ })
+ })
})
})
diff --git a/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts b/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts
index 21914aca..513c04ae 100644
--- a/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts
+++ b/apps/web/src/lib/spawner/__tests__/runtime-artifacts.test.ts
@@ -288,6 +288,37 @@ describe('runtime artifacts', () => {
expect(artifacts.agentsMd).toContain('- `acme/web`')
})
+ it('ignores undecryptable and invalid GitHub connector configs when injecting repositories', async () => {
+ await createRuntimeRepo({
+ 'AGENTS.md': '# Base instructions\n',
+ 'CommonWorkspaceConfig.json': createWorkspaceConfig(),
+ })
+ findEnabledGithubConnectorsForUserMock.mockResolvedValue([
+ { config: 'valid-config' },
+ { config: 'undecryptable-config' },
+ { config: 'invalid-config' },
+ ])
+ decryptConfigMock.mockImplementation((config: string) => {
+ if (config === 'undecryptable-config') {
+ throw new Error('decrypt failed')
+ }
+ if (config === 'invalid-config') {
+ return { pat: 'invalid', pinnedRepos: ['acme/private'] }
+ }
+ return { pat: 'github_pat_example', pinnedRepos: ['acme/api'] }
+ })
+
+ const {
+ buildWorkspaceRuntimeArtifacts,
+ getWebProviderGatewayConfig,
+ } = await loadRuntimeArtifactsModule()
+
+ const artifacts = await buildWorkspaceRuntimeArtifacts('alice', getWebProviderGatewayConfig())
+
+ expect(artifacts.agentsMd).toContain('- `acme/api`')
+ expect(artifacts.agentsMd).not.toContain('acme/private')
+ })
+
it('adds configured Ollama models to the runtime provider config', async () => {
await createRuntimeRepo({
'CommonWorkspaceConfig.json': createWorkspaceConfig(),
From 8ba03ded85265376acffe0f2b81834e864cae2f7 Mon Sep 17 00:00:00 2001
From: convoy
Date: Mon, 20 Jul 2026 23:06:52 +0100
Subject: [PATCH 06/10] =?UTF-8?q?convoy(adversarial):=20Adversarial=20Revi?=
=?UTF-8?q?ew=20=E2=80=94=20GitHub=20Repositories=20as=20Knowledge=20Base?=
=?UTF-8?q?=20Sources?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/web/e2e/connectors-github.spec.ts | 2 +-
.../connectors/__tests__/add-connector-modal.test.tsx | 2 +-
.../src/components/connectors/add-connector/github/section.tsx | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/web/e2e/connectors-github.spec.ts b/apps/web/e2e/connectors-github.spec.ts
index 7b81d691..f061ac55 100644
--- a/apps/web/e2e/connectors-github.spec.ts
+++ b/apps/web/e2e/connectors-github.spec.ts
@@ -47,7 +47,7 @@ async function openGithubConnectorDialog(page: Page) {
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible()
- await dialog.getByRole('button', { name: 'GitHub' }).click()
+ await dialog.getByRole('button', { name: /GitHub/ }).click()
return dialog
}
diff --git a/apps/web/src/components/connectors/__tests__/add-connector-modal.test.tsx b/apps/web/src/components/connectors/__tests__/add-connector-modal.test.tsx
index 3bfa2ed8..c23e3aea 100644
--- a/apps/web/src/components/connectors/__tests__/add-connector-modal.test.tsx
+++ b/apps/web/src/components/connectors/__tests__/add-connector-modal.test.tsx
@@ -143,7 +143,7 @@ describe('AddConnectorModal', () => {
/>
)
- fireEvent.click(screen.getByRole('button', { name: 'GitHub' }))
+ fireEvent.click(screen.getByRole('button', { name: /GitHub/i }))
expect(screen.getByRole('button', { name: 'Save connector' }).hasAttribute('disabled')).toBe(true)
fireEvent.change(screen.getByLabelText('Personal access token'), {
diff --git a/apps/web/src/components/connectors/add-connector/github/section.tsx b/apps/web/src/components/connectors/add-connector/github/section.tsx
index 1357a17a..ef47bba5 100644
--- a/apps/web/src/components/connectors/add-connector/github/section.tsx
+++ b/apps/web/src/components/connectors/add-connector/github/section.tsx
@@ -117,7 +117,7 @@ export const GithubSection = forwardRef<
{repoError ? {repoError}
: null}
{pinnedRepos.length > 0 ? (
-
+
{pinnedRepos.map((repo) => (
-
Date: Wed, 22 Jul 2026 23:23:23 +0100
Subject: [PATCH 07/10] fix(web): address review feedback on GitHub KB
connector
Resolves ixjosemi's review on PR #384:
- Preserve stored PAT in mergeConnectorConfigWithPreservedOAuth so GitHub
connectors can be edited (e.g. add a pinned repo) after creation.
- Persist normalized parsed config on create/PATCH (trimmed PAT, deduped
repos, dropped stray keys); remove the now-redundant host special-case.
- Dedupe pinned repos case-insensitively (GitHub repo names are).
- Remove unreachable `return null` in mcp/server-url.ts to keep the
connector-type switch exhaustive.
- Log before falling back to [] in getLinkedRepositoriesForOwner.
- Commit a valid pending repo input on save instead of dropping it.
- Document pinned-repos-as-advisory as an accepted risk in ARCHITECTURE.md.
Co-Authored-By: Claude Opus 4.8
---
ARCHITECTURE.md | 14 +++++++++
.../connectors/[id]/__tests__/route.test.ts | 1 +
.../app/api/u/[slug]/connectors/[id]/route.ts | 6 ++--
.../[slug]/connectors/__tests__/route.test.ts | 1 +
.../src/app/api/u/[slug]/connectors/route.ts | 5 ++-
.../add-connector/__tests__/sections.test.tsx | 25 +++++++++++++++
.../add-connector/github/section.tsx | 16 ++++++++--
.../__tests__/github-config.test.ts | 31 ++++++++++++++++++-
.../connectors/__tests__/oauth-config.test.ts | 25 +++++++++++++++
apps/web/src/lib/connectors/github.ts | 19 ++++++++++--
apps/web/src/lib/connectors/mcp/server-url.ts | 2 --
apps/web/src/lib/connectors/oauth-config.ts | 10 ++++++
apps/web/src/lib/connectors/validators.ts | 18 +++++++++++
apps/web/src/lib/spawner/runtime-artifacts.ts | 3 +-
14 files changed, 164 insertions(+), 12 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b6cdec3d..5d4bebd7 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -103,6 +103,20 @@ Runtime behavior:
- `kb-config` (bare repo): runtime `CommonWorkspaceConfig.json` + generated `AGENTS.md`
- both repos start empty and are populated by kickstart on first setup
+### GitHub repositories as KB sources
+
+The GitHub connector wires GitHub's hosted remote MCP server (`api.githubcopilot.com/mcp/`)
+into a workspace using an encrypted personal access token. Its "pinned repositories"
+are **advisory, not an enforcement boundary**: they are injected into the workspace
+prompt (`AGENTS.md`) to guide agents toward the most relevant source, but they are
+**not** sent to the MCP server. The agent can read any repository the PAT can reach,
+and read-only access depends on GitHub honoring the `X-MCP-Readonly` header. The raw
+PAT is also embedded in the workspace MCP config (the same precedent as other API-key
+connectors), so it is readable by any code the agent runs there.
+
+**Accepted risk / mitigation:** scope the token, not the repo list. Use a fine-grained
+PAT whose read access is limited to exactly the repositories that should be reachable.
+
## Desktop Vault Model
Desktop keeps the existing single-user, single-workspace runtime model per process.
diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/__tests__/route.test.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/__tests__/route.test.ts
index 6be5d0f9..00efd8f4 100644
--- a/apps/web/src/app/api/u/[slug]/connectors/[id]/__tests__/route.test.ts
+++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/__tests__/route.test.ts
@@ -121,6 +121,7 @@ vi.mock('@/lib/connectors/validators', () => ({
validateConnectorType: (...args: unknown[]) => mocks.validateConnectorTypeMock(...args),
validateConnectorConfig: (...args: unknown[]) => mocks.validateConnectorConfigMock(...args),
validateConnectorName: (...args: unknown[]) => mocks.validateConnectorNameMock(...args),
+ normalizeConnectorConfigForPersistence: (_type: unknown, config: unknown) => config,
}))
// ---------------------------------------------------------------------------
diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/route.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/route.ts
index 92d20b9a..c5fe62de 100644
--- a/apps/web/src/app/api/u/[slug]/connectors/[id]/route.ts
+++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/route.ts
@@ -13,6 +13,7 @@ import { sanitizeConnectorConfigForResponse } from '@/lib/connectors/response-co
import { preserveConnectorToolPermissions } from '@/lib/connectors/tool-permissions'
import type { ConnectorType } from '@/lib/connectors/types'
import {
+ normalizeConnectorConfigForPersistence,
validateConnectorConfig,
validateConnectorName,
validateConnectorType,
@@ -248,9 +249,10 @@ export const PATCH = withAuth<
{ status: 400 }
)
}
+ const configToPersist = normalizeConnectorConfigForPersistence(connectorType, mergedConfig)
try {
- updateData.config = encryptConfig(mergedConfig)
- responseConfig = mergedConfig
+ updateData.config = encryptConfig(configToPersist)
+ responseConfig = configToPersist
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to encrypt config'
return NextResponse.json(
diff --git a/apps/web/src/app/api/u/[slug]/connectors/__tests__/route.test.ts b/apps/web/src/app/api/u/[slug]/connectors/__tests__/route.test.ts
index 8895a2c9..f8b92476 100644
--- a/apps/web/src/app/api/u/[slug]/connectors/__tests__/route.test.ts
+++ b/apps/web/src/app/api/u/[slug]/connectors/__tests__/route.test.ts
@@ -113,6 +113,7 @@ vi.mock('@/lib/connectors/validators', () => ({
}
return { valid: true }
},
+ normalizeConnectorConfigForPersistence: (_type: unknown, config: unknown) => config,
}))
// ---------------------------------------------------------------------------
diff --git a/apps/web/src/app/api/u/[slug]/connectors/route.ts b/apps/web/src/app/api/u/[slug]/connectors/route.ts
index ad1b352a..a9a72475 100644
--- a/apps/web/src/app/api/u/[slug]/connectors/route.ts
+++ b/apps/web/src/app/api/u/[slug]/connectors/route.ts
@@ -11,6 +11,7 @@ import {
} from '@/lib/connectors/require-connector-capability'
import { isSingleInstanceConnectorType } from '@/lib/connectors/types'
import {
+ normalizeConnectorConfigForPersistence,
validateConnectorType,
validateConnectorConfig,
validateConnectorName,
@@ -237,9 +238,11 @@ export const POST = withAuth {
})
})
+ it('commits a valid repository left in the input when submitting without pressing Add', () => {
+ const ref = createRef()
+ render()
+
+ fireEvent.change(screen.getByLabelText('Personal access token'), {
+ target: { value: 'github_pat_example' },
+ })
+ fireEvent.change(screen.getByLabelText('Pinned repositories'), {
+ target: { value: 'acme/api' },
+ })
+ fireEvent.keyDown(screen.getByLabelText('Pinned repositories'), { key: 'Enter' })
+ // Second repo typed but not committed with Enter/Add.
+ fireEvent.change(screen.getByLabelText('Pinned repositories'), {
+ target: { value: 'acme/web' },
+ })
+
+ expect(expectSuccessfulSubmission(ref)).toMatchObject({
+ config: {
+ pat: 'github_pat_example',
+ pinnedRepos: ['acme/api', 'acme/web'],
+ toolsets: ['repos', 'git'],
+ },
+ })
+ })
+
it('validates GitHub repositories and becomes incomplete when the last repository is removed', () => {
const ref = createRef()
render()
diff --git a/apps/web/src/components/connectors/add-connector/github/section.tsx b/apps/web/src/components/connectors/add-connector/github/section.tsx
index ef47bba5..80587cda 100644
--- a/apps/web/src/components/connectors/add-connector/github/section.tsx
+++ b/apps/web/src/components/connectors/add-connector/github/section.tsx
@@ -38,6 +38,10 @@ export const GithubSection = forwardRef<
useNotifyStateChange(onStateChange, state)
+ function includesRepo(repos: string[], repo: string): boolean {
+ return repos.some((item) => item.toLowerCase() === repo.toLowerCase())
+ }
+
function addPinnedRepo() {
const repo = repoInput.trim()
if (!isGithubPinnedRepo(repo)) {
@@ -46,7 +50,7 @@ export const GithubSection = forwardRef<
}
setPinnedRepos((current) =>
- current.includes(repo) ? current : [...current, repo]
+ includesRepo(current, repo) ? current : [...current, repo]
)
setRepoInput('')
setRepoError(null)
@@ -55,7 +59,15 @@ export const GithubSection = forwardRef<
useImperativeHandle(ref, () => ({
isComplete: () => isGithubConnectorConfigurationComplete(state),
getSubmission: (): AddConnectorSubmissionResult => {
- const configResult = buildGithubConnectorConfig(state)
+ // Commit a valid repo left in the input but not yet added so it isn't
+ // silently dropped when the user clicks Save without pressing Enter/Add.
+ const pendingRepo = repoInput.trim()
+ const submissionState =
+ isGithubPinnedRepo(pendingRepo) && !includesRepo(pinnedRepos, pendingRepo)
+ ? { ...state, pinnedRepos: [...pinnedRepos, pendingRepo] }
+ : state
+
+ const configResult = buildGithubConnectorConfig(submissionState)
if (!configResult.ok) {
return { ok: false, message: configResult.message }
}
diff --git a/apps/web/src/lib/connectors/__tests__/github-config.test.ts b/apps/web/src/lib/connectors/__tests__/github-config.test.ts
index df1169de..1bdf69e5 100644
--- a/apps/web/src/lib/connectors/__tests__/github-config.test.ts
+++ b/apps/web/src/lib/connectors/__tests__/github-config.test.ts
@@ -39,12 +39,41 @@ describe('GitHub connector config', () => {
})
})
+ it('dedupes pinned repos case-insensitively, keeping the first spelling', () => {
+ expect(parseGithubConnectorConfig({
+ pat: 'ghp_example',
+ pinnedRepos: ['Acme/API', 'acme/api', 'other/repo'],
+ })).toEqual({
+ ok: true,
+ config: {
+ pat: 'ghp_example',
+ pinnedRepos: ['Acme/API', 'other/repo'],
+ toolsets: ['repos', 'git'],
+ },
+ })
+ })
+
+ it('drops stray keys and returns only the normalized config', () => {
+ expect(parseGithubConnectorConfig({
+ pat: 'ghp_example',
+ pinnedRepos: ['owner/repo'],
+ host: 'https://github.example.com',
+ hasPat: true,
+ })).toEqual({
+ ok: true,
+ config: {
+ pat: 'ghp_example',
+ pinnedRepos: ['owner/repo'],
+ toolsets: ['repos', 'git'],
+ },
+ })
+ })
+
it.each([
{ pat: 'token', pinnedRepos: ['owner/repo'] },
{ pat: 'ghp_example', pinnedRepos: 'owner/repo' },
{ pat: 'ghp_example', pinnedRepos: ['owner/repo/path'] },
{ pat: 'ghp_example', pinnedRepos: ['owner/ repo'] },
- { pat: 'ghp_example', host: 'https://github.example.com', pinnedRepos: ['owner/repo'] },
{ pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: ['all'] },
{ pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: [] },
{ pat: 'ghp_example', pinnedRepos: ['owner/repo'], toolsets: ['repos', 'repos'] },
diff --git a/apps/web/src/lib/connectors/__tests__/oauth-config.test.ts b/apps/web/src/lib/connectors/__tests__/oauth-config.test.ts
index adc1d5ff..621dd92d 100644
--- a/apps/web/src/lib/connectors/__tests__/oauth-config.test.ts
+++ b/apps/web/src/lib/connectors/__tests__/oauth-config.test.ts
@@ -207,6 +207,31 @@ describe('oauth-config', () => {
})
expect(result.oauth).toEqual({ provider: 'linear', accessToken: 'new' })
})
+
+ it('preserves the stored GitHub pat when next config omits it', async () => {
+ mockIsOAuthConnectorType.mockReturnValue(false)
+ const { mergeConnectorConfigWithPreservedOAuth } = await import('@/lib/connectors/oauth-config')
+ const result = mergeConnectorConfigWithPreservedOAuth({
+ connectorType: 'github',
+ currentConfig: { pat: 'ghp_stored', pinnedRepos: ['owner/repo'] },
+ nextConfig: { pinnedRepos: ['owner/repo', 'owner/other'] },
+ })
+ expect(result).toEqual({
+ pat: 'ghp_stored',
+ pinnedRepos: ['owner/repo', 'owner/other'],
+ })
+ })
+
+ it('does not overwrite a GitHub pat provided in next config', async () => {
+ mockIsOAuthConnectorType.mockReturnValue(false)
+ const { mergeConnectorConfigWithPreservedOAuth } = await import('@/lib/connectors/oauth-config')
+ const result = mergeConnectorConfigWithPreservedOAuth({
+ connectorType: 'github',
+ currentConfig: { pat: 'ghp_stored', pinnedRepos: ['owner/repo'] },
+ nextConfig: { pat: 'ghp_new', pinnedRepos: ['owner/repo'] },
+ })
+ expect(result.pat).toBe('ghp_new')
+ })
})
describe('clearConnectorOAuthConfig', () => {
diff --git a/apps/web/src/lib/connectors/github.ts b/apps/web/src/lib/connectors/github.ts
index dd7ee6d6..2f90719d 100644
--- a/apps/web/src/lib/connectors/github.ts
+++ b/apps/web/src/lib/connectors/github.ts
@@ -54,8 +54,6 @@ export function parseGithubConnectorConfig(
)
if (pinnedRepos.some((repo) => !isGithubPinnedRepo(repo))) return { ok: false }
- if (config.host !== undefined) return { ok: false }
-
const toolsets = parseToolsets(config.toolsets)
if (!toolsets) return { ok: false }
@@ -63,12 +61,27 @@ export function parseGithubConnectorConfig(
ok: true,
config: {
pat,
- pinnedRepos: Array.from(new Set(pinnedRepos)),
+ pinnedRepos: dedupePinnedRepos(pinnedRepos),
toolsets,
},
}
}
+// GitHub repository names are case-insensitive, so `Acme/API` and `acme/api`
+// refer to the same repo. Dedupe case-insensitively while preserving the first
+// spelling the user entered.
+function dedupePinnedRepos(repos: string[]): string[] {
+ const seen = new Set()
+ const deduped: string[] = []
+ for (const repo of repos) {
+ const key = repo.toLowerCase()
+ if (seen.has(key)) continue
+ seen.add(key)
+ deduped.push(repo)
+ }
+ return deduped
+}
+
export function getGithubMcpServerUrl(): string {
return GITHUB_MCP_SERVER_URL
}
diff --git a/apps/web/src/lib/connectors/mcp/server-url.ts b/apps/web/src/lib/connectors/mcp/server-url.ts
index 811f66af..74200c32 100644
--- a/apps/web/src/lib/connectors/mcp/server-url.ts
+++ b/apps/web/src/lib/connectors/mcp/server-url.ts
@@ -42,6 +42,4 @@ export function getConnectorMcpServerUrl(type: ConnectorType, config: Record
+): Record {
+ if (type === 'github') {
+ const parsed = parseGithubConnectorConfig(config)
+ if (parsed.ok) return { ...parsed.config }
+ }
+ return config
+}
+
export function validateConnectorName(name: unknown): { valid: boolean; error?: string } {
if (typeof name !== 'string') {
return { valid: false, error: 'Name must be a string' }
diff --git a/apps/web/src/lib/spawner/runtime-artifacts.ts b/apps/web/src/lib/spawner/runtime-artifacts.ts
index da93cadb..dd5bfbff 100644
--- a/apps/web/src/lib/spawner/runtime-artifacts.ts
+++ b/apps/web/src/lib/spawner/runtime-artifacts.ts
@@ -134,7 +134,8 @@ async function getLinkedRepositoriesForOwner(owner: WorkspaceOwner): Promise left.localeCompare(right))
- } catch {
+ } catch (error) {
+ console.warn('[workspace-runtime] Failed to load linked GitHub repositories', error)
return []
}
}
From eecd8b2c7f45a6ba9046553d6263597ce9a206bc Mon Sep 17 00:00:00 2001
From: Alberto Perdomo
Date: Thu, 23 Jul 2026 13:39:27 +0100
Subject: [PATCH 08/10] feat(web): add settings dialog for editing GitHub
connector pinned repos
After initial setup, users had no way to add or remove pinned repositories
from an existing GitHub connector. This adds a dedicated settings dialog
(following the Zendesk/Meta Ads pattern) that loads the current config,
lets users manage pinned repos via chips, and saves via PATCH without
resending the PAT.
Co-Authored-By: Claude Opus 4.6
---
.../connectors/connectors-panel.tsx | 17 +-
.../github-connector-settings-dialog.tsx | 274 ++++++++++++++++++
2 files changed, 290 insertions(+), 1 deletion(-)
create mode 100644 apps/web/src/components/connectors/github-connector-settings-dialog.tsx
diff --git a/apps/web/src/components/connectors/connectors-panel.tsx b/apps/web/src/components/connectors/connectors-panel.tsx
index 28a3d41b..776e84fa 100644
--- a/apps/web/src/components/connectors/connectors-panel.tsx
+++ b/apps/web/src/components/connectors/connectors-panel.tsx
@@ -6,6 +6,7 @@ import { AddConnectorModal } from '@/components/connectors/add-connector-modal'
import { ConnectorList } from '@/components/connectors/connector-list'
import { ConnectorToolPermissionsDialog } from '@/components/connectors/connector-tool-permissions-dialog'
import { getConnectorErrorMessage } from '@/components/connectors/error-messages'
+import { GithubConnectorSettingsDialog } from '@/components/connectors/github-connector-settings-dialog'
import { MetaAdsConnectorSettingsDialog } from '@/components/connectors/meta-ads-connector-settings-dialog'
import { ZendeskConnectorSettingsDialog } from '@/components/connectors/zendesk-connector-settings-dialog'
import type {
@@ -15,7 +16,7 @@ import type {
} from '@/components/connectors/types'
import { notifyWorkspaceConfigChanged } from '@/lib/runtime/config-status-events'
-type SettingsDialogVariant = 'generic' | 'meta-ads' | 'zendesk'
+type SettingsDialogVariant = 'generic' | 'github' | 'meta-ads' | 'zendesk'
export type ConnectorsPanelHandle = {
openAddModal: () => void
@@ -92,6 +93,8 @@ function formatTestResult(result: ConnectorTestResult): ConnectorTestState {
function getSettingsDialogVariant(connector: ConnectorListItem | null): SettingsDialogVariant | null {
switch (connector?.type) {
+ case 'github':
+ return 'github'
case 'zendesk':
return 'zendesk'
case 'meta-ads':
@@ -388,6 +391,18 @@ export function ConnectorsPanel({ slug, oauthReturnTo, ref }: ConnectorsPanelPro
}}
/>
+ {
+ if (!open) {
+ setSettingsConnector(null)
+ }
+ }}
+ />
+
void
+}
+
+function includesRepo(repos: string[], repo: string): boolean {
+ return repos.some((item) => item.toLowerCase() === repo.toLowerCase())
+}
+
+export function GithubConnectorSettingsDialog({
+ open,
+ slug,
+ connectorId,
+ connectorName,
+ onOpenChange,
+}: GithubConnectorSettingsDialogProps) {
+ const [pinnedRepos, setPinnedRepos] = useState([])
+ const [repoInput, setRepoInput] = useState('')
+ const [repoError, setRepoError] = useState(null)
+ const [hasLoaded, setHasLoaded] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [error, setError] = useState(null)
+
+ const isLoading = open && Boolean(connectorId) && !hasLoaded && error === null
+
+ function resetState() {
+ setPinnedRepos([])
+ setRepoInput('')
+ setRepoError(null)
+ setHasLoaded(false)
+ setIsSaving(false)
+ setError(null)
+ }
+
+ function handleOpenChange(nextOpen: boolean) {
+ if (!nextOpen) resetState()
+ onOpenChange(nextOpen)
+ }
+
+ useEffect(() => {
+ if (!open || !connectorId) return
+
+ let cancelled = false
+
+ async function loadConfig() {
+ try {
+ const response = await fetch(`/api/u/${slug}/connectors/${connectorId}`, {
+ cache: 'no-store',
+ })
+ const data = (await response.json().catch(() => null)) as
+ | { config?: { pinnedRepos?: string[] }; error?: string }
+ | null
+
+ if (cancelled) return
+
+ if (!response.ok || !data) {
+ setError(getConnectorErrorMessage(data, 'load_settings_failed'))
+ return
+ }
+
+ setPinnedRepos(
+ Array.isArray(data.config?.pinnedRepos) ? data.config.pinnedRepos : [],
+ )
+ setHasLoaded(true)
+ setError(null)
+ } catch {
+ if (!cancelled) {
+ setError(getConnectorErrorMessage(null, 'network_error'))
+ }
+ }
+ }
+
+ void loadConfig()
+
+ return () => {
+ cancelled = true
+ }
+ }, [connectorId, open, slug])
+
+ function addPinnedRepo() {
+ const repo = repoInput.trim()
+ if (!isGithubPinnedRepo(repo)) {
+ setRepoError('Enter a repository as owner/repository.')
+ return
+ }
+
+ setPinnedRepos((current) =>
+ includesRepo(current, repo) ? current : [...current, repo],
+ )
+ setRepoInput('')
+ setRepoError(null)
+ }
+
+ async function handleSave() {
+ if (!connectorId || !hasLoaded || isLoading || isSaving) return
+
+ const pendingRepo = repoInput.trim()
+ const finalRepos =
+ isGithubPinnedRepo(pendingRepo) && !includesRepo(pinnedRepos, pendingRepo)
+ ? [...pinnedRepos, pendingRepo]
+ : pinnedRepos
+
+ setIsSaving(true)
+ setError(null)
+
+ try {
+ const response = await fetch(`/api/u/${slug}/connectors/${connectorId}`, {
+ method: 'PATCH',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ config: { pinnedRepos: finalRepos } }),
+ })
+ const data = (await response.json().catch(() => null)) as
+ | { error?: string }
+ | null
+
+ if (!response.ok) {
+ setError(getConnectorErrorMessage(data, 'save_failed'))
+ return
+ }
+
+ handleOpenChange(false)
+ } catch {
+ setError(getConnectorErrorMessage(null, 'network_error'))
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
From def6b6d499a45a75205e88d6bfc2c593eee7e314 Mon Sep 17 00:00:00 2001
From: Alberto Perdomo
Date: Thu, 23 Jul 2026 14:15:11 +0100
Subject: [PATCH 09/10] fix(web): remove nonexistent compare_commits from
GitHub MCP tool list
The AGENTS.md linked repositories section referenced compare_commits as
a GitHub MCP tool, but it does not exist in the GitHub Copilot MCP
server's tool inventory.
Co-Authored-By: Claude Opus 4.6
---
apps/web/src/lib/spawner/runtime-config-utils.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/lib/spawner/runtime-config-utils.ts b/apps/web/src/lib/spawner/runtime-config-utils.ts
index 134f9ff2..1af7f134 100644
--- a/apps/web/src/lib/spawner/runtime-config-utils.ts
+++ b/apps/web/src/lib/spawner/runtime-config-utils.ts
@@ -85,7 +85,7 @@ export function withLinkedRepositories(agentsMd: string, repos: string[]): strin
const block =
`\n\n## Linked Repositories\n\n` +
`You have GitHub MCP tools (\`get_file_contents\`, \`search_code\`, ` +
- `\`list_commits\`, \`get_commit\`, \`compare_commits\`) to query these ` +
+ `\`list_commits\`, \`get_commit\`) to query these ` +
`repositories. Use \`main\` unless correlating across branches.\n\n` +
`${list}\n`
From 0ac7e3c59017f89f193318745bef94b20df8bf57 Mon Sep 17 00:00:00 2001
From: Alberto Perdomo
Date: Thu, 23 Jul 2026 15:18:17 +0100
Subject: [PATCH 10/10] fix(web): update test for compare_commits removal,
enable reportOnFailure
Update the withLinkedRepositories test to match the corrected tool list.
Add reportOnFailure to shared vitest coverage config so coverage reports
are written even when a test fails.
Co-Authored-By: Claude Opus 4.6
---
apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts | 2 +-
apps/web/vitest.config.ts | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts b/apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts
index a47cd23d..99f521fe 100644
--- a/apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts
+++ b/apps/web/src/lib/spawner/__tests__/runtime-config-utils.test.ts
@@ -10,7 +10,7 @@ describe('withLinkedRepositories', () => {
it('appends linked repository guidance and the pinned repositories', () => {
expect(withLinkedRepositories('# Base instructions', ['acme/api', 'acme/web'])).toBe(
'# Base instructions\n\n## Linked Repositories\n\n' +
- 'You have GitHub MCP tools (`get_file_contents`, `search_code`, `list_commits`, `get_commit`, `compare_commits`) to query these repositories. Use `main` unless correlating across branches.\n\n' +
+ 'You have GitHub MCP tools (`get_file_contents`, `search_code`, `list_commits`, `get_commit`) to query these repositories. Use `main` unless correlating across branches.\n\n' +
'- `acme/api`\n- `acme/web`\n'
)
})
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
index eede6163..1fbb369e 100644
--- a/apps/web/vitest.config.ts
+++ b/apps/web/vitest.config.ts
@@ -18,6 +18,7 @@ export const sharedCoverageConfig = {
exclude: sharedCoverageExclude,
include: ['src/**/*.{ts,tsx}'],
provider: 'v8' as const,
+ reportOnFailure: true,
reporter: ['text', 'json-summary', 'html', 'lcov'],
}