mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
0b335476d6
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
163 lines
6.3 KiB
Python
163 lines
6.3 KiB
Python
"""Canvas creation tool for Basic Memory MCP server.
|
|
|
|
This tool creates Obsidian canvas files (.canvas) using the JSON Canvas 1.0 spec.
|
|
"""
|
|
|
|
import json
|
|
from typing import Annotated, Dict, List, Any, Optional
|
|
|
|
from loguru import logger
|
|
from fastmcp import Context
|
|
from pydantic import AliasChoices, BeforeValidator, Field
|
|
|
|
from basic_memory.mcp.project_context import get_project_client
|
|
from basic_memory.utils import coerce_list
|
|
from basic_memory.mcp.server import mcp
|
|
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
|
|
|
|
|
|
@mcp.tool(
|
|
description="Create an Obsidian canvas file to visualize concepts and connections.",
|
|
annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},
|
|
)
|
|
async def canvas(
|
|
nodes: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
|
|
edges: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)],
|
|
title: str,
|
|
directory: Annotated[
|
|
str,
|
|
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
|
|
],
|
|
project: Optional[str] = None,
|
|
project_id: Optional[str] = None,
|
|
context: Context | None = None,
|
|
) -> str:
|
|
"""Create an Obsidian canvas file with the provided nodes and edges.
|
|
|
|
This tool creates a .canvas file compatible with Obsidian's Canvas feature,
|
|
allowing visualization of relationships between concepts or documents.
|
|
|
|
Project Resolution:
|
|
Server resolves projects in this order: Single Project Mode → project parameter → default project.
|
|
If project unknown, use list_memory_projects() or recent_activity() first.
|
|
|
|
For the full JSON Canvas 1.0 specification, see the 'spec://canvas' resource.
|
|
|
|
Args:
|
|
project: Project name to create canvas in. Optional - server will resolve using hierarchy.
|
|
If unknown, use list_memory_projects() to discover available projects.
|
|
project_id: Project external_id (UUID). Prefer this over `project` when known —
|
|
it routes to the exact project regardless of name collisions across cloud
|
|
workspaces. Takes precedence over `project`. Get from list_memory_projects().
|
|
nodes: List of node objects following JSON Canvas 1.0 spec
|
|
edges: List of edge objects following JSON Canvas 1.0 spec
|
|
title: The title of the canvas (will be saved as title.canvas)
|
|
directory: Directory path relative to project root where the canvas should be saved.
|
|
Use forward slashes (/) as separators. Examples: "diagrams", "projects/2025", "visual/maps"
|
|
context: Optional FastMCP context for performance caching.
|
|
|
|
Returns:
|
|
A summary of the created canvas file
|
|
|
|
Important Notes:
|
|
- When referencing files, use the exact file path as shown in Obsidian
|
|
Example: "docs/Document Name.md" (not permalink format)
|
|
- For file nodes, the "file" attribute must reference an existing file
|
|
- Nodes require id, type, x, y, width, height properties
|
|
- Edges require id, fromNode, toNode properties
|
|
- Position nodes in a logical layout (x,y coordinates in pixels)
|
|
- Use color attributes ("1"-"6" or hex) for visual organization
|
|
|
|
Basic Structure:
|
|
```json
|
|
{
|
|
"nodes": [
|
|
{
|
|
"id": "node1",
|
|
"type": "file", // Options: "file", "text", "link", "group"
|
|
"file": "docs/Document.md",
|
|
"x": 0,
|
|
"y": 0,
|
|
"width": 400,
|
|
"height": 300
|
|
}
|
|
],
|
|
"edges": [
|
|
{
|
|
"id": "edge1",
|
|
"fromNode": "node1",
|
|
"toNode": "node2",
|
|
"label": "connects to"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Examples:
|
|
# Create canvas in default/current project
|
|
canvas(nodes=[...], edges=[...], title="My Canvas", directory="diagrams")
|
|
|
|
# Create canvas with explicit project
|
|
canvas(nodes=[...], edges=[...], title="Process Flow", directory="visual/maps", project="work-project")
|
|
|
|
Raises:
|
|
ToolError: If project doesn't exist or directory path is invalid
|
|
"""
|
|
async with get_project_client(project, context=context, project_id=project_id) as (
|
|
client,
|
|
active_project,
|
|
):
|
|
# Ensure path has .canvas extension
|
|
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
|
|
file_path = f"{directory}/{file_title}"
|
|
|
|
# Create canvas data structure
|
|
canvas_data = {"nodes": nodes, "edges": edges}
|
|
|
|
# Convert to JSON
|
|
canvas_json = json.dumps(canvas_data, indent=2)
|
|
|
|
# Try to create the canvas file first (optimistic create)
|
|
logger.info(f"Creating canvas file: {file_path} in project {project}")
|
|
try:
|
|
response = await call_post(
|
|
client,
|
|
f"/v2/projects/{active_project.external_id}/resource",
|
|
json={"file_path": file_path, "content": canvas_json},
|
|
)
|
|
action = "Created"
|
|
except Exception as e:
|
|
# If creation failed due to conflict (already exists), try to update
|
|
if (
|
|
"409" in str(e)
|
|
or "conflict" in str(e).lower()
|
|
or "already exists" in str(e).lower()
|
|
):
|
|
logger.info(f"Canvas file exists, updating instead: {file_path}")
|
|
try:
|
|
entity_id = await resolve_entity_id(
|
|
client, active_project.external_id, file_path
|
|
)
|
|
# For update, send content in JSON body
|
|
response = await call_put(
|
|
client,
|
|
f"/v2/projects/{active_project.external_id}/resource/{entity_id}",
|
|
json={"content": canvas_json},
|
|
)
|
|
action = "Updated"
|
|
except Exception as update_error: # pragma: no cover
|
|
# Re-raise the original error if update also fails
|
|
raise e from update_error # pragma: no cover
|
|
else:
|
|
# Re-raise if it's not a conflict error
|
|
raise # pragma: no cover
|
|
|
|
# Parse response
|
|
result = response.json()
|
|
logger.debug(result)
|
|
|
|
# Build summary
|
|
summary = [f"# {action}: {file_path}", "\nThe canvas is ready to open in Obsidian."]
|
|
|
|
return "\n".join(summary)
|