Skip to content
Closed
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
3 changes: 3 additions & 0 deletions apps/mesh/.env.development
Original file line number Diff line number Diff line change
@@ -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
16 changes: 12 additions & 4 deletions apps/mesh/src/mcp-clients/outbound/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -179,10 +180,17 @@ async function _buildRequestHeaders(
}
}

const refreshResult = await refreshAccessToken({
...cachedToken,
tokenEndpoint: tokenEndpointForRefresh,
});
const repoScope = getGithubConnectionRepoScope(
(connection.metadata ?? null) as Record<string, unknown> | 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)
Expand Down
5 changes: 5 additions & 0 deletions apps/mesh/src/oauth/refresh-access-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface TokenRefreshResult {

export async function refreshAccessToken(
token: DownstreamToken,
options?: { repositoryId?: number },
): Promise<TokenRefreshResult> {
if (!token.refreshToken) {
return {
Expand Down Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion apps/mesh/src/oauth/token-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ export function canRefresh(token: DownstreamToken): boolean {
export async function refreshAndStore(
token: DownstreamToken,
tokenStorage: DownstreamTokenStorage,
options?: { repositoryId?: number },
): Promise<string | null> {
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).
Expand Down
19 changes: 18 additions & 1 deletion apps/mesh/src/shared/github-clone-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
RECONNECT_ERROR,
refreshAndStore,
} from "../oauth/token-refresh";
import { getGithubConnectionRepoScope } from "./github-connection";

export interface GitHubCloneInfo {
cloneUrl: string;
Expand Down Expand Up @@ -51,6 +52,18 @@
vault: CredentialVault,
): Promise<GitHubCloneInfo> {
const tokenStorage = new DownstreamTokenStorage(db, vault);
const connection = await db

Check failure on line 55 in apps/mesh/src/shared/github-clone-info.ts

View workflow job for this annotation

GitHub Actions / test (1, 4)

TypeError: null is not an object (evaluating 'db.selectFrom')

at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:55:28) at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:48:3) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:278:15) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:243:3) at <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:133:38) at async <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.test.ts:610:40)

Check failure on line 55 in apps/mesh/src/shared/github-clone-info.ts

View workflow job for this annotation

GitHub Actions / test (1, 4)

TypeError: null is not an object (evaluating 'db.selectFrom')

at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:55:28) at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:48:3) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:278:15) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:243:3) at <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:133:38) at async <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.test.ts:559:25)

Check failure on line 55 in apps/mesh/src/shared/github-clone-info.ts

View workflow job for this annotation

GitHub Actions / test (1, 4)

TypeError: null is not an object (evaluating 'db.selectFrom')

at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:55:28) at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:48:3) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:278:15) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:243:3) at <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:133:38) at async <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.test.ts:473:40)

Check failure on line 55 in apps/mesh/src/shared/github-clone-info.ts

View workflow job for this annotation

GitHub Actions / test (1, 4)

TypeError: null is not an object (evaluating 'db.selectFrom')

at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:55:28) at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:48:3) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:278:15) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:243:3) at <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:133:38) at async <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.test.ts:459:40)

Check failure on line 55 in apps/mesh/src/shared/github-clone-info.ts

View workflow job for this annotation

GitHub Actions / test (1, 4)

TypeError: null is not an object (evaluating 'db.selectFrom')

at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:55:28) at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:48:3) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:278:15) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:243:3) at <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:133:38) at async <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.test.ts:420:25)

Check failure on line 55 in apps/mesh/src/shared/github-clone-info.ts

View workflow job for this annotation

GitHub Actions / test (1, 4)

TypeError: null is not an object (evaluating 'db.selectFrom')

at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:55:28) at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:48:3) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:278:15) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:243:3) at <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:133:38) at async <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.test.ts:388:25)

Check failure on line 55 in apps/mesh/src/shared/github-clone-info.ts

View workflow job for this annotation

GitHub Actions / test (1, 4)

TypeError: null is not an object (evaluating 'db.selectFrom')

at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:55:28) at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:48:3) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:278:15) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:243:3) at <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:133:38) at async <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.test.ts:343:40)

Check failure on line 55 in apps/mesh/src/shared/github-clone-info.ts

