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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions src/lib/mcp/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
43 changes: 42 additions & 1 deletion src/lib/mcp/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
"$groups",
"$insert_id",
Expand All @@ -79,6 +82,8 @@ const SENT_PROPERTIES = new Set<string>([
"$mcp_connection_scope",
"$mcp_credential_scope",
"$mcp_scope_source",
MCP_USED_PROJECT_ID_PROPERTY,
MCP_USED_PROJECT_PROPERTY,
PostHogMCPAnalyticsProperty.ClientName,
PostHogMCPAnalyticsProperty.ClientVersion,
PostHogMCPAnalyticsProperty.DurationMs,
Expand Down Expand Up @@ -132,6 +137,39 @@ const INTENT_REDACTIONS: readonly [RegExp, string][] = [
],
];

function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}

function hasNonEmptyParam(
args: Record<string, unknown> | 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<string, unknown>,
): Record<string, unknown> | 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<string, unknown>) {
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),
Expand Down Expand Up @@ -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];
Expand Down
2 changes: 1 addition & 1 deletion src/lib/mcp/dependencies.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
6 changes: 3 additions & 3 deletions src/lib/mcp/kernel-client.test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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),
}));
7 changes: 5 additions & 2 deletions src/lib/mcp/kernel-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/lib/mcp/kernel-client.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
79 changes: 77 additions & 2 deletions src/lib/mcp/project-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
Loading
Loading