Skip to content

Commit f195415

Browse files
committed
Fix MCP error responses and share endpoint redaction
1 parent 5f0dc16 commit f195415

5 files changed

Lines changed: 147 additions & 36 deletions

File tree

Sources/agentd/AgentdMCP.swift

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ struct SystemAgentdMCPRuntime: AgentdMCPRuntime {
7979
deviceId: config.deviceId,
8080
organizationId: config.organizationId,
8181
mode: config.localOnly ? "local-only" : "managed",
82-
endpoint: Self.redactEndpoint(config.endpoint),
82+
endpoint: EndpointRedaction.redact(config.endpoint),
8383
permissions: AgentdMCPPermissionStatus(
8484
accessibilityTrusted: permissions.accessibilityTrusted,
8585
screenCaptureTrusted: permissions.screenCaptureTrusted,
@@ -112,13 +112,6 @@ struct SystemAgentdMCPRuntime: AgentdMCPRuntime {
112112
)
113113
}
114114

115-
private static func redactEndpoint(_ endpoint: URL) -> String {
116-
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
117-
components?.query = nil
118-
components?.user = nil
119-
components?.password = nil
120-
return components?.url?.absoluteString ?? "[redacted]"
121-
}
122115
}
123116

124117
struct AgentdMCPServer {
@@ -128,8 +121,24 @@ struct AgentdMCPServer {
128121
self.runtime = runtime
129122
}
130123

131-
func handle(_ data: Data) async throws -> Data {
132-
let request = try AgentdMCPRequest(data: data)
124+
func handle(_ data: Data) async -> Data {
125+
do {
126+
let request = try AgentdMCPRequest(data: data)
127+
do {
128+
return try await handleRequest(request)
129+
} catch {
130+
let mcpError = Self.jsonRPCError(for: error)
131+
return safeErrorResponse(id: request.id, code: mcpError.code, message: mcpError.message)
132+
}
133+
} catch let error as AgentdMCPError {
134+
let mcpError = Self.jsonRPCError(for: error)
135+
return safeErrorResponse(id: nil, code: mcpError.code, message: mcpError.message)
136+
} catch {
137+
return safeErrorResponse(id: nil, code: -32700, message: "parse error")
138+
}
139+
}
140+
141+
private func handleRequest(_ request: AgentdMCPRequest) async throws -> Data {
133142
switch request.method {
134143
case "initialize":
135144
return try response(
@@ -230,6 +239,12 @@ struct AgentdMCPServer {
230239
])
231240
}
232241

242+
private func safeErrorResponse(id: Any?, code: Int, message: String) -> Data {
243+
(try? errorResponse(id: id, code: code, message: message))
244+
?? (Data(#"{"error":{"code":-32603,"message":"internal error"},"id":null,"jsonrpc":"2.0"}"#.utf8)
245+
+ Data([0x0A]))
246+
}
247+
233248
private static func toolCatalog() -> [[String: Any]] {
234249
[
235250
[
@@ -285,6 +300,17 @@ struct AgentdMCPServer {
285300
try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
286301
+ Data([0x0A])
287302
}
303+
304+
private static func jsonRPCError(for error: Error) -> (code: Int, message: String) {
305+
switch error {
306+
case let error as AgentdMCPError:
307+
return (error.code, error.localizedDescription)
308+
case let error as DiagnosticCLIError:
309+
return (-32602, error.localizedDescription)
310+
default:
311+
return (-32603, error.localizedDescription)
312+
}
313+
}
288314
}
289315

290316
struct AgentdMCPRequest {
@@ -308,6 +334,13 @@ struct AgentdMCPRequest {
308334
enum AgentdMCPError: Error, LocalizedError {
309335
case invalidRequest
310336

337+
var code: Int {
338+
switch self {
339+
case .invalidRequest:
340+
return -32600
341+
}
342+
}
343+
311344
var errorDescription: String? {
312345
switch self {
313346
case .invalidRequest:
@@ -321,13 +354,9 @@ enum AgentdMCPStdio {
321354
while let line = readLine(strippingNewline: true) {
322355
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
323356
guard !trimmed.isEmpty else { continue }
324-
do {
325-
let response = try await server.handle(Data(trimmed.utf8))
326-
if !response.isEmpty {
327-
FileHandle.standardOutput.write(response)
328-
}
329-
} catch {
330-
FileHandle.standardError.write(Data("agentd mcp: \(error.localizedDescription)\n".utf8))
357+
let response = await server.handle(Data(trimmed.utf8))
358+
if !response.isEmpty {
359+
FileHandle.standardOutput.write(response)
331360
}
332361
}
333362
return 0

Sources/agentd/Diagnostics.swift

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ enum DiagnosticsReport {
3434
lines.append("- Screen capture preflight: \(snapshot.permissions.screenCaptureTrusted)")
3535
lines.append("- Mode: \(snapshot.config.localOnly ? "local-only" : "managed")")
3636
lines.append("- Secret Broker: \(snapshot.config.secretBroker == nil ? "disabled" : "enabled")")
37-
lines.append("- Endpoint: \(redactEndpoint(snapshot.config.endpoint))")
37+
lines.append("- Endpoint: \(EndpointRedaction.redact(snapshot.config.endpoint))")
3838
lines.append("- Policy version: \(snapshot.policyVersion ?? "none")")
3939
lines.append("- Policy source: \(redact(snapshot.policySource ?? "none"))")
4040
lines.append("- Last control error: \(redact(snapshot.controlError ?? "none"))")
@@ -185,14 +185,6 @@ enum DiagnosticsReport {
185185
return redact(value)
186186
}
187187

188-
private static func redactEndpoint(_ endpoint: URL) -> String {
189-
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
190-
components?.query = nil
191-
components?.user = nil
192-
components?.password = nil
193-
return components?.url?.absoluteString ?? "[redacted]"
194-
}
195-
196188
private static func iso(_ date: Date) -> String {
197189
ISO8601DateFormatter().string(from: date)
198190
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
import Foundation
4+
5+
enum EndpointRedaction {
6+
static func redact(_ endpoint: URL) -> String {
7+
var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
8+
components?.query = nil
9+
components?.user = nil
10+
components?.password = nil
11+
return components?.url?.absoluteString ?? "[redacted]"
12+
}
13+
}

Tests/agentdTests/AgentdMCPTestSupport.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,24 @@ final class AgentdMCPRuntimeStub: AgentdMCPRuntime {
3333
instructionsPath: "/tmp/instructions.md",
3434
resourcePaths: ["/tmp/resources/activity.md"]
3535
)
36+
var deviceSnapshotError: Error?
37+
var activityError: Error?
38+
var diagnosticsError: Error?
3639
private(set) var requestedActivity: ActivityOptions?
3740
private(set) var requestedDiagnostics: ActivityOptions?
3841
private(set) var requestedDiagnosticsOutDir: URL?
3942

4043
func deviceSnapshot() async throws -> AgentdMCPDeviceSnapshot {
44+
if let deviceSnapshotError {
45+
throw deviceSnapshotError
46+
}
4147
deviceSnapshot
4248
}
4349

4450
func activityRecent(options: ActivityOptions) async throws -> ActivitySummary {
51+
if let activityError {
52+
throw activityError
53+
}
4554
requestedActivity = options
4655
return activitySummary.replacing(
4756
batchDirectory: options.batchDirectory.path,
@@ -52,6 +61,9 @@ final class AgentdMCPRuntimeStub: AgentdMCPRuntime {
5261
func collectDiagnostics(options: ActivityOptions, outputDirectory: URL) async throws
5362
-> AgentdMCPDiagnosticsResult
5463
{
64+
if let diagnosticsError {
65+
throw diagnosticsError
66+
}
5567
requestedDiagnostics = options
5668
requestedDiagnosticsOutDir = outputDirectory
5769
return diagnosticsResult
@@ -74,6 +86,12 @@ func mcpText(_ data: Data) throws -> String {
7486
return try XCTUnwrap(content.first?["text"] as? String)
7587
}
7688

89+
struct AgentdMCPStubError: LocalizedError {
90+
let message: String
91+
92+
var errorDescription: String? { message }
93+
}
94+
7795
extension ActivitySummaryTests {
7896
static func summary(
7997
batchDirectory: URL,

Tests/agentdTests/DiagnosticCLITests.swift

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ final class DiagnosticCLITests: XCTestCase {
2222
let runtime = AgentdMCPRuntimeStub()
2323
let server = AgentdMCPServer(runtime: runtime)
2424

25-
let initialize = try await server.handle(
26-
jsonData(
25+
let initialize = await server.handle(
26+
try jsonData(
2727
[
2828
"jsonrpc": "2.0",
2929
"id": 1,
@@ -41,8 +41,8 @@ final class DiagnosticCLITests: XCTestCase {
4141
let initializeResult = try XCTUnwrap(initializeRoot["result"] as? [String: Any])
4242
XCTAssertEqual(initializeResult["protocolVersion"] as? String, "2025-06-18")
4343

44-
let tools = try await server.handle(
45-
jsonData(["jsonrpc": "2.0", "id": "tools", "method": "tools/list"]))
44+
let tools = await server.handle(
45+
try jsonData(["jsonrpc": "2.0", "id": "tools", "method": "tools/list"]))
4646
let toolsRoot = try jsonObject(tools)
4747
let toolsResult = try XCTUnwrap(toolsRoot["result"] as? [String: Any])
4848
let toolList = try XCTUnwrap(toolsResult["tools"] as? [[String: Any]])
@@ -85,8 +85,8 @@ final class DiagnosticCLITests: XCTestCase {
8585
)
8686
let server = AgentdMCPServer(runtime: runtime)
8787

88-
let response = try await server.handle(
89-
jsonData([
88+
let response = await server.handle(
89+
try jsonData([
9090
"jsonrpc": "2.0",
9191
"id": "activity",
9292
"method": "tools/call",
@@ -123,8 +123,8 @@ final class DiagnosticCLITests: XCTestCase {
123123
)
124124
let server = AgentdMCPServer(runtime: runtime)
125125

126-
let response = try await server.handle(
127-
jsonData([
126+
let response = await server.handle(
127+
try jsonData([
128128
"jsonrpc": "2.0",
129129
"id": "diag",
130130
"method": "tools/call",
@@ -173,8 +173,8 @@ final class DiagnosticCLITests: XCTestCase {
173173
)
174174
let server = AgentdMCPServer(runtime: runtime)
175175

176-
let response = try await server.handle(
177-
jsonData([
176+
let response = await server.handle(
177+
try jsonData([
178178
"jsonrpc": "2.0",
179179
"id": "snapshot",
180180
"method": "tools/call",
@@ -193,6 +193,65 @@ final class DiagnosticCLITests: XCTestCase {
193193
XCTAssertEqual(privacy["deniedPathPrefixCount"] as? Int, 2)
194194
}
195195

196+
func testMcpInvalidRequestsReturnJsonRpcErrorResponses() async throws {
197+
let server = AgentdMCPServer(runtime: AgentdMCPRuntimeStub())
198+
199+
let missingMethod = await server.handle(
200+
try jsonData(["jsonrpc": "2.0", "id": 1, "params": [:]])
201+
)
202+
let invalidRoot = try jsonObject(missingMethod)
203+
let invalidError = try XCTUnwrap(invalidRoot["error"] as? [String: Any])
204+
XCTAssertTrue(invalidRoot["id"] is NSNull)
205+
XCTAssertEqual(invalidError["code"] as? Int, -32600)
206+
XCTAssertEqual(invalidError["message"] as? String, "invalid MCP JSON-RPC request")
207+
208+
let malformed = await server.handle(Data("{".utf8))
209+
let malformedRoot = try jsonObject(malformed)
210+
let malformedError = try XCTUnwrap(malformedRoot["error"] as? [String: Any])
211+
XCTAssertTrue(malformedRoot["id"] is NSNull)
212+
XCTAssertEqual(malformedError["code"] as? Int, -32700)
213+
XCTAssertEqual(malformedError["message"] as? String, "parse error")
214+
}
215+
216+
func testMcpThrownToolErrorsReturnJsonRpcErrorResponses() async throws {
217+
let runtime = AgentdMCPRuntimeStub()
218+
runtime.deviceSnapshotError = AgentdMCPStubError(message: "snapshot failed")
219+
runtime.activityError = DiagnosticCLIError.usage("--window requires one of 10m, 6h, or 24h")
220+
let server = AgentdMCPServer(runtime: runtime)
221+
222+
let snapshotResponse = await server.handle(
223+
try jsonData([
224+
"jsonrpc": "2.0",
225+
"id": "snapshot-error",
226+
"method": "tools/call",
227+
"params": [
228+
"name": "agentd_device_snapshot",
229+
"arguments": [:],
230+
],
231+
]))
232+
let snapshotRoot = try jsonObject(snapshotResponse)
233+
let snapshotError = try XCTUnwrap(snapshotRoot["error"] as? [String: Any])
234+
XCTAssertEqual(snapshotRoot["id"] as? String, "snapshot-error")
235+
XCTAssertEqual(snapshotError["code"] as? Int, -32603)
236+
XCTAssertEqual(snapshotError["message"] as? String, "snapshot failed")
237+
238+
let activityResponse = await server.handle(
239+
try jsonData([
240+
"jsonrpc": "2.0",
241+
"id": "activity-error",
242+
"method": "tools/call",
243+
"params": [
244+
"name": "agentd_activity_recent",
245+
"arguments": ["window": "6h"],
246+
],
247+
]))
248+
let activityRoot = try jsonObject(activityResponse)
249+
let activityError = try XCTUnwrap(activityRoot["error"] as? [String: Any])
250+
XCTAssertEqual(activityRoot["id"] as? String, "activity-error")
251+
XCTAssertEqual(activityError["code"] as? Int, -32602)
252+
XCTAssertEqual(activityError["message"] as? String, "--window requires one of 10m, 6h, or 24h")
253+
}
254+
196255
func testCaptureOnceParserAcceptsSafeFlags() throws {
197256
let command = try DiagnosticCommand.parse([
198257
"capture-once", "--display-id", "42", "--no-ocr", "--out", "/tmp/agentd.json",

0 commit comments

Comments
 (0)