View workflow job for this annotation

GitHub Actions / test (1, 4)

TypeError: null is not an object (evaluating 'db.selectFrom')

at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:55:28) at buildCloneInfo (/home/runner/work/studio/studio/apps/mesh/src/shared/github-clone-info.ts:48:3) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:278:15) at provisionSandbox (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:243:3) at <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.ts:133:38) at async <anonymous> (/home/runner/work/studio/studio/apps/mesh/src/tools/sandbox/start.test.ts:317:25)
.selectFrom("connections")
.select("metadata")
.where("id", "=", connectionId)
.executeTakeFirst();
const repoScope = getGithubConnectionRepoScope(
(connection?.metadata ?? null) as Record<string, unknown> | null,
);
const refreshOptions = repoScope
? { repositoryId: repoScope.repositoryId }
: undefined;
Comment on lines +63 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Validate that the requested owner/name matches the connection’s scoped repo before using repositoryId for refresh, otherwise token scope and clone target can diverge.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/shared/github-clone-info.ts, line 63:

<comment>Validate that the requested `owner/name` matches the connection’s scoped repo before using `repositoryId` for refresh, otherwise token scope and clone target can diverge.</comment>

<file context>
@@ -51,6 +52,18 @@ export async function buildCloneInfo(
+  const repoScope = getGithubConnectionRepoScope(
+    (connection?.metadata ?? null) as Record<string, unknown> | null,
+  );
+  const refreshOptions = repoScope
+    ? { repositoryId: repoScope.repositoryId }
+    : undefined;
</file context>
Suggested change
const refreshOptions = repoScope
? { repositoryId: repoScope.repositoryId }
: undefined;
const refreshOptions =
repoScope && repoScope.owner === owner && repoScope.name === name
? { repositoryId: repoScope.repositoryId }
: undefined;


const token = await tokenStorage.get(connectionId);
if (!token) {
throw new Error(
Expand All @@ -65,7 +78,11 @@
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);
}
Expand Down
76 changes: 76 additions & 0 deletions apps/mesh/src/shared/github-connection.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
74 changes: 74 additions & 0 deletions apps/mesh/src/shared/github-connection.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null | undefined,
): GithubConnectionRepoScope | null {
const githubRepo = metadata?.githubRepo;
if (!githubRepo || typeof githubRepo !== "object") return null;

const record = githubRepo as Record<string, unknown>;
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}`;
}
61 changes: 61 additions & 0 deletions apps/mesh/src/shared/github-mcp-url.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>)[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;
}
}
4 changes: 3 additions & 1 deletion apps/mesh/src/tools/github/list-user-orgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
29 changes: 27 additions & 2 deletions apps/mesh/src/tools/github/list-user-orgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
);
Comment on lines +49 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Validate that the org-scoped connection exists before continuing. Without this check, the tool can still operate using a token row keyed only by connectionId even when the connection lookup failed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/tools/github/list-user-orgs.ts, line 49:

<comment>Validate that the org-scoped connection exists before continuing. Without this check, the tool can still operate using a token row keyed only by `connectionId` even when the connection lookup failed.</comment>

<file context>
@@ -40,6 +41,22 @@ export const GITHUB_LIST_USER_ORGS = defineTool({
+      throw new Error("Organization context required");
+    }
+
+    const connection = await ctx.storage.connections.findById(
+      input.connectionId,
+      organizationId,
</file context>
Suggested change
const connection = await ctx.storage.connections.findById(
input.connectionId,
organizationId,
);
const connection = await ctx.storage.connections.findById(
input.connectionId,
organizationId,
);
if (!connection) {
throw new Error("Connection not found");
}

const repoScope = getGithubConnectionRepoScope(
(connection?.metadata ?? null) as Record<string, unknown> | null,
);
const refreshOptions = repoScope
? { repositoryId: repoScope.repositoryId }
: undefined;

let token = await tokenStorage.get(input.connectionId);
if (!token) {
throw new Error(
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions apps/mesh/src/web/components/add-storefront-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ function UrlEntry({ onComplete }: { onComplete: () => void }) {
private: false,
description: null,
updatedAt: "",
repositoryId: 0,
},
connectionId,
installationId: undefined,
Expand Down
Loading
Loading