Skip to content

Commit 9b4df41

Browse files
authored
Merge pull request #109 from kernel/codex/api-keys-mcp-tool
Add MCP API key management tool
2 parents 6a1f838 + 8074fb9 commit 9b4df41

7 files changed

Lines changed: 305 additions & 94 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ API_BASE_URL=<x>
1717
MINTLIFY_ASSISTANT_API_TOKEN=mint_dsc_<x>
1818
MINTLIFY_DOMAIN=<x>
1919

20+
# Optional MCP toolset gating. Comma-separated values.
21+
# Example: api_keys hides manage_api_keys so deployments can opt out of key management.
22+
# Supported: apps, api_keys, browser_pools, browsers, computer, docs, extensions, playwright, profiles, projects, proxies, shell
23+
# KERNEL_MCP_DISABLED_TOOLSETS=api_keys
24+
2025
# Redis Configuration
2126
REDIS_URL=<x> # redis://127.0.0.1:6379
2227
# REDIS_TLS_SERVER_NAME=<x> # optional; requires REDIS_URL to use rediss://

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,10 +255,12 @@ Many other MCP-capable tools accept:
255255

256256
Configure these values wherever the tool expects MCP server settings.
257257

258-
## Tools (10 total)
258+
## Tools (12 total)
259259

260260
Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Four standalone tools handle high-frequency workflows.
261261

262+
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.
263+
262264
### manage\_\* tools
263265

264266
- `manage_browsers` - Create, list, get, and delete browser sessions. Supports headless/stealth modes, profiles, proxies, viewports, extensions, and SSH tunneling.
@@ -267,6 +269,8 @@ Each Kernel feature has a single `manage_*` tool with an `action` parameter, kee
267269
- `manage_proxies` - Create, list, and delete proxy configurations (datacenter, ISP, residential, mobile, custom).
268270
- `manage_extensions` - List and delete uploaded browser extensions.
269271
- `manage_apps` - List apps, invoke actions, get/list deployments, and get invocation results.
272+
- `manage_projects` - Create, list, get, update, and delete organization projects.
273+
- `manage_api_keys` - Create, list, get, update, and delete Kernel API keys. Create returns the plaintext key once.
270274

271275
### Standalone tools
272276

src/lib/mcp/register.ts

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { registerKernelPrompts } from "@/lib/mcp/prompts";
3+
import { registerAPIKeyCapabilities } from "@/lib/mcp/tools/api-keys";
34
import { registerAppCapabilities } from "@/lib/mcp/tools/apps";
45
import { registerBrowserPoolCapabilities } from "@/lib/mcp/tools/browser-pools";
56
import { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers";
@@ -12,17 +13,107 @@ import { registerProjectCapabilities } from "@/lib/mcp/tools/projects";
1213
import { registerProxyTools } from "@/lib/mcp/tools/proxies";
1314
import { registerShellTool } from "@/lib/mcp/tools/shell";
1415

16+
type RegisterMcpToolset = (server: McpServer) => void;
17+
18+
const mcpToolRegistrations = [
19+
["profiles", registerProfileCapabilities],
20+
["docs", registerDocsTools],
21+
["browsers", registerBrowserCapabilities],
22+
["projects", registerProjectCapabilities],
23+
["api_keys", registerAPIKeyCapabilities],
24+
["browser_pools", registerBrowserPoolCapabilities],
25+
["proxies", registerProxyTools],
26+
["extensions", registerExtensionTools],
27+
["apps", registerAppCapabilities],
28+
["computer", registerComputerActionTool],
29+
["shell", registerShellTool],
30+
["playwright", registerPlaywrightTool],
31+
] as const satisfies readonly (readonly [string, RegisterMcpToolset])[];
32+
33+
type McpToolset = (typeof mcpToolRegistrations)[number][0];
34+
35+
const mcpToolsets = mcpToolRegistrations.map(([toolset]) => toolset);
36+
const mcpToolsetSet: ReadonlySet<string> = new Set(mcpToolsets);
37+
38+
const standaloneToolsetAliases: Partial<Record<string, McpToolset>> = {
39+
computer_action: "computer",
40+
search_docs: "docs",
41+
execute_playwright_code: "playwright",
42+
exec_command: "shell",
43+
};
44+
45+
function isMcpToolset(value: string): value is McpToolset {
46+
return mcpToolsetSet.has(value);
47+
}
48+
49+
function resolveMcpToolset(token: string): McpToolset | undefined {
50+
if (isMcpToolset(token)) return token;
51+
return standaloneToolsetAliases[token];
52+
}
53+
54+
function normalizeMcpToolset(value: string): McpToolset | undefined {
55+
const token = value.trim().toLowerCase().replace(/-/g, "_");
56+
const toolset = resolveMcpToolset(token);
57+
if (toolset) return toolset;
58+
59+
const managePrefix = "manage_";
60+
if (token.startsWith(managePrefix)) {
61+
return resolveMcpToolset(token.slice(managePrefix.length));
62+
}
63+
64+
return undefined;
65+
}
66+
67+
function disabledMcpToolsetsFromEnv() {
68+
const raw = process.env.KERNEL_MCP_DISABLED_TOOLSETS;
69+
if (!raw?.trim()) return new Set<McpToolset>();
70+
71+
const disabled = new Set<McpToolset>();
72+
let disableAll = false;
73+
const unknown: string[] = [];
74+
75+
for (const value of raw.split(/[,\s]+/)) {
76+
const token = value.trim().toLowerCase();
77+
if (!token || token === "none") continue;
78+
if (token === "all") {
79+
disableAll = true;
80+
continue;
81+
}
82+
83+
const toolset = normalizeMcpToolset(token);
84+
if (toolset) {
85+
disabled.add(toolset);
86+
} else {
87+
unknown.push(value);
88+
}
89+
}
90+
91+
if (unknown.length > 0) {
92+
throw new Error(
93+
`Unknown KERNEL_MCP_DISABLED_TOOLSETS value(s): ${unknown.join(", ")}. Supported toolsets: ${mcpToolsets.join(", ")}.`,
94+
);
95+
}
96+
97+
if (disableAll) return new Set<McpToolset>(mcpToolsets);
98+
99+
return disabled;
100+
}
101+
102+
function toolsetEnabled(
103+
disabledToolsets: Set<McpToolset>,
104+
toolset: McpToolset,
105+
) {
106+
return !disabledToolsets.has(toolset);
107+
}
108+
15109
export function registerMcpCapabilities(server: McpServer) {
16-
registerProfileCapabilities(server);
110+
const disabledToolsets = disabledMcpToolsetsFromEnv();
111+
17112
registerKernelPrompts(server);
18-
registerDocsTools(server);
19-
registerBrowserCapabilities(server);
20-
registerProjectCapabilities(server);
21-
registerBrowserPoolCapabilities(server);
22-
registerProxyTools(server);
23-
registerExtensionTools(server);
24-
registerAppCapabilities(server);
25-
registerComputerActionTool(server);
26-
registerShellTool(server);
27-
registerPlaywrightTool(server);
113+
114+
for (const [toolset, registerToolset] of mcpToolRegistrations) {
115+
if (toolsetEnabled(disabledToolsets, toolset)) {
116+
registerToolset(server);
117+
}
118+
}
28119
}

src/lib/mcp/responses.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
type PaginatedPage<T> = {
2+
getPaginatedItems(): T[];
3+
has_more?: boolean | null;
4+
next_offset?: number | null;
5+
};
6+
7+
export function textResponse(text: string) {
8+
return { content: [{ type: "text" as const, text }] };
9+
}
10+
11+
export function jsonResponse(value: unknown) {
12+
return textResponse(JSON.stringify(value, null, 2) ?? String(value));
13+
}
14+
15+
export function paginatedJsonResponse<T>(page: PaginatedPage<T>) {
16+
return jsonResponse({
17+
items: page.getPaginatedItems(),
18+
has_more: page.has_more,
19+
next_offset: page.next_offset,
20+
});
21+
}
22+
23+
export function errorResponse(text: string) {
24+
return { ...textResponse(text), isError: true as const };
25+
}
26+
27+
function errorMessage(error: unknown) {
28+
return error instanceof Error ? error.message : String(error);
29+
}
30+
31+
export function toolErrorResponse(
32+
toolName: string,
33+
action: string,
34+
error: unknown,
35+
) {
36+
return errorResponse(
37+
`Error in ${toolName} (${action}): ${errorMessage(error)}`,
38+
);
39+
}

src/lib/mcp/schemas.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { z } from "zod";
2+
3+
export const paginationParams = {
4+
limit: z
5+
.number()
6+
.int()
7+
.describe(
8+
"(list) Max results per page. Defaults to 20; API clamps to 1-100.",
9+
)
10+
.optional(),
11+
offset: z
12+
.number()
13+
.int()
14+
.describe(
15+
"(list) Pagination offset. Defaults to 0; API clamps negatives to 0.",
16+
)
17+
.optional(),
18+
};

src/lib/mcp/tools/api-keys.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { z } from "zod";
3+
import { createKernelClient } from "@/lib/mcp/kernel-client";
4+
import {
5+
errorResponse,
6+
jsonResponse,
7+
paginatedJsonResponse,
8+
textResponse,
9+
toolErrorResponse,
10+
} from "@/lib/mcp/responses";
11+
import { paginationParams } from "@/lib/mcp/schemas";
12+
13+
export function registerAPIKeyCapabilities(server: McpServer) {
14+
// manage_api_keys -- Create, list, get, update, and delete Kernel API keys
15+
server.tool(
16+
"manage_api_keys",
17+
'Manage Kernel API keys. Use "create" to create an org-wide or project-scoped key, "list" to discover masked keys, "get" to retrieve one masked key, "update" to rename a key, or "delete" to revoke a key. Created keys include the plaintext key once.',
18+
{
19+
action: z
20+
.enum(["create", "list", "get", "update", "delete"])
21+
.describe("Operation to perform."),
22+
api_key_id: z
23+
.string()
24+
.describe("API key ID. Required for get, update, and delete.")
25+
.optional(),
26+
name: z.string().describe("(create, update) API key name.").optional(),
27+
project_id: z
28+
.string()
29+
.nullable()
30+
.describe(
31+
"(create) Project ID for project-scoped keys. Omit or use null for org-wide keys.",
32+
)
33+
.optional(),
34+
days_to_expire: z
35+
.number()
36+
.int()
37+
.min(1)
38+
.max(3650)
39+
.nullable()
40+
.describe(
41+
"(create) Days until expiry, up to 3650. Use null for no expiry.",
42+
)
43+
.optional(),
44+
...paginationParams,
45+
},
46+
{
47+
title: "Manage Kernel API keys",
48+
readOnlyHint: false,
49+
destructiveHint: true,
50+
idempotentHint: false,
51+
openWorldHint: false,
52+
},
53+
async (params, extra) => {
54+
if (!extra.authInfo) throw new Error("Authentication required");
55+
const client = createKernelClient(extra.authInfo.token);
56+
57+
try {
58+
switch (params.action) {
59+
case "create": {
60+
if (!params.name) {
61+
return errorResponse("Error: name is required for create.");
62+
}
63+
const createParams: Parameters<typeof client.apiKeys.create>[0] = {
64+
name: params.name,
65+
};
66+
if (params.project_id !== undefined) {
67+
createParams.project_id = params.project_id;
68+
}
69+
if (params.days_to_expire !== undefined) {
70+
createParams.days_to_expire = params.days_to_expire;
71+
}
72+
const apiKey = await client.apiKeys.create(createParams);
73+
return jsonResponse(apiKey);
74+
}
75+
case "list": {
76+
const page = await client.apiKeys.list({
77+
...(params.limit !== undefined && { limit: params.limit }),
78+
...(params.offset !== undefined && { offset: params.offset }),
79+
});
80+
return paginatedJsonResponse(page);
81+
}
82+
case "get": {
83+
if (!params.api_key_id) {
84+
return errorResponse("Error: api_key_id is required for get.");
85+
}
86+
const apiKey = await client.apiKeys.retrieve(params.api_key_id);
87+
return jsonResponse(apiKey);
88+
}
89+
case "update": {
90+
if (!params.api_key_id) {
91+
return errorResponse("Error: api_key_id is required for update.");
92+
}
93+
if (!params.name) {
94+
return errorResponse("Error: name is required for update.");
95+
}
96+
const apiKey = await client.apiKeys.update(params.api_key_id, {
97+
name: params.name,
98+
});
99+
return jsonResponse(apiKey);
100+
}
101+
case "delete": {
102+
if (!params.api_key_id) {
103+
return errorResponse("Error: api_key_id is required for delete.");
104+
}
105+
await client.apiKeys.delete(params.api_key_id);
106+
return textResponse("API key deleted successfully");
107+
}
108+
}
109+
} catch (error) {
110+
return toolErrorResponse("manage_api_keys", params.action, error);
111+
}
112+
},
113+
);
114+
}

0 commit comments

Comments
 (0)