Skip to content

feat: add local MCP context tools#120

Merged
haasonsaas merged 2 commits intomainfrom
codex/agentd-local-mcp-impl-20260508
May 8, 2026
Merged

feat: add local MCP context tools#120
haasonsaas merged 2 commits intomainfrom
codex/agentd-local-mcp-impl-20260508

Conversation

@haasonsaas
Copy link
Copy Markdown
Contributor

Summary

  • add agentd mcp, a stdio JSON-RPC MCP server for local agent/device context
  • expose device snapshot, recent activity, and diagnostics collection tools with redacted/sanitized outputs
  • document the local MCP surface and cover tool behavior with XCTest stubs

Verification

  • swift test
  • xcrun swift-format lint --strict --recursive Sources Tests Package.swift
  • git diff --check
  • printf ... | .build/debug/agentd mcp smoke test

@cursor
Copy link
Copy Markdown

cursor Bot commented May 8, 2026

PR Summary

Medium Risk
Adds a new stdio JSON-RPC surface that exposes local device/activity/diagnostic metadata; mistakes could leak sensitive data despite redaction, but scope is confined to local diagnostics paths.

Overview
Adds agentd mcp, a local stdio MCP (JSON-RPC) server that exposes three tools: agentd_device_snapshot (redacted config/permission/privacy + local batch stats), agentd_activity_recent (sanitized activity summaries from JSON batches), and agentd_collect_diagnostics (writes Chronicle-style artifacts and returns paths).

Updates diagnostics redaction to make redactEndpoint reusable and drop URL fragments in addition to query/userinfo, documents the MCP workflow in README.md, and adds XCTest coverage with a stub runtime to validate tool catalog, request/response framing, option parsing, and error handling.

Reviewed by Cursor Bugbot for commit 519f2f1. Bugbot is set up for automated code reviews on this repo. Configure here.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Thrown errors produce no JSON-RPC response, clients hang
    • AgentdMCP request handling now converts parse, invalid-parameter, and runtime failures into JSON-RPC error responses instead of dropping them on stderr.
  • ✅ Fixed: Duplicated redactEndpoint across two files
    • Endpoint redaction now lives in one shared helper used by both diagnostics and MCP snapshot code.
Preview (f19541561f)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -382,6 +382,13 @@
 encrypted `.agentdbatch` files remain unreadable without the configured local
 batch key, and raw OCR is not copied into the summary layer.
 
