Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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