Add drawing tools, Mermaid support, and MCP server bridge

- Convert flat project to Cargo workspace (crates/agcanvas, crates/agcanvas-mcp)
- Add drawing layer: rectangles, ellipses, lines, arrows, text with select/move/resize
- Add Mermaid diagram rendering via mermaid-rs-renderer
- Add agcanvas-mcp: MCP server bridge for Claude Code, OpenCode, and Codex
- Add toolbar UI with keyboard shortcuts (V/R/E/L/A/T) and shape interaction
- Add example MCP configs for Claude Code, OpenCode, and Codex
- Update README with full feature docs, MCP setup, and updated architecture
This commit is contained in:
David Ibia
2026-02-08 22:49:24 +01:00
parent 732e205943
commit d248864ee2
32 changed files with 2833 additions and 733 deletions

View File

@@ -0,0 +1,23 @@
[package]
name = "agcanvas-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "MCP server bridge for agcanvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
[[bin]]
name = "agcanvas-mcp"
path = "src/main.rs"
[dependencies]
rmcp = { version = "0.14", features = ["server", "macros", "transport-io"] }
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "io-std", "sync"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
schemars = "1.0"
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4.0", features = ["derive"] }

View File

@@ -0,0 +1,29 @@
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
pub async fn send_request(ws_url: &str, request_json: &str) -> Result<String> {
let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url)
.await
.map_err(|e| {
anyhow::anyhow!(
"Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}",
ws_url,
e
)
})?;
let (mut write, mut read) = ws_stream.split();
// Skip the initial Connected event
let _connected = read.next().await;
write.send(Message::Text(request_json.to_string())).await?;
match read.next().await {
Some(Ok(Message::Text(response))) => Ok(response),
Some(Ok(other)) => Err(anyhow::anyhow!("Unexpected message type: {:?}", other)),
Some(Err(e)) => Err(anyhow::anyhow!("WebSocket error: {}", e)),
None => Err(anyhow::anyhow!("Connection closed before response")),
}
}

View File

@@ -0,0 +1,38 @@
mod bridge;
mod tools;
use anyhow::Result;
use clap::Parser;
use rmcp::ServiceExt;
use tools::AgCanvasServer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Parser)]
#[command(name = "agcanvas-mcp", about = "MCP server bridge for agcanvas")]
struct Cli {
#[arg(long, default_value = "9876")]
port: u16,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "agcanvas_mcp=info".into()),
))
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.init();
let cli = Cli::parse();
let ws_url = format!("ws://127.0.0.1:{}", cli.port);
tracing::info!("Starting agcanvas MCP server, connecting to {}", ws_url);
let server = AgCanvasServer::new(ws_url);
let service = server.serve(rmcp::transport::stdio()).await?;
tracing::info!("MCP server running on stdio");
service.waiting().await?;
Ok(())
}

View File

@@ -0,0 +1,202 @@
use crate::bridge::send_request;
use rmcp::{
ErrorData as McpError, ServerHandler,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::*,
schemars, tool, tool_handler, tool_router,
};
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SessionIdParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetElementParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "Element ID to look up")]
pub id: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetElementsAtPointParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "X coordinate in canvas space")]
pub x: f32,
#[schemars(description = "Y coordinate in canvas space")]
pub y: f32,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GenerateCodeParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "Code generation target: html, react, tailwind, svelte, or vue")]
pub target: String,
#[schemars(description = "Element ID to generate code for. If omitted, generates for the entire tree.")]
pub element_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AgCanvasServer {
ws_url: String,
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl AgCanvasServer {
pub fn new(ws_url: String) -> Self {
Self {
ws_url,
tool_router: Self::tool_router(),
}
}
#[tool(description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, and whether they have SVG or drawing content loaded.")]
async fn list_sessions(&self) -> Result<CallToolResult, McpError> {
let request = serde_json::json!({"type": "ListSessions"});
self.call_agcanvas(&request).await
}
#[tool(description = "Get the full parsed SVG element tree from the canvas. Returns a hierarchical JSON structure with element types (Group, Rectangle, Circle, Path, Text, etc.), bounds, styles, and children. This is the primary way to understand what design is on the canvas.")]
async fn get_element_tree(
&self,
Parameters(params): Parameters<SessionIdParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "GetTree"});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Get a human-readable semantic description of the canvas content. Returns structured text describing the element hierarchy with types, dimensions, and colors. Useful for quickly understanding a design without parsing JSON.")]
async fn describe_canvas(
&self,
Parameters(params): Parameters<SessionIdParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "Describe"});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Get a specific element by its ID from the SVG element tree.")]
async fn get_element_by_id(
&self,
Parameters(params): Parameters<GetElementParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "GetElementById", "id": params.id});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Query which elements exist at a specific (x, y) coordinate on the canvas.")]
async fn get_elements_at_point(
&self,
Parameters(params): Parameters<GetElementsAtPointParam>,
) -> Result<CallToolResult, McpError> {
let mut request =
serde_json::json!({"type": "GetElementsAtPoint", "x": params.x, "y": params.y});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Get all user-drawn shapes (rectangles, ellipses, lines, arrows, text) from the drawing layer.")]
async fn get_drawing_elements(
&self,
Parameters(params): Parameters<SessionIdParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "GetDrawingElements"});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Generate a code stub from the SVG structure. Targets: html, react, tailwind, svelte, vue. Returns a starting template based on the element hierarchy.")]
async fn generate_code(
&self,
Parameters(params): Parameters<GenerateCodeParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "GenerateCode",
"target": params.target,
"element_id": params.element_id,
});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
}
impl AgCanvasServer {
async fn call_agcanvas(
&self,
request: &serde_json::Value,
) -> Result<CallToolResult, McpError> {
let request_str = serde_json::to_string(request)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
match send_request(&self.ws_url, &request_str).await {
Ok(response) => {
let parsed: serde_json::Value = serde_json::from_str(&response)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let is_error = parsed
.get("type")
.and_then(serde_json::Value::as_str)
== Some("Error");
if is_error {
let msg = parsed
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("Unknown error from agcanvas");
return Ok(CallToolResult::error(vec![Content::text(msg)]));
}
let pretty = serde_json::to_string_pretty(&parsed)
.unwrap_or_else(|_| response.clone());
Ok(CallToolResult::success(vec![Content::text(pretty)]))
}
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
"Failed to communicate with agcanvas: {}. Make sure agcanvas is running.",
e
))])),
}
}
}
#[tool_handler]
impl ServerHandler for AgCanvasServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: Some(
"agcanvas MCP server — connects to agcanvas desktop app to query SVG designs, \
element trees, and user-drawn shapes. Use describe_canvas to understand the \
current design, get_element_tree for structured data, and generate_code for \
code stubs. Requires agcanvas to be running."
.into(),
),
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation {
name: "agcanvas-mcp".into(),
version: env!("CARGO_PKG_VERSION").into(),
title: None,
icons: None,
website_url: None,
},
..Default::default()
}
}
}