shared: the permission to add an MCP server, mcp.servers.add, and the events of a server the Dot adds

mcp.servers.add asks by default and is high risk; a server's name holds no `.`, so it is never a server's own
permission. `mcp.server.added {arguments}` is the guest's report of an add_mcp_server call that ran, and
`mcp.server.refused {name, reason}` the host's when it does not write the server. parseMcpServerEntry is the one
check of a server's entry, the config's own schema.
This commit is contained in:
feder-cr committed 2026-10-11 10:44:26 +02:00
1 parent 2c803ef5b5
commit 7067f0357b
6 files changed
+37 -4

No files matched your search

+11 -1
View File
@@ -252,6 +252,16 @@ export type McpServerConfig = z.output<typeof mcpServer>;
export type McpStdioServerConfig = z.output<typeof mcpStdioServer>;
export type McpHttpServerConfig = z.output<typeof mcpHttpServer>;
/**
* One MCP server's entry as the config would hold it, with its defaults, or why it is not one: what the host checks of
* a server the Dot asks to add (`mcp.server.added`) before it writes it into the config.
*/
export function parseMcpServerEntry(value: unknown): { ok: true; server: McpServerConfig } | { ok: false; problem: string } {
const result = mcpServer.safeParse(value);
if (result.success) return { ok: true, server: result.data };
return { ok: false, problem: result.error.issues.map((issue) => `${issue.path.join(".") || "entry"}: ${issue.message}`).join("; ") };
}
export function isMcpStdioServer(server: McpServerConfig): server is McpStdioServerConfig {
return "command" in server;
}
@@ -486,7 +496,7 @@ export function resolvePermission(
return config.permissions[permission] ?? defaultPermission(permission as PermissionName);
}
const ASK_BY_DEFAULT: ReadonlySet<Permission> = new Set<Permission>(["browser.identity.delete", "automations"]);
const ASK_BY_DEFAULT: ReadonlySet<Permission> = new Set<Permission>(["browser.identity.delete", "automations", "mcp.servers.add"]);
const ALLOWED_NAMESPACES: ReadonlySet<string> = new Set(["computer", "files", "browser"]);
/** What a permission is when the config says nothing of it: the decision `resolvePermission` falls back to. */
+13
View File
@@ -46,6 +46,7 @@ export const OUTBOUND_EVENT_TYPES = [
...IDENTITY_EVENT_TYPES,
"automation.next_run",
"memory.updated",
"mcp.server.added",
] as const;
export type OutboundEventType = (typeof OUTBOUND_EVENT_TYPES)[number];
@@ -60,6 +61,7 @@ export const HOST_EVENT_TYPES = [
"task.cancelled",
"approval.resolved",
"guest.event.refused",
"mcp.server.refused",
"channel.status",
"channel.peer.paired",
"channel.changed",
@@ -223,6 +225,14 @@ export interface OutboundEventDataMap {
* failed since the last of these).
*/
"memory.updated": { conversations: number; changed: boolean } & SpentUsd;
/**
* The Dot added an MCP server to its own config (`add_mcp_server`, which `mcp.servers.add` allowed or the person
* approved): the call's arguments as it made them, `name` and the entry (`command`, `args`, `env` or `url`,
* `headers`, and `secrets`, names only) with an optional `reason`. The host writes the server into the config only
* when the config allows the permission or the person approved exactly these arguments, and says so otherwise
* (`mcp.server.refused`).
*/
"mcp.server.added": { arguments: Record<string, unknown> };
}
/**
@@ -267,6 +277,8 @@ export interface HostEventDataMap {
/** The person paused, resumed or removed a channel (a removed one has no status left to report): every view of the channel follows it. */
"channel.changed": { kind: ChannelKind; change: ChannelChange };
"guest.event.refused": RefusedEvent;
/** The host did not add the MCP server a Dot asked for (`mcp.server.added`), and why. */
"mcp.server.refused": { name: string; reason: string };
}
export type InboundEvent<T extends InboundEventType = InboundEventType> = {
@@ -408,6 +420,7 @@ export const outboundEventSchema = z.discriminatedUnion("type", [
type: z.literal("memory.updated"),
data: z.object({ conversations: z.number().int().nonnegative(), changed: z.boolean(), spent_usd: spentUsd }),
}),
z.object({ ...outboundBase, type: z.literal("mcp.server.added"), data: z.object({ arguments: z.record(z.string(), z.unknown()) }) }),
]);
/** Validate an outbound event read from the guest stream; throws with every problem listed. */
+9 -1
View File
@@ -20,6 +20,7 @@ export const PERMISSIONS = [
"browser.read",
"browser.act",
"automations",
"mcp.servers.add",
] as const;
export type Permission = (typeof PERMISSIONS)[number];
@@ -104,12 +105,19 @@ export const PERMISSION_INFO: Record<Permission, PermissionInfo> = {
description: "Add, list and remove the Dot's own scheduled automations, which keep working after a turn ends.",
risk: "medium",
},
"mcp.servers.add": {
label: "Add MCP servers",
description:
"Add an MCP server to the Dot's own config (a program it installed, or a URL), whose tools it may then use, each asking under the server's own permission.",
risk: "high",
},
};
/**
* An MCP server the person declares in a Dot's config (`mcp_servers`, section 7) is named by this rule. The name is
* the prefix of the server's tools as the model sees them, `mcp_<server>_<tool>` (nanobot's naming), and the name
* of its permission, `mcp.<server>`; it holds no `_` so the server can be read back from a tool's name. The engine
* of its permission, `mcp.<server>`; it holds no `_` so the server can be read back from a tool's name, and no `.`,
* so `mcp.servers.add`, the permission to add a server, is never a server's. The engine
* keeps a copy in nanobot/dots/protocol.py, kept equal by tests/repo/vendored-nanobot.test.ts.
*/
export const MCP_SERVER_NAME_PATTERN = "^[a-z0-9][a-z0-9-]{0,31}$";
+1 -1
View File
@@ -318,7 +318,7 @@ describe("resolvePermission", () => {
const defaults = parseDotConfig(MINIMAL);
it("allows every known permission by default except those that act beyond the Dot's computer", () => {
const asks = new Set(["browser.identity.delete", "automations"]);
const asks = new Set(["browser.identity.delete", "automations", "mcp.servers.add"]);
for (const permission of PERMISSIONS) {
expect(resolvePermission(defaults, permission)).toBe(asks.has(permission) ? "ask" : "allow");
}
+2 -1
View File
@@ -34,7 +34,8 @@ describe("event type lists", () => {
it("match section 5.4", () => {
expect(INBOUND_EVENT_TYPES).toEqual(["user.message", "task.created", "approval.received", "system.event"]);
expect(OUTBOUND_EVENT_TYPES).toHaveLength(15);
expect(OUTBOUND_EVENT_TYPES).toHaveLength(16);
expect(OUTBOUND_EVENT_TYPES).toContain("mcp.server.added");
expect(OUTBOUND_EVENT_TYPES).toContain("browser.identity.launched");
expect(OUTBOUND_EVENT_TYPES).toContain("automation.next_run");
expect(OUTBOUND_EVENT_TYPES).toContain("memory.updated");
+1
View File
@@ -22,6 +22,7 @@ describe("the permission registry", () => {
"browser.read",
"browser.act",
"automations",
"mcp.servers.add",
]);
});