From 3cb3a2141d42890d883eee11b8893759b95ccc3c Mon Sep 17 00:00:00 2001 From: guitavano Date: Tue, 26 May 2026 20:22:36 -0300 Subject: [PATCH] feat(github): per-repo import connections with scoped OAuth tokens Create a dedicated GitHub MCP connection per import session, scope the user token to the selected repository, and refresh downstream tokens with repository_id from connection metadata. Adds a local MCP URL override for dev. Co-authored-by: Cursor --- apps/mesh/.env.development | 3 + apps/mesh/src/mcp-clients/outbound/headers.ts | 16 ++- apps/mesh/src/oauth/refresh-access-token.ts | 5 + apps/mesh/src/oauth/token-refresh.ts | 3 +- apps/mesh/src/shared/github-clone-info.ts | 19 ++- .../mesh/src/shared/github-connection.test.ts | 76 +++++++++++ apps/mesh/src/shared/github-connection.ts | 74 ++++++++++ apps/mesh/src/shared/github-mcp-url.ts | 61 +++++++++ .../src/tools/github/list-user-orgs.test.ts | 4 +- apps/mesh/src/tools/github/list-user-orgs.ts | 29 +++- .../web/components/add-storefront-modal.tsx | 1 + .../src/web/components/github-repo-picker.tsx | 107 +++++++-------- .../src/web/hooks/use-auto-install-github.ts | 116 ++++++++++------ apps/mesh/src/web/lib/github-oauth.ts | 129 ++++++++++++++++++ packages/mesh-sdk/src/types/virtual-mcp.ts | 6 + packages/runtime/src/oauth.ts | 1 + packages/runtime/src/tools.ts | 2 + 17 files changed, 548 insertions(+), 104 deletions(-) create mode 100644 apps/mesh/.env.development create mode 100644 apps/mesh/src/shared/github-connection.test.ts create mode 100644 apps/mesh/src/shared/github-connection.ts create mode 100644 apps/mesh/src/shared/github-mcp-url.ts create mode 100644 apps/mesh/src/web/lib/github-oauth.ts diff --git a/apps/mesh/.env.development b/apps/mesh/.env.development new file mode 100644 index 0000000000..b43b09bf0f --- /dev/null +++ b/apps/mesh/.env.development @@ -0,0 +1,3 @@ +# Local GitHub MCP (wrangler dev in ../mcps/github) +# Both /mcp and /api/mcp work on the local Worker. +VITE_GITHUB_MCP_URL=http://localhost:8787/api/mcp diff --git a/apps/mesh/src/mcp-clients/outbound/headers.ts b/apps/mesh/src/mcp-clients/outbound/headers.ts index d6ec55fe82..f883b7ab67 100644 --- a/apps/mesh/src/mcp-clients/outbound/headers.ts +++ b/apps/mesh/src/mcp-clients/outbound/headers.ts @@ -11,6 +11,7 @@ import type { MeshContext } from "@/core/mesh-context"; import { SpanStatusCode } from "@opentelemetry/api"; import { refreshAccessToken } from "@/oauth/token-refresh"; import { resolveOriginTokenEndpoint } from "@/oauth/resolve-token-endpoint"; +import { getGithubConnectionRepoScope } from "@/shared/github-connection"; import { DownstreamTokenStorage } from "@/storage/downstream-token"; import type { ConnectionEntity } from "@/tools/connection/schema"; @@ -179,10 +180,17 @@ async function _buildRequestHeaders( } } - const refreshResult = await refreshAccessToken({ - ...cachedToken, - tokenEndpoint: tokenEndpointForRefresh, - }); + const repoScope = getGithubConnectionRepoScope( + (connection.metadata ?? null) as Record | null, + ); + + const refreshResult = await refreshAccessToken( + { + ...cachedToken, + tokenEndpoint: tokenEndpointForRefresh, + }, + repoScope ? { repositoryId: repoScope.repositoryId } : undefined, + ); if (refreshResult.success && refreshResult.accessToken) { // Save refreshed token (with resolved origin endpoint for future refreshes) diff --git a/apps/mesh/src/oauth/refresh-access-token.ts b/apps/mesh/src/oauth/refresh-access-token.ts index 0e5a711797..fe885bb4dd 100644 --- a/apps/mesh/src/oauth/refresh-access-token.ts +++ b/apps/mesh/src/oauth/refresh-access-token.ts @@ -33,6 +33,7 @@ export interface TokenRefreshResult { export async function refreshAccessToken( token: DownstreamToken, + options?: { repositoryId?: number }, ): Promise { if (!token.refreshToken) { return { @@ -73,6 +74,10 @@ export async function refreshAccessToken( params.set("scope", token.scope); } + if (options?.repositoryId !== undefined) { + params.set("repository_id", String(options.repositoryId)); + } + const response = await fetch(token.tokenEndpoint, { method: "POST", headers: { diff --git a/apps/mesh/src/oauth/token-refresh.ts b/apps/mesh/src/oauth/token-refresh.ts index e17a9e519d..fd08ccbc1d 100644 --- a/apps/mesh/src/oauth/token-refresh.ts +++ b/apps/mesh/src/oauth/token-refresh.ts @@ -30,8 +30,9 @@ export function canRefresh(token: DownstreamToken): boolean { export async function refreshAndStore( token: DownstreamToken, tokenStorage: DownstreamTokenStorage, + options?: { repositoryId?: number }, ): Promise { - const result = await refreshAccessToken(token); + const result = await refreshAccessToken(token, options); if (!result.success || !result.accessToken) { // Only delete the cached row when the OAuth server told us the // refresh_token is permanently invalid (RFC 6749: 400 invalid_grant). diff --git a/apps/mesh/src/shared/github-clone-info.ts b/apps/mesh/src/shared/github-clone-info.ts index 5b7e240142..bb0c222d00 100644 --- a/apps/mesh/src/shared/github-clone-info.ts +++ b/apps/mesh/src/shared/github-clone-info.ts @@ -20,6 +20,7 @@ import { RECONNECT_ERROR, refreshAndStore, } from "../oauth/token-refresh"; +import { getGithubConnectionRepoScope } from "./github-connection"; export interface GitHubCloneInfo { cloneUrl: string; @@ -51,6 +52,18 @@ export async function buildCloneInfo( vault: CredentialVault, ): Promise { const tokenStorage = new DownstreamTokenStorage(db, vault); + const connection = await db + .selectFrom("connections") + .select("metadata") + .where("id", "=", connectionId) + .executeTakeFirst(); + const repoScope = getGithubConnectionRepoScope( + (connection?.metadata ?? null) as Record | null, + ); + const refreshOptions = repoScope + ? { repositoryId: repoScope.repositoryId } + : undefined; + const token = await tokenStorage.get(connectionId); if (!token) { throw new Error( @@ -65,7 +78,11 @@ export async function buildCloneInfo( canRefresh(token) && tokenStorage.isExpired(token, PROACTIVE_REFRESH_BUFFER_MS) ) { - const refreshed = await refreshAndStore(token, tokenStorage); + const refreshed = await refreshAndStore( + token, + tokenStorage, + refreshOptions, + ); if (!refreshed) { throw new Error(RECONNECT_ERROR); } diff --git a/apps/mesh/src/shared/github-connection.test.ts b/apps/mesh/src/shared/github-connection.test.ts new file mode 100644 index 0000000000..9eea879b78 --- /dev/null +++ b/apps/mesh/src/shared/github-connection.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { + encodeMeshOAuthClientState, + getGithubConnectionRepoScope, + githubConnectionTitle, + isGithubMcpConnection, +} from "./github-connection"; +import { + isGithubMcpConnectionUrl, + isLocalGithubMcpUrl, +} from "./github-mcp-url"; + +describe("github-connection", () => { + test("isGithubMcpConnection matches app_name and canonical URL", () => { + expect(isGithubMcpConnection({ app_name: "mcp-github" })).toBe(true); + expect( + isGithubMcpConnection({ + connection_url: "https://github-mcp.decocms.com/mcp", + }), + ).toBe(true); + expect( + isGithubMcpConnection({ + connection_url: "http://localhost:8787/api/mcp", + }), + ).toBe(true); + expect(isGithubMcpConnection({ app_name: "other" })).toBe(false); + }); + + test("getGithubConnectionRepoScope reads repositoryId from metadata", () => { + expect( + getGithubConnectionRepoScope({ + githubRepo: { + owner: "deco", + name: "mesh", + url: "https://github.com/deco/mesh", + repositoryId: 123, + installationId: 456, + }, + }), + ).toEqual({ + owner: "deco", + name: "mesh", + url: "https://github.com/deco/mesh", + repositoryId: 123, + installationId: 456, + }); + }); + + test("encodeMeshOAuthClientState round-trips repositoryId", () => { + const encoded = encodeMeshOAuthClientState({ repositoryId: 99 }); + expect(encoded.startsWith("mesh:")).toBe(true); + }); + + test("githubConnectionTitle formats owner/repo", () => { + expect(githubConnectionTitle("deco", "mesh")).toBe("GitHub — deco/mesh"); + }); +}); + +describe("github-mcp-url", () => { + test("isLocalGithubMcpUrl detects localhost", () => { + expect(isLocalGithubMcpUrl("http://localhost:8787/api/mcp")).toBe(true); + expect(isLocalGithubMcpUrl("https://github-mcp.decocms.com/mcp")).toBe( + false, + ); + }); + + test("isGithubMcpConnectionUrl accepts local and prod hosts", () => { + expect(isGithubMcpConnectionUrl("http://localhost:8787/mcp")).toBe(true); + expect(isGithubMcpConnectionUrl("http://localhost:8787/api/mcp")).toBe( + true, + ); + expect(isGithubMcpConnectionUrl("https://github-mcp.decocms.com/mcp")).toBe( + true, + ); + }); +}); diff --git a/apps/mesh/src/shared/github-connection.ts b/apps/mesh/src/shared/github-connection.ts new file mode 100644 index 0000000000..2ef417ba44 --- /dev/null +++ b/apps/mesh/src/shared/github-connection.ts @@ -0,0 +1,74 @@ +/** + * GitHub MCP connection helpers — repo-scoped tokens and connection detection. + */ + +import { isGithubMcpConnectionUrl } from "./github-mcp-url"; + +export const GITHUB_MCP_APP_NAME = "mcp-github"; +export const GITHUB_MCP_HOST = "api.githubcopilot.com"; +export const GITHUB_MCP_PROXY_HOST = "github-mcp.decocms.com"; + +export interface GithubConnectionRepoScope { + owner: string; + name: string; + url: string; + repositoryId: number; + installationId?: number; +} + +export function isGithubMcpConnection(connection: { + app_name?: string | null; + connection_url?: string | null; +}): boolean { + if (connection.app_name === GITHUB_MCP_APP_NAME) return true; + const url = connection.connection_url; + if (typeof url !== "string" || url.length === 0) return false; + return isGithubMcpConnectionUrl(url); +} + +export function getGithubConnectionRepoScope( + metadata: Record | null | undefined, +): GithubConnectionRepoScope | null { + const githubRepo = metadata?.githubRepo; + if (!githubRepo || typeof githubRepo !== "object") return null; + + const record = githubRepo as Record; + const owner = record.owner; + const name = record.name; + const url = record.url; + const repositoryId = record.repositoryId; + + if ( + typeof owner !== "string" || + typeof name !== "string" || + typeof url !== "string" || + typeof repositoryId !== "number" + ) { + return null; + } + + const installationId = record.installationId; + return { + owner, + name, + url, + repositoryId, + installationId: + typeof installationId === "number" ? installationId : undefined, + }; +} + +export function githubConnectionTitle(owner: string, name: string): string { + return `GitHub — ${owner}/${name}`; +} + +export function encodeMeshOAuthClientState(state: { + repositoryId?: number; +}): string { + const json = JSON.stringify(state); + const base64 = btoa(json) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `mesh:${base64}`; +} diff --git a/apps/mesh/src/shared/github-mcp-url.ts b/apps/mesh/src/shared/github-mcp-url.ts new file mode 100644 index 0000000000..b03fe0b4c4 --- /dev/null +++ b/apps/mesh/src/shared/github-mcp-url.ts @@ -0,0 +1,61 @@ +/** + * Dev override for the GitHub MCP connection URL. + * + * In development, Studio points new GitHub imports at a local Worker + * (wrangler dev) instead of github-mcp.decocms.com. Set + * VITE_GITHUB_MCP_URL in apps/mesh/.env.development to change the target. + */ + +const DEFAULT_LOCAL_GITHUB_MCP_URL = "http://localhost:8787/api/mcp"; + +function readViteEnv(name: string): string | undefined { + const value = (import.meta.env as Record)[name]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** Resolve the HTTP URL used when creating a GitHub MCP connection. */ +export function resolveGithubMcpConnectionUrl( + registryUrl: string | undefined, +): string { + const override = readViteEnv("VITE_GITHUB_MCP_URL"); + if (override) return override; + + if (import.meta.env.DEV) { + return DEFAULT_LOCAL_GITHUB_MCP_URL; + } + + if (!registryUrl) { + throw new Error("Registry item is missing a remote URL for mcp-github"); + } + + return registryUrl; +} + +export function isLocalGithubMcpUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || + parsed.hostname.endsWith(".localhost") + ); + } catch { + return false; + } +} + +export function isGithubMcpConnectionUrl(url: string): boolean { + try { + const parsed = new URL(url); + const path = parsed.pathname.replace(/\/+$/, ""); + if (!path.endsWith("/mcp")) return false; + + return ( + parsed.hostname === "github-mcp.decocms.com" || + parsed.hostname === "api.githubcopilot.com" || + isLocalGithubMcpUrl(url) + ); + } catch { + return false; + } +} diff --git a/apps/mesh/src/tools/github/list-user-orgs.test.ts b/apps/mesh/src/tools/github/list-user-orgs.test.ts index e4b05be02f..5b606676b8 100644 --- a/apps/mesh/src/tools/github/list-user-orgs.test.ts +++ b/apps/mesh/src/tools/github/list-user-orgs.test.ts @@ -143,7 +143,9 @@ describe("GITHUB_LIST_USER_ORGS", () => { slug: "test-org", name: "Test Organization", }, - storage: {} as never, + storage: { + connections: connectionStorage, + } as never, vault, authInstance: null as never, boundAuth: createMockBoundAuth(), diff --git a/apps/mesh/src/tools/github/list-user-orgs.ts b/apps/mesh/src/tools/github/list-user-orgs.ts index 689c317d90..3a34a4a439 100644 --- a/apps/mesh/src/tools/github/list-user-orgs.ts +++ b/apps/mesh/src/tools/github/list-user-orgs.ts @@ -7,6 +7,7 @@ import { refreshAndStore, } from "@/oauth/token-refresh"; import { DownstreamTokenStorage } from "../../storage/downstream-token"; +import { getGithubConnectionRepoScope } from "@/shared/github-connection"; const GITHUB_API = "https://api.github.com"; @@ -40,6 +41,22 @@ export const GITHUB_LIST_USER_ORGS = defineTool({ await ctx.access.check(); const tokenStorage = new DownstreamTokenStorage(ctx.db, ctx.vault); + const organizationId = ctx.organization?.id; + if (!organizationId) { + throw new Error("Organization context required"); + } + + const connection = await ctx.storage.connections.findById( + input.connectionId, + organizationId, + ); + const repoScope = getGithubConnectionRepoScope( + (connection?.metadata ?? null) as Record | null, + ); + const refreshOptions = repoScope + ? { repositoryId: repoScope.repositoryId } + : undefined; + let token = await tokenStorage.get(input.connectionId); if (!token) { throw new Error( @@ -55,7 +72,11 @@ export const GITHUB_LIST_USER_ORGS = defineTool({ canRefresh(token) && tokenStorage.isExpired(token, PROACTIVE_REFRESH_BUFFER_MS) ) { - const refreshed = await refreshAndStore(token, tokenStorage); + const refreshed = await refreshAndStore( + token, + tokenStorage, + refreshOptions, + ); if (!refreshed) { throw new Error(RECONNECT_ERROR); } @@ -101,7 +122,11 @@ export const GITHUB_LIST_USER_ORGS = defineTool({ if (!current || !canRefresh(current)) { throw new Error(RECONNECT_ERROR); } - const refreshed = await refreshAndStore(current, tokenStorage); + const refreshed = await refreshAndStore( + current, + tokenStorage, + refreshOptions, + ); if (!refreshed) { throw new Error(RECONNECT_ERROR); } diff --git a/apps/mesh/src/web/components/add-storefront-modal.tsx b/apps/mesh/src/web/components/add-storefront-modal.tsx index 8c708f209a..b90718eacd 100644 --- a/apps/mesh/src/web/components/add-storefront-modal.tsx +++ b/apps/mesh/src/web/components/add-storefront-modal.tsx @@ -402,6 +402,7 @@ function UrlEntry({ onComplete }: { onComplete: () => void }) { private: false, description: null, updatedAt: "", + repositoryId: 0, }, connectionId, installationId: undefined, diff --git a/apps/mesh/src/web/components/github-repo-picker.tsx b/apps/mesh/src/web/components/github-repo-picker.tsx index 2babd5db8b..d8e671f1cc 100644 --- a/apps/mesh/src/web/components/github-repo-picker.tsx +++ b/apps/mesh/src/web/components/github-repo-picker.tsx @@ -19,10 +19,9 @@ import { invalidateVirtualMcpQueries } from "@/web/lib/query-keys"; import { useProjectContext, useMCPClient, - useConnections, + useConnectionActions, SELF_MCP_ALIAS_ID, } from "@decocms/mesh-sdk"; -import type { ConnectionEntity } from "@decocms/mesh-sdk"; import { KEYS } from "@/web/lib/query-keys"; import { toast } from "sonner"; import { @@ -31,13 +30,15 @@ import { Lock01, LockUnlocked01, } from "@untitledui/icons"; -import { useAutoInstallGitHub } from "@/web/hooks/use-auto-install-github"; +import { useGithubImportConnection } from "@/web/hooks/use-auto-install-github"; import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; import { GitHubIcon } from "@/web/components/icons/github-icon"; import { STOREFRONT_GITHUB_AUTOMATIONS, setupStorefrontGithubAutomations, } from "@/tools/virtual/storefront-github-automations"; +import { githubConnectionTitle } from "@/shared/github-connection"; +import { scopeGithubConnectionToRepository } from "@/web/lib/github-oauth"; export interface GitHubInstallation { installationId: number; @@ -54,6 +55,7 @@ export interface Repo { private: boolean; description: string | null; updatedAt: string; + repositoryId: number; } export interface GitHubImportPayload { @@ -154,8 +156,7 @@ function PickerContent({ const { org } = useProjectContext(); const queryClient = useQueryClient(); const navigateToAgent = useNavigateToAgent(); - const [selectedConnection, setSelectedConnection] = - useState(null); + const connectionActions = useConnectionActions(); const [autoRespondEnabled, setAutoRespondEnabled] = useState(true); const [selectedAutomationKeys, setSelectedAutomationKeys] = useState< Set @@ -176,16 +177,11 @@ function PickerContent({ ? selectedAutomationKeys : new Set(); - const githubConnections = useConnections({ slug: "mcp-github" }); - - const autoInstall = useAutoInstallGitHub({ - enabled: githubConnections.length === 0, + const importSession = useGithubImportConnection({ + enabled: true, }); - const effectiveConnection = - githubConnections.length === 1 - ? (githubConnections[0] ?? null) - : selectedConnection; + const effectiveConnection = importSession.connection; const githubClient = useMCPClient({ connectionId: effectiveConnection?.id ?? "", @@ -289,6 +285,33 @@ function PickerContent({ const connectionId = effectiveConnection.id; + await scopeGithubConnectionToRepository({ + githubClient: githubClient as unknown as Parameters< + typeof scopeGithubConnectionToRepository + >[0]["githubClient"], + orgSlug: org.slug, + connectionId, + repositoryId: repo.repositoryId, + target: repo.owner, + existingTokenInfo: importSession.tokenInfo ?? undefined, + }); + + await connectionActions.update.mutateAsync({ + id: connectionId, + data: { + title: githubConnectionTitle(repo.owner, repo.name), + metadata: { + githubRepo: { + owner: repo.owner, + name: repo.name, + url: repo.url, + repositoryId: repo.repositoryId, + installationId: selectedInstallation.installationId, + }, + }, + }, + }); + const result = (await selfClient.callTool({ name: "COLLECTION_VIRTUAL_MCP_CREATE", arguments: { @@ -302,6 +325,7 @@ function PickerContent({ owner: repo.owner, name: repo.name, url: repo.url, + repositoryId: repo.repositoryId, installationId: selectedInstallation.installationId, connectionId, }, @@ -385,71 +409,38 @@ function PickerContent({ }); if ( - autoInstall.status === "installing" || - autoInstall.status === "authenticating" + importSession.status === "installing" || + importSession.status === "authenticating" ) { return ( ); } - if (autoInstall.status === "error") { + if (importSession.status === "error") { return ( ); } - if (githubConnections.length === 0 && autoInstall.status === "idle") { + if (!effectiveConnection && importSession.status === "idle") { return ( ); } - if (githubConnections.length > 1 && !effectiveConnection) { - return ( -
-
-

