Files
oai-swift/oAI/Services/AnytypeMCPService.swift

561 lines
25 KiB
Swift

//
// AnytypeMCPService.swift
// oAI
//
// Anytype MCP integration via local HTTP API at http://127.0.0.1:31009
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
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<String, Error> {
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)"
}
}
}