From b65fe5efe5bd9d318f2c154ef9b577224aff3056 Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Mon, 12 Oct 2026 04:49:41 +0300 Subject: [PATCH] MCP: offer client roots so servers can write files MCPManager(roots:) declares the MCP `roots` capability and answers roots/list with the given folders. chrome-devtools-mcp refuses every file path outside the client's roots ("is not within any of the configured workspace roots"), so take_screenshot's filePath failed for every location the agent picked. Empty roots keep the old behavior. Live test checks both ways: with a root the screenshot is written; with none the server returns the access-denied error. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/SwiftAgentKitMCP/MCPManager.swift | 27 +++++++++- .../MCPManagerTests.swift | 52 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftAgentKitMCP/MCPManager.swift b/Sources/SwiftAgentKitMCP/MCPManager.swift index 964decd..7be373c 100644 --- a/Sources/SwiftAgentKitMCP/MCPManager.swift +++ b/Sources/SwiftAgentKitMCP/MCPManager.swift @@ -22,12 +22,35 @@ public actor MCPManager { /// readResource). Tool-call timeouts are configured on `MCPToolBridge`. public var requestTimeout: TimeInterval = 60 - public init() {} + /// Folders this client offers servers as MCP roots. Servers that confine + /// file writes to the client's roots (chrome-devtools-mcp's screenshot + /// `filePath`, the filesystem server) refuse every path when a client + /// offers none. Empty = the `roots` capability is not declared. + public let roots: [URL] + + public init(roots: [URL] = []) { + self.roots = roots + } + + /// `roots` as MCP `Root`s: `file://` directory URIs, named by folder. + static func mcpRoots(from urls: [URL]) -> [Root] { + urls.map { url in + let dir = URL(fileURLWithPath: url.standardizedFileURL.path, isDirectory: true) + return Root(uri: dir.absoluteString, name: dir.lastPathComponent) + } + } /// Connect to an MCP server using the given configuration. @discardableResult public func connect(_ config: MCPClientConfig) async throws -> MCPServerInfo { - let client = Client(name: "SwiftAgentKit", version: "0.3.0-alpha.7") + let client = Client( + name: "SwiftAgentKit", version: "0.3.0-alpha.7", + capabilities: roots.isEmpty ? .init() : .init(roots: .init(listChanged: false)) + ) + if !roots.isEmpty { + let offered = Self.mcpRoots(from: roots) + await client.withRootsHandler { offered } + } let transport: Transport var process: Process? diff --git a/Tests/SwiftAgentKitMCPTests/MCPManagerTests.swift b/Tests/SwiftAgentKitMCPTests/MCPManagerTests.swift index 4dd72fe..c418022 100644 --- a/Tests/SwiftAgentKitMCPTests/MCPManagerTests.swift +++ b/Tests/SwiftAgentKitMCPTests/MCPManagerTests.swift @@ -167,6 +167,58 @@ final class MCPManagerTests: XCTestCase { ) } + func testRootsBecomeFileDirectoryURIs() { + let roots = MCPManager.mcpRoots(from: [ + URL(fileURLWithPath: "/Users/someone"), + URL(fileURLWithPath: "/tmp/with space/../out"), + ]) + XCTAssertEqual(roots.map(\.uri), ["file:///Users/someone/", "file:///tmp/out/"]) + XCTAssertEqual(roots.map(\.name), ["someone", "out"]) + } + + func testManagerKeepsOfferedRoots() async { + let tmp = URL(fileURLWithPath: NSTemporaryDirectory()) + let manager = MCPManager(roots: [tmp]) + let roots = await manager.roots + XCTAssertEqual(roots, [tmp]) + let none = await MCPManager().roots + XCTAssertTrue(none.isEmpty) + } + + /// Live: chrome-devtools-mcp saves a screenshot inside an offered root. + /// Without roots it answers "is not within any of the configured workspace + /// roots" for every path. Needs npx + Chrome; pre-warm the package. + /// SAK_LIVE_TESTS=1 swift test --filter testLiveChromeDevToolsWritesInsideRoot + func testLiveChromeDevToolsWritesInsideRoot() async throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["SAK_LIVE_TESTS"] == "1") + + // Outside the temp directory: chrome-devtools-mcp always allows temp, + // so a temp folder would pass with no roots at all. + let dir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent(".build/mcp-roots-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let manager = MCPManager(roots: [dir]) + defer { Task { await manager.disconnectAll() } } + _ = try await manager.connect(.stdio( + command: "npx", + args: ["-y", "chrome-devtools-mcp@latest", "--isolated", "--headless"] + )) + + let tools = try await manager.bridgedTools() + let list = try XCTUnwrap(tools.first { $0.name == "list_pages" }) + let pages = try await list.execute(parameters: [:]).result + // "1: about:blank [selected]" — the id of the open page. + let pageId = try XCTUnwrap(pages.split(separator: "\n") + .lazy.compactMap { Int($0.prefix { $0.isNumber }) }.first, pages) + let shot = try XCTUnwrap(tools.first { $0.name == "take_screenshot" }) + let file = dir.appendingPathComponent("shot.png") + let result = try await shot.execute(parameters: ["pageId": pageId, "filePath": file.path]) + XCTAssertTrue(FileManager.default.fileExists(atPath: file.path), "screenshot not written: \(result)") + } + func testExecutableResolverReportsMissingCommand() { XCTAssertThrowsError( try MCPExecutableResolver.resolve(