Skip to content

Expose project selection to org-wide MCP connections - #141

Merged
rgarcia merged 14 commits into
mainfrom
hypeship/mcp-project-tool-scope
Aug 10, 2026
Merged

Expose project selection to org-wide MCP connections#141
rgarcia merged 14 commits into
mainfrom
hypeship/mcp-project-tool-scope

Conversation

@rgarcia

@rgarcia rgarcia commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • update @onkernel/sdk to 0.87.0 and normalize the authoritative GET /auth/context response into one canonical organization-or-project ConnectionScope
  • expose get_connection_context with that canonical scope so agents can determine whether project_id is required or fixed
  • keep one stable project-aware tool contract: organization-wide connections must select a project per operation, while project-bound credentials and KERNEL_PROJECT deployments reject overrides
  • qualify browser, profile, browser-pool, and app resource identities as kernel://orgs/{organizationId}/projects/{projectId}/...
  • enrich MCP initialize analytics from the same canonical scope with auth method, credential/connection scope, scope source, organization grouping, anonymous API-key identity, and canonical OAuth user identity

project_id selects the request target but never grants access; the Kernel API remains the authorization boundary. OAuth scope is cached by signed MCP session across token refreshes; opaque API keys revalidate on every request. Stale OAuth scope is time-bounded and used only for transient failures after a successful resolution. MCP initialize events are deduplicated by session and omit credential/project identifiers.

Validation

  • bun test (137 passing)
  • bunx tsc --noEmit --incremental false
  • bun run check:managed-auth-app
  • production bun run build with test auth configuration
  • Prettier on all changed TypeScript and Markdown files
  • git diff --check origin/main...HEAD

Note

Medium Risk
Touches authentication request path, connection-scope caching (OAuth vs API keys), and project-scoped tool/resource identity; authorization still depends on the Kernel API but mis-scoped context could affect which project operations target.

Overview
Bumps @onkernel/sdk to 0.87.0 and wires every authenticated MCP request through resolveMcpConnectionContext (from GET /auth/context), attaching that scope to auth extras for tools and resources. Opaque API keys always revalidate context; OAuth may cache it keyed by signed transport session.

Adds get_connection_context and documents a stable project_id contract: org-wide connections must select a project per call; project-bound credentials reject overrides. Project resources move to kernel://orgs/{organizationId}/projects/{projectId}/... URIs (README updated; legacy browsers://-style prefixes removed from docs).

Initialize analytics now enrich PostHog with auth method, credential/connection scope, scope source, org grouping, canonical OAuth user ID (API keys stay session-anonymous), and session-level $insert_id deduplication—only on initialize, via a private context property stripped before capture. Transport routing is refactored through handleMcpRequestWithIdentity and connection observation on initialize only.

Reviewed by Cursor Bugbot for commit 1b303b3. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mcp Ready Ready Preview Aug 9, 2026 10:08pm

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Scope flicker drops project_id
    • Project-selection capability is now cached against the authenticated transport-session identity so token refreshes or transient lookup failures no longer flip handlers and strip a previously enabled project_id.

Create PR

Or push these changes by commenting:

