From 8426188cb95dce2e7b1486b46b85cc119424316f Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Thu, 7 May 2026 20:50:34 -0700 Subject: [PATCH] feat: print MCP client config --- README.md | 14 +++++++ Sources/agentd/AgentdMCP.swift | 48 ++++++++++++++++++++++ Sources/agentd/DiagnosticCLI.swift | 24 ++++++++++- Tests/agentdTests/DiagnosticCLITests.swift | 28 +++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e517b14..6097a68 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,20 @@ from JSON batches, and `agentd_collect_diagnostics` for writing the same Chronicle-style activity artifacts to a caller-provided local directory. The MCP surface never returns raw frames or encrypted fallback batches. +Run `agentd mcp config --command /path/to/agentd` to print a Claude/Codex-style +client config snippet: + +```json +{ + "mcpServers": { + "agentd": { + "command": "/path/to/agentd", + "args": ["mcp"] + } + } +} +``` + `scripts/mock_chronicle.py` provides a strict local mock Chronicle and Secret Broker harness. CI validates the golden fixtures in `Tests/Fixtures/chronicle` so request-shape drift is explicit until generated `chronicle.v1` Swift types diff --git a/Sources/agentd/AgentdMCP.swift b/Sources/agentd/AgentdMCP.swift index 1e514b3..b776635 100644 --- a/Sources/agentd/AgentdMCP.swift +++ b/Sources/agentd/AgentdMCP.swift @@ -49,6 +49,54 @@ struct AgentdMCPDiagnosticsResult: Codable, Equatable, Sendable { let resourcePaths: [String] } +struct AgentdMCPConfigOptions: Equatable { + var command: String? + var serverName = "agentd" + + static func parse(_ arguments: [String]) throws -> AgentdMCPConfigOptions { + var options = AgentdMCPConfigOptions() + var index = 0 + while index < arguments.count { + let flag = arguments[index] + switch flag { + case "--command": + index += 1 + guard index < arguments.count, !arguments[index].isEmpty else { + throw DiagnosticCLIError.usage("--command requires an agentd executable path") + } + options.command = arguments[index] + case "--server-name": + index += 1 + guard index < arguments.count, !arguments[index].isEmpty else { + throw DiagnosticCLIError.usage("--server-name requires a non-empty MCP server name") + } + options.serverName = arguments[index] + case "--help", "-h": + throw DiagnosticCLIError.usage("") + default: + throw DiagnosticCLIError.usage("unknown mcp config flag '\(flag)'") + } + index += 1 + } + return options + } +} + +struct AgentdMCPClientConfig: Codable, Equatable { + let mcpServers: [String: AgentdMCPClientServerConfig] + + init(command: String, serverName: String) { + self.mcpServers = [ + serverName: AgentdMCPClientServerConfig(command: command, args: ["mcp"]) + ] + } +} + +struct AgentdMCPClientServerConfig: Codable, Equatable { + let command: String + let args: [String] +} + protocol AgentdMCPRuntime { func deviceSnapshot() async throws -> AgentdMCPDeviceSnapshot func activityRecent(options: ActivityOptions) async throws -> ActivitySummary diff --git a/Sources/agentd/DiagnosticCLI.swift b/Sources/agentd/DiagnosticCLI.swift index be74a9e..2709d51 100644 --- a/Sources/agentd/DiagnosticCLI.swift +++ b/Sources/agentd/DiagnosticCLI.swift @@ -139,6 +139,12 @@ enum DiagnosticCLI { try writeJSON(payload, to: nil) case .mcp: return await AgentdMCPStdio.run() + case .mcpConfig(let options): + let payload = AgentdMCPClientConfig( + command: options.command ?? executablePath(), + serverName: options.serverName + ) + try writeJSON(payload, to: nil) case .activity(let options): let payload = try await ActivitySummary.run(options: options) if let summaryRoot = options.summaryRoot { @@ -196,12 +202,19 @@ enum DiagnosticCLI { } } + private static func executablePath() -> String { + guard let first = CommandLine.arguments.first, !first.isEmpty else { return "agentd" } + guard first.contains("/") else { return first } + return URL(fileURLWithPath: first).standardizedFileURL.path + } + static let help = """ Usage: agentd list-displays agentd capture-once [--display-id ID] [--no-ocr] [--out PATH] agentd activity [--since HOURS] [--window 10m|6h|24h] [--format json|markdown] [--batch-dir PATH] [--write-summaries PATH] agentd mcp + agentd mcp config [--command PATH] [--server-name NAME] agentd selftest Diagnostic commands emit redacted JSON and never start the menu-bar app. @@ -209,6 +222,7 @@ enum DiagnosticCLI { activity summarizes locally persisted JSON batches without reading encrypted batch files. --write-summaries writes Chronicle-style instructions.md and resources/*.md locally. mcp starts a local JSON-RPC stdio MCP server with redacted device/context tools. + mcp config prints a Claude/Codex-style MCP client config snippet for this binary. """ } @@ -222,6 +236,7 @@ enum DiagnosticCommand: Equatable { case selftest case activity(ActivityOptions) case mcp + case mcpConfig(AgentdMCPConfigOptions) static func parse(_ arguments: [String]) throws -> DiagnosticCommand { guard let command = arguments.first else { return .help } @@ -242,8 +257,13 @@ enum DiagnosticCommand: Equatable { guard tail.isEmpty else { throw DiagnosticCLIError.usage("selftest takes no flags") } return .selftest case "mcp": - guard tail.isEmpty else { throw DiagnosticCLIError.usage("mcp takes no flags") } - return .mcp + guard let subcommand = tail.first else { return .mcp } + switch subcommand { + case "config": + return .mcpConfig(try AgentdMCPConfigOptions.parse(Array(tail.dropFirst()))) + default: + throw DiagnosticCLIError.usage("unknown mcp subcommand '\(subcommand)'") + } case "activity": return .activity(try ActivityOptions.parse(tail)) default: diff --git a/Tests/agentdTests/DiagnosticCLITests.swift b/Tests/agentdTests/DiagnosticCLITests.swift index 3f4f9be..a12f28b 100644 --- a/Tests/agentdTests/DiagnosticCLITests.swift +++ b/Tests/agentdTests/DiagnosticCLITests.swift @@ -65,6 +65,34 @@ final class DiagnosticCLITests: XCTestCase { XCTAssertEqual(annotationsByName["agentd_collect_diagnostics"]?["readOnlyHint"] as? Bool, false) } + func testMcpConfigParserAcceptsCommandAndServerName() throws { + let command = try DiagnosticCommand.parse([ + "mcp", "config", "--command", "/Applications/EvalOps agentd.app/Contents/MacOS/agentd", + "--server-name", "evalops-agentd", + ]) + + XCTAssertEqual( + command, + .mcpConfig( + AgentdMCPConfigOptions( + command: "/Applications/EvalOps agentd.app/Contents/MacOS/agentd", + serverName: "evalops-agentd" + )) + ) + } + + func testMcpClientConfigEncodesClaudeStyleServerConfig() throws { + let payload = AgentdMCPClientConfig(command: "/usr/local/bin/agentd", serverName: "agentd") + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let encoded = try jsonObject(encoder.encode(payload)) + let servers = try XCTUnwrap(encoded["mcpServers"] as? [String: Any]) + let agentd = try XCTUnwrap(servers["agentd"] as? [String: Any]) + + XCTAssertEqual(agentd["command"] as? String, "/usr/local/bin/agentd") + XCTAssertEqual(agentd["args"] as? [String], ["mcp"]) + } + func testMcpResponsesAreSingleLineJSONRPCMessages() async throws { let server = AgentdMCPServer(runtime: AgentdMCPRuntimeStub())