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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion github/server/lib/installation-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ interface KVNamespaceLike {
get(key: string): Promise<string | null>;
put(key: string, value: string): Promise<void>;
delete(key: string): Promise<void>;
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;
Expand All @@ -21,6 +25,7 @@ export interface InstallationStore {
get(installationId: number): Promise<string | undefined>;
set(installationId: number, connectionId: string): Promise<void>;
removeByConnection(connectionId: string): Promise<void>;
hasAnyForConnection(connectionId: string): Promise<boolean>;
}

class MemoryInstallationStore implements InstallationStore {
Expand All @@ -41,6 +46,13 @@ class MemoryInstallationStore implements InstallationStore {
}
}
}

async hasAnyForConnection(connectionId: string): Promise<boolean> {
for (const conn of this.map.values()) {
if (conn === connectionId) return true;
}
return false;
}
}

class KvInstallationStore implements InstallationStore {
Expand Down Expand Up @@ -91,6 +103,14 @@ class KvInstallationStore implements InstallationStore {
cursor = list_complete ? undefined : nextCursor;
} while (cursor);
}

async hasAnyForConnection(connectionId: string): Promise<boolean> {
const { keys } = await this.kv.list({
prefix: `connection:${connectionId}:`,
limit: 1,
});
return keys.length > 0;
}
}

const memoryStore = new MemoryInstallationStore();
Expand All @@ -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<void> {
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.
Expand Down
21 changes: 19 additions & 2 deletions github/server/lib/mcp-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/";

Expand Down Expand Up @@ -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>).env
.MESH_REQUEST_CONTEXT?.authorization;
const env = (ctx as unknown as AppContext<Env>).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({
Expand Down
4 changes: 2 additions & 2 deletions github/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -129,7 +129,7 @@ async function getRuntime(): Promise<Runtime> {
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,
Expand Down
Loading