Skip to content
Open
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
88 changes: 88 additions & 0 deletions Logue.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions Logue/Agent/AgentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ final class AgentCoordinator {
if !disabledNames.isEmpty {
tools.removeAll { disabledNames.contains($0.name) }
}

// Tools from the user's MCP servers, alongside the built-ins rather than in a second
// list — the model, the approval gate and both surfaces then treat them identically,
// which is #63's requirement and the reason none of this needed a second pipeline.
//
// The disable list is handed to the catalog rather than applied afterwards: a remote
// tool's registry name is namespaced, so the filter above would not have matched it,
// and a remote tool is not exempt from "I never want the agent to do X".
tools.append(contentsOf: MCPCatalog.shared.tools(disabledToolNames: disabledNames))
return tools
}

Expand Down
109 changes: 109 additions & 0 deletions Logue/Agent/MCP/MCPCatalog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import Foundation
import os.log

/// What each server last said it offers, and whether it answered.
///
/// The registry asks this for tools on every rebuild, so it holds the last known answer
/// rather than going to the network — a rebuild happens on every send, and a send must not
/// wait on someone else's server before the model sees a tool list.
///
/// **`refresh()` and `forget(id:)` have no caller yet, and that is the current state of the
/// feature rather than an oversight.** Both belong to the Settings screen that adds, enables
/// and removes servers, which is the one remaining box of #63. Until it lands there is no way
/// to add a server, `discovered` stays empty, and no MCP tool is ever published — which is
/// why this half could land without being able to reach the network at all.
@MainActor
@Observable
final class MCPCatalog {
static let shared = MCPCatalog()

private(set) var discovered: [UUID: [MCPToolDescriptor]] = [:]
private(set) var health: [UUID: MCPServerHealth.State] = [:]

private let store: MCPServerStore
private let transport: any MCPTransport
private let logger = Logger(subsystem: AppConstants.bundleID, category: "MCP")

init(store: MCPServerStore = .shared, transport: any MCPTransport = MCPHTTPTransport()) {
self.store = store
self.transport = transport
}

/// The tools to hand the registry.
///
/// Every gate lives in `MCPRegistryPlan`, which is pure; this only supplies what it needs
/// and turns the answer into tools.
func tools(disabledToolNames: Set<String>) -> [any AgentTool] {
MCPRegistryPlan.publications(
servers: store.servers,
discovered: discovered,
health: health,
disabledToolNames: disabledToolNames
)
.map { publication in
MCPRemoteTool(
server: publication.server,
descriptor: publication.descriptor,
transport: transport
)
}
}

/// Asks every enabled server what it can do.
///
/// Failure is per-server: one server being down must not stop the others being
/// discovered, which is why each is its own task and its own recorded state.
func refresh() async {
await withTaskGroup(of: (UUID, Result<[MCPToolDescriptor], any Error>).self) { group in
for server in store.enabledServers {
group.addTask { [transport] in
do {
return try await (server.id, .success(transport.listTools(server: server)))
} catch {
return (server.id, .failure(error))
}
}
}
for await (id, result) in group {
switch result {
case let .success(descriptors):
discovered[id] = descriptors
health[id] = .reachable(toolCount: descriptors.count)
case let .failure(error):
// The tool list is kept. A server that is down now may be back before the
// next send, and re-discovering from nothing would mean a flap costs the
// user every tool until a refresh completes. `MCPRegistryPlan` already
// refuses to publish while the state is `.unreachable`.
health[id] = .unreachable(reason: Self.reason(for: error))
// Host only, never the address — see the project logging rule.
logger.error(
"MCP server unreachable: \(self.store.servers.first { $0.id == id }?.endpoint.host ?? "?", privacy: .public)"
)
}
}
}
}

/// Forgets a server entirely. Called when the user removes one.
func forget(id: UUID) {
discovered[id] = nil
health[id] = nil
}

private static func reason(for error: Error) -> String {
if error is MCPCallError {
return "It did not respond in time."
}
if let wire = error as? MCPWireFormat.WireError {
switch wire {
case .tooLarge: return "It sent more than Logue will read."
case .notJSON, .missingResult: return "Its reply could not be understood."
case let .server(message): return message
}
}
if let urlError = error as? URLError {
return urlError.localizedDescription
}
return "It could not be reached."
}
}
104 changes: 104 additions & 0 deletions Logue/Agent/MCP/MCPHTTPTransport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation
import os.log

/// Talking to a server over HTTP.
///
/// Deliberately thin. Every decision that matters — whether the address is one we will talk
/// to, whether the server is allowed to run, what its tools are called, how much they are
/// trusted, and what their output may do — is settled before anything gets here. This only
/// moves bytes, and it is the last piece of #63 for exactly that reason.
struct MCPHTTPTransport: MCPTransport {
private static let logger = Logger(subsystem: AppConstants.bundleID, category: "MCP")

/// One session, configured with the timeouts rather than trusting a caller to pass them.
///
/// `timeoutIntervalForResource` as well as `forRequest`: a server that dribbles a byte a
/// second keeps resetting the request timeout and would otherwise hold the connection
/// open indefinitely without ever being idle.
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = MCPTimeout.call
configuration.timeoutIntervalForResource = MCPTimeout.call
// Nothing about a tool call should be served from a cache, and an ephemeral session
// keeps nothing on disk — this is somebody else's server, and Logue's posture is that
// it stores as little as it can.
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.httpCookieStorage = nil
return URLSession(configuration: configuration)
}()

