561 lines
25 KiB
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)"
|
|
}
|
|
}
|
|
}
|