// // AnytypeMCPService.swift // oAI // // Anytype MCP integration via local HTTP API at http://127.0.0.1:31009 // import Foundation import os @Observable class AnytypeMCPService { static let shared = AnytypeMCPService() private let settings = SettingsService.shared private let log = Logger(subsystem: "com.oai.oAI", category: "mcp") private let apiVersion = "2025-11-08" private let timeout: TimeInterval = 10 private(set) var isConnected = false private init() {} // MARK: - Connection Test func testConnection() async -> Result { do { let result = try await request(endpoint: "/v1/spaces", method: "GET", body: nil) if let spaces = result["data"] as? [[String: Any]] { isConnected = true return .success("Connected (\(spaces.count) space\(spaces.count == 1 ? "" : "s"))") } else { isConnected = true return .success("Connected to Anytype") } } catch { isConnected = false return .failure(error) } } // MARK: - Tool Schemas func getToolSchemas() -> [Tool] { return [ makeTool( name: "anytype_search_global", description: "Search across all Anytype spaces for objects matching a query. Returns matching objects with their IDs, names, types, and space info.", properties: [ "query": prop("string", "The search query text"), "limit": prop("number", "Maximum number of results to return (default: 20)") ], required: ["query"] ), makeTool( name: "anytype_list_spaces", description: "List all available Anytype spaces (workspaces). Returns space IDs, names, and basic info.", properties: [:], required: [] ), makeTool( name: "anytype_get_space_objects", description: "Get objects in a specific Anytype space. Returns a list of objects with their IDs, names, and types. Use search tools to find specific objects rather than listing all.", properties: [ "space_id": prop("string", "The ID of the Anytype space"), "limit": prop("number", "Maximum number of objects to return (default: 20, max recommended: 50)") ], required: ["space_id"] ), makeTool( name: "anytype_get_object", description: "Get the full details and content of a specific Anytype object by ID. The 'body' field contains the COMPLETE markdown including Anytype internal links (anytype://object?...) and file links that MUST be preserved exactly when updating.", properties: [ "space_id": prop("string", "The ID of the space containing the object"), "object_id": prop("string", "The ID of the object to retrieve") ], required: ["space_id", "object_id"] ), makeTool( name: "anytype_create_object", description: "Create a new object (note, task, or page) in an Anytype space.", properties: [ "space_id": prop("string", "The ID of the space to create the object in"), "name": prop("string", "The name/title of the object"), "body": prop("string", "The text content/body of the object"), "type": prop("string", "The type of object: 'note', 'task', or 'page' (default: note)") ], required: ["space_id", "name"] ), makeTool( name: "anytype_update_object", description: """ Replace the full markdown body or rename an Anytype object. \ IMPORTANT: For toggling a checkbox (to-do item), use anytype_toggle_checkbox instead — \ it is safer and does not risk modifying other content. \ Use anytype_update_object ONLY for large content changes (adding paragraphs, rewriting sections, etc.). \ CRITICAL RULES when using this tool: \ 1) Always call anytype_get_object first to get the current EXACT markdown. \ 2) Make ONLY the minimal requested change — nothing else. \ 3) Copy ALL other content CHARACTER-FOR-CHARACTER, including anytype://object?objectId=... links, \ file links, headings, and all formatting. Do NOT summarise, paraphrase, or reformat anything you are not asked to change. """, properties: [ "space_id": prop("string", "The ID of the space containing the object"), "object_id": prop("string", "The ID of the object to update"), "name": prop("string", "New name/title for the object (optional)"), "body": prop("string", "COMPLETE replacement markdown content — must preserve ALL existing content with ONLY the minimal intended change applied") ], required: ["space_id", "object_id"] ), makeTool( name: "anytype_set_done", description: "Mark a task as done or undone in Anytype by setting its 'done' relation. This is the correct way to check/uncheck tasks — do not use anytype_update_object body text for this.", properties: [ "space_id": prop("string", "The ID of the space containing the task"), "object_id": prop("string", "The ID of the task to update"), "done": prop("boolean", "true to mark as done, false to mark as undone") ], required: ["space_id", "object_id", "done"] ), makeTool( name: "anytype_toggle_checkbox", description: """ Surgically toggle a specific checkbox in an Anytype object without rewriting the full body. \ Use this INSTEAD of anytype_update_object when marking a to-do item done/undone. \ Provide partial text that uniquely identifies the checkbox line (do not include the '- [ ]' or '- [x]' prefix). \ The tool finds the matching line and flips its checkbox state, preserving all other content exactly. """, properties: [ "space_id": prop("string", "The ID of the space containing the object"), "object_id": prop("string", "The ID of the object containing the checkbox"), "line_text": prop("string", "Partial text of the checkbox line to find (e.g. 'Buy groceries'). Do NOT include the '- [ ]' prefix."), "done": prop("boolean", "true to check the box, false to uncheck it") ], required: ["space_id", "object_id", "line_text", "done"] ), makeTool( name: "anytype_search_space", description: "Search within a specific Anytype space for objects matching a query.", properties: [ "space_id": prop("string", "The ID of the Anytype space to search in"), "query": prop("string", "The search query text"), "limit": prop("number", "Maximum number of results (default: 20)") ], required: ["space_id", "query"] ) ] } // MARK: - Tool Execution func executeTool(name: String, arguments: String) async -> [String: Any] { log.info("Executing Anytype tool: \(name)") guard let argData = arguments.data(using: .utf8), let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else { return ["error": "Invalid arguments JSON"] } return await executeToolAsync(name: name, args: args) } private func executeToolAsync(name: String, args: [String: Any]) async -> [String: Any] { do { switch name { case "anytype_search_global": guard let query = args["query"] as? String else { return ["error": "Missing required parameter: query"] } let limit = args["limit"] as? Int ?? 20 return try await searchGlobal(query: query, limit: limit) case "anytype_list_spaces": return try await listSpaces() case "anytype_get_space_objects": guard let spaceId = args["space_id"] as? String else { return ["error": "Missing required parameter: space_id"] } let limit = min(args["limit"] as? Int ?? 20, 50) return try await getSpaceObjects(spaceId: spaceId, limit: limit) case "anytype_get_object": guard let spaceId = args["space_id"] as? String, let objectId = args["object_id"] as? String else { return ["error": "Missing required parameters: space_id, object_id"] } return try await getObject(spaceId: spaceId, objectId: objectId) case "anytype_create_object": guard let spaceId = args["space_id"] as? String, let name = args["name"] as? String else { return ["error": "Missing required parameters: space_id, name"] } let body = args["body"] as? String ?? "" let type_ = args["type"] as? String ?? "note" return try await createObject(spaceId: spaceId, name: name, body: body, type: type_) case "anytype_update_object": guard let spaceId = args["space_id"] as? String, let objectId = args["object_id"] as? String else { return ["error": "Missing required parameters: space_id, object_id"] } let name = args["name"] as? String let body = args["body"] as? String return try await updateObject(spaceId: spaceId, objectId: objectId, name: name, body: body) case "anytype_set_done": guard let spaceId = args["space_id"] as? String, let objectId = args["object_id"] as? String else { return ["error": "Missing required parameters: space_id, object_id"] } // Accept done as Bool or as Int (1/0) since JSON can arrive either way let done: Bool if let b = args["done"] as? Bool { done = b } else if let n = args["done"] as? Int { done = n != 0 } else { return ["error": "Missing or invalid parameter: done (expected boolean)"] } return try await setDone(spaceId: spaceId, objectId: objectId, done: done) case "anytype_toggle_checkbox": guard let spaceId = args["space_id"] as? String, let objectId = args["object_id"] as? String, let lineText = args["line_text"] as? String else { return ["error": "Missing required parameters: space_id, object_id, line_text"] } let done: Bool if let b = args["done"] as? Bool { done = b } else if let n = args["done"] as? Int { done = n != 0 } else { return ["error": "Missing or invalid parameter: done (expected boolean)"] } return try await toggleCheckbox(spaceId: spaceId, objectId: objectId, lineText: lineText, done: done) case "anytype_search_space": guard let spaceId = args["space_id"] as? String, let query = args["query"] as? String else { return ["error": "Missing required parameters: space_id, query"] } let limit = args["limit"] as? Int ?? 20 return try await searchSpace(spaceId: spaceId, query: query, limit: limit) default: return ["error": "Unknown Anytype tool: \(name)"] } } catch AnytypeError.notRunning { return ["error": "Cannot connect to Anytype. Make sure the desktop app is running."] } catch AnytypeError.unauthorized { return ["error": "Invalid API key. Check your Anytype API key in Settings > MCP."] } catch AnytypeError.httpError(let code, let msg) { return ["error": "Anytype API error \(code): \(msg)"] } catch { return ["error": "Anytype error: \(error.localizedDescription)"] } } // MARK: - API Operations private func searchGlobal(query: String, limit: Int) async throws -> [String: Any] { let body: [String: Any] = ["query": query, "limit": limit] let result = try await request(endpoint: "/v1/search", method: "POST", body: body) if let objects = result["data"] as? [[String: Any]] { let formatted = objects.map { formatObject($0) } return ["count": formatted.count, "objects": formatted] } return ["count": 0, "objects": [], "message": "No results found"] } private func listSpaces() async throws -> [String: Any] { let result = try await request(endpoint: "/v1/spaces", method: "GET", body: nil) if let spaces = result["data"] as? [[String: Any]] { let formatted = spaces.map { space -> [String: Any] in [ "id": space["id"] ?? "", "name": space["name"] ?? "Unnamed Space", "type": space["spaceType"] ?? "unknown" ] } return ["count": formatted.count, "spaces": formatted] } return ["count": 0, "spaces": []] } private func getSpaceObjects(spaceId: String, limit: Int) async throws -> [String: Any] { let result = try await request( endpoint: "/v1/spaces/\(spaceId)/objects", method: "GET", body: nil, queryParams: ["limit": String(limit)] ) if let objects = result["data"] as? [[String: Any]] { let formatted = Array(objects.prefix(limit).map { formatObject($0) }) return ["count": formatted.count, "objects": formatted] } return ["count": 0, "objects": []] } private func getObject(spaceId: String, objectId: String) async throws -> [String: Any] { let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil) if let object = result["object"] as? [String: Any] { return formatObjectDetail(object) } // Return full raw response if expected structure not found return result } private func createObject(spaceId: String, name: String, body: String, type: String) async throws -> [String: Any] { let typeKey: String switch type.lowercased() { case "task": typeKey = "ot-task" case "page": typeKey = "ot-page" default: typeKey = "ot-note" } let requestBody: [String: Any] = [ "name": name, "body": body, // some API versions use "body" "markdown": body, // newer API versions use "markdown" "typeKey": typeKey, "template_id": "" ] let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects", method: "POST", body: requestBody) if let object = result["object"] as? [String: Any] { return ["success": true, "id": object["id"] ?? "", "name": name, "message": "Object created successfully"] } return ["success": true, "message": "Object created"] } private func updateObject(spaceId: String, objectId: String, name: String?, body: String?) async throws -> [String: Any] { var requestBody: [String: Any] = [:] if let name = name { requestBody["name"] = name } // The Anytype API GET response uses "markdown" for body content, not "body" if let body = body { requestBody["markdown"] = body } guard !requestBody.isEmpty else { return ["error": "No fields to update. Provide name or body."] } let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "PATCH", body: requestBody) if result.isEmpty { return ["success": true, "message": "Object updated (empty response from Anytype API)"] } return result } private func toggleCheckbox(spaceId: String, objectId: String, lineText: String, done: Bool) async throws -> [String: Any] { // Fetch current content let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil) guard let object = result["object"] as? [String: Any] else { return ["error": "Object not found"] } let markdown: String if let md = object["markdown"] as? String { markdown = md } else if let body = object["body"] as? String { markdown = body } else { return ["error": "Object has no markdown body"] } // Find the line containing the search text (case-insensitive) let lines = markdown.components(separatedBy: "\n") let searchLower = lineText.lowercased() guard let idx = lines.firstIndex(where: { let stripped = $0.replacingOccurrences(of: "- [ ] ", with: "") .replacingOccurrences(of: "- [x] ", with: "") .replacingOccurrences(of: "- [X] ", with: "") return stripped.lowercased().contains(searchLower) }) else { return ["error": "No checkbox line found containing: '\(lineText)'"] } var line = lines[idx] let wasDone = line.hasPrefix("- [x] ") || line.hasPrefix("- [X] ") if done == wasDone { return ["success": true, "message": "Checkbox was already \(done ? "checked" : "unchecked")", "line": line] } // Flip the checkbox marker, preserving the rest of the line exactly if done { if line.hasPrefix("- [ ] ") { line = "- [x] " + line.dropFirst(6) } } else { if line.hasPrefix("- [x] ") || line.hasPrefix("- [X] ") { line = "- [ ] " + line.dropFirst(6) } } var updated = lines updated[idx] = line let newMarkdown = updated.joined(separator: "\n") let patchResult = try await request( endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "PATCH", body: ["markdown": newMarkdown] ) return ["success": true, "done": done, "line": line, "message": "Checkbox updated"] } private func setDone(spaceId: String, objectId: String, done: Bool) async throws -> [String: Any] { // Anytype stores task completion as a relation called "done" let requestBody: [String: Any] = [ "properties": [ "done": done ] ] let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "PATCH", body: requestBody) if result.isEmpty { return ["success": true, "done": done, "message": "setDone sent (empty response from Anytype API — verify in app)"] } return result } private func searchSpace(spaceId: String, query: String, limit: Int) async throws -> [String: Any] { let body: [String: Any] = ["query": query, "limit": limit] let result = try await request(endpoint: "/v1/spaces/\(spaceId)/search", method: "POST", body: body) if let objects = result["data"] as? [[String: Any]] { let formatted = objects.map { formatObject($0) } return ["count": formatted.count, "objects": formatted] } return ["count": 0, "objects": [], "message": "No results found"] } // MARK: - HTTP Client private func request(endpoint: String, method: String, body: [String: Any]?, queryParams: [String: String] = [:]) async throws -> [String: Any] { guard let apiKey = settings.anytypeMcpAPIKey, !apiKey.isEmpty else { throw AnytypeError.unauthorized } let baseURL = settings.anytypeMcpEffectiveURL var urlString = baseURL + endpoint if !queryParams.isEmpty { var comps = URLComponents(string: urlString) ?? URLComponents() comps.queryItems = queryParams.map { URLQueryItem(name: $0.key, value: $0.value) } urlString = comps.url?.absoluteString ?? urlString } guard let url = URL(string: urlString) else { throw AnytypeError.httpError(0, "Invalid URL: \(urlString)") } var urlRequest = URLRequest(url: url, timeoutInterval: timeout) urlRequest.httpMethod = method urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") urlRequest.setValue(apiVersion, forHTTPHeaderField: "Anytype-Version") if let body = body, method != "GET" { urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: body) } do { let (data, response) = try await URLSession.shared.data(for: urlRequest) guard let httpResponse = response as? HTTPURLResponse else { throw AnytypeError.httpError(0, "Invalid response") } if httpResponse.statusCode == 401 { throw AnytypeError.unauthorized } guard (200...299).contains(httpResponse.statusCode) else { let msg = String(data: data, encoding: .utf8) ?? "Unknown error" throw AnytypeError.httpError(httpResponse.statusCode, msg) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } return json } catch let error as AnytypeError { throw error } catch { // Connection refused or network error = Anytype not running throw AnytypeError.notRunning } } // MARK: - Helpers private func formatObject(_ obj: [String: Any]) -> [String: Any] { [ "id": obj["id"] ?? "", "name": obj["name"] ?? "Unnamed", "type": obj["type"] ?? obj["objectType"] ?? "unknown", "space_id": obj["spaceId"] ?? "" ] } private func formatObjectDetail(_ obj: [String: Any]) -> [String: Any] { var result: [String: Any] = formatObject(obj) // Anytype API returns content as "markdown" not "body" if let markdown = obj["markdown"] as? String { result["body"] = markdown } else if let body = obj["body"] as? String { result["body"] = body } if let snippet = obj["snippet"] as? String { result["snippet"] = snippet } return result } private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool { Tool( type: "function", function: Tool.Function( name: name, description: description, parameters: Tool.Function.Parameters( type: "object", properties: properties, required: required ) ) ) } private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property { Tool.Function.Parameters.Property(type: type, description: description, enum: nil) } } // MARK: - Error Types enum AnytypeError: LocalizedError { case notRunning case unauthorized case httpError(Int, String) var errorDescription: String? { switch self { case .notRunning: return "Cannot connect to Anytype. Make sure the desktop app is running." case .unauthorized: return "Invalid API key. Check your Anytype API key in Settings > MCP." case .httpError(let code, let msg): return "Anytype API error \(code): \(msg)" } } }