func listTools(server: MCPServer) async throws -> [MCPToolDescriptor] {
let result = try await send(
MCPWireFormat.listToolsBody(),
to: server,
timeout: MCPTimeout.discovery
)
return MCPWireFormat.tools(from: result)
}

func call(server: MCPServer, tool: String, arguments: [String: Any]) async throws -> String {
let result = try await send(
MCPWireFormat.callToolBody(name: tool, arguments: arguments),
to: server,
timeout: MCPTimeout.call
)
return MCPWireFormat.callText(from: result)
}

private func send(
_ body: Data,
to server: MCPServer,
timeout: TimeInterval
) async throws -> [String: Any] {
var request = URLRequest(url: server.endpoint, timeoutInterval: timeout)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")

let (stream, response) = try await Self.session.bytes(for: request)

// The status is in the headers, which have arrived; the body has not. Checking it
// first means a server answering 500 with a megabyte of HTML costs us the headers
// and nothing else — there is no reason to read a body we are going to discard.
if let http = response as? HTTPURLResponse, !(200 ..< 300).contains(http.statusCode) {
// Host only, never the address — the project rule, and it applies to error paths
// as much as to success ones.
Self.logger.error(
"MCP server returned \(http.statusCode) — \(server.endpoint.host ?? "?", privacy: .public)"
)
throw MCPWireFormat.WireError.server("HTTP \(http.statusCode)")
}

let data = try await Self.read(stream, declaring: response.expectedContentLength)
return try MCPWireFormat.result(from: data)
}

/// Reads a reply, stopping the moment it exceeds what we are willing to hold.
///
/// `URLSession.data(for:)` buffers the whole body before returning it, so checking the
/// size afterwards checks a allocation that has already happened — a server could make
/// Logue hold a hundred megabytes and the bound in `MCPWireFormat` would only stop it
/// being *parsed*. This is what makes that bound real: the read stops at the cap, so the
/// most a server can make us hold is the cap itself.
///
/// `expectedContentLength` is a fast reject for a server that declares the size honestly,
/// and it is only that — a server that lies, or sends no `Content-Length`, is caught by
/// the running total, which is the check that does not depend on the server telling the
/// truth.
private static func read(_ stream: URLSession.AsyncBytes, declaring declared: Int64) async throws -> Data {
if declared > Int64(MCPWireFormat.maxResponseBytes) {
throw MCPWireFormat.WireError.tooLarge
}

var data = Data()
data.reserveCapacity(min(Int(max(declared, 0)), MCPWireFormat.maxResponseBytes))
for try await byte in stream {
data.append(byte)
if data.count > MCPWireFormat.maxResponseBytes {
throw MCPWireFormat.WireError.tooLarge
}
}
return data
}
}
59 changes: 59 additions & 0 deletions Logue/Agent/MCP/MCPRegistryPlan.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation

/// Which of a user's MCP tools may be offered to the model right now.
///
/// Four separate gates, and the order they are applied in is not arbitrary — each one is a
/// different person's decision and a later gate must never re-open an earlier one:
///
/// 1. **The server is enabled.** The user's decision, and the only one that authorises
/// network egress at all.
/// 2. **The server is not known to be down.** Logue's observation, not a permission.
/// 3. **The tool is not on the disable list.** The user's decision again, per tool, and the
/// same list that turns off built-ins — a remote tool is not exempt from it.
/// 4. **Nothing collides.** Two servers can publish the same namespaced name if the user
/// names them alike, and a flat registry cannot hold both.
///
/// Free of networking and of the store, so the matrix is testable directly.
enum MCPRegistryPlan {
/// One tool that will be registered.
struct Publication: Equatable {
let server: MCPServer
let descriptor: MCPToolDescriptor
/// The name it will occupy in the registry.
let publishedName: String
}

/// - Parameters:
/// - servers: every server the user has, enabled or not.
/// - discovered: what each server last said it offers, keyed by server id.
/// - health: what is known about each server, keyed by server id. A server missing
/// from this map has not been contacted, which is not the same as being down.
/// - disabledToolNames: the per-tool disable list, in published-name form.
static func publications(
servers: [MCPServer],
discovered: [UUID: [MCPToolDescriptor]],
health: [UUID: MCPServerHealth.State],
disabledToolNames: Set<String>
) -> [Publication] {
var claimed: Set<String> = []
var result: [Publication] = []

for server in servers {
// 1. Only the user can authorise a server to run.
guard server.isEnabled else { continue }
// 2. A server known to be down offers nothing. Absent means not yet contacted,
// which still offers — see `MCPServerHealth.State.offersTools`.
guard (health[server.id] ?? .unknown).offersTools else { continue }

for descriptor in discovered[server.id] ?? [] {
let name = MCPToolNaming.published(serverName: server.name, toolName: descriptor.name)
// 3. The same list that turns off built-ins.
guard !disabledToolNames.contains(name) else { continue }
// 4. First claim wins, deterministically, because `servers` is ordered.
guard claimed.insert(name).inserted else { continue }
result.append(Publication(server: server, descriptor: descriptor, publishedName: name))
}
}
return result
}
}
Loading
Loading