+For local agent context, run `agentd mcp` as a stdio MCP server. It exposes
+three local tools: `agentd_device_snapshot` for redacted device/permission and
+privacy-policy status, `agentd_activity_recent` for sanitized recent activity
+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.
+
 `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
@@ -404,6 +411,7 @@
 Sources/agentd/
   main.swift              # NSApplication + AppController boot
   ChronicleControl.swift  # RegisterDevice/Heartbeat + policy response client
+  AgentdMCP.swift         # Local stdio MCP server for redacted device context
   ActivitySummary.swift   # Sanitized local activity summaries/resources
   Diagnostics.swift       # Redacted local report generation
   PauseState.swift        # Manual/scheduled/policy pause precedence

diff --git a/Sources/agentd/AgentdMCP.swift b/Sources/agentd/AgentdMCP.swift
new file mode 100644
--- /dev/null
+++ b/Sources/agentd/AgentdMCP.swift
@@ -1,0 +1,364 @@
+// SPDX-License-Identifier: BUSL-1.1
+
+import Foundation
+
+struct AgentdMCPPermissionStatus: Codable, Equatable, Sendable {
+  let accessibilityTrusted: Bool
+  let screenCaptureTrusted: Bool
+  let menuSummary: String
+}
+
+struct AgentdMCPPrivacyStatus: Codable, Equatable, Sendable {
+  let allowedBundleCount: Int
+  let deniedBundleCount: Int
+  let deniedPathPrefixCount: Int
+  let pauseTitlePatternCount: Int
+  let captureAllDisplays: Bool
+  let selectedDisplayIds: [UInt32]
+}
+
+struct AgentdMCPLocalBatchStats: Codable, Equatable, Sendable {
+  let fileCount: Int
+  let bytes: Int64
+
+  init(fileCount: Int, bytes: Int64) {
+    self.fileCount = fileCount
+    self.bytes = bytes
+  }
+
+  init(_ stats: LocalBatchStats) {
+    self.fileCount = stats.fileCount
+    self.bytes = stats.bytes
+  }
+}
+
+struct AgentdMCPDeviceSnapshot: Codable, Equatable, Sendable {
+  let generatedAt: Date
+  let appVersion: String
+  let deviceId: String
+  let organizationId: String
+  let mode: String
+  let endpoint: String
+  let permissions: AgentdMCPPermissionStatus
+  let localBatchStats: AgentdMCPLocalBatchStats
+  let privacy: AgentdMCPPrivacyStatus
+}
+
+struct AgentdMCPDiagnosticsResult: Codable, Equatable, Sendable {
+  let instructionsPath: String
+  let resourcePaths: [String]
+}
+
+protocol AgentdMCPRuntime {
+  func deviceSnapshot() async throws -> AgentdMCPDeviceSnapshot
+  func activityRecent(options: ActivityOptions) async throws -> ActivitySummary
+  func collectDiagnostics(options: ActivityOptions, outputDirectory: URL) async throws
+    -> AgentdMCPDiagnosticsResult
+}
+
+struct SystemAgentdMCPRuntime: AgentdMCPRuntime {
+  func deviceSnapshot() async throws -> AgentdMCPDeviceSnapshot {
+    let config = ConfigStore.load()
+    let permissions = await MainActor.run {
+      PermissionSnapshot.current(promptForAccessibility: false)
+    }
+    let submitter = try Submitter(
+      endpoint: config.endpoint,
+      localOnly: true,
+      authMode: .none,
+      maxBatchBytes: config.maxBatchBytes,
+      maxBatchAgeDays: config.maxBatchAgeDays,
+      deviceId: config.deviceId,
+      encryptLocalBatches: config.encryptLocalBatches
+    )
+    let batchStats = await submitter.localBatchStats()
+
+    return AgentdMCPDeviceSnapshot(
+      generatedAt: Date(),
+      appVersion: Bundle.main.appVersion,
+      deviceId: config.deviceId,
+      organizationId: config.organizationId,
+      mode: config.localOnly ? "local-only" : "managed",
+      endpoint: EndpointRedaction.redact(config.endpoint),
+      permissions: AgentdMCPPermissionStatus(
+        accessibilityTrusted: permissions.accessibilityTrusted,
+        screenCaptureTrusted: permissions.screenCaptureTrusted,
+        menuSummary: permissions.menuSummary
+      ),
+      localBatchStats: AgentdMCPLocalBatchStats(batchStats),
+      privacy: AgentdMCPPrivacyStatus(
+        allowedBundleCount: config.allowedBundleIds.count,
+        deniedBundleCount: config.deniedBundleIds.count,
+        deniedPathPrefixCount: config.deniedPathPrefixes.count,
+        pauseTitlePatternCount: config.pauseWindowTitlePatterns.count,
+        captureAllDisplays: config.captureAllDisplays,
+        selectedDisplayIds: config.selectedDisplayIds
+      )
+    )
+  }
+
+  func activityRecent(options: ActivityOptions) async throws -> ActivitySummary {
+    try await ActivitySummary.run(options: options)
+  }
+
+  func collectDiagnostics(options: ActivityOptions, outputDirectory: URL) async throws
+    -> AgentdMCPDiagnosticsResult
+  {
+    let summary = try await ActivitySummary.run(options: options)
+    let resource = try ActivitySummaryArtifacts.write(summary, root: outputDirectory)
+    return AgentdMCPDiagnosticsResult(
+      instructionsPath: outputDirectory.appendingPathComponent("instructions.md").path,
+      resourcePaths: [resource.path]
+    )
+  }
+
+}
+
+struct AgentdMCPServer {
+  private let runtime: AgentdMCPRuntime
+
+  init(runtime: AgentdMCPRuntime = SystemAgentdMCPRuntime()) {
+    self.runtime = runtime
+  }
+
+  func handle(_ data: Data) async -> Data {
+    do {
+      let request = try AgentdMCPRequest(data: data)
+      do {
+        return try await handleRequest(request)
+      } catch {
+        let mcpError = Self.jsonRPCError(for: error)
+        return safeErrorResponse(id: request.id, code: mcpError.code, message: mcpError.message)
+      }
+    } catch let error as AgentdMCPError {
+      let mcpError = Self.jsonRPCError(for: error)
+      return safeErrorResponse(id: nil, code: mcpError.code, message: mcpError.message)
+    } catch {
+      return safeErrorResponse(id: nil, code: -32700, message: "parse error")
+    }
+  }
+
+  private func handleRequest(_ request: AgentdMCPRequest) async throws -> Data {
+    switch request.method {
+    case "initialize":
+      return try response(
+        id: request.id,
+        result: [
+          "protocolVersion": "2025-06-18",
+          "capabilities": ["tools": ["listChanged": false]],
+          "serverInfo": ["name": "agentd-local", "version": Bundle.main.appVersion],
+        ])
+    case "notifications/initialized":
+      return Data()
+    case "tools/list":
+      return try response(id: request.id, result: ["tools": Self.toolCatalog()])
+    case "tools/call":
+      return try await callTool(request)
+    default:
+      return try errorResponse(id: request.id, code: -32601, message: "method not found")
+    }
+  }
+
+  private func callTool(_ request: AgentdMCPRequest) async throws -> Data {
+    guard let params = request.params,
+      let name = params["name"] as? String
+    else {
+      return try errorResponse(id: request.id, code: -32602, message: "tools/call requires name")
+    }
+    let arguments = params["arguments"] as? [String: Any] ?? [:]
+
+    switch name {
+    case "agentd_device_snapshot":
+      return try await toolResponse(id: request.id, value: runtime.deviceSnapshot())
+    case "agentd_activity_recent":
+      let options = try activityOptions(from: arguments)
+      return try await toolResponse(id: request.id, value: runtime.activityRecent(options: options))
+    case "agentd_collect_diagnostics":
+      let options = try activityOptions(from: arguments)
+      let outputDirectory = outputDirectory(from: arguments)
+      return try await toolResponse(
+        id: request.id,
+        value: runtime.collectDiagnostics(options: options, outputDirectory: outputDirectory)
+      )
+    default:
+      return try errorResponse(id: request.id, code: -32602, message: "unknown tool '\(name)'")
+    }
+  }
+
+  private func toolResponse<T: Encodable>(id: Any?, value: T) async throws -> Data {
+    let text = try Self.jsonString(value)
+    return try response(
+      id: id,
+      result: [
+        "content": [
+          [
+            "type": "text",
+            "mimeType": "application/json",
+            "text": text,
+          ]
+        ],
+        "isError": false,
+      ])
+  }
+
+  private func activityOptions(from arguments: [String: Any]) throws -> ActivityOptions {
+    var raw: [String] = []
+    if let window = arguments["window"] as? String {
+      raw += ["--window", window]
+    }
+    if let since = arguments["since"] {
+      raw += ["--since", String(describing: since)]
+    }
+    if let batchDirectory = arguments["batch_dir"] as? String {
+      raw += ["--batch-dir", batchDirectory]
+    }
+    return try ActivityOptions.parse(raw)
+  }
+
+  private func outputDirectory(from arguments: [String: Any]) -> URL {
+    if let path = arguments["out_dir"] as? String, !path.isEmpty {
+      return URL(fileURLWithPath: path, isDirectory: true)
+    }
+    return FileManager.default.homeDirectoryForCurrentUser
+      .appendingPathComponent(".evalops/agentd/mcp-diagnostics", isDirectory: true)
+  }
+
+  private func response(id: Any?, result: [String: Any]) throws -> Data {
+    try Self.jsonData([
+      "jsonrpc": "2.0",
+      "id": id ?? NSNull(),
+      "result": result,
+    ])
+  }
+
+  private func errorResponse(id: Any?, code: Int, message: String) throws -> Data {
+    try Self.jsonData([
+      "jsonrpc": "2.0",
+      "id": id ?? NSNull(),
+      "error": ["code": code, "message": message],
+    ])
+  }
+
+  private func safeErrorResponse(id: Any?, code: Int, message: String) -> Data {
+    (try? errorResponse(id: id, code: code, message: message))
+      ?? (Data(#"{"error":{"code":-32603,"message":"internal error"},"id":null,"jsonrpc":"2.0"}"#.utf8)
+        + Data([0x0A]))
+  }
+
+  private static func toolCatalog() -> [[String: Any]] {
+    [
+      [
+        "name": "agentd_device_snapshot",
+        "description":
+          "Return a redacted local device snapshot including agentd mode, permissions, privacy policy counts, and queued local batch stats.",
+        "inputSchema": ["type": "object", "additionalProperties": false, "properties": [:]],
+        "annotations": ["title": "Device Snapshot", "readOnlyHint": true],
+      ],
+      [
+        "name": "agentd_activity_recent",
+        "description":
+          "Summarize recent local agentd activity from persisted redacted batch JSON without returning raw frames.",
+        "inputSchema": [
+          "type": "object",
+          "additionalProperties": false,
+          "properties": [
+            "window": ["type": "string", "enum": ["10m", "6h", "24h"]],
+            "since": ["type": "number"],
+            "batch_dir": ["type": "string"],
+          ],
+        ],
+        "annotations": ["title": "Recent Activity", "readOnlyHint": true],
+      ],
+      [
+        "name": "agentd_collect_diagnostics",
+        "description":
+          "Write Chronicle-style local activity summary artifacts for support/debugging and return their paths.",
+        "inputSchema": [
+          "type": "object",
+          "required": ["out_dir"],
+          "additionalProperties": false,
+          "properties": [
+            "window": ["type": "string", "enum": ["10m", "6h", "24h"]],
+            "since": ["type": "number"],
+            "batch_dir": ["type": "string"],
+            "out_dir": ["type": "string"],
+          ],
+        ],
+        "annotations": ["title": "Collect Diagnostics", "readOnlyHint": false],
+      ],
+    ]
+  }
+
+  private static func jsonString<T: Encodable>(_ value: T) throws -> String {
+    let encoder = JSONEncoder()
+    encoder.dateEncodingStrategy = .iso8601
+    encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+    return String(decoding: try encoder.encode(value), as: UTF8.self)
+  }
+
+  private static func jsonData(_ object: [String: Any]) throws -> Data {
+    try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
+      + Data([0x0A])
+  }
+
+  private static func jsonRPCError(for error: Error) -> (code: Int, message: String) {
+    switch error {
+    case let error as AgentdMCPError:
+      return (error.code, error.localizedDescription)
+    case let error as DiagnosticCLIError:
+      return (-32602, error.localizedDescription)
+    default:
+      return (-32603, error.localizedDescription)
+    }
+  }
+}
+
+struct AgentdMCPRequest {
+  let id: Any?
+  let method: String
+  let params: [String: Any]?
+
+  init(data: Data) throws {
+    let object = try JSONSerialization.jsonObject(with: data)
+    guard let root = object as? [String: Any],
+      let method = root["method"] as? String
+    else {
+      throw AgentdMCPError.invalidRequest
+    }
+    self.id = root["id"]
+    self.method = method
+    self.params = root["params"] as? [String: Any]
+  }
+}
+
+enum AgentdMCPError: Error, LocalizedError {
+  case invalidRequest
+
+  var code: Int {
+    switch self {
+    case .invalidRequest:
+      return -32600
+    }
+  }
+
+  var errorDescription: String? {
+    switch self {
+    case .invalidRequest:
+      return "invalid MCP JSON-RPC request"
+    }
+  }
+}
+
+enum AgentdMCPStdio {
+  static func run(server: AgentdMCPServer = AgentdMCPServer()) async -> Int32 {
+    while let line = readLine(strippingNewline: true) {
+      let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+      guard !trimmed.isEmpty else { continue }
+      let response = await server.handle(Data(trimmed.utf8))
+      if !response.isEmpty {
+        FileHandle.standardOutput.write(response)
+      }
+    }
+    return 0
+  }
+}

diff --git a/Sources/agentd/DiagnosticCLI.swift b/Sources/agentd/DiagnosticCLI.swift
--- a/Sources/agentd/DiagnosticCLI.swift
+++ b/Sources/agentd/DiagnosticCLI.swift
@@ -109,7 +109,7 @@
 enum DiagnosticCLI {
   static let handledCommands = [
     "list-displays", "capture-once", "capture-worker-once", "capture-worker-stream", "selftest",
-    "activity", "help", "--help", "-h",
+    "activity", "mcp", "help", "--help", "-h",
   ]
 
   static func shouldHandle(_ arguments: [String]) -> Bool {
@@ -137,6 +137,8 @@
       case .selftest:
         let payload = await SelftestDiagnostics.run()
         try writeJSON(payload, to: nil)
+      case .mcp:
+        return await AgentdMCPStdio.run()
       case .activity(let options):
         let payload = try await ActivitySummary.run(options: options)
         if let summaryRoot = options.summaryRoot {
@@ -199,12 +201,14 @@
       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 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.
 
     """
 }
@@ -217,6 +221,7 @@
   case captureWorkerStream(CaptureStreamOptions)
   case selftest
   case activity(ActivityOptions)
+  case mcp
 
   static func parse(_ arguments: [String]) throws -> DiagnosticCommand {
     guard let command = arguments.first else { return .help }
@@ -236,6 +241,9 @@
     case "selftest":
       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
     case "activity":
       return .activity(try ActivityOptions.parse(tail))
     default:

diff --git a/Sources/agentd/Diagnostics.swift b/Sources/agentd/Diagnostics.swift
--- a/Sources/agentd/Diagnostics.swift
+++ b/Sources/agentd/Diagnostics.swift
@@ -34,7 +34,7 @@
     lines.append("- Screen capture preflight: \(snapshot.permissions.screenCaptureTrusted)")
     lines.append("- Mode: \(snapshot.config.localOnly ? "local-only" : "managed")")
     lines.append("- Secret Broker: \(snapshot.config.secretBroker == nil ? "disabled" : "enabled")")
-    lines.append("- Endpoint: \(redactEndpoint(snapshot.config.endpoint))")
+    lines.append("- Endpoint: \(EndpointRedaction.redact(snapshot.config.endpoint))")
     lines.append("- Policy version: \(snapshot.policyVersion ?? "none")")
     lines.append("- Policy source: \(redact(snapshot.policySource ?? "none"))")
     lines.append("- Last control error: \(redact(snapshot.controlError ?? "none"))")
@@ -185,14 +185,6 @@
     return redact(value)
   }
 
-  private static func redactEndpoint(_ endpoint: URL) -> String {
-    var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
-    components?.query = nil
-    components?.user = nil
-    components?.password = nil
-    return components?.url?.absoluteString ?? "[redacted]"
-  }
-
   private static func iso(_ date: Date) -> String {
     ISO8601DateFormatter().string(from: date)
   }

diff --git a/Sources/agentd/EndpointRedaction.swift b/Sources/agentd/EndpointRedaction.swift
new file mode 100644
--- /dev/null
+++ b/Sources/agentd/EndpointRedaction.swift
@@ -1,0 +1,13 @@
+// SPDX-License-Identifier: BUSL-1.1
+
+import Foundation
+
+enum EndpointRedaction {
+  static func redact(_ endpoint: URL) -> String {
+    var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
+    components?.query = nil
+    components?.user = nil
+    components?.password = nil
+    return components?.url?.absoluteString ?? "[redacted]"
+  }
+}

diff --git a/Tests/agentdTests/AgentdMCPTestSupport.swift b/Tests/agentdTests/AgentdMCPTestSupport.swift
new file mode 100644
--- /dev/null
+++ b/Tests/agentdTests/AgentdMCPTestSupport.swift
@@ -1,0 +1,143 @@
+// SPDX-License-Identifier: BUSL-1.1
+
+import Foundation
+import XCTest
+
+@testable import agentd
+
+final class AgentdMCPRuntimeStub: AgentdMCPRuntime {
+  var deviceSnapshot = AgentdMCPDeviceSnapshot(
+    generatedAt: Date(timeIntervalSince1970: 0),
+    appVersion: "test",
+    deviceId: "device_test",
+    organizationId: "org_test",
+    mode: "local-only",
+    endpoint: "http://127.0.0.1:8787/chronicle.v1.ChronicleService/SubmitBatch",
+    permissions: AgentdMCPPermissionStatus(
+      accessibilityTrusted: true,
+      screenCaptureTrusted: true,
+      menuSummary: "Ready"
+    ),
+    localBatchStats: AgentdMCPLocalBatchStats(fileCount: 0, bytes: 0),
+    privacy: AgentdMCPPrivacyStatus(
+      allowedBundleCount: 0,
+      deniedBundleCount: 0,
+      deniedPathPrefixCount: 0,
+      pauseTitlePatternCount: 0,
+      captureAllDisplays: true,
+      selectedDisplayIds: []
+    )
+  )
+  var activitySummary = ActivitySummaryTests.summary(batchDirectory: URL(fileURLWithPath: "/tmp"))
+  var diagnosticsResult = AgentdMCPDiagnosticsResult(
+    instructionsPath: "/tmp/instructions.md",
+    resourcePaths: ["/tmp/resources/activity.md"]
+  )
+  var deviceSnapshotError: Error?
+  var activityError: Error?
+  var diagnosticsError: Error?
+  private(set) var requestedActivity: ActivityOptions?
+  private(set) var requestedDiagnostics: ActivityOptions?
+  private(set) var requestedDiagnosticsOutDir: URL?
+
+  func deviceSnapshot() async throws -> AgentdMCPDeviceSnapshot {
+    if let deviceSnapshotError {
+      throw deviceSnapshotError
+    }
+    deviceSnapshot
+  }
+
+  func activityRecent(options: ActivityOptions) async throws -> ActivitySummary {
+    if let activityError {
+      throw activityError
+    }
+    requestedActivity = options
+    return activitySummary.replacing(
+      batchDirectory: options.batchDirectory.path,
+      windowLabel: options.windowLabel
+    )
+  }
+
+  func collectDiagnostics(options: ActivityOptions, outputDirectory: URL) async throws
+    -> AgentdMCPDiagnosticsResult
+  {
+    if let diagnosticsError {
+      throw diagnosticsError
+    }
+    requestedDiagnostics = options
+    requestedDiagnosticsOutDir = outputDirectory
+    return diagnosticsResult
+  }
+}
+
+func jsonData(_ object: [String: Any]) throws -> Data {
+  try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
+}
+
+func jsonObject(_ data: Data) throws -> [String: Any] {
+  let decoded = try JSONSerialization.jsonObject(with: data)
+  return try XCTUnwrap(decoded as? [String: Any])
+}
+
+func mcpText(_ data: Data) throws -> String {
+  let root = try jsonObject(data)
+  let result = try XCTUnwrap(root["result"] as? [String: Any])
+  let content = try XCTUnwrap(result["content"] as? [[String: Any]])
+  return try XCTUnwrap(content.first?["text"] as? String)
+}
+
+struct AgentdMCPStubError: LocalizedError {
+  let message: String
+
+  var errorDescription: String? { message }
+}
+
+extension ActivitySummaryTests {
+  static func summary(
+    batchDirectory: URL,
+    windowLabel: String = "24h",
+    windows: [ActivityWindowSummary] = []
+  ) -> ActivitySummary {
+    ActivitySummary(
+      generatedAt: Date(timeIntervalSince1970: 1_000),
+      since: Date(timeIntervalSince1970: 0),
+      until: Date(timeIntervalSince1970: 1_000),
+      staleAfter: Date(timeIntervalSince1970: 1_600),
+      windowLabel: windowLabel,
+      batchDirectory: batchDirectory.path,
+      batchCount: 0,
+      nonemptyBatchCount: 0,
+      frameCount: 0,
+      sourceBatchIds: [],
+      displayIds: [],
+      droppedCounts: DropCounts(secret: 0, duplicate: 0, deniedApp: 0, deniedPath: 0),
+      droppedReasonCounts: [:],
+      apps: [],
+      windows: windows,
+      artifacts: []
+    )
+  }
+}
+
+extension ActivitySummary {
+  func replacing(batchDirectory: String, windowLabel: String) -> ActivitySummary {
+    ActivitySummary(
+      generatedAt: generatedAt,
+      since: since,
+      until: until,
+      staleAfter: staleAfter,
+      windowLabel: windowLabel,
+      batchDirectory: batchDirectory,
+      batchCount: batchCount,
+      nonemptyBatchCount: nonemptyBatchCount,
+      frameCount: frameCount,
+      sourceBatchIds: sourceBatchIds,
+      displayIds: displayIds,
+      droppedCounts: droppedCounts,
+      droppedReasonCounts: droppedReasonCounts,
+      apps: apps,
+      windows: windows,
+      artifacts: artifacts
+    )
+  }
+}

diff --git a/Tests/agentdTests/DiagnosticCLITests.swift b/Tests/agentdTests/DiagnosticCLITests.swift
--- a/Tests/agentdTests/DiagnosticCLITests.swift
+++ b/Tests/agentdTests/DiagnosticCLITests.swift
@@ -14,9 +14,244 @@
     XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "capture-worker-stream"]))
     XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "selftest"]))
     XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "activity"]))
+    XCTAssertTrue(DiagnosticCLI.shouldHandle(["agentd", "mcp"]))
     XCTAssertFalse(DiagnosticCLI.shouldHandle(["agentd", "--local-only"]))
   }
 
+  func testMcpInitializeAndToolsListExposeLocalContextTools() async throws {
+    let runtime = AgentdMCPRuntimeStub()
+    let server = AgentdMCPServer(runtime: runtime)
+
+    let initialize = await server.handle(
+      try jsonData(
+        [
+          "jsonrpc": "2.0",
+          "id": 1,
+          "method": "initialize",
+          "params": [
+            "protocolVersion": "2025-06-18",
+            "capabilities": [:],
+            "clientInfo": ["name": "codex-test", "version": "dev"],
+          ],
+        ]))
+    let initializeRoot = try jsonObject(initialize)
+
+    XCTAssertEqual(initializeRoot["jsonrpc"] as? String, "2.0")
+    XCTAssertEqual(initializeRoot["id"] as? Int, 1)
+    let initializeResult = try XCTUnwrap(initializeRoot["result"] as? [String: Any])
+    XCTAssertEqual(initializeResult["protocolVersion"] as? String, "2025-06-18")
+
+    let tools = await server.handle(
+      try jsonData(["jsonrpc": "2.0", "id": "tools", "method": "tools/list"]))
+    let toolsRoot = try jsonObject(tools)
+    let toolsResult = try XCTUnwrap(toolsRoot["result"] as? [String: Any])
+    let toolList = try XCTUnwrap(toolsResult["tools"] as? [[String: Any]])
+    let names = Set(toolList.compactMap { $0["name"] as? String })
+
+    XCTAssertEqual(
+      names,
+      ["agentd_device_snapshot", "agentd_activity_recent", "agentd_collect_diagnostics"]
+    )
+    let annotationsByName = Dictionary(
+      uniqueKeysWithValues: try toolList.map { tool in
+        (
+          try XCTUnwrap(tool["name"] as? String),
+          try XCTUnwrap(tool["annotations"] as? [String: Any])
+        )
+      }
+    )
+    XCTAssertEqual(annotationsByName["agentd_device_snapshot"]?["readOnlyHint"] as? Bool, true)
+    XCTAssertEqual(annotationsByName["agentd_activity_recent"]?["readOnlyHint"] as? Bool, true)
+    XCTAssertEqual(annotationsByName["agentd_collect_diagnostics"]?["readOnlyHint"] as? Bool, false)
+  }
+
+  func testMcpActivityRecentReturnsRedactedActivitySummary() async throws {
+    let root = try temporaryDirectory()
+    defer { try? FileManager.default.removeItem(at: root) }
+    let runtime = AgentdMCPRuntimeStub()
+    runtime.activitySummary = ActivitySummaryTests.summary(
+      batchDirectory: root,
+      windows: [
+        ActivityWindowSummary(
+          appName: "Google Chrome",
+          bundleId: "com.google.Chrome",
+          windowTitle: "Review EvalOps",
+          documentPath: "https://github.com/evalops/platform/pull/123?code=REDACTED&safe=1",
+          frameCount: 3,
+          firstSeenAt: Date(timeIntervalSince1970: 100),
+          lastSeenAt: Date(timeIntervalSince1970: 120)
+        )
+      ]
+    )
+    let server = AgentdMCPServer(runtime: runtime)
+
+    let response = await server.handle(
+      try jsonData([
+        "jsonrpc": "2.0",
+        "id": "activity",
+        "method": "tools/call",
+        "params": [
+          "name": "agentd_activity_recent",
+          "arguments": ["window": "6h", "batch_dir": root.path],
+        ],
+      ]))
+    let text = try mcpText(response)
+    let decoded = try jsonObject(Data(text.utf8))
+
+    XCTAssertEqual(decoded["windowLabel"] as? String, "6h")
+    XCTAssertEqual(decoded["batchDirectory"] as? String, root.path)
+    let windows = try XCTUnwrap(decoded["windows"] as? [[String: Any]])
+    XCTAssertEqual(
+      windows.first?["documentPath"] as? String,
+      "https://github.com/evalops/platform/pull/123?code=REDACTED&safe=1"
+    )
+    XCTAssertEqual(runtime.requestedActivity?.windowLabel, "6h")
+    XCTAssertEqual(runtime.requestedActivity?.batchDirectory.path, root.path)
+  }
+
+  func testMcpCollectDiagnosticsWritesActivityArtifactsAndReturnsPaths() async throws {
+    let root = try temporaryDirectory()
+    let out = try temporaryDirectory()
+    defer {
+      try? FileManager.default.removeItem(at: root)
+      try? FileManager.default.removeItem(at: out)
+    }
+    let runtime = AgentdMCPRuntimeStub()
+    runtime.diagnosticsResult = AgentdMCPDiagnosticsResult(
+      instructionsPath: out.appendingPathComponent("instructions.md").path,
+      resourcePaths: [out.appendingPathComponent("resources/activity-24h.md").path]
+    )
+    let server = AgentdMCPServer(runtime: runtime)
+
+    let response = await server.handle(
+      try jsonData([
+        "jsonrpc": "2.0",
+        "id": "diag",
+        "method": "tools/call",
+        "params": [
+          "name": "agentd_collect_diagnostics",
+          "arguments": ["batch_dir": root.path, "out_dir": out.path, "window": "24h"],
+        ],
+      ]))
+    let decoded = try jsonObject(Data(try mcpText(response).utf8))
+
+    XCTAssertEqual(
+      decoded["instructionsPath"] as? String,
+      out.appendingPathComponent("instructions.md").path
+    )
+    XCTAssertEqual(
+      decoded["resourcePaths"] as? [String],
+      [out.appendingPathComponent("resources/activity-24h.md").path]
+    )
+    XCTAssertEqual(runtime.requestedDiagnostics?.batchDirectory.path, root.path)
+    XCTAssertEqual(runtime.requestedDiagnosticsOutDir?.path, out.path)
+  }
+
+  func testMcpDeviceSnapshotReportsRedactedLocalStatus() async throws {
+    let runtime = AgentdMCPRuntimeStub()
+    runtime.deviceSnapshot = AgentdMCPDeviceSnapshot(
+      generatedAt: Date(timeIntervalSince1970: 0),
+      appVersion: "0.2.0",
+      deviceId: "device_1",
+      organizationId: "evalops",
+      mode: "managed",
+      endpoint: "https://chronicle.evalops.dev/chronicle.v1.ChronicleService/SubmitBatch",
+      permissions: AgentdMCPPermissionStatus(
+        accessibilityTrusted: true,
+        screenCaptureTrusted: false,
+        menuSummary: "Needs Screen Recording"
... diff truncated: showing 800 of 895 lines

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 5f0dc16. Configure here.

Comment thread Sources/agentd/AgentdMCP.swift
Comment thread Sources/agentd/AgentdMCP.swift Outdated
@haasonsaas haasonsaas merged commit e176633 into main May 8, 2026
4 checks passed
@haasonsaas haasonsaas deleted the codex/agentd-local-mcp-impl-20260508 branch May 8, 2026 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant