Skip to content
Merged
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions Sources/agentd/AgentdMCP.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions Sources/agentd/DiagnosticCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -196,19 +202,27 @@ 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.
capture-once uses the normal privacy filters, SecretScrubber, and OCR pipeline.
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.

"""
}
Expand All @@ -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 }
Expand All @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions Tests/agentdTests/DiagnosticCLITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down