@cursor push 815b79ebd3
Preview (815b79ebd3)
diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts
--- a/src/app/[transport]/route.ts
+++ b/src/app/[transport]/route.ts
@@ -107,13 +107,21 @@
   if (!isValidJwtFormat(token)) {
     // Opaque API keys are authenticated by the Kernel API rather than Clerk.
     const authSubject = mcpAppsAuthSubject({ token });
+    const projectSelectionCacheIdentity = transportSessionId
+      ? `${authSubject}\0${transportSessionId}`
+      : undefined;
     const [mcpApps, projectSelection] = await Promise.all([
       requestUsesMcpApps(req, {
         authSubject,
         transportSessionId,
         ttlSeconds: 24 * 60 * 60,
       }),
-      connectionAllowsProjectSelection(token, false),
+      connectionAllowsProjectSelection(
+        token,
+        false,
+        undefined,
+        projectSelectionCacheIdentity,
+      ),
     ]);
     const selectedHandler = selectHandler({ mcpApps, projectSelection });
     const authHandler = withMcpAuth(
@@ -147,13 +155,21 @@
     // Capability state is keyed only after Clerk verifies the JWT, and uses
     // the verified user plus this signed MCP transport session.
     const authSubject = mcpAppsAuthSubject({ token, userId: payload.sub });
+    const projectSelectionCacheIdentity = transportSessionId
+      ? `${authSubject}\0${transportSessionId}`
+      : undefined;
     const [mcpApps, projectSelection] = await Promise.all([
       requestUsesMcpApps(req, {
         authSubject,
         transportSessionId,
         ttlSeconds: 24 * 60 * 60,
       }),
-      connectionAllowsProjectSelection(token, true),
+      connectionAllowsProjectSelection(
+        token,
+        true,
+        undefined,
+        projectSelectionCacheIdentity,
+      ),
     ]);
     const selectedHandler = selectHandler({ mcpApps, projectSelection });
 

diff --git a/src/lib/mcp/project-selection.test.ts b/src/lib/mcp/project-selection.test.ts
--- a/src/lib/mcp/project-selection.test.ts
+++ b/src/lib/mcp/project-selection.test.ts
@@ -129,6 +129,52 @@
       ),
     ).toBe(false);
   });
+
+  test("keeps project selection stable across token refreshes in one session", async () => {
+    delete process.env.KERNEL_PROJECT;
+    const cacheIdentity = "user:scope\0session_123";
+    let lookups = 0;
+    expect(
+      await connectionAllowsProjectSelection(
+        "jwt.org-wide.old",
+        true,
+        {
+          getJwtContext: async () => {
+            lookups += 1;
+            return "org_123";
+          },
+          createClient: () =>
+            ({
+              apiKeys: {
+                list: async () => ({ getPaginatedItems: () => [] }),
+              },
+            }) as unknown as KernelClient,
+        },
+        cacheIdentity,
+      ),
+    ).toBe(true);
+
+    expect(
+      await connectionAllowsProjectSelection(
+        "jwt.org-wide.new",
+        true,
+        {
+          getJwtContext: async () => {
+            lookups += 1;
+            throw new Error("lookup failed");
+          },
+          createClient: () =>
+            ({
+              apiKeys: {
+                list: async () => ({ getPaginatedItems: () => [] }),
+              },
+            }) as unknown as KernelClient,
+        },
+        cacheIdentity,
+      ),
+    ).toBe(true);
+    expect(lookups).toBe(1);
+  });
 });
 
 describe("project selection helpers", () => {

diff --git a/src/lib/mcp/project-selection.ts b/src/lib/mcp/project-selection.ts
--- a/src/lib/mcp/project-selection.ts
+++ b/src/lib/mcp/project-selection.ts
@@ -111,10 +111,12 @@
   token: string,
   jwt: boolean,
   dependencies: ScopeResolutionDependencies = scopeResolutionDependencies,
+  cacheIdentity?: string,
 ) {
   if (process.env.KERNEL_PROJECT) return false;
 
-  const cached = readScopeCache(token);
+  const cacheKey = cacheIdentity ?? token;
+  const cached = readScopeCache(cacheKey);
   if (cached !== undefined) return cached;
 
   let allowsProjectSelection = false;
@@ -139,7 +141,7 @@
     console.warn("Failed to resolve MCP connection project scope");
   }
 
-  if (resolved) writeScopeCache(token, allowsProjectSelection);
+  if (resolved) writeScopeCache(cacheKey, allowsProjectSelection);
   return allowsProjectSelection;
 }

You can send follow-ups to the cloud agent here.

Comment thread src/app/[transport]/route.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Request body bypasses project lock
    • Handler selection now relies only on connectionAllowsProjectSelection, so request bodies containing project_id can no longer enable project selection on pinned or project-scoped connections.

Create PR

Or push these changes by commenting:

