Skip to content
7 changes: 7 additions & 0 deletions packages/coding-agent/src/cli/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
usage: "config",
summary: "Configure package resources",
},
{
path: ["mcp"],
usage: "mcp <list|inspect|preview|add|enable|disable|remove> ... [--project]",
summary: "Manage declarative MCP endpoint records",
description:
"Commands only read or write credential-free declarations. They never start an MCP runtime or authentication flow.",
},
];

export const PUBLIC_COMMAND_NAMES = new Set(
Expand Down
61 changes: 61 additions & 0 deletions packages/coding-agent/src/cli/public-command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import chalk from "chalk";
import { APP_NAME, SELF_UPDATE_INTERACTIVE_CHILD_ENV } from "../config.js";
import { executeMcpDeclarationCommand, parseMcpDeclarationCommand } from "../core/mcp/mcp-declaration-command.js";
import {
admitGlobalMcpProjectDeclarations,
McpProjectDeclarationReader,
} from "../core/mcp/mcp-project-declaration-reader.js";
import { releaseProjectMcpDeclarationAdmission } from "../core/mcp/mcp-project-trust.js";
import { type Settings, SettingsManager } from "../core/settings-manager.js";
import { handlePackageCommand, isSelfUpdateSource } from "../package-manager-cli.js";
import { INTERNAL_RUNTIME_COMMAND_MARKER, parseArgs } from "./args.js";
import {
Expand Down Expand Up @@ -140,11 +147,65 @@ async function runPublicCommand(args: string[]): Promise<PublicCommandResult> {
case "config":
if (!requireArgumentCount(args.slice(1), 0, "config")) return HANDLED;
return continueWith(args);
case "mcp":
return runMcpDeclarationCommand(args.slice(1));
default:
return continueWith(args);
}
}

/**
* Sole public-command composition point for project MCP policy. It receives a
* SettingsManager already loaded by the CLI and reads only its global snapshot.
* A project settings value can never create a grant.
*/
export function composeMcpProjectDeclarationAdmission(
command: ReturnType<typeof parseMcpDeclarationCommand>,
globalSettings: Pick<Settings, "mcpProjectTrustPolicy">,
workingDirectory: string,
) {
if (command.scope !== "project") return undefined;
// The only raw-path authorization. Downstream receives no path or authority
// policy, only the opaque admission returned by the shared global composer.
return admitGlobalMcpProjectDeclarations(globalSettings, workingDirectory);
}

async function runMcpDeclarationCommand(args: string[]): Promise<PublicCommandResult> {
// Probing is an internal injected-executor capability only. Reject this
// public spelling before parsing or any settings read can occur.
if (args[0] === "test") throw new Error("MCP probe is unavailable in this command context.");
const command = parseMcpDeclarationCommand(args);
const workingDirectory = process.cwd();
if (command.scope === "project") {
// This global-only read deliberately precedes SettingsManager.create(): a
// denied/missing/malformed policy must never open project settings.
const admission = composeMcpProjectDeclarationAdmission(
command,
SettingsManager.loadGlobalSettings(workingDirectory),
workingDirectory,
);
if (!admission) throw new Error("Project MCP declarations are unavailable.");
// Do not construct SettingsManager here: it eagerly reads project scope.
// This capability-scoped adapter validates around every declaration I/O.
try {
const reader = await McpProjectDeclarationReader.create(admission);
const settings = reader.asCommandSettings();
const result = await executeMcpDeclarationCommand(command, settings as SettingsManager, admission);
await settings.flush();
console.log(JSON.stringify(result, null, 2));
return HANDLED;
} finally {
releaseProjectMcpDeclarationAdmission(admission);
}
}
// User declarations retain the existing full settings behavior.
const settings = SettingsManager.create(workingDirectory);
const result = await executeMcpDeclarationCommand(command, settings);
await settings.flush();
console.log(JSON.stringify(result, null, 2));
return HANDLED;
}

function normalizeLeadingDaemonSocketOption(args: string[]): string[] {
const option = args[0];
if (option !== "--daemon-socket") {
Expand Down
236 changes: 126 additions & 110 deletions packages/coding-agent/src/core/agent-messages.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { HostRequestHandler } from "./kernel/index.js";
import { createHostRequestHandler, type HostRequestContext, type HostRequestHandler } from "./kernel/index.js";
import type { CustomMessage } from "./messages.js";
import { canonicalSessionPath } from "./session-lease.js";

Expand Down Expand Up @@ -257,72 +257,81 @@ function sameAgentSessionNameParent(
if (left.depth === 0 && right.depth === 0) {
return true;
}
return sameAgentFamilyParent(left, right, catalog);
if (sameAgentFamilyParent(left, right, catalog)) return true;

// A passive child can outlive the active parent row that would normally
// resolve its family edge. Name reservation must still protect that parent's
// sibling namespace, but this weaker direct-claim fallback is deliberately
// not used for family reach: reach continues to require an unambiguous,
// catalog-resolved parent.
if (left.depth !== right.depth || left.depth === 0) return false;
return (
(left.parentSessionId !== undefined && left.parentSessionId === right.parentSessionId) ||
(left.parentSessionPath !== undefined &&
right.parentSessionPath !== undefined &&
canonicalSessionPath(left.parentSessionPath) === canonicalSessionPath(right.parentSessionPath))
);
}

function sameAgentFamilyParent(
left: AgentSessionNameScope,
right: AgentSessionNameScope,
catalog: readonly AgentFamilyCatalogEntry[],
): boolean {
if (left.parentSessionPath !== undefined && left.parentSessionPath === right.parentSessionPath) {
return true;
}
if (left.parentSessionId !== undefined && left.parentSessionId === right.parentSessionId) {
return true;
}
const hasCatalogParentPair = (parentSessionId: string | undefined, parentSessionPath: string | undefined) =>
parentSessionId !== undefined &&
parentSessionPath !== undefined &&
catalog.some(
(entry) =>
(entry.id === parentSessionId && entry.sessionPath === parentSessionPath) ||
(entry.parentSessionId === parentSessionId && entry.parentSessionPath === parentSessionPath),
if (left.depth === 0 && right.depth === 0) {
return (
left.parentSessionId === undefined &&
left.parentSessionPath === undefined &&
right.parentSessionId === undefined &&
right.parentSessionPath === undefined
);
if (
hasCatalogParentPair(left.parentSessionId, right.parentSessionPath) ||
hasCatalogParentPair(right.parentSessionId, left.parentSessionPath)
) {
return true;
}
if (
left.depth === 0 &&
right.depth === 0 &&
left.parentSessionPath === undefined &&
right.parentSessionPath === undefined &&
left.parentSessionId === undefined &&
right.parentSessionId === undefined
) {
return true;
}
// Unresolved mixed identifiers stay unrelated to avoid false name conflicts across families.
return false;
if (left.depth !== right.depth || left.depth === 0) return false;
const parentFor = (child: AgentSessionNameScope) => {
const parents = catalog.filter((entry) => isAgentFamilyParent(entry, child));
return parents.length === 1 ? parents[0] : undefined;
};
const leftParent = parentFor(left);
const rightParent = parentFor(right);
return leftParent !== undefined && leftParent.id === rightParent?.id;
}

function isAgentFamilyParent(parent: AgentFamilyCatalogEntry, child: AgentFamilyCatalogEntry): boolean {
/**
* Validates one persisted parent edge. A child may supply either durable
* identifier, but when it supplies both they must identify this same direct
* parent. This keeps contradictory records from becoming relatives through
* whichever identifier happens to match.
*/
function isAgentFamilyParent(parent: AgentFamilyCatalogEntry, child: AgentSessionNameScope): boolean {
if (child.depth <= 0 || parent.depth !== child.depth - 1) return false;
const claimsId = child.parentSessionId !== undefined;
const claimsPath = child.parentSessionPath !== undefined;
return (
(child.parentSessionPath !== undefined && child.parentSessionPath === parent.sessionPath) ||
(child.parentSessionId !== undefined && child.parentSessionId === parent.id)
(claimsId || claimsPath) &&
(!claimsId || child.parentSessionId === parent.id) &&
(!claimsPath || child.parentSessionPath === parent.sessionPath)
);
}

/** Pure nuclear-family policy over persisted parent-edge snapshots. */
export function agentFamilyRelationship(
current: AgentFamilyCatalogEntry,
target: AgentFamilyCatalogEntry,
catalog: readonly AgentFamilyCatalogEntry[] = [current, target],
): AgentFamilyRelationship | undefined {
if (current.id === target.id) return undefined;
if (isAgentFamilyParent(target, current)) return "parent";
if (isAgentFamilyParent(current, target)) return "child";
if (current.depth === target.depth && sameAgentFamilyParent(current, target, [current, target])) return "sibling";
if (sameAgentFamilyParent(current, target, catalog)) return "sibling";
return undefined;
}

export function assertAgentFamilyReach(
current: AgentFamilyCatalogEntry,
target: AgentFamilyCatalogEntry,
catalog?: readonly AgentFamilyCatalogEntry[],
): AgentFamilyRelationship {
const relationship = agentFamilyRelationship(current, target);
const relationship = agentFamilyRelationship(current, target, catalog);
if (!relationship) throw new Error(AGENT_FAMILY_REACH_ERROR);
return relationship;
}
Expand Down Expand Up @@ -526,83 +535,90 @@ export function createAgentMessageHostHandlers(
controller: Pick<AgentSessionMessageController, "roster" | "sendAgentMessage" | "awaitPendingChildPublication">,
): Record<string, HostRequestHandler> {
return {
"agent_message.list_agents": async () => {
if (!controller.roster) throw new Error("agent family roster is not available in this session");
return (await controller.roster()) as unknown as Record<string, unknown>;
},
"agent_message.send": async (payload) => {
if (typeof payload.message !== "string") {
throw new Error("agent_message.send message must be a string");
}
let target: string;
if (typeof payload.target === "string") {
if (payload.target !== "all") {
throw new Error(
"positional agent_message.send targets are not supported; use receiver_role and receiver_name",
);
}
if (payload.receiver_role !== undefined || payload.receiver_name !== undefined) {
throw new Error("agent_message.send broadcast cannot be combined with receiver_role/receiver_name");
}
"agent_message.list_agents": createHostRequestHandler(
async (_payload: Record<string, unknown>, _context: HostRequestContext) => {
if (!controller.roster) throw new Error("agent family roster is not available in this session");
const roster = await controller.roster();
const results = await Promise.allSettled(
roster.entries.map((entry) =>
controller.sendAgentMessage({
target: entry.id,
message: payload.message as string,
receiverRole: entry.relationship,
}),
),
);
const receipts = results.map((result, index) =>
result.status === "fulfilled"
? result.value
: {
target: roster.entries[index]!.id,
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
},
);
return { receipts } as unknown as Record<string, unknown>;
} else {
const role = payload.receiver_role;
if (role !== "parent" && role !== "sibling" && role !== "child") {
throw new Error('agent_message.send receiver_role must be "parent", "sibling", or "child"');
return (await controller.roster()) as unknown as Record<string, unknown>;
},
),
"agent_message.send": createHostRequestHandler(
async (payload: Record<string, unknown>, _context: HostRequestContext) => {
if (typeof payload.message !== "string") {
throw new Error("agent_message.send message must be a string");
}
const receiverName = payload.receiver_name;
if (role === "parent" && receiverName !== undefined && receiverName !== null) {
throw new Error("agent_message.send receiver_name must be omitted for parent messages");
}
if (role !== "parent" && (typeof receiverName !== "string" || !receiverName.trim())) {
throw new Error("agent_message.send receiver_name is required for sibling and child messages");
}
if (!controller.roster) throw new Error("agent family roster is not available in this session");
const selector = typeof receiverName === "string" ? receiverName.trim() : undefined;
const publishedId =
role === "child" && selector && controller.awaitPendingChildPublication
? await controller.awaitPendingChildPublication(selector)
: undefined;
const roster = await controller.roster();
const matches = roster.entries.filter(
(entry) =>
entry.relationship === role &&
(role === "parent" || entry.name === selector || entry.id === selector || entry.id === publishedId),
);
if (matches.length !== 1) {
throw new Error(
matches.length === 0
? `No ${role} matches ${role === "parent" ? "the current agent" : JSON.stringify(receiverName)}`
: `${role} selector ${JSON.stringify(receiverName)} is ambiguous`,
let target: string;
if (typeof payload.target === "string") {
if (payload.target !== "all") {
throw new Error(
"positional agent_message.send targets are not supported; use receiver_role and receiver_name",
);
}
if (payload.receiver_role !== undefined || payload.receiver_name !== undefined) {
throw new Error("agent_message.send broadcast cannot be combined with receiver_role/receiver_name");
}
if (!controller.roster) throw new Error("agent family roster is not available in this session");
const roster = await controller.roster();
const results = await Promise.allSettled(
roster.entries.map((entry) =>
controller.sendAgentMessage({
target: entry.id,
message: payload.message as string,
receiverRole: entry.relationship,
}),
),
);
const receipts = results.map((result, index) =>
result.status === "fulfilled"
? result.value
: {
target: roster.entries[index]!.id,
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
},
);
return { receipts } as unknown as Record<string, unknown>;
} else {
const role = payload.receiver_role;
if (role !== "parent" && role !== "sibling" && role !== "child") {
throw new Error('agent_message.send receiver_role must be "parent", "sibling", or "child"');
}
const receiverName = payload.receiver_name;
if (role === "parent" && receiverName !== undefined && receiverName !== null) {
throw new Error("agent_message.send receiver_name must be omitted for parent messages");
}
if (role !== "parent" && (typeof receiverName !== "string" || !receiverName.trim())) {
throw new Error("agent_message.send receiver_name is required for sibling and child messages");
}
if (!controller.roster) throw new Error("agent family roster is not available in this session");
const selector = typeof receiverName === "string" ? receiverName.trim() : undefined;
const publishedId =
role === "child" && selector && controller.awaitPendingChildPublication
? await controller.awaitPendingChildPublication(selector)
: undefined;
const roster = await controller.roster();
const matches = roster.entries.filter(
(entry) =>
entry.relationship === role &&
(role === "parent" ||
entry.name === selector ||
entry.id === selector ||
entry.id === publishedId),
);
if (matches.length !== 1) {
throw new Error(
matches.length === 0
? `No ${role} matches ${role === "parent" ? "the current agent" : JSON.stringify(receiverName)}`
: `${role} selector ${JSON.stringify(receiverName)} is ambiguous`,
);
}
target = matches[0]!.id;
}
target = matches[0]!.id;
}
return (await controller.sendAgentMessage({
target,
message: payload.message,
receiverRole: payload.receiver_role as AgentFamilyRelationship,
})) as unknown as Record<string, unknown>;
},
return (await controller.sendAgentMessage({
target,
message: payload.message,
receiverRole: payload.receiver_role as AgentFamilyRelationship,
})) as unknown as Record<string, unknown>;
},
),
};
}

Expand Down
Loading