From ef6e1c9f39f6a3ea1fcce586ad8597a5abe83f0e Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Thu, 23 Apr 2026 11:08:09 -0300 Subject: [PATCH] fix(github): auto-repair missing installation mappings on tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connections that OAuthed before the User-Agent fix ended up with no installation:* entries in KV — captureInstallationMappings silently caught the 403 from GitHub and returned, so webhook deliveries ever since have skipped with "no mapping for installation=...". Adds ensureInstallationMappings(): cheap check via a prefixed KV list(limit=1), running the full capture only when a connection has no mappings. Called on every upstream tool execute, so the next MCP request each affected user makes auto-rebuilds their mapping. No user-visible change, no forced re-auth. Co-Authored-By: Claude Opus 4.7 (1M context) --- github/server/lib/installation-map.ts | 39 ++++++++++++++++++++++++++- github/server/lib/mcp-proxy.ts | 21 +++++++++++++-- github/server/main.ts | 4 +-- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/github/server/lib/installation-map.ts b/github/server/lib/installation-map.ts index 74a8dc50..55c9aa2e 100644 --- a/github/server/lib/installation-map.ts +++ b/github/server/lib/installation-map.ts @@ -10,7 +10,11 @@ interface KVNamespaceLike { get(key: string): Promise; put(key: string, value: string): Promise; delete(key: string): Promise; - list(options?: { prefix?: string; cursor?: string }): Promise<{ + list(options?: { + prefix?: string; + cursor?: string; + limit?: number; + }): Promise<{ keys: Array<{ name: string }>; list_complete: boolean; cursor?: string; @@ -21,6 +25,7 @@ export interface InstallationStore { get(installationId: number): Promise; set(installationId: number, connectionId: string): Promise; removeByConnection(connectionId: string): Promise; + hasAnyForConnection(connectionId: string): Promise; } class MemoryInstallationStore implements InstallationStore { @@ -41,6 +46,13 @@ class MemoryInstallationStore implements InstallationStore { } } } + + async hasAnyForConnection(connectionId: string): Promise { + for (const conn of this.map.values()) { + if (conn === connectionId) return true; + } + return false; + } } class KvInstallationStore implements InstallationStore { @@ -91,6 +103,14 @@ class KvInstallationStore implements InstallationStore { cursor = list_complete ? undefined : nextCursor; } while (cursor); } + + async hasAnyForConnection(connectionId: string): Promise { + const { keys } = await this.kv.list({ + prefix: `connection:${connectionId}:`, + limit: 1, + }); + return keys.length > 0; + } } const memoryStore = new MemoryInstallationStore(); @@ -101,6 +121,23 @@ export function getInstallationStore( return kv ? new KvInstallationStore(kv) : memoryStore; } +/** + * Opportunistic bootstrap: if this connection has no installation mappings + * yet, run captureInstallationMappings. A single KV list() when mappings + * already exist (common case), one GitHub API round-trip per installation + * when they don't (one-time per connection). + * + * Safe to call on every MCP request — cheap when already populated. + */ +export async function ensureInstallationMappings( + token: string, + connectionId: string, + store: InstallationStore, +): Promise { + if (await store.hasAnyForConnection(connectionId)) return; + await captureInstallationMappings(token, connectionId, store); +} + /** * Fetch the user's GitHub App installations and persist mappings. * Swaps mappings atomically after successful fetch of all pages. diff --git a/github/server/lib/mcp-proxy.ts b/github/server/lib/mcp-proxy.ts index b922e6c4..36d8e64c 100644 --- a/github/server/lib/mcp-proxy.ts +++ b/github/server/lib/mcp-proxy.ts @@ -14,6 +14,10 @@ import { createTool, type AppContext } from "@decocms/runtime/tools"; import { z } from "zod"; import type { Env } from "../types/env.ts"; import { getAppInstallationToken } from "./github-app-auth.ts"; +import { + ensureInstallationMappings, + getInstallationStore, +} from "./installation-map.ts"; const DEFAULT_UPSTREAM_URL = "https://api.githubcopilot.com/mcp/"; @@ -154,12 +158,25 @@ export function buildUpstreamTools( description: toolDef.description || `GitHub tool: ${toolDef.name}`, inputSchema: jsonSchemaToZod(toolDef.inputSchema as any), execute: async ({ context }, ctx) => { - const currentToken = (ctx as unknown as AppContext).env - .MESH_REQUEST_CONTEXT?.authorization; + const env = (ctx as unknown as AppContext).env; + const currentToken = env.MESH_REQUEST_CONTEXT?.authorization; + const connectionId = env.MESH_REQUEST_CONTEXT?.connectionId; if (!currentToken) { throw new Error("GitHub authorization token not found"); } + // Repair any connection whose installation mappings are missing + // (e.g. first OAuth happened before the User-Agent fix was + // deployed, so the original captureInstallationMappings silently + // 403'd). Cheap — one KV list() when already populated. + if (connectionId) { + await ensureInstallationMappings( + currentToken, + connectionId, + getInstallationStore(env.INSTALLATIONS), + ); + } + const client = await connectUpstreamClient(currentToken); try { return await client.callTool({ diff --git a/github/server/main.ts b/github/server/main.ts index 5c0a172b..f38efbc0 100644 --- a/github/server/main.ts +++ b/github/server/main.ts @@ -16,7 +16,7 @@ import { refreshAccessToken, } from "./lib/github-client.ts"; import { - captureInstallationMappings, + ensureInstallationMappings, getInstallationStore, } from "./lib/installation-map.ts"; import { handleProxiedRequest } from "./lib/mcp-proxy.ts"; @@ -129,7 +129,7 @@ async function getRuntime(): Promise { const connectionId = env.MESH_REQUEST_CONTEXT?.connectionId; if (token && connectionId) { const store = getInstallationStore(env.INSTALLATIONS); - await captureInstallationMappings(token, connectionId, store); + await ensureInstallationMappings(token, connectionId, store); } }, state: StateSchema,