@cursor push 04f1b9768b
Preview (04f1b9768b)
diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts
--- a/src/app/[transport]/route.ts
+++ b/src/app/[transport]/route.ts
@@ -13,10 +13,7 @@
   createMcpTransportSession,
   verifyMcpTransportSession,
 } from "@/lib/mcp-transport-session";
-import {
-  connectionAllowsProjectSelection,
-  requestIncludesProjectSelection,
-} from "@/lib/mcp/project-selection";
+import { connectionAllowsProjectSelection } from "@/lib/mcp/project-selection";
 import { registerMcpCapabilities } from "@/lib/mcp/register";
 import { name, version } from "../../../server.json";
 
@@ -110,19 +107,17 @@
   if (!isValidJwtFormat(token)) {
     // Opaque API keys are authenticated by the Kernel API rather than Clerk.
     const authSubject = mcpAppsAuthSubject({ token });
-    const [mcpApps, connectionProjectSelection, requestProjectSelection] =
-      await Promise.all([
-        requestUsesMcpApps(req, {
-          authSubject,
-          transportSessionId,
-          ttlSeconds: 24 * 60 * 60,
-        }),
-        connectionAllowsProjectSelection(token, false),
-        requestIncludesProjectSelection(req),
-      ]);
+    const [mcpApps, connectionProjectSelection] = await Promise.all([
+      requestUsesMcpApps(req, {
+        authSubject,
+        transportSessionId,
+        ttlSeconds: 24 * 60 * 60,
+      }),
+      connectionAllowsProjectSelection(token, false),
+    ]);
     const selectedHandler = selectHandler({
       mcpApps,
-      projectSelection: connectionProjectSelection || requestProjectSelection,
+      projectSelection: connectionProjectSelection,
     });
     const authHandler = withMcpAuth(
       selectedHandler,
@@ -155,19 +150,17 @@
     // Capability state is keyed only after Clerk verifies the JWT, and uses
     // the verified user plus this signed MCP transport session.
     const authSubject = mcpAppsAuthSubject({ token, userId: payload.sub });
-    const [mcpApps, connectionProjectSelection, requestProjectSelection] =
-      await Promise.all([
-        requestUsesMcpApps(req, {
-          authSubject,
-          transportSessionId,
-          ttlSeconds: 24 * 60 * 60,
-        }),
-        connectionAllowsProjectSelection(token, true),
-        requestIncludesProjectSelection(req),
-      ]);
+    const [mcpApps, connectionProjectSelection] = await Promise.all([
+      requestUsesMcpApps(req, {
+        authSubject,
+        transportSessionId,
+        ttlSeconds: 24 * 60 * 60,
+      }),
+      connectionAllowsProjectSelection(token, true),
+    ]);
     const selectedHandler = selectHandler({
       mcpApps,
-      projectSelection: connectionProjectSelection || requestProjectSelection,
+      projectSelection: connectionProjectSelection,
     });
 
     // Create authenticated handler with auth info

You can send follow-ups to the cloud agent here.

Comment thread src/app/[transport]/route.ts
@socket-security

socket-security Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​onkernel/​sdk@​0.85.0 ⏵ 0.87.082 +1100100 +199 +1100

View full report

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Missing factory reset in tests
    • Added suite-level afterEach cleanup that always calls resetKernelClientFactory after tests mutating kernelClientMock.factory.
  • ✅ Fixed: Stubbed connection-context contract test
    • Rewrote the test to use a real McpServer and Client over InMemoryTransport with tools/list discovery, invalid-input rejection, auth propagation, and serialized result assertions.

Create PR

Or push these changes by commenting:

@cursor push 43383e3de2
Preview (43383e3de2)
diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts
--- a/src/lib/mcp/tools/auth-connections.test.ts
+++ b/src/lib/mcp/tools/auth-connections.test.ts
@@ -1,14 +1,19 @@
-import { describe, expect, test } from "bun:test";
+import { afterEach, describe, expect, test } from "bun:test";
 import { toSafeAuthConnection } from "./managed-auth-state";
 import {
   assertNoSecrets,
   captureHandler,
   connection,
   kernelClientMock,
+  resetKernelClientFactory,
   unusedKernelClient,
 } from "./auth-connections.test-fixtures";
 
 describe("manage_auth_connections programmatic surface", () => {
+  afterEach(() => {
+    resetKernelClientFactory();
+  });
+
   test("keeps every legacy action and adds wait", () => {
     const { schema } = captureHandler();
     expect(schema?.action.safeParse("list").success).toBe(true);

diff --git a/src/lib/mcp/tools/connection-context.test.ts b/src/lib/mcp/tools/connection-context.test.ts
--- a/src/lib/mcp/tools/connection-context.test.ts
+++ b/src/lib/mcp/tools/connection-context.test.ts
@@ -1,23 +1,12 @@
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 import { describe, expect, test } from "bun:test";
-import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 import type { KernelClient } from "@/lib/mcp/kernel-client";
 import { registerConnectionContextTool } from "@/lib/mcp/tools/connection-context";
 
 describe("get_connection_context", () => {
-  test("returns the authoritative API auth context", async () => {
-    let handler:
-      | ((params: unknown, extra: unknown) => Promise<any>)
-      | undefined;
-    const server = {
-      tool(
-        _name: string,
-        _description: string,
-        _schema: object,
-        ...rest: any[]
-      ) {
-        handler = rest[rest.length - 1];
-      },
-    } as unknown as McpServer;
+  test("discovers schema, rejects invalid input, and returns auth context", async () => {
     const context = {
       authentication: {
         credential_id: "key_123",
@@ -31,21 +20,81 @@
       organization: { id: "org_123" },
       principal: { id: "key_123", type: "api_key" },
     };
-    let receivedToken: string | undefined;
+    const receivedTokens: string[] = [];
+    let retrieveCalls = 0;
+    const server = new McpServer({ name: "test", version: "0.0.0" });
 
     registerConnectionContextTool(server, {
       createKernelClient: (token) => {
-        receivedToken = token;
+        receivedTokens.push(token);
         return {
           auth: {
-            context: { retrieve: async () => context },
+            context: {
+              retrieve: async () => {
+                retrieveCalls++;
+                return context;
+              },
+            },
           },
         } as unknown as KernelClient;
       },
     });
 
-    const result = await handler!({}, { authInfo: { token: "secret-token" } });
-    expect(receivedToken).toBe("secret-token");
-    expect(JSON.parse(result.content[0].text)).toEqual(context);
+    const client = new Client({ name: "test-client", version: "0.0.0" });
+    const [clientTransport, serverTransport] =
+      InMemoryTransport.createLinkedPair();
+    const send = clientTransport.send.bind(clientTransport);
+    clientTransport.send = (message, options) =>
+      send(message, {
+        ...options,
+        authInfo: {
+          token: "secret-token",
+          clientId: "test-client",
+          scopes: [],
+        },
+      });
+
+    await Promise.all([
+      server.connect(serverTransport),
+      client.connect(clientTransport),
+    ]);
+
+    try {
+      const tools = await client.listTools();
+      const tool = tools.tools.find(
+        (item) => item.name === "get_connection_context",
+      );
+      const schema = tool?.inputSchema as
+        | {
+            type?: string;
+            properties?: Record<string, unknown>;
+            additionalProperties?: boolean;
+          }
+        | undefined;
+      expect(tool).toBeDefined();
+      expect(schema?.type).toBe("object");
+      expect(schema?.properties ?? {}).toEqual({});
+
+      await expect(
+        client.callTool({
+          name: "get_connection_context",
+          arguments: "invalid" as unknown as Record<string, unknown>,
+        }),
+      ).rejects.toThrow("Invalid input: expected record, received string");
+      expect(retrieveCalls).toBe(0);
+
+      const result = await client.callTool({
+        name: "get_connection_context",
+        arguments: {},
+      });
+      expect(result.isError).toBeUndefined();
+      expect(result.content).toEqual([
+        { type: "text", text: JSON.stringify(context, null, 2) },
+      ]);
+      expect(retrieveCalls).toBe(1);
+      expect(receivedTokens).toEqual(["secret-token"]);
+    } finally {
+      await client.close();
+    }
   });
 });

You can send follow-ups to the cloud agent here.

Comment thread src/lib/mcp/tools/auth-connections.test.ts
Comment thread src/lib/mcp/tools/connection-context.test.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Unsafe analytics enrich before null guard
    • Moved enrichment to run only after confirming event.properties exists so events without properties pass through safely.

Create PR

Or push these changes by commenting:

@cursor push f81ed208ef
Preview (f81ed208ef)
diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts
--- a/src/lib/mcp/analytics.ts
+++ b/src/lib/mcp/analytics.ts
@@ -230,9 +230,9 @@
     beforeSend: (event) => {
       if (event.event === PostHogMCPAnalyticsEvent.Exception) return null;
 
-      enrichMcpAnalyticsEvent(event);
       const properties = event.properties;
       if (!properties) return event;
+      enrichMcpAnalyticsEvent(event);
 
       for (const key of Object.keys(properties)) {
         if (!SENT_PROPERTIES.has(key)) delete properties[key];

You can send follow-ups to the cloud agent here.

Comment thread src/lib/mcp/analytics.ts Outdated

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The explicit per-call project target for an intentionally organization-wide credential is the right product model, and project-scoped OAuth should remain the default. The blockers here are how that model is represented in MCP: scope interpretation is duplicated, tool schemas vary by connection through a handler/cache matrix, and MCP resources remain unscoped. Please move to one canonical request-scope resolver, a stable project-aware tool contract, and project-qualified resource identities. Authorization must remain API-enforced; project_id selects a target but never grants access.

Comment thread src/app/[transport]/route.ts
Comment thread src/lib/mcp/project-selection.ts Outdated
Comment thread src/lib/mcp/tools/browsers.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Resource collections omit later pages
    • Updated browsers and apps resource collection readers to async-iterate client.*.list() so all paginated items are returned instead of only the first page.
  • ✅ Fixed: Resource tests bypass MCP boundary
    • Reworked resource-templates.test.ts to use a real McpServer and Client over InMemoryTransport with resource discovery and readResource calls, exercising auth propagation and serialized MCP responses.

Create PR

Or push these changes by commenting:

@cursor push e0ef162c48
Preview (e0ef162c48)
diff --git a/src/lib/mcp/resource-templates.test.ts b/src/lib/mcp/resource-templates.test.ts
--- a/src/lib/mcp/resource-templates.test.ts
+++ b/src/lib/mcp/resource-templates.test.ts
@@ -1,5 +1,6 @@
-import type { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
-import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 import { describe, expect, test } from "bun:test";
 import { organizationWideAuthInfo } from "@/lib/mcp/auth-context.test-fixtures";
 import type { KernelClient } from "@/lib/mcp/kernel-client";
@@ -8,35 +9,35 @@
   registerJsonResourceTemplate,
 } from "@/lib/mcp/resource-templates";
 
-type ResourceHandler = (
-  uri: URL,
-  variables: Record<string, string>,
-  extra: { authInfo: ReturnType<typeof organizationWideAuthInfo> },
-) => Promise<{ contents: Array<{ text: string }> }>;
+async function connectResourceClient(
+  server: McpServer,
+  authInfo = organizationWideAuthInfo("org_123"),
+) {
+  const client = new Client({ name: "test-client", version: "0.0.0" });
+  const [clientTransport, serverTransport] =
+    InMemoryTransport.createLinkedPair();
+  const send = clientTransport.send.bind(clientTransport);
+  clientTransport.send = (message, options) =>
+    send(message, {
+      ...options,
+      authInfo,
+    });
 
-function captureResources() {
-  const resources = new Map<
-    string,
-    { template: ResourceTemplate; handler: ResourceHandler }
-  >();
-  const server = {
-    resource(
-      name: string,
-      template: ResourceTemplate,
-      handler: ResourceHandler,
-    ) {
-      resources.set(name, { template, handler });
-    },
-  } as unknown as McpServer;
-  return { server, resources };
+  await Promise.all([
+    server.connect(serverTransport),
+    client.connect(clientTransport),
+  ]);
+  return client;
 }
 
 describe("project-qualified resources", () => {
   test("uses project identity from the URI for collections and items", async () => {
-    const { server, resources } = captureResources();
     const projects: Array<string | undefined> = [];
+    const tokens: string[] = [];
+    const server = new McpServer({ name: "test", version: "0.0.0" });
     const dependencies = {
-      createKernelClient: (_token: string, projectId?: string) => {
+      createKernelClient: (token: string, projectId?: string) => {
+        tokens.push(token);
         projects.push(projectId);
         return {} as KernelClient;
       },
@@ -66,54 +67,76 @@
       dependencies,
     );
 
-    expect(resources.get("widgets")?.template.uriTemplate.toString()).toBe(
-      "kernel://orgs/{organizationId}/projects/{projectId}/widgets",
+    const client = await connectResourceClient(
+      server,
+      organizationWideAuthInfo("org_123"),
     );
-    const variables = {
-      organizationId: "org_123",
-      projectId: "proj_123",
-    };
-    const extra = { authInfo: organizationWideAuthInfo("org_123") };
-    const collection = await resources
-      .get("widgets")!
-      .handler(
-        new URL("kernel://orgs/org_123/projects/proj_123/widgets"),
-        variables,
-        extra,
+    try {
+      const templates = await client.listResourceTemplates();
+      expect(templates.resourceTemplates).toEqual(
+        expect.arrayContaining([
+          {
+            name: "widgets",
+            uriTemplate:
+              "kernel://orgs/{organizationId}/projects/{projectId}/widgets",
+          },
+          {
+            name: "widget",
+            uriTemplate:
+              "kernel://orgs/{organizationId}/projects/{projectId}/widgets/{widgetId}",
+          },
+        ]),
       );
-    const item = await resources
-      .get("widget")!
-      .handler(
-        new URL("kernel://orgs/org_123/projects/proj_123/widgets/widget_1"),
-        { ...variables, widgetId: "widget_1" },
-        extra,
-      );
 
-    expect(JSON.parse(collection.contents[0].text)).toEqual([
-      { id: "widget_1" },
-    ]);
-    expect(JSON.parse(item.contents[0].text)).toEqual({ id: "widget_1" });
-    expect(projects).toEqual(["proj_123", "proj_123"]);
+      const collection = await client.readResource({
+        uri: "kernel://orgs/org_123/projects/proj_123/widgets",
+      });
+      const item = await client.readResource({
+        uri: "kernel://orgs/org_123/projects/proj_123/widgets/widget_1",
+      });
+
+      expect(JSON.parse(collection.contents[0].text)).toEqual([
+        { id: "widget_1" },
+      ]);
+      expect(JSON.parse(item.contents[0].text)).toEqual({ id: "widget_1" });
+      expect(projects).toEqual(["proj_123", "proj_123"]);
+      expect(tokens).toEqual(["test-token", "test-token"]);
+    } finally {
+      await client.close();
+    }
   });
 
   test("rejects a resource from another organization", async () => {
-    const { server, resources } = captureResources();
-    registerJsonResourceCollection(server, {
-      name: "widgets",
-      uriTemplate:
-        "kernel://orgs/{organizationId}/projects/{projectId}/widgets",
-      emptyText: "No widgets found",
-      read: async () => [],
-    });
+    const server = new McpServer({ name: "test", version: "0.0.0" });
+    registerJsonResourceCollection(
+      server,
+      {
+        name: "widgets",
+        uriTemplate:
+          "kernel://orgs/{organizationId}/projects/{projectId}/widgets",
+        emptyText: "No widgets found",
+        read: async () => [],
+      },
+      {
+        createKernelClient: () => {
+          throw new Error("createKernelClient should not be called");
+        },
+      },
+    );
 
-    await expect(
-      resources
-        .get("widgets")!
-        .handler(
-          new URL("kernel://orgs/org_other/projects/proj_123/widgets"),
-          { organizationId: "org_other", projectId: "proj_123" },
-          { authInfo: organizationWideAuthInfo("org_123") },
-        ),
-    ).rejects.toThrow("Resource organization must match");
+    const client = await connectResourceClient(
+      server,
+      organizationWideAuthInfo("org_123"),
+    );
+    try {
+      await client.listResourceTemplates();
+      await expect(
+        client.readResource({
+          uri: "kernel://orgs/org_other/projects/proj_123/widgets",
+        }),
+      ).rejects.toThrow("Resource organization must match");
+    } finally {
+      await client.close();
+    }
   });
 });

diff --git a/src/lib/mcp/tools/apps.ts b/src/lib/mcp/tools/apps.ts
--- a/src/lib/mcp/tools/apps.ts
+++ b/src/lib/mcp/tools/apps.ts
@@ -24,8 +24,11 @@
     uriTemplate: "kernel://orgs/{organizationId}/projects/{projectId}/apps",
     emptyText: "No apps found",
     read: async (client) => {
-      const appsPage = await client.apps.list();
-      return appsPage.getPaginatedItems();
+      const apps = [];
+      for await (const app of client.apps.list()) {
+        apps.push(app);
+      }
+      return apps;
     },
   });
 

diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts
--- a/src/lib/mcp/tools/browsers.ts
+++ b/src/lib/mcp/tools/browsers.ts
@@ -324,8 +324,11 @@
     uriTemplate: "kernel://orgs/{organizationId}/projects/{projectId}/browsers",
     emptyText: "No browsers found",
     read: async (client) => {
-      const browsersPage = await client.browsers.list();
-      return browsersPage.getPaginatedItems();
+      const browsers = [];
+      for await (const browser of client.browsers.list()) {
+        browsers.push(browser);
+      }
+      return browsers;
     },
   });

You can send follow-ups to the cloud agent here.

Comment thread src/lib/mcp/tools/browsers.ts
Comment thread src/lib/mcp/resource-templates.test.ts Outdated
Comment thread src/app/[transport]/route.ts

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

latest revision addresses the structural findings: the tool contract is stable, request scope has one canonical model, and project-scoped resources use validated project-qualified URIs. The follow-up fixes also cover full resource pagination and exercise URI matching/auth propagation through the real MCP boundary. Local tests, typechecking, generated-app verification, CI, and BugBot are green.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Stale auth bypasses API-key checks
    • Opaque API-key requests no longer use connection-context caching, so each request revalidates via auth.context.retrieve and revoked keys cannot pass on stale cache data.
  • ✅ Fixed: Inconsistent scope leaves cache reusable
    • When scope normalization is inconsistent, the resolver now removes that identity’s cached context to prevent stale scope resurrection on later refresh failures.

Create PR

Or push these changes by commenting:

@cursor push 483e48836a
Preview (483e48836a)
diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts
--- a/src/app/[transport]/route.ts
+++ b/src/app/[transport]/route.ts
@@ -103,9 +103,11 @@
     resolveMcpConnectionContext({
       token,
       signal: req.signal,
-      cacheIdentity: transportSessionId
-        ? `${authSubject}\0${transportSessionId}`
-        : token,
+      cacheIdentity: authInfoExtra.clerkToken
+        ? transportSessionId
+          ? `${authSubject}\0${transportSessionId}`
+          : token
+        : undefined,
     }),
   ]);
   if (!connectionContext) {

diff --git a/src/lib/mcp/auth-context.test.ts b/src/lib/mcp/auth-context.test.ts
--- a/src/lib/mcp/auth-context.test.ts
+++ b/src/lib/mcp/auth-context.test.ts
@@ -191,6 +191,40 @@
     expect(refreshed).toBe(first);
   });
 
+  test("clears stale cache after inconsistent scope", async () => {
+    const cacheIdentity = "user_123\0session_123";
+    const first = await resolveMcpConnectionContext({
+      token: "old-token",
+      cacheIdentity,
+      dependencies: dependencies(response()),
+    });
+    expect(first).not.toBeNull();
+
+    expireMcpConnectionContextCacheForTests();
+    const inconsistent = await resolveMcpConnectionContext({
+      token: "new-token",
+      cacheIdentity,
+      dependencies: dependencies(
+        response({
+          credentialProjectId: "project_1",
+          effectiveProjectId: "project_2",
+        }),
+      ),
+    });
+    expect(inconsistent).toBeNull();
+
+    const unavailable = await resolveMcpConnectionContext({
+      token: "new-token",
+      cacheIdentity,
+      dependencies: {
+        createKernelClient: () => {
+          throw new Error("temporary outage");
+        },
+      },
+    });
+    expect(unavailable).toBeNull();
+  });
+
   test("fails closed when auth context is unavailable or malformed", async () => {
     const unavailable = await resolveMcpConnectionContext({
       token: "sk_secret",

diff --git a/src/lib/mcp/auth-context.ts b/src/lib/mcp/auth-context.ts
--- a/src/lib/mcp/auth-context.ts
+++ b/src/lib/mcp/auth-context.ts
@@ -156,6 +156,10 @@
   });
 }
 
+function deleteConnectionContextCache(identity: string) {
+  connectionContextCache.delete(connectionContextCacheKey(identity));
+}
+
 export async function resolveMcpConnectionContext({
   token,
   signal,
@@ -180,6 +184,7 @@
   const scope = connectionScopeFromAuthContext(authContext);
   if (!scope) {
     console.warn("Received inconsistent MCP connection scope");
+    if (cacheIdentity) deleteConnectionContextCache(cacheIdentity);
     return null;
   }

You can send follow-ups to the cloud agent here.

Comment thread src/lib/mcp/auth-context.ts Outdated
Comment thread src/lib/mcp/auth-context.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: API-key sessions skip scope cache
    • API-key auth now derives the same session-scoped cache identity from authSubject and transportSessionId, restoring cached/stale auth-context reuse for session requests.

Create PR

Or push these changes by commenting:

@cursor push 285f0c212b
Preview (285f0c212b)
diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts
--- a/src/app/[transport]/route.ts
+++ b/src/app/[transport]/route.ts
@@ -153,13 +153,17 @@
 
   if (!isValidJwtFormat(token)) {
     // Opaque API keys are authenticated by the Kernel API rather than Clerk.
+    const authSubject = mcpAppsAuthSubject({ token });
     return await handleMcpRequestWithIdentity({
       req,
       token,
-      authSubject: mcpAppsAuthSubject({ token }),
+      authSubject,
       scopes: ["apikey"],
       authInfoExtra: { userId: null, clerkToken: null },
       transportSessionId,
+      connectionContextCacheIdentity: transportSessionId
+        ? `${authSubject}\0${transportSessionId}`
+        : undefined,
       observeConnection,
     });
   }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 2a853ab. Configure here.

Comment thread src/app/[transport]/route.ts

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

reviewed the connection-scope cache follow-up. The cache is limited to Clerk-verified OAuth sessions, keyed by authenticated subject plus signed transport session, bounded to a 5-minute fresh/30-minute stale window, and invalidated on 4xx rejection, malformed context, or inconsistent scope. Opaque API keys intentionally continue through /auth/context on every request so revocation remains immediate at the MCP boundary. Tests, typechecking, generated-app verification, CI, Vercel review, and the latest security scans are green.

@rgarcia
rgarcia merged commit 4ff325b into main Aug 10, 2026
10 checks passed
@rgarcia
rgarcia deleted the hypeship/mcp-project-tool-scope branch August 10, 2026 01:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants