Skip to content
Open
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
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|test|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. A test probe requires an injected local transport.",
},
];

export const PUBLIC_COMMAND_NAMES = new Set(
Expand Down
58 changes: 58 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,62 @@ 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> {
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
166 changes: 103 additions & 63 deletions packages/coding-agent/src/core/agent-session-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import type { AgentRlmHeartbeatController } from "./cron-jobs.js";
import { createHerdrAgentStateExtension } from "./extensions/builtin/herdr-agent-state.js";
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js";
import { McpManager } from "./mcp/mcp-manager.js";
import { composeMcpProjectDeclarationReader } from "./mcp/mcp-project-declaration-reader.js";
import {
type ProjectMcpDeclarationAdmission,
validateProjectMcpDeclarationAdmission,
} from "./mcp/mcp-project-trust.js";
import { createMcpRuntimeDeclarationSnapshot } from "./mcp/mcp-runtime-declaration-snapshot.js";
import { ModelRegistry } from "./model-registry.js";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js";
import type { SubagentRuntimeHost } from "./rlm-runtime.js";
Expand Down Expand Up @@ -44,6 +50,8 @@ export interface CreateAgentSessionServicesOptions {
agentDir?: string;
authStorage?: AuthStorage;
settingsManager?: SettingsManager;
/** Explicit opaque admission for an injected manager; absence is fail-closed. */
projectMcpAdmission?: ProjectMcpDeclarationAdmission;
modelRegistry?: ModelRegistry;
extensionFlagValues?: Map<string, boolean | string>;
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
Expand Down Expand Up @@ -180,78 +188,110 @@ export async function createAgentSessionServices(
const cwd = options.cwd;
const agentDir = options.agentDir ?? getAgentDir();
const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json"));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json"));

// MCP integrations: registers OAuth providers and gates the built-in
// integration skills by whether the user is logged in (enable-by-login).
const mcpManager = new McpManager({
authStorage,
getUserServers: () => settingsManager.getMcpServers(),
});
// refresh() resets the OAuth registry to built-ins; re-add user MCP providers too.
modelRegistry.setOnOAuthProvidersReset(() => mcpManager.registerUserProviders());

const userExtensionFactories = options.resourceLoaderOptions?.extensionFactories ?? [];
// The built-in Herdr reporter defers to Herdr's own file-based integration
// when the loader actually loaded it; two reporters would race on the same
// pane. Deferral is late-bound to the loader's loaded paths (inline
// factories run after file extensions load), so a file that exists but is
// disabled or never discovered does not silence the built-in.
// noExtensions is a full opt-out: it disables the built-in reporter too,
// not just discovered extension files.
const skipHerdrReporter = options.noBuiltinHerdrReporter || options.resourceLoaderOptions?.noExtensions;
const builtinExtensionFactories = skipHerdrReporter
? []
: [createHerdrAgentStateExtension(() => resourceLoader.getLoadedExtensionPaths())];
const resourceLoader: DefaultResourceLoader = new DefaultResourceLoader({
...(options.resourceLoaderOptions ?? {}),
extensionFactories: [...builtinExtensionFactories, ...userExtensionFactories],
// Compose the global-only admission and scoped reader before SettingsManager
// can load project state. Injected managers remain project-inert unless the
// caller carries an explicit opaque admission.
const { projectMcpAdmission, projectReader, releaseProjectMcpAdmission } = await composeMcpProjectDeclarationReader({
cwd,
agentDir,
settingsManager,
extraBuiltinSkillOverrides: () => mcpManager.getDisabledBuiltinSkillOverrides(),
settingsManager: options.settingsManager,
projectMcpAdmission: options.projectMcpAdmission,
});
await resourceLoader.reload();
try {
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json"));

const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
if (
!options.telemetryDisabled &&
isTelemetryEnabled(settingsManager) &&
!settingsManager.getTelemetryNoticeShown()
) {
diagnostics.push({
type: "info",
message:
"Prime Agent sends pseudonymous usage and performance metrics without prompts, responses, tool content, file paths, or repository data. Disable this with telemetry.enabled=false, PRIME_AGENT_TELEMETRY=0, DO_NOT_TRACK=1, or offline mode.",
// A single declaration-only snapshot is captured before any legacy manager
// behavior. The scoped reader validates its opaque admission around every
// project filesystem operation.
const runtimeMcpDeclarations = createMcpRuntimeDeclarationSnapshot({
userDocument: settingsManager.getMcpDeclarationDocument("user"),
projectAdmission: projectMcpAdmission,
readProjectDocument: projectReader
? () => {
try {
return projectReader.getDocument();
} catch (error) {
// Root replacement/revocation during the scoped callback discards
// only the project contribution. Genuine still-authorized I/O or
// parse errors retain their normal failure behavior.
if (validateProjectMcpDeclarationAdmission(projectMcpAdmission).kind === "granted") throw error;
return undefined;
}
}
: undefined,
});
settingsManager.setTelemetryNoticeShown(true);
}
const extensionsResult = resourceLoader.getExtensions();
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try {
modelRegistry.registerProvider(name, config);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const mcpManager = new McpManager({
authStorage,
getUserServers: () => settingsManager.getGlobalMcpServers(),
getRuntimeDeclarations: () => runtimeMcpDeclarations,
});
// refresh() resets the OAuth registry to built-ins; re-add user MCP providers too.
modelRegistry.setOnOAuthProvidersReset(() => mcpManager.registerUserProviders());

const userExtensionFactories = options.resourceLoaderOptions?.extensionFactories ?? [];
// The built-in Herdr reporter defers to Herdr's own file-based integration
// when the loader actually loaded it; two reporters would race on the same
// pane. Deferral is late-bound to the loader's loaded paths (inline
// factories run after file extensions load), so a file that exists but is
// disabled or never discovered does not silence the built-in.
// noExtensions is a full opt-out: it disables the built-in reporter too,
// not just discovered extension files.
const skipHerdrReporter = options.noBuiltinHerdrReporter || options.resourceLoaderOptions?.noExtensions;
const builtinExtensionFactories = skipHerdrReporter
? []
: [createHerdrAgentStateExtension(() => resourceLoader.getLoadedExtensionPaths())];
const resourceLoader: DefaultResourceLoader = new DefaultResourceLoader({
...(options.resourceLoaderOptions ?? {}),
extensionFactories: [...builtinExtensionFactories, ...userExtensionFactories],
cwd,
agentDir,
settingsManager,
extraBuiltinSkillOverrides: () => mcpManager.getDisabledBuiltinSkillOverrides(),
});
await resourceLoader.reload();

const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
if (
!options.telemetryDisabled &&
isTelemetryEnabled(settingsManager) &&
!settingsManager.getTelemetryNoticeShown()
) {
diagnostics.push({
type: "error",
message: `Extension "${extensionPath}" error: ${message}`,
type: "info",
message:
"Prime Agent sends pseudonymous usage and performance metrics without prompts, responses, tool content, file paths, or repository data. Disable this with telemetry.enabled=false, PRIME_AGENT_TELEMETRY=0, DO_NOT_TRACK=1, or offline mode.",
});
settingsManager.setTelemetryNoticeShown(true);
}
}
extensionsResult.runtime.pendingProviderRegistrations = [];
diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));
const extensionsResult = resourceLoader.getExtensions();
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try {
modelRegistry.registerProvider(name, config);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
diagnostics.push({
type: "error",
message: `Extension "${extensionPath}" error: ${message}`,
});
}
}
extensionsResult.runtime.pendingProviderRegistrations = [];
diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));

return {
cwd,
agentDir,
authStorage,
settingsManager,
modelRegistry,
resourceLoader,
mcpManager,
diagnostics,
};
return {
cwd,
agentDir,
authStorage,
settingsManager,
modelRegistry,
resourceLoader,
mcpManager,
diagnostics,
};
} finally {
releaseProjectMcpAdmission?.();
}
}

/**
Expand Down
15 changes: 15 additions & 0 deletions packages/coding-agent/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ export {
type TurnStartEvent,
type WorkingIndicatorOptions,
} from "./extensions/index.js";
export {
type CreateMcpRuntimeDeclarationSnapshotInput,
createMcpRuntimeDeclarationSnapshot,
type McpRuntimeDeclaration,
type McpRuntimeDeclarationSnapshot,
type McpRuntimeDeclarationSource,
} from "./mcp/mcp-runtime-declaration-snapshot.js";
export {
createMcpProjectTrustAuthority,
type McpProjectTrustAuthority,
type McpProjectTrustAuthorityInput,
type McpProjectTrustAuthorization,
type McpProjectTrustBinding,
type McpProjectTrustBindingValidation,
} from "./mcp/project-trust-authority.js";
export type { RefinementResult } from "./refinement/index.js";
export type { CreateRlmSubagentRuntimeOptions, RlmSubagentRuntime, SubagentRuntimeHost } from "./rlm-runtime.js";
export { SessionImportFileNotFoundError } from "./session-import-errors.js";
Expand Down
Loading