diff --git a/README.md b/README.md index 4dd621b..fc54973 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ One additional Managed Auth helper (`begin_auth_login`) is marked app-only (`_me Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_DISABLED_TOOLSETS` to a comma-separated list. For example, `KERNEL_MCP_DISABLED_TOOLSETS=api_keys` prevents `manage_api_keys` from being registered. -Call `get_connection_context` before deciding whether to create or select a project. Its canonical `connection_scope` reports whether the connection is organization-wide or fixed to a project. Project-scoped tools always advertise an optional `project_id`: organization-wide connections may omit it to preserve organization-wide reads and API default-project behavior, while fixed-project connections may omit it or pass the matching ID. Project resources use project-qualified `kernel://orgs/{organizationId}/projects/{projectId}/...` URIs. Authorization remains enforced by the Kernel API; selecting a project never grants access to it. +Call `get_connection_context` before deciding whether to create or select a project. Its canonical `connection_scope` reports whether the connection is organization-wide or fixed to a project. Project-scoped tools advertise an optional `project` (name or ID) and a deprecated `project_id`: organization-wide connections may omit them to preserve organization-wide reads and API default-project behavior, while fixed-project connections may omit them or pass the matching project. Project resources use project-qualified `kernel://orgs/{organizationId}/projects/{projectId}/...` URIs. Authorization remains enforced by the Kernel API; selecting a project never grants access to it. ### manage\_\* tools diff --git a/bun.lock b/bun.lock index 10303bd..c6f62fe 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", "@onkernel/managed-auth-react": "0.4.1", - "@onkernel/sdk": "^0.87.0", + "@onkernel/sdk": "^0.90.0", "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -152,7 +152,7 @@ "@onkernel/managed-auth-react": ["@onkernel/managed-auth-react@0.4.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-8p+pMljBRQMKLiBFWbyJQ2ohYflsomWYGgQtlF2sbb4b2w/z+CBsnxUiBs1q23h/W1OtHsbw/jKGX51ZCjNYzA=="], - "@onkernel/sdk": ["@onkernel/sdk@0.87.0", "", {}, "sha512-95y8VGWyOthKg+0esaVXpHMInHuRYsU+6AjZSGK47pa9+oActRy3tqJscfxYRWSgWkI362D6/mAp9ElG9W2zWw=="], + "@onkernel/sdk": ["@onkernel/sdk@0.90.0", "", {}, "sha512-KvOusR4JVrb1ifT1sqjUlZkoj5EFcxVu+nKrBWLYWGyZgJSu626f7uJEphigCrDVUYpuzimOGNsvXS9ILtjx6Q=="], "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w=="], diff --git a/package.json b/package.json index f265dbb..f6602b5 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", "@onkernel/managed-auth-react": "0.4.1", - "@onkernel/sdk": "^0.87.0", + "@onkernel/sdk": "^0.90.0", "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", diff --git a/src/lib/mcp/analytics.test.ts b/src/lib/mcp/analytics.test.ts index 7a6bdeb..d24c845 100644 --- a/src/lib/mcp/analytics.test.ts +++ b/src/lib/mcp/analytics.test.ts @@ -11,6 +11,8 @@ import { enrichMcpAnalyticsEvent, instrumentMcpAnalytics, MCP_CONNECTION_SCOPE_FAILURE_EVENT, + MCP_USED_PROJECT_ID_PROPERTY, + MCP_USED_PROJECT_PROPERTY, OAUTH_TOKEN_EXCHANGE_EVENT, sanitizeMcpAnalyticsEvent, } from "@/lib/mcp/analytics"; @@ -285,6 +287,69 @@ describe("sanitizeMcpAnalyticsEvent", () => { expect(await sanitizeMcpAnalyticsEvent(event)).toBeNull(); }); + + test("records which project selector was passed without the value", async () => { + const event = toolCallEvent({ + [PostHogMCPAnalyticsProperty.Parameters]: { + request: { + params: { + arguments: { + action: "list", + project_id: "proj_secret", + project: "billing", + }, + }, + }, + }, + }); + + const result = await sanitizeMcpAnalyticsEvent(event); + + expect(result?.properties[MCP_USED_PROJECT_ID_PROPERTY]).toBe(true); + expect(result?.properties[MCP_USED_PROJECT_PROPERTY]).toBe(true); + expect( + result?.properties[PostHogMCPAnalyticsProperty.Parameters], + ).toBeUndefined(); + expect(JSON.stringify(result)).not.toContain("proj_secret"); + expect(JSON.stringify(result)).not.toContain("billing"); + }); + + test("marks deprecated project_id usage when only that param is set", async () => { + const event = toolCallEvent({ + [PostHogMCPAnalyticsProperty.Parameters]: { + request: { + params: { arguments: { project_id: "proj_123" } }, + }, + }, + }); + + const result = await sanitizeMcpAnalyticsEvent(event); + + expect(result?.properties[MCP_USED_PROJECT_ID_PROPERTY]).toBe(true); + expect(result?.properties[MCP_USED_PROJECT_PROPERTY]).toBe(false); + }); + + test("marks project usage when only the new param is set", async () => { + const event = toolCallEvent({ + [PostHogMCPAnalyticsProperty.Parameters]: { + request: { + params: { arguments: { project: "my-project" } }, + }, + }, + }); + + const result = await sanitizeMcpAnalyticsEvent(event); + + expect(result?.properties[MCP_USED_PROJECT_ID_PROPERTY]).toBe(false); + expect(result?.properties[MCP_USED_PROJECT_PROPERTY]).toBe(true); + }); + + test("records false/false when a tool call omits both project selectors", async () => { + const result = await sanitizeMcpAnalyticsEvent(toolCallEvent()); + + expect(result?.properties[MCP_USED_PROJECT_ID_PROPERTY]).toBe(false); + expect(result?.properties[MCP_USED_PROJECT_PROPERTY]).toBe(false); + }); }); describe("captureOAuthTokenExchange", () => { diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index cefe264..92a2707 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -66,11 +66,14 @@ const posthog = projectToken }) : null; +export const MCP_USED_PROJECT_ID_PROPERTY = "$mcp_used_project_id"; +export const MCP_USED_PROJECT_PROPERTY = "$mcp_used_project"; + // Every property this integration sends. An allow-list rather than a deny-list so a // property the pinned SDK doesn't emit today — a renamed payload field, a new one — // can't start flowing on an upgrade. Deliberately absent: $mcp_parameters and // $mcp_response (call payloads), and $mcp_error_message (the text a failed tool -// returned). +// returned). $mcp_used_project_id / $mcp_used_project are presence flags only. const SENT_PROPERTIES = new Set([ "$groups", "$insert_id", @@ -79,6 +82,8 @@ const SENT_PROPERTIES = new Set([ "$mcp_connection_scope", "$mcp_credential_scope", "$mcp_scope_source", + MCP_USED_PROJECT_ID_PROPERTY, + MCP_USED_PROJECT_PROPERTY, PostHogMCPAnalyticsProperty.ClientName, PostHogMCPAnalyticsProperty.ClientVersion, PostHogMCPAnalyticsProperty.DurationMs, @@ -132,6 +137,39 @@ const INTENT_REDACTIONS: readonly [RegExp, string][] = [ ], ]; +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function hasNonEmptyParam( + args: Record | undefined, + key: string, +) { + if (!args || !Object.prototype.hasOwnProperty.call(args, key)) return false; + const value = args[key]; + return value !== undefined && value !== ""; +} + +// $mcp_parameters is { request: { params: { arguments: { ...tool args } } } }. +function toolCallArguments( + properties: Record, +): Record | undefined { + const parameters = properties[PostHogMCPAnalyticsProperty.Parameters]; + if (!isRecord(parameters) || !isRecord(parameters.request)) return undefined; + const params = parameters.request.params; + if (!isRecord(params) || !isRecord(params.arguments)) return undefined; + return params.arguments; +} + +function annotateProjectParamUsage(properties: Record) { + const args = toolCallArguments(properties); + properties[MCP_USED_PROJECT_ID_PROPERTY] = hasNonEmptyParam( + args, + "project_id", + ); + properties[MCP_USED_PROJECT_PROPERTY] = hasNonEmptyParam(args, "project"); +} + function sanitizeIntent(intent: string) { const redacted = INTENT_REDACTIONS.reduce( (text, [pattern, replacement]) => text.replace(pattern, replacement), @@ -194,6 +232,9 @@ export const sanitizeMcpAnalyticsEvent: BeforeSendFn = (event) => { const properties = event.properties; if (!properties) return event; enrichMcpAnalyticsEvent(event); + if (event.event === PostHogMCPAnalyticsEvent.ToolCall) { + annotateProjectParamUsage(properties); + } for (const key of Object.keys(properties)) { if (!SENT_PROPERTIES.has(key)) delete properties[key]; diff --git a/src/lib/mcp/dependencies.ts b/src/lib/mcp/dependencies.ts index 1fbdfba..e4d0426 100644 --- a/src/lib/mcp/dependencies.ts +++ b/src/lib/mcp/dependencies.ts @@ -1,7 +1,7 @@ import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; export type McpDependencies = { - createKernelClient: (token: string, projectID?: string) => KernelClient; + createKernelClient: (token: string, project?: string) => KernelClient; }; export const defaultMcpDependencies: McpDependencies = { diff --git a/src/lib/mcp/kernel-client.test-fixtures.ts b/src/lib/mcp/kernel-client.test-fixtures.ts index 96dd91b..8125a17 100644 --- a/src/lib/mcp/kernel-client.test-fixtures.ts +++ b/src/lib/mcp/kernel-client.test-fixtures.ts @@ -10,7 +10,7 @@ export const unusedKernelClient = new Proxy( ); export const kernelClientMock: { - factory: (token: string, projectID?: string) => any; + factory: (token: string, project?: string) => any; } = { factory: () => unusedKernelClient, }; @@ -20,6 +20,6 @@ export function resetKernelClientFactory() { } mock.module("@/lib/mcp/kernel-client", () => ({ - createKernelClient: (token: string, projectID?: string) => - kernelClientMock.factory(token, projectID), + createKernelClient: (token: string, project?: string) => + kernelClientMock.factory(token, project), })); diff --git a/src/lib/mcp/kernel-client.test.ts b/src/lib/mcp/kernel-client.test.ts index f89aab2..e1e1fb5 100644 --- a/src/lib/mcp/kernel-client.test.ts +++ b/src/lib/mcp/kernel-client.test.ts @@ -6,10 +6,13 @@ describe("createKernelClient", () => { const previous = process.env.KERNEL_PROJECT; process.env.KERNEL_PROJECT = "proj_default"; try { - expect(createKernelClient("test-key", "proj_explicit").projectID).toBe( + expect(createKernelClient("test-key", "proj_explicit").project).toBe( "proj_explicit", ); - expect(createKernelClient("test-key").projectID).toBe("proj_default"); + expect(createKernelClient("test-key", "proj_explicit").projectID).toBe( + null, + ); + expect(createKernelClient("test-key").project).toBe("proj_default"); } finally { if (previous === undefined) { delete process.env.KERNEL_PROJECT; diff --git a/src/lib/mcp/kernel-client.ts b/src/lib/mcp/kernel-client.ts index edfe611..d6d7dee 100644 --- a/src/lib/mcp/kernel-client.ts +++ b/src/lib/mcp/kernel-client.ts @@ -1,9 +1,9 @@ import { Kernel } from "@onkernel/sdk"; -export function createKernelClient(apiKey: string, projectID?: string) { +export function createKernelClient(apiKey: string, project?: string) { return new Kernel({ apiKey, - projectID: projectID ?? process.env.KERNEL_PROJECT, + project: project ?? process.env.KERNEL_PROJECT, baseURL: process.env.API_BASE_URL, defaultHeaders: { "X-Source": "mcp-server", diff --git a/src/lib/mcp/project-selection.test.ts b/src/lib/mcp/project-selection.test.ts index e1031af..54246f7 100644 --- a/src/lib/mcp/project-selection.test.ts +++ b/src/lib/mcp/project-selection.test.ts @@ -6,8 +6,10 @@ import type { } from "@/lib/mcp/auth-context"; import { connectionContextFromAuthInfo, + projectForOperation, projectIDForOperation, projectSelectionInputSchema, + requestedProject, } from "@/lib/mcp/project-selection"; function authInfo(scope: ConnectionScope): AuthInfo { @@ -38,13 +40,86 @@ const projectScope: ConnectionScope = { }; describe("project selection schema", () => { - test("always advertises an optional project_id", () => { + test("advertises project plus deprecated project_id", () => { const schema = projectSelectionInputSchema(); + expect(schema).toHaveProperty("project"); expect(schema).toHaveProperty("project_id"); + expect(schema.project.safeParse(undefined).success).toBe(true); + expect(schema.project.safeParse("my-project").success).toBe(true); + expect(schema.project.safeParse("").success).toBe(false); expect(schema.project_id.safeParse(undefined).success).toBe(true); expect(schema.project_id.safeParse("proj_123").success).toBe(true); expect(schema.project_id.safeParse("").success).toBe(false); }); + + test("keeps non-empty validation when descriptions are overridden", () => { + const schema = projectSelectionInputSchema({ + project: "Project name or ID.", + project_id: "Deprecated.", + }); + expect(schema.project.safeParse("").success).toBe(false); + expect(schema.project_id.safeParse("").success).toBe(false); + expect(schema.project.safeParse("billing").success).toBe(true); + }); +}); + +describe("requestedProject", () => { + test("prefers project over project_id", () => { + expect(requestedProject({ project: "by-name" })).toBe("by-name"); + expect(requestedProject({ project_id: "proj_123" })).toBe("proj_123"); + expect( + requestedProject({ project: "by-name", project_id: "proj_123" }), + ).toBe("by-name"); + expect(requestedProject({})).toBeUndefined(); + expect(requestedProject({ project: "", project_id: "proj_123" })).toBe( + "proj_123", + ); + }); +}); + +describe("projectForOperation", () => { + test("preserves unscoped access for organization-wide connections", () => { + const info = authInfo(organizationScope); + expect(projectForOperation(info)).toBeUndefined(); + expect(projectForOperation(info, { project: "my-project" })).toBe( + "my-project", + ); + expect(projectForOperation(info, { project_id: "proj_123" })).toBe( + "proj_123", + ); + expect( + projectForOperation(info, { + project: "my-project", + project_id: "proj_123", + }), + ).toBe("my-project"); + }); + + test("uses the fixed project for project-scoped connections", () => { + const info = authInfo(projectScope); + expect(projectForOperation(info)).toBe("proj_fixed"); + expect(projectForOperation(info, { project_id: "proj_fixed" })).toBe( + "proj_fixed", + ); + expect(projectForOperation(info, { project: "proj_fixed" })).toBe( + "proj_fixed", + ); + }); + + test("rejects a selector override on project-scoped connections", () => { + expect(() => + projectForOperation(authInfo(projectScope), { project_id: "proj_other" }), + ).toThrow("project must match"); + expect(() => + projectForOperation(authInfo(projectScope), { project: "fixed-name" }), + ).toThrow("project must match"); + expect(() => + projectForOperation(authInfo(projectScope), { + project: "fixed-name", + project_id: "proj_other", + }), + ).toThrow("project must match"); + }); }); describe("projectIDForOperation", () => { @@ -63,7 +138,7 @@ describe("projectIDForOperation", () => { test("rejects an override on project-scoped connections", () => { expect(() => projectIDForOperation(authInfo(projectScope), "proj_other"), - ).toThrow("project_id must match"); + ).toThrow("project must match"); }); test("fails when canonical connection context is absent", () => { diff --git a/src/lib/mcp/project-selection.ts b/src/lib/mcp/project-selection.ts index 6d16acf..4a942f0 100644 --- a/src/lib/mcp/project-selection.ts +++ b/src/lib/mcp/project-selection.ts @@ -2,18 +2,43 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { z } from "zod"; import type { McpConnectionContext } from "@/lib/mcp/auth-context"; -const projectIDSchema = z - .string() - .min(1) - .describe( - "Optional project ID used to scope this operation. On organization-wide connections, omit it to use the API's organization-wide or default-project behavior. On project-scoped connections, omit it or pass the fixed project ID returned by get_connection_context.", - ) - .optional(); - -export function projectSelectionInputSchema(): { - project_id: typeof projectIDSchema; -} { - return { project_id: projectIDSchema }; +const DEFAULT_PROJECT_DESCRIPTION = + "Optional project name or ID used to scope this operation. On organization-wide connections, omit it to use the API's organization-wide or default-project behavior. On project-scoped connections, omit it or pass the fixed project returned by get_connection_context."; + +const DEFAULT_PROJECT_ID_DESCRIPTION = + "Deprecated: use `project` instead. Optional project ID used to scope this operation. On organization-wide connections, omit it to use the API's organization-wide or default-project behavior. On project-scoped connections, omit it or pass the fixed project ID returned by get_connection_context."; + +export type ProjectSelection = { + project?: string; + project_id?: string; +}; + +export type ProjectSelectionDescriptions = { + project?: string; + project_id?: string; +}; + +export function projectSelectionInputSchema( + descriptions: ProjectSelectionDescriptions = {}, +) { + return { + project: z + .string() + .min(1) + .describe(descriptions.project ?? DEFAULT_PROJECT_DESCRIPTION) + .optional(), + project_id: z + .string() + .min(1) + .describe(descriptions.project_id ?? DEFAULT_PROJECT_ID_DESCRIPTION) + .optional(), + }; +} + +export function requestedProject( + selection: ProjectSelection, +): string | undefined { + return selection.project || selection.project_id || undefined; } export function connectionContextFromAuthInfo( @@ -26,19 +51,27 @@ export function connectionContextFromAuthInfo( return context as McpConnectionContext; } -export function projectIDForOperation( +export function projectForOperation( authInfo: AuthInfo, - requestedProjectId?: string, + selection: ProjectSelection = {}, ): string | undefined { const { scope } = connectionContextFromAuthInfo(authInfo); if (scope.kind === "organization") { - return requestedProjectId; + return requestedProject(selection); } - if (requestedProjectId && requestedProjectId !== scope.projectId) { + const selector = requestedProject(selection); + if (selector && selector !== scope.projectId) { throw new Error( - `project_id must match this connection's fixed project (${scope.projectId})`, + `project must match this connection's fixed project (${scope.projectId})`, ); } return scope.projectId; } + +export function projectIDForOperation( + authInfo: AuthInfo, + requestedProjectId?: string, +): string | undefined { + return projectForOperation(authInfo, { project_id: requestedProjectId }); +} diff --git a/src/lib/mcp/register.test.ts b/src/lib/mcp/register.test.ts index 1cfe675..52061de 100644 --- a/src/lib/mcp/register.test.ts +++ b/src/lib/mcp/register.test.ts @@ -106,14 +106,23 @@ describe("project selection registration", () => { const registration = captureRegistration(true); for (const name of projectScopedTools) { + expect(registration.schemas.get(name)).toHaveProperty("project"); expect(registration.schemas.get(name)).toHaveProperty("project_id"); } + expect(registration.schemas.get("manage_projects")).toHaveProperty( + "project", + ); + expect(registration.schemas.get("manage_projects")).toHaveProperty( + "project_id", + ); + for (const name of [ "get_connection_context", "search_docs", "manage_credential_providers", ]) { + expect(registration.schemas.get(name)).not.toHaveProperty("project"); expect(registration.schemas.get(name)).not.toHaveProperty("project_id"); } }); diff --git a/src/lib/mcp/tools/apps.test.ts b/src/lib/mcp/tools/apps.test.ts index d0891af..df0d57e 100644 --- a/src/lib/mcp/tools/apps.test.ts +++ b/src/lib/mcp/tools/apps.test.ts @@ -109,6 +109,7 @@ describe("manage_apps invocation contract", () => { headless: false, stealth: true, timeout_seconds: 600, + region: "us-east", }, ], }; diff --git a/src/lib/mcp/tools/apps.ts b/src/lib/mcp/tools/apps.ts index 941abe9..5bd0476 100644 --- a/src/lib/mcp/tools/apps.ts +++ b/src/lib/mcp/tools/apps.ts @@ -17,7 +17,7 @@ import { } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; @@ -119,7 +119,7 @@ export function registerAppCapabilities( if (!extra.authInfo) throw new Error("Authentication required"); const client = dependencies.createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, params.project_id), + projectForOperation(extra.authInfo, params), ); try { diff --git a/src/lib/mcp/tools/auth-connections.test-fixtures.ts b/src/lib/mcp/tools/auth-connections.test-fixtures.ts index e006c64..c520f54 100644 --- a/src/lib/mcp/tools/auth-connections.test-fixtures.ts +++ b/src/lib/mcp/tools/auth-connections.test-fixtures.ts @@ -160,7 +160,9 @@ export function captureHandler() { ...extra, authInfo: extra.authInfo ? { - ...projectScopedAuthInfo(params.project_id ?? "proj_test"), + ...projectScopedAuthInfo( + params.project ?? params.project_id ?? "proj_test", + ), ...extra.authInfo, } : undefined, diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts index 80573f9..c589e3f 100644 --- a/src/lib/mcp/tools/auth-connections.test.ts +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -64,6 +64,7 @@ describe("manage_auth_connections programmatic surface", () => { }; }; + expect(schema?.project).toBeDefined(); expect(schema?.project_id).toBeDefined(); await handler( { action: "list", project_id: "proj_123" }, @@ -71,6 +72,12 @@ describe("manage_auth_connections programmatic surface", () => { ); expect(selectedProject).toBe("proj_123"); + await handler( + { action: "list", project: "billing" }, + { authInfo: { token: "test-token" } }, + ); + expect(selectedProject).toBe("billing"); + await handler({ action: "list" }, { authInfo: organizationWideAuthInfo() }); expect(selectedProject).toBeUndefined(); }); diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts index b43d508..192055a 100644 --- a/src/lib/mcp/tools/auth-connections.ts +++ b/src/lib/mcp/tools/auth-connections.ts @@ -15,7 +15,7 @@ import { } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; @@ -179,7 +179,7 @@ export function registerAuthConnectionTools(server: McpServer) { if (!extra.authInfo) throw new Error("Authentication required"); const client = createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, params.project_id), + projectForOperation(extra.authInfo, params), ); const buildProxy = () => diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index bf19559..6871bc8 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -202,7 +202,7 @@ describe("managed-auth MCP App registration", () => { domain_filter: "example.com", profile_name: "work", wait_seconds: 25, - project_id: "proj_test", + project: "proj_test", }, }, }); diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index bde6f49..7d700ff 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -20,10 +20,13 @@ import { import { managedAuthBrowserTelemetrySchema } from "@/lib/mcp/tools/managed-auth-telemetry"; import { errorResponse } from "@/lib/mcp/responses"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, + type ProjectSelection, } from "@/lib/mcp/project-selection"; +type AuthLoginParams = AuthLoginInput & ProjectSelection; + export { initializeDeclaresMcpApps }; const MCP_APPS_GATE_DENIED_MESSAGE = @@ -76,7 +79,7 @@ function waitAction( connectionId: string, flowCheckpoint: string, waitSeconds: number, - projectID?: string, + project?: string, ) { return { tool: "manage_auth_connections" as const, @@ -85,18 +88,17 @@ function waitAction( id: connectionId, flow_checkpoint: flowCheckpoint, wait_seconds: waitSeconds, - ...(projectID && { project_id: projectID }), + ...(project && { project }), }, }; } -function inputFromParams(params: AuthLoginInput): AuthLoginInput { +function inputFromParams(params: AuthLoginParams): AuthLoginInput { return { mode: params.mode, ...(params.connection_id && { connection_id: params.connection_id }), ...(params.domain && { domain: params.domain }), ...(params.profile_name && { profile_name: params.profile_name }), - ...(params.project_id && { project_id: params.project_id }), ...(params.save_credentials !== undefined && { save_credentials: params.save_credentials, }), @@ -155,13 +157,11 @@ export function registerAuthLoginApp(server: McpServer) { }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); - const input = { - ...inputFromParams(params), - project_id: projectIDForOperation(extra.authInfo, params.project_id), - }; + const project = projectForOperation(extra.authInfo, params); + const input = inputFromParams(params); const validationError = validateAuthLoginInput(input); if (validationError) return errorResponse(`Error: ${validationError}`); - const client = createKernelClient(extra.authInfo.token, input.project_id); + const client = createKernelClient(extra.authInfo.token, project); try { const reauthConnection = @@ -183,7 +183,7 @@ export function registerAuthLoginApp(server: McpServer) { hasLiveAuthFlow(reauthConnection) ? "event" : "after", ), 25, - input.project_id, + project, ) : { tool: "manage_auth_connections" as const, @@ -192,7 +192,7 @@ export function registerAuthLoginApp(server: McpServer) { domain_filter: input.domain!, profile_name: input.profile_name!, wait_seconds: 25, - ...(input.project_id && { project_id: input.project_id }), + ...(project && { project }), }, }; const waitArguments = nextAction.arguments; @@ -254,13 +254,11 @@ export function registerAuthLoginApp(server: McpServer) { MCP_APPS_GATE_DENIED_MESSAGE, ); if (gateError) return errorResponse(gateError); - const input = { - ...inputFromParams(params), - project_id: projectIDForOperation(extra.authInfo, params.project_id), - }; + const project = projectForOperation(extra.authInfo, params); + const input = inputFromParams(params); const validationError = validateAuthLoginInput(input); if (validationError) return errorResponse(`Error: ${validationError}`); - const client = createKernelClient(extra.authInfo.token, input.project_id); + const client = createKernelClient(extra.authInfo.token, project); try { const result = await beginAuthLogin(client, input); @@ -292,7 +290,7 @@ export function registerAuthLoginApp(server: McpServer) { result.connection.id, result.flow_checkpoint, 5, - input.project_id, + project, ), }), // Execution is gated on the client's MCP Apps capability, so diff --git a/src/lib/mcp/tools/browser-curl.ts b/src/lib/mcp/tools/browser-curl.ts index 56385ef..61b2b3e 100644 --- a/src/lib/mcp/tools/browser-curl.ts +++ b/src/lib/mcp/tools/browser-curl.ts @@ -7,7 +7,7 @@ import { throwToolError, } from "@/lib/mcp/responses"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; @@ -69,12 +69,13 @@ export function registerBrowserCurlTool(server: McpServer) { if (!extra.authInfo) throw new Error("Authentication required"); const client = createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, params.project_id), + projectForOperation(extra.authInfo, params), ); try { const { session_id, + project: _project, project_id: _projectID, ...curlParams } = params satisfies { diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 708d1d8..c06cb82 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -20,7 +20,7 @@ import { } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; @@ -407,7 +407,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { if (!extra.authInfo) throw new Error("Authentication required"); const client = createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, params.project_id), + projectForOperation(extra.authInfo, params), ); try { diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index fde5dac..1fa4c17 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -24,7 +24,7 @@ import { } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; import { @@ -199,6 +199,7 @@ function compactTelemetryEvent({ seq, event }: TelemetryEnvelope) { type BrowserTelemetryReadParams = { session_id: string; + project?: string; project_id?: string; categories?: TelemetryEventsQuery["category"]; limit?: number; @@ -308,6 +309,7 @@ async function readBrowserTelemetry( : { action: "get_telemetry", session_id: params.session_id, + ...(params.project && { project: params.project }), ...(params.project_id && { project_id: params.project_id }), ...(query.category && { categories: query.category }), limit: Math.min(query.limit ?? 100, maxRawTelemetryEvents), @@ -646,7 +648,7 @@ export function registerBrowserCapabilities( if (!extra.authInfo) throw new Error("Authentication required"); const client = dependencies.createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, params.project_id), + projectForOperation(extra.authInfo, params), ); try { @@ -773,6 +775,7 @@ export function registerBrowserCapabilities( } return await readBrowserTelemetry(client, { session_id: params.session_id, + project: params.project, project_id: params.project_id, categories: params.categories, limit: params.limit, diff --git a/src/lib/mcp/tools/computer-action.ts b/src/lib/mcp/tools/computer-action.ts index 1d2ec80..ff1f55f 100644 --- a/src/lib/mcp/tools/computer-action.ts +++ b/src/lib/mcp/tools/computer-action.ts @@ -8,7 +8,7 @@ import { throwToolError, } from "@/lib/mcp/responses"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; @@ -272,11 +272,11 @@ export function registerComputerActionTool(server: McpServer) { idempotentHint: false, openWorldHint: true, }, - async ({ session_id, actions, project_id }, extra) => { + async ({ session_id, actions, project, project_id }, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); const client = createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, project_id), + projectForOperation(extra.authInfo, { project, project_id }), ); try { diff --git a/src/lib/mcp/tools/connection-context.ts b/src/lib/mcp/tools/connection-context.ts index 9376861..399c004 100644 --- a/src/lib/mcp/tools/connection-context.ts +++ b/src/lib/mcp/tools/connection-context.ts @@ -5,7 +5,7 @@ import { jsonResponse } from "@/lib/mcp/responses"; export function registerConnectionContextTool(server: McpServer) { server.tool( "get_connection_context", - "Inspect the authenticated Kernel connection before a project-scoped operation. connection_scope.kind=organization may omit project_id for organization-wide reads and default-project creates, or pass one to select a project. connection_scope.kind=project is fixed to connection_scope.project_id; omit project_id or pass that exact value.", + "Inspect the authenticated Kernel connection before a project-scoped operation. connection_scope.kind=organization may omit project for organization-wide reads and default-project creates, or pass a project name or ID to select a project. connection_scope.kind=project is fixed to connection_scope.project_id; omit project or pass that project.", {}, { title: "Get Kernel connection context", diff --git a/src/lib/mcp/tools/credentials.ts b/src/lib/mcp/tools/credentials.ts index f24af51..a8547e0 100644 --- a/src/lib/mcp/tools/credentials.ts +++ b/src/lib/mcp/tools/credentials.ts @@ -10,7 +10,7 @@ import { } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; @@ -71,7 +71,7 @@ export function registerCredentialTools(server: McpServer) { if (!extra.authInfo) throw new Error("Authentication required"); const client = createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, params.project_id), + projectForOperation(extra.authInfo, params), ); try { diff --git a/src/lib/mcp/tools/extensions.ts b/src/lib/mcp/tools/extensions.ts index 7ced72f..deae1ba 100644 --- a/src/lib/mcp/tools/extensions.ts +++ b/src/lib/mcp/tools/extensions.ts @@ -9,7 +9,7 @@ import { } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; @@ -38,7 +38,7 @@ export function registerExtensionTools(server: McpServer) { if (!extra.authInfo) throw new Error("Authentication required"); const client = createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, params.project_id), + projectForOperation(extra.authInfo, params), ); try { diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index 8e58de0..1f654fb 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -44,7 +44,6 @@ export interface AuthLoginInput { connection_id?: string; domain?: string; profile_name?: string; - project_id?: string; save_credentials?: boolean; record_session?: boolean; browser_telemetry?: ManagedAuthBrowserTelemetry; diff --git a/src/lib/mcp/tools/playwright.ts b/src/lib/mcp/tools/playwright.ts index fd87d53..585bff5 100644 --- a/src/lib/mcp/tools/playwright.ts +++ b/src/lib/mcp/tools/playwright.ts @@ -5,7 +5,7 @@ import { type McpDependencies, } from "@/lib/mcp/dependencies"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; import { longOperationOptions } from "@/lib/mcp/request-options"; @@ -45,11 +45,11 @@ export function registerPlaywrightTool( idempotentHint: false, openWorldHint: true, }, - async ({ code, session_id, project_id }, extra) => { + async ({ code, session_id, project, project_id }, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); const client = options.createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, project_id), + projectForOperation(extra.authInfo, { project, project_id }), ); try { diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index 449f626..eaf5f55 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -19,7 +19,7 @@ import { } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; @@ -143,7 +143,7 @@ export function registerProfileCapabilities( if (!extra.authInfo) throw new Error("Authentication required"); const client = options.createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, params.project_id), + projectForOperation(extra.authInfo, params), ); try { diff --git a/src/lib/mcp/tools/projects.test.ts b/src/lib/mcp/tools/projects.test.ts new file mode 100644 index 0000000..f5bc03a --- /dev/null +++ b/src/lib/mcp/tools/projects.test.ts @@ -0,0 +1,88 @@ +/// + +import { describe, expect, test } from "bun:test"; +import { connectTestMcp, toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { registerProjectCapabilities } from "@/lib/mcp/tools/projects"; + +describe("manage_projects", () => { + test("requires a non-empty project selector for get", async () => { + const retrieveArgs: string[] = []; + const { client, close } = await connectTestMcp( + registerProjectCapabilities, + { + projects: { + retrieve: async (idOrName: string) => { + retrieveArgs.push(idOrName); + return { id: idOrName }; + }, + }, + }, + ); + try { + const missing = await client.callTool({ + name: "manage_projects", + arguments: { action: "get" }, + }); + const empty = await client.callTool({ + name: "manage_projects", + arguments: { action: "get", project: "", project_id: "proj_123" }, + }); + + expect(missing.isError).toBe(true); + expect(missing.content).toEqual([ + { + type: "text", + text: "Error: project or project_id is required for get.", + }, + ]); + expect(empty.isError).toBe(true); + expect(retrieveArgs).toEqual([]); + } finally { + await close(); + } + }); + + test("retrieves by project name or deprecated project_id", async () => { + const retrieveArgs: string[] = []; + const { client, tokens, close } = await connectTestMcp( + registerProjectCapabilities, + { + projects: { + retrieve: async (idOrName: string) => { + retrieveArgs.push(idOrName); + return { id: idOrName }; + }, + }, + }, + ); + try { + const byName = await client.callTool({ + name: "manage_projects", + arguments: { action: "get", project: "billing" }, + }); + const byID = await client.callTool({ + name: "manage_projects", + arguments: { action: "get", project_id: "proj_123" }, + }); + const both = await client.callTool({ + name: "manage_projects", + arguments: { + action: "get", + project: "billing", + project_id: "proj_123", + }, + }); + + expect(byName.isError).toBeUndefined(); + expect(byID.isError).toBeUndefined(); + expect(both.isError).toBeUndefined(); + expect(tokens).toEqual(["test-token", "test-token", "test-token"]); + expect(retrieveArgs).toEqual(["billing", "proj_123", "billing"]); + expect(toolResultJSON(byName)).toEqual({ id: "billing" }); + expect(toolResultJSON(byID)).toEqual({ id: "proj_123" }); + expect(toolResultJSON(both)).toEqual({ id: "billing" }); + } finally { + await close(); + } + }); +}); diff --git a/src/lib/mcp/tools/projects.ts b/src/lib/mcp/tools/projects.ts index f8af0e0..6c749ee 100644 --- a/src/lib/mcp/tools/projects.ts +++ b/src/lib/mcp/tools/projects.ts @@ -1,6 +1,9 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { + defaultMcpDependencies, + type McpDependencies, +} from "@/lib/mcp/dependencies"; import { errorResponse, jsonResponse, @@ -8,9 +11,16 @@ import { textResponse, throwToolError, } from "@/lib/mcp/responses"; +import { + projectSelectionInputSchema, + requestedProject, +} from "@/lib/mcp/project-selection"; import { paginationParams } from "@/lib/mcp/schemas"; -export function registerProjectCapabilities(server: McpServer) { +export function registerProjectCapabilities( + server: McpServer, + dependencies: McpDependencies = defaultMcpDependencies, +) { // manage_projects -- Create, list, get, update, delete, and manage organization project limits server.tool( "manage_projects", @@ -27,12 +37,12 @@ export function registerProjectCapabilities(server: McpServer) { "update_limits", ]) .describe("Operation to perform."), - project_id: z - .string() - .describe( - "Project ID. Required for get, update, delete, get_limits, and update_limits.", - ) - .optional(), + ...projectSelectionInputSchema({ + project: + "Project name or ID. Required for get, update, delete, get_limits, and update_limits.", + project_id: + "Deprecated: use `project` instead. Project ID. Required for get, update, delete, get_limits, and update_limits.", + }), name: z.string().describe("(create, update) Project name.").optional(), status: z .enum(["active", "archived"]) @@ -79,7 +89,7 @@ export function registerProjectCapabilities(server: McpServer) { }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); - const client = createKernelClient(extra.authInfo.token); + const client = dependencies.createKernelClient(extra.authInfo.token); try { switch (params.action) { @@ -99,15 +109,21 @@ export function registerProjectCapabilities(server: McpServer) { return paginatedJsonResponse(page); } case "get": { - if (!params.project_id) { - return errorResponse("Error: project_id is required for get."); + const idOrName = requestedProject(params); + if (!idOrName) { + return errorResponse( + "Error: project or project_id is required for get.", + ); } - const project = await client.projects.retrieve(params.project_id); + const project = await client.projects.retrieve(idOrName); return jsonResponse(project); } case "update": { - if (!params.project_id) { - return errorResponse("Error: project_id is required for update."); + const idOrName = requestedProject(params); + if (!idOrName) { + return errorResponse( + "Error: project or project_id is required for update.", + ); } if (!params.name && !params.status) { return errorResponse( @@ -119,33 +135,36 @@ export function registerProjectCapabilities(server: McpServer) { if (params.name) updateParams.name = params.name; if (params.status) updateParams.status = params.status; const project = await client.projects.update( - params.project_id, + idOrName, updateParams, ); return jsonResponse(project); } case "delete": { - if (!params.project_id) { - return errorResponse("Error: project_id is required for delete."); + const idOrName = requestedProject(params); + if (!idOrName) { + return errorResponse( + "Error: project or project_id is required for delete.", + ); } - await client.projects.delete(params.project_id); + await client.projects.delete(idOrName); return textResponse("Project deleted successfully"); } case "get_limits": { - if (!params.project_id) { + const idOrName = requestedProject(params); + if (!idOrName) { return errorResponse( - "Error: project_id is required for get_limits.", + "Error: project or project_id is required for get_limits.", ); } - const limits = await client.projects.limits.retrieve( - params.project_id, - ); + const limits = await client.projects.limits.retrieve(idOrName); return jsonResponse(limits); } case "update_limits": { - if (!params.project_id) { + const idOrName = requestedProject(params); + if (!idOrName) { return errorResponse( - "Error: project_id is required for update_limits.", + "Error: project or project_id is required for update_limits.", ); } const updateParams: Parameters< @@ -168,7 +187,7 @@ export function registerProjectCapabilities(server: McpServer) { ); } const limits = await client.projects.limits.update( - params.project_id, + idOrName, updateParams, ); return jsonResponse(limits); diff --git a/src/lib/mcp/tools/proxies.ts b/src/lib/mcp/tools/proxies.ts index d9b8e1b..571a75d 100644 --- a/src/lib/mcp/tools/proxies.ts +++ b/src/lib/mcp/tools/proxies.ts @@ -13,7 +13,7 @@ import { } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; @@ -104,7 +104,7 @@ export function registerProxyTools( if (!extra.authInfo) throw new Error("Authentication required"); const client = options.createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, params.project_id), + projectForOperation(extra.authInfo, params), ); try { diff --git a/src/lib/mcp/tools/replays.ts b/src/lib/mcp/tools/replays.ts index d8197a3..3baeda6 100644 --- a/src/lib/mcp/tools/replays.ts +++ b/src/lib/mcp/tools/replays.ts @@ -9,7 +9,7 @@ import { throwToolError, } from "@/lib/mcp/responses"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; @@ -57,7 +57,7 @@ export function registerReplayTools(server: McpServer) { if (!extra.authInfo) throw new Error("Authentication required"); const client = createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, params.project_id), + projectForOperation(extra.authInfo, params), ); try { diff --git a/src/lib/mcp/tools/shell.ts b/src/lib/mcp/tools/shell.ts index b35f812..71e8e05 100644 --- a/src/lib/mcp/tools/shell.ts +++ b/src/lib/mcp/tools/shell.ts @@ -7,7 +7,7 @@ import { import { longOperationOptions } from "@/lib/mcp/request-options"; import { throwToolError } from "@/lib/mcp/responses"; import { - projectIDForOperation, + projectForOperation, projectSelectionInputSchema, } from "@/lib/mcp/project-selection"; @@ -58,13 +58,22 @@ export function registerShellTool( openWorldHint: true, }, async ( - { session_id, command, args, cwd, timeout_sec, as_root, project_id }, + { + session_id, + command, + args, + cwd, + timeout_sec, + as_root, + project, + project_id, + }, extra, ) => { if (!extra.authInfo) throw new Error("Authentication required"); const client = options.createKernelClient( extra.authInfo.token, - projectIDForOperation(extra.authInfo, project_id), + projectForOperation(extra.authInfo, { project, project_id }), ); try {