- Select a connection -

-
- {githubConnections.map((conn) => ( - - ))} -
- ); - } - if (!effectiveConnection) return null; if (!selectedInstallation) { @@ -459,8 +450,8 @@ function PickerContent({ orgId={org.id} orgSlug={org.slug} onSelect={onSelectInstallation} - showBackButton={githubConnections.length > 1} - onBack={() => setSelectedConnection(null)} + showBackButton={false} + onBack={() => onSelectInstallation(null)} /> ); } @@ -761,6 +752,7 @@ function RepoList({ if (!content) throw new Error("No response from search_repositories"); const parsed = JSON.parse(content) as { items?: Array<{ + id: number; name: string; full_name: string; html_url: string; @@ -777,6 +769,7 @@ function RepoList({ private: r.private, description: r.description, updatedAt: r.updated_at, + repositoryId: r.id, })); }, }); diff --git a/apps/mesh/src/web/hooks/use-auto-install-github.ts b/apps/mesh/src/web/hooks/use-auto-install-github.ts index 39d5a11850..4dbcbe1d5f 100644 --- a/apps/mesh/src/web/hooks/use-auto-install-github.ts +++ b/apps/mesh/src/web/hooks/use-auto-install-github.ts @@ -1,6 +1,7 @@ /** - * Hook to auto-install the mcp-github connection from registry and run OAuth. - * Used by the GitHub repo picker when no GitHub connection exists. + * Create a per-import GitHub MCP connection and run OAuth (GitHub UI selects repos). + * Each import session gets its own connection; the token is scoped to one repo + * after the user picks it in the repo picker. */ import { useRef, useState } from "react"; @@ -15,21 +16,32 @@ import { authClient } from "@/web/lib/auth-client"; import { useRegistryApp } from "@/web/hooks/use-registry-app"; import { extractConnectionData } from "@/web/utils/extract-connection-data"; import { invalidateVirtualMcpQueries } from "@/web/lib/query-keys"; +import { resolveGithubMcpConnectionUrl } from "@/shared/github-mcp-url"; type Status = "idle" | "installing" | "authenticating" | "ready" | "error"; -interface UseAutoInstallGitHubResult { +export interface GithubImportTokenInfo { + refreshToken?: string | null; + expiresIn?: number | null; + scope?: string | null; + clientId?: string | null; + clientSecret?: string | null; + tokenEndpoint?: string | null; +} + +interface UseGithubImportConnectionResult { status: Status; error: string | null; connection: ConnectionEntity | null; + tokenInfo: GithubImportTokenInfo | null; retry: () => void; } -const GITHUB_APP_ID = "deco/mcp-github"; +const GITHUB_REGISTRY_APP_ID = "deco/mcp-github"; -export function useAutoInstallGitHub(opts: { +export function useGithubImportConnection(opts: { enabled: boolean; -}): UseAutoInstallGitHubResult { +}): UseGithubImportConnectionResult { const { org } = useProjectContext(); const { data: session } = authClient.useSession(); const actions = useConnectionActions(); @@ -38,18 +50,17 @@ export function useAutoInstallGitHub(opts: { const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); const [connection, setConnection] = useState(null); + const [tokenInfo, setTokenInfo] = useState( + null, + ); const { data: registryItem, isLoading: isRegistryLoading } = useRegistryApp( - GITHUB_APP_ID, + GITHUB_REGISTRY_APP_ID, { enabled: opts.enabled }, ); - // Track whether we've started the flow to avoid re-triggering. - // useRef (not useState) because refs mutate synchronously — prevents - // duplicate fires under React 19 concurrent rendering / Strict Mode. const startedRef = useRef(false); - // Auto-trigger when registry data arrives and we haven't started yet if ( opts.enabled && registryItem && @@ -61,16 +72,16 @@ export function useAutoInstallGitHub(opts: { ) { // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- TODO: refactor render-time .current access startedRef.current = true; - runInstallFlow(); + runImportConnectionFlow(); } - async function runInstallFlow() { + async function runImportConnectionFlow() { if (!registryItem || !session?.user?.id || !org) return; try { - // Step 1: Create connection from registry setStatus("installing"); setError(null); + setTokenInfo(null); const connectionData = extractConnectionData( registryItem, @@ -79,14 +90,20 @@ export function useAutoInstallGitHub(opts: { { remoteIndex: 0 }, ); - const remoteUrl = connectionData.connection_url; - if (!remoteUrl) { - throw new Error("Registry item is missing a remote URL for mcp-github"); + connectionData.title = "GitHub (importing…)"; + if (connectionData.metadata) { + ( + connectionData.metadata as Record + ).githubImportPending = true; } + const remoteUrl = resolveGithubMcpConnectionUrl( + connectionData.connection_url ?? undefined, + ); + connectionData.connection_url = remoteUrl; + const { id } = await actions.create.mutateAsync(connectionData); - // Step 2: Check if OAuth is needed setStatus("authenticating"); const mcpProxyUrl = new URL( `/api/${org.slug}/mcp/${id}`, @@ -98,11 +115,12 @@ export function useAutoInstallGitHub(opts: { orgId: org.id, }); + let savedTokenInfo: GithubImportTokenInfo | null = null; + if (authStatus.supportsOAuth && !authStatus.isAuthenticated) { - // Step 3: Run OAuth flow const { token, - tokenInfo, + tokenInfo: oauthTokenInfo, error: oauthError, } = await authenticateMcp({ connectionId: id, @@ -111,7 +129,6 @@ export function useAutoInstallGitHub(opts: { }); if (oauthError || !token) { - // OAuth failed or was cancelled — clean up the connection try { await actions.delete.mutateAsync(id); } catch { @@ -120,25 +137,22 @@ export function useAutoInstallGitHub(opts: { throw new Error(oauthError ?? "No token received from GitHub"); } - // Step 4: Persist OAuth token - if (tokenInfo) { + if (oauthTokenInfo) { try { const response = await fetch( `/api/${org.slug}/connections/${id}/oauth-token`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ - accessToken: tokenInfo.accessToken, - refreshToken: tokenInfo.refreshToken, - expiresIn: tokenInfo.expiresIn, - scope: tokenInfo.scope, - clientId: tokenInfo.clientId, - clientSecret: tokenInfo.clientSecret, - tokenEndpoint: tokenInfo.tokenEndpoint, + accessToken: oauthTokenInfo.accessToken, + refreshToken: oauthTokenInfo.refreshToken, + expiresIn: oauthTokenInfo.expiresIn, + scope: oauthTokenInfo.scope, + clientId: oauthTokenInfo.clientId, + clientSecret: oauthTokenInfo.clientSecret, + tokenEndpoint: oauthTokenInfo.tokenEndpoint, }), }, ); @@ -154,13 +168,22 @@ export function useAutoInstallGitHub(opts: { data: { connection_token: token }, }); } + + savedTokenInfo = { + refreshToken: oauthTokenInfo.refreshToken, + expiresIn: oauthTokenInfo.expiresIn, + scope: oauthTokenInfo.scope, + clientId: oauthTokenInfo.clientId, + clientSecret: oauthTokenInfo.clientSecret, + tokenEndpoint: oauthTokenInfo.tokenEndpoint, + }; } } - // Step 5: Invalidate connection queries so picker re-renders invalidateVirtualMcpQueries(queryClient, org.id); - setConnection(connectionData as ConnectionEntity); + setConnection({ ...connectionData, id } as ConnectionEntity); + setTokenInfo(savedTokenInfo); setStatus("ready"); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -172,13 +195,30 @@ export function useAutoInstallGitHub(opts: { setStatus("idle"); setError(null); setConnection(null); + setTokenInfo(null); startedRef.current = false; } - // While registry is loading, show installing status if (opts.enabled && isRegistryLoading && status === "idle") { - return { status: "installing", error: null, connection: null, retry }; + return { + status: "installing", + error: null, + connection: null, + tokenInfo: null, + retry, + }; } - return { status, error, connection, retry }; + return { status, error, connection, tokenInfo, retry }; +} + +/** @deprecated Use useGithubImportConnection — kept for storefront checklist. */ +export function useAutoInstallGitHub(opts: { enabled: boolean }) { + const result = useGithubImportConnection(opts); + return { + status: result.status, + error: result.error, + connection: result.connection, + retry: result.retry, + }; } diff --git a/apps/mesh/src/web/lib/github-oauth.ts b/apps/mesh/src/web/lib/github-oauth.ts new file mode 100644 index 0000000000..b1f19f470f --- /dev/null +++ b/apps/mesh/src/web/lib/github-oauth.ts @@ -0,0 +1,129 @@ +/** + * Persist OAuth tokens and scope GitHub connections to a single repository. + */ + +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import { + encodeMeshOAuthClientState, + getGithubConnectionRepoScope, +} from "@/shared/github-connection"; + +export interface PersistOAuthTokenInput { + orgSlug: string; + connectionId: string; + accessToken: string; + refreshToken?: string | null; + expiresIn?: number | null; + scope?: string | null; + clientId?: string | null; + clientSecret?: string | null; + tokenEndpoint?: string | null; +} + +export async function persistDownstreamOAuthToken( + input: PersistOAuthTokenInput, +): Promise { + const response = await fetch( + `/api/${input.orgSlug}/connections/${input.connectionId}/oauth-token`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + accessToken: input.accessToken, + refreshToken: input.refreshToken, + expiresIn: input.expiresIn, + scope: input.scope, + clientId: input.clientId, + clientSecret: input.clientSecret, + tokenEndpoint: input.tokenEndpoint, + }), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to persist OAuth token: ${response.status}`); + } +} + +type GithubMcpClient = { + callTool: (...args: unknown[]) => Promise; +}; + +function parseScopeTokenResult(result: unknown): { + access_token: string; + expires_in?: number; +} { + const typed = result as { + structuredContent?: { access_token?: string; expires_in?: number }; + content?: Array<{ text?: string }>; + }; + + if (typed.structuredContent?.access_token) { + return { + access_token: typed.structuredContent.access_token, + expires_in: typed.structuredContent.expires_in, + }; + } + + const content = typed.content?.[0]?.text; + if (content) { + const parsed = JSON.parse(content) as { + access_token?: string; + expires_in?: number; + }; + if (parsed.access_token) { + return { + access_token: parsed.access_token, + expires_in: parsed.expires_in, + }; + } + } + + throw new Error("GITHUB_SCOPE_TOKEN did not return access_token"); +} + +export async function scopeGithubConnectionToRepository(params: { + githubClient: GithubMcpClient; + orgSlug: string; + connectionId: string; + repositoryId: number; + target: string; + existingTokenInfo?: { + refreshToken?: string | null; + clientId?: string | null; + clientSecret?: string | null; + tokenEndpoint?: string | null; + scope?: string | null; + }; +}): Promise { + const result = await params.githubClient.callTool({ + name: "GITHUB_SCOPE_TOKEN", + arguments: { + repository_id: params.repositoryId, + target: params.target, + }, + }); + + const parsed = parseScopeTokenResult(result); + + await persistDownstreamOAuthToken({ + orgSlug: params.orgSlug, + connectionId: params.connectionId, + accessToken: parsed.access_token, + refreshToken: params.existingTokenInfo?.refreshToken, + expiresIn: parsed.expires_in ?? null, + scope: params.existingTokenInfo?.scope ?? null, + clientId: params.existingTokenInfo?.clientId, + clientSecret: params.existingTokenInfo?.clientSecret, + tokenEndpoint: params.existingTokenInfo?.tokenEndpoint, + }); +} + +export function connectionHasRepoScopedToken( + connection: ConnectionEntity | null | undefined, +): boolean { + return getGithubConnectionRepoScope(connection?.metadata ?? null) !== null; +} + +export { encodeMeshOAuthClientState }; diff --git a/packages/mesh-sdk/src/types/virtual-mcp.ts b/packages/mesh-sdk/src/types/virtual-mcp.ts index ac4f0a3bc1..e918ff51ff 100644 --- a/packages/mesh-sdk/src/types/virtual-mcp.ts +++ b/packages/mesh-sdk/src/types/virtual-mcp.ts @@ -194,6 +194,12 @@ const GithubRepoSchema = z.object({ url: z.string().describe("GitHub repository URL"), owner: z.string().describe("Repository owner"), name: z.string().describe("Repository name"), + repositoryId: z + .number() + .optional() + .describe( + "GitHub repository ID used for repo-scoped OAuth tokens on the connection.", + ), installationId: z .number() .optional() diff --git a/packages/runtime/src/oauth.ts b/packages/runtime/src/oauth.ts index bcd867f9d0..f69f7fb54a 100644 --- a/packages/runtime/src/oauth.ts +++ b/packages/runtime/src/oauth.ts @@ -263,6 +263,7 @@ export function createOAuthHandlers(oauth: OAuthConfig) { const oauthParams: OAuthParams = { code, redirect_uri: cleanRedirectUri, + state: pending.clientState, }; const tokenResponse = await oauth.exchangeCode(oauthParams); diff --git a/packages/runtime/src/tools.ts b/packages/runtime/src/tools.ts index 04de187506..69f36a7ef7 100644 --- a/packages/runtime/src/tools.ts +++ b/packages/runtime/src/tools.ts @@ -373,6 +373,8 @@ export interface OAuthParams { * MUST be identical if included in the authorization request */ redirect_uri?: string; + /** OPTIONAL - MCP client state echoed from the authorization request */ + state?: string; } /**