Skip to content

Commit de47f2e

Browse files
authored
Merge pull request #113 from kernel/admin-app-proxy-extension-cleanup-mcp-tool
Add admin/app/proxy cleanup parity tools
2 parents f59b6e4 + af60879 commit de47f2e

7 files changed

Lines changed: 242 additions & 116 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,13 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_
264264
### manage\_\* tools
265265

266266
- `manage_browsers` - Create, list, get, and delete browser sessions. Supports headless/stealth modes, profiles, proxies, viewports, extensions, and SSH tunneling.
267-
- `manage_profiles` - Setup (with guided live browser session), list, and delete browser profiles for persisting cookies and logins.
267+
- `manage_profiles` - Setup (with guided live browser session), search/list with pagination, get, and delete browser profiles for persisting cookies and logins.
268+
- `manage_projects` - Create, list, get, update, and delete organization projects. Inspect and update per-project resource limits.
269+
- `manage_api_keys` - Create, list, get, update, and delete org-wide or project-scoped API keys. Create returns the plaintext key once.
268270
- `manage_browser_pools` - Create, list, get, delete, and flush pools of pre-warmed browsers. Acquire and release browsers from pools.
269-
- `manage_proxies` - Create, list, and delete proxy configurations (datacenter, ISP, residential, mobile, custom).
271+
- `manage_proxies` - Create, list, get, check, and delete proxy configurations (datacenter, ISP, residential, mobile, custom).
270272
- `manage_extensions` - List and delete uploaded browser extensions.
271-
- `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.
273+
- `manage_apps` - List/search apps, invoke actions, get/list/delete deployments, and get invocation results.
274274
- `manage_auth_connections` - Create, list, get, delete managed auth connections; start login flows (returns a hosted URL and live view); submit MFA codes or SSO selections.
275275
- `manage_credentials` - Create, list, get, update, and delete stored credentials; fetch a current TOTP code for credentials with a configured totp_secret.
276276
- `manage_credential_providers` - Create, list, get, update, and delete external credential providers (e.g. 1Password); list available items and test the provider connection.

src/lib/mcp/responses.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,17 @@ export function itemsJsonResponse<T, U = T>(
2929
items: T[],
3030
options: ItemsJsonResponseOptions<T, U> = {},
3131
) {
32-
if (items.length === 0 && options.emptyText) {
33-
return textResponse(options.emptyText);
34-
}
32+
// Keep the response shape uniform JSON for every list outcome. When empty,
33+
// surface emptyText as a `note` (e.g. setup guidance) rather than swapping to
34+
// a plain-text body, so agents always get { items, has_more, next_offset }.
35+
const note =
36+
items.length === 0 ? (options.emptyText ?? options.note) : options.note;
3537

3638
return jsonResponse({
3739
items: options.mapItem ? items.map(options.mapItem) : items,
3840
has_more: options.has_more,
3941
next_offset: options.next_offset,
40-
...(options.note && { note: options.note }),
42+
...(note && { note }),
4143
});
4244
}
4345

src/lib/mcp/tools/apps.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
errorResponse,
77
jsonResponse,
88
paginatedJsonResponse,
9+
textResponse,
910
toolErrorResponse,
1011
} from "@/lib/mcp/responses";
1112
import { paginationParams } from "@/lib/mcp/schemas";
@@ -45,14 +46,15 @@ export function registerAppCapabilities(server: McpServer) {
4546
// manage_apps -- List apps, invoke actions, manage deployments, check invocations
4647
server.tool(
4748
"manage_apps",
48-
'Manage Kernel apps when an agent needs to discover deployed app actions, invoke an app, or inspect deployment/invocation state. Use "list_apps" before invoking an unknown app, "invoke" to run an action, and get/list actions to inspect results.',
49+
'Manage Kernel apps when an agent needs to discover deployed app actions, invoke an app, or inspect deployment/invocation state. Use "list_apps" before invoking an unknown app, "invoke" to run an action, get/list actions to inspect results, and "delete_deployment" to remove a deployment.',
4950
{
5051
action: z
5152
.enum([
5253
"list_apps",
5354
"invoke",
5455
"get_deployment",
5556
"list_deployments",
57+
"delete_deployment",
5658
"get_invocation",
5759
])
5860
.describe("Operation to perform."),
@@ -65,9 +67,10 @@ export function registerAppCapabilities(server: McpServer) {
6567
version: z
6668
.string()
6769
.describe(
68-
"(list_apps, invoke) App version filter. Defaults to 'latest' for invoke.",
70+
"(list_apps, invoke, list_deployments) App version filter. Defaults to 'latest' for invoke. Deployment version filtering requires app_name.",
6971
)
7072
.optional(),
73+
query: z.string().describe("(list_apps) Search apps by name.").optional(),
7174
action_name: z
7275
.string()
7376
.describe("(invoke) Action to execute within the app.")
@@ -78,7 +81,7 @@ export function registerAppCapabilities(server: McpServer) {
7881
.optional(),
7982
deployment_id: z
8083
.string()
81-
.describe("(get_deployment) Deployment ID to retrieve.")
84+
.describe("(get_deployment, delete_deployment) Deployment ID.")
8285
.optional(),
8386
invocation_id: z
8487
.string()
@@ -89,7 +92,7 @@ export function registerAppCapabilities(server: McpServer) {
8992
{
9093
title: "Manage Kernel apps and invocations",
9194
readOnlyHint: false,
92-
destructiveHint: false,
95+
destructiveHint: true,
9396
idempotentHint: false,
9497
openWorldHint: true,
9598
},
@@ -103,6 +106,7 @@ export function registerAppCapabilities(server: McpServer) {
103106
const page = await client.apps.list({
104107
...(params.app_name && { app_name: params.app_name }),
105108
...(params.version && { version: params.version }),
109+
...(params.query && { query: params.query }),
106110
...(params.limit !== undefined && { limit: params.limit }),
107111
...(params.offset !== undefined && { offset: params.offset }),
108112
});
@@ -164,13 +168,30 @@ export function registerAppCapabilities(server: McpServer) {
164168
return jsonResponse(deployment);
165169
}
166170
case "list_deployments": {
171+
if (params.version && !params.app_name) {
172+
return errorResponse(
173+
"Error: app_name is required when filtering deployments by version.",
174+
);
175+
}
167176
const page = await client.deployments.list({
168177
...(params.app_name && { app_name: params.app_name }),
178+
...(params.version && { app_version: params.version }),
169179
...(params.limit !== undefined && { limit: params.limit }),
170180
...(params.offset !== undefined && { offset: params.offset }),
171181
});
172182
return paginatedJsonResponse(page);
173183
}
184+
case "delete_deployment": {
185+
if (!params.deployment_id) {
186+
return errorResponse(
187+
"Error: deployment_id is required for delete_deployment.",
188+
);
189+
}
190+
await client.deployments.delete(params.deployment_id);
191+
return textResponse(
192+
`Deployment "${params.deployment_id}" deleted successfully.`,
193+
);
194+
}
174195
case "get_invocation": {
175196
if (!params.invocation_id)
176197
return errorResponse("Error: invocation_id is required.");

src/lib/mcp/tools/extensions.ts

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { z } from "zod";
33
import { createKernelClient } from "@/lib/mcp/kernel-client";
4+
import {
5+
errorResponse,
6+
itemsJsonResponse,
7+
textResponse,
8+
toolErrorResponse,
9+
} from "@/lib/mcp/responses";
410

511
export function registerExtensionTools(server: McpServer) {
612
// manage_extensions -- List and delete browser extensions
713
server.tool(
814
"manage_extensions",
9-
'Manage browser extensions uploaded to your organization. Use "list" to see all extensions or "delete" to remove one.',
15+
'Manage browser extensions uploaded to Kernel. Use "list" to see all extensions available to the current project or "delete" to remove one by ID or name.',
1016
{
1117
action: z.enum(["list", "delete"]).describe("Operation to perform."),
1218
id_or_name: z
@@ -29,45 +35,22 @@ export function registerExtensionTools(server: McpServer) {
2935
switch (params.action) {
3036
case "list": {
3137
const extensions = await client.extensions.list();
32-
return {
33-
content: [
34-
{
35-
type: "text",
36-
text:
37-
extensions?.length > 0
38-
? JSON.stringify(extensions, null, 2)
39-
: "No extensions found",
40-
},
41-
],
42-
};
38+
return itemsJsonResponse(extensions ?? [], {
39+
has_more: false,
40+
next_offset: null,
41+
emptyText: "No extensions found",
42+
});
4343
}
4444
case "delete": {
45-
if (!params.id_or_name)
46-
return {
47-
content: [
48-
{
49-
type: "text",
50-
text: "Error: id_or_name is required for delete.",
51-
},
52-
],
53-
};
45+
if (!params.id_or_name) {
46+
return errorResponse("Error: id_or_name is required for delete.");
47+
}
5448
await client.extensions.delete(params.id_or_name);
55-
return {
56-
content: [
57-
{ type: "text", text: "Extension deleted successfully" },
58-
],
59-
};
49+
return textResponse("Extension deleted successfully");
6050
}
6151
}
6252
} catch (error) {
63-
return {
64-
content: [
65-
{
66-
type: "text",
67-
text: `Error in manage_extensions (${params.action}): ${error}`,
68-
},
69-
],
70-
};
53+
return toolErrorResponse("manage_extensions", params.action, error);
7154
}
7255
},
7356
);

src/lib/mcp/tools/profiles.ts

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates";
55
import {
66
errorResponse,
77
itemsJsonResponse,
8+
jsonResponse,
89
paginatedJsonResponse,
910
textResponse,
1011
toolErrorResponse,
@@ -24,12 +25,15 @@ async function listProfiles(client: KernelClient, query?: ProfileListParams) {
2425
return profiles;
2526
}
2627

27-
function fullProfileListResponse(profiles: Profile[]) {
28+
function fullProfileListResponse(profiles: Profile[], query?: string) {
2829
return itemsJsonResponse(profiles, {
2930
has_more: false,
3031
next_offset: null,
31-
emptyText:
32-
"No profiles found. Use manage_profiles with action 'setup' to create one.",
32+
// A search that matches nothing shouldn't claim the inventory is empty or
33+
// suggest setup — other profiles may exist that just don't match the query.
34+
emptyText: query
35+
? `No profiles match "${query}".`
36+
: "No profiles found. Use manage_profiles with action 'setup' to create one.",
3337
});
3438
}
3539

@@ -65,20 +69,18 @@ export function registerProfileCapabilities(server: McpServer) {
6569

6670
server.tool(
6771
"manage_profiles",
68-
'Manage browser profiles when an agent needs persistent cookies, login state, or reusable browser state. Use "setup" for a guided login session, "list" to find a profile, and "delete" only when a profile should be removed.',
72+
'Manage browser profiles when an agent needs persistent cookies, login state, or reusable browser state. Use "setup" for a guided login session, "list" to find a profile, "get" to retrieve one, and "delete" only when a profile should be removed.',
6973
{
7074
action: z
71-
.enum(["setup", "list", "delete"])
75+
.enum(["setup", "list", "get", "delete"])
7276
.describe("Operation to perform."),
7377
profile_name: z
7478
.string()
75-
.describe(
76-
"(setup, delete) Profile name. For setup: 1-255 chars. For delete: name of profile to remove.",
77-
)
79+
.describe("(setup, get, delete) Profile name. For setup: 1-255 chars.")
7880
.optional(),
7981
profile_id: z
8082
.string()
81-
.describe("(delete) Profile ID to delete. Alternative to profile_name.")
83+
.describe("(get, delete) Profile ID. Alternative to profile_name.")
8284
.optional(),
8385
update_existing: z
8486
.boolean()
@@ -108,6 +110,9 @@ export function registerProfileCapabilities(server: McpServer) {
108110
return errorResponse(
109111
"Error: profile_name is required for setup.",
110112
);
113+
// Scan all profiles for an exact name match: the list `query` is a
114+
// search and may not reliably return an exact-named profile, which
115+
// would let setup create a duplicate.
111116
const existingProfiles = await listProfiles(client);
112117
const existingProfile = existingProfiles?.find(
113118
(p) => p.name === params.profile_name,
@@ -153,15 +158,43 @@ export function registerProfileCapabilities(server: McpServer) {
153158
client,
154159
params.query ? { query: params.query } : undefined,
155160
);
156-
return fullProfileListResponse(profiles);
161+
return fullProfileListResponse(profiles, params.query);
157162
}
158163

159164
const page = await client.profiles.list({
160165
...(params.query && { query: params.query }),
161166
...(params.limit !== undefined && { limit: params.limit }),
162167
...(params.offset !== undefined && { offset: params.offset }),
163168
} satisfies ProfileListParams);
164-
return paginatedJsonResponse(page);
169+
// On the first page of a search with no results, note the empty
170+
// match so agents can tell a failed search from an empty org. Skip
171+
// it past offset 0, where an empty page may just be beyond the
172+
// matches rather than a true miss.
173+
const emptySearch =
174+
params.query &&
175+
!params.offset &&
176+
page.getPaginatedItems().length === 0;
177+
return paginatedJsonResponse(
178+
page,
179+
emptySearch
180+
? { note: `No profiles match "${params.query}".` }
181+
: {},
182+
);
183+
}
184+
case "get": {
185+
if (params.profile_name && params.profile_id) {
186+
return errorResponse(
187+
"Error: Cannot specify both profile_name and profile_id.",
188+
);
189+
}
190+
const identifier = params.profile_name || params.profile_id;
191+
if (!identifier) {
192+
return errorResponse(
193+
"Error: profile_name or profile_id is required for get.",
194+
);
195+
}
196+
const profile = await client.profiles.retrieve(identifier);
197+
return jsonResponse(profile);
165198
}
166199
case "delete": {
167200
if (params.profile_name && params.profile_id) {

0 commit comments

Comments
 (0)