mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
refactor(mcp): simplify history implementation
- Reduce history.ts from 169 to 51 lines
- Remove AI tools (list_history, restore_version, get_version)
- Remove /api/update-svg endpoint
- Remove 10-second history polling
- Simplify HistoryEntry to just {xml, svg}
- Use array index instead of version numbers
Total reduction: 1936 → 923 lines (-52%)
This commit is contained in:
@@ -1,169 +1,51 @@
|
|||||||
/**
|
/**
|
||||||
* Diagram Version History for MCP Server
|
* Simple diagram history - matches Next.js app pattern
|
||||||
*
|
* Stores {xml, svg} entries in a circular buffer
|
||||||
* Stores diagram versions in-memory per session.
|
|
||||||
* Enables users and AI to restore previous diagram states.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { log } from "./logger.js"
|
import { log } from "./logger.js"
|
||||||
|
|
||||||
export interface HistoryEntry {
|
const MAX_HISTORY = 20
|
||||||
version: number
|
const historyStore = new Map<string, Array<{ xml: string; svg: string }>>()
|
||||||
xml: string
|
|
||||||
svg: string // SVG data for thumbnail preview
|
|
||||||
source: "ai" | "human"
|
|
||||||
tool?: string // Which tool made the change (display_diagram, edit_diagram, browser_sync)
|
|
||||||
timestamp: Date
|
|
||||||
description?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionHistory {
|
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||||
entries: HistoryEntry[]
|
|
||||||
nextVersion: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory history store keyed by session ID
|
|
||||||
const historyStore = new Map<string, SessionHistory>()
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const MAX_HISTORY_ENTRIES = 50
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new entry to session history
|
|
||||||
* Returns the assigned version number
|
|
||||||
*/
|
|
||||||
export function addHistoryEntry(
|
|
||||||
sessionId: string,
|
|
||||||
entry: Omit<HistoryEntry, "version">,
|
|
||||||
): number {
|
|
||||||
let history = historyStore.get(sessionId)
|
let history = historyStore.get(sessionId)
|
||||||
if (!history) {
|
if (!history) {
|
||||||
history = { entries: [], nextVersion: 1 }
|
history = []
|
||||||
historyStore.set(sessionId, history)
|
historyStore.set(sessionId, history)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate: skip if XML is identical to last entry
|
// Dedupe: skip if same as last entry
|
||||||
const lastEntry = history.entries[history.entries.length - 1]
|
const last = history[history.length - 1]
|
||||||
if (lastEntry && lastEntry.xml === entry.xml) {
|
if (last?.xml === xml) {
|
||||||
log.debug(`Skipping duplicate history entry for session ${sessionId}`)
|
return history.length - 1
|
||||||
return lastEntry.version
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const version = history.nextVersion++
|
history.push({ xml, svg })
|
||||||
const newEntry: HistoryEntry = {
|
|
||||||
...entry,
|
// Circular buffer
|
||||||
version,
|
if (history.length > MAX_HISTORY) {
|
||||||
|
history.shift()
|
||||||
}
|
}
|
||||||
|
|
||||||
history.entries.push(newEntry)
|
log.debug(`History: session=${sessionId}, entries=${history.length}`)
|
||||||
|
return history.length - 1
|
||||||
// Prune oldest entries if over limit (circular buffer)
|
|
||||||
if (history.entries.length > MAX_HISTORY_ENTRIES) {
|
|
||||||
const removed = history.entries.shift()
|
|
||||||
log.debug(`Pruned oldest history entry v${removed?.version}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
`Added history v${version} for session ${sessionId} (source: ${entry.source}, entries: ${history.entries.length})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return version
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get history entries for a session
|
|
||||||
* Returns newest first, limited to specified count
|
|
||||||
*/
|
|
||||||
export function getHistory(
|
export function getHistory(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
limit: number = 20,
|
): Array<{ xml: string; svg: string }> {
|
||||||
): HistoryEntry[] {
|
return historyStore.get(sessionId) || []
|
||||||
const history = historyStore.get(sessionId)
|
|
||||||
if (!history) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return newest first
|
|
||||||
return [...history.entries].reverse().slice(0, limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function getHistoryEntry(
|
||||||
* Get a specific version from history
|
|
||||||
*/
|
|
||||||
export function getVersion(
|
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
version: number,
|
index: number,
|
||||||
): HistoryEntry | undefined {
|
): { xml: string; svg: string } | undefined {
|
||||||
const history = historyStore.get(sessionId)
|
const history = historyStore.get(sessionId)
|
||||||
if (!history) {
|
return history?.[index]
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return history.entries.find((e) => e.version === version)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the latest version number for a session
|
|
||||||
*/
|
|
||||||
export function getLatestVersion(sessionId: string): number {
|
|
||||||
const history = historyStore.get(sessionId)
|
|
||||||
if (!history || history.entries.length === 0) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return history.entries[history.entries.length - 1].version
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear history for a session (used on session expiry)
|
|
||||||
*/
|
|
||||||
export function clearHistory(sessionId: string): void {
|
export function clearHistory(sessionId: string): void {
|
||||||
if (historyStore.has(sessionId)) {
|
historyStore.delete(sessionId)
|
||||||
historyStore.delete(sessionId)
|
|
||||||
log.info(`Cleared history for session ${sessionId}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the SVG of the latest entry (or specific version) that has empty SVG
|
|
||||||
* Used when browser generates SVG after loading AI diagram
|
|
||||||
*/
|
|
||||||
export function updateLatestEntrySvg(
|
|
||||||
sessionId: string,
|
|
||||||
svg: string,
|
|
||||||
targetVersion?: number,
|
|
||||||
): boolean {
|
|
||||||
const history = historyStore.get(sessionId)
|
|
||||||
if (!history || history.entries.length === 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find entry to update - either specific version or latest without SVG
|
|
||||||
let entry: HistoryEntry | undefined
|
|
||||||
if (targetVersion !== undefined) {
|
|
||||||
entry = history.entries.find((e) => e.version === targetVersion)
|
|
||||||
} else {
|
|
||||||
// Find most recent entry without SVG
|
|
||||||
for (let i = history.entries.length - 1; i >= 0; i--) {
|
|
||||||
if (!history.entries[i].svg) {
|
|
||||||
entry = history.entries[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry && !entry.svg) {
|
|
||||||
entry.svg = svg
|
|
||||||
log.debug(`Updated SVG for history v${entry.version}`)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of history entries for a session
|
|
||||||
*/
|
|
||||||
export function getHistoryCount(sessionId: string): number {
|
|
||||||
const history = historyStore.get(sessionId)
|
|
||||||
return history?.entries.length || 0
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,23 +4,16 @@
|
|||||||
*
|
*
|
||||||
* Enables AI agents (Claude Desktop, Cursor, etc.) to generate and edit
|
* Enables AI agents (Claude Desktop, Cursor, etc.) to generate and edit
|
||||||
* draw.io diagrams with real-time browser preview.
|
* draw.io diagrams with real-time browser preview.
|
||||||
*
|
|
||||||
* Uses an embedded HTTP server - no external dependencies required.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Setup DOM polyfill for Node.js (required for XML operations)
|
// Setup DOM polyfill for Node.js (required for XML operations)
|
||||||
import { DOMParser } from "linkedom"
|
import { DOMParser } from "linkedom"
|
||||||
;(globalThis as any).DOMParser = DOMParser
|
;(globalThis as any).DOMParser = DOMParser
|
||||||
|
|
||||||
// Create XMLSerializer polyfill using outerHTML
|
|
||||||
class XMLSerializerPolyfill {
|
class XMLSerializerPolyfill {
|
||||||
serializeToString(node: any): string {
|
serializeToString(node: any): string {
|
||||||
if (node.outerHTML !== undefined) {
|
if (node.outerHTML !== undefined) return node.outerHTML
|
||||||
return node.outerHTML
|
if (node.documentElement) return node.documentElement.outerHTML
|
||||||
}
|
|
||||||
if (node.documentElement) {
|
|
||||||
return node.documentElement.outerHTML
|
|
||||||
}
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,40 +27,18 @@ import {
|
|||||||
applyDiagramOperations,
|
applyDiagramOperations,
|
||||||
type DiagramOperation,
|
type DiagramOperation,
|
||||||
} from "./diagram-operations.js"
|
} from "./diagram-operations.js"
|
||||||
import {
|
import { addHistory } from "./history.js"
|
||||||
addHistoryEntry,
|
import { getState, setState, startHttpServer } from "./http-server.js"
|
||||||
getHistory,
|
|
||||||
getHistoryCount,
|
|
||||||
getVersion as getHistoryVersion,
|
|
||||||
} from "./history.js"
|
|
||||||
import {
|
|
||||||
getServerPort,
|
|
||||||
getState,
|
|
||||||
setState,
|
|
||||||
startHttpServer,
|
|
||||||
} from "./http-server.js"
|
|
||||||
import { log } from "./logger.js"
|
import { log } from "./logger.js"
|
||||||
import { validateAndFixXml } from "./xml-validation.js"
|
import { validateAndFixXml } from "./xml-validation.js"
|
||||||
|
|
||||||
// Server configuration
|
const config = { port: parseInt(process.env.PORT || "6002") }
|
||||||
const config = {
|
|
||||||
port: parseInt(process.env.PORT || "6002"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session state (single session for simplicity)
|
let currentSession: { id: string; xml: string } | null = null
|
||||||
let currentSession: {
|
|
||||||
id: string
|
|
||||||
xml: string
|
|
||||||
version: number
|
|
||||||
} | null = null
|
|
||||||
|
|
||||||
// Create MCP server
|
const server = new McpServer({ name: "next-ai-drawio", version: "0.1.2" })
|
||||||
const server = new McpServer({
|
|
||||||
name: "next-ai-drawio",
|
|
||||||
version: "0.1.2",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Register prompt with workflow guidance
|
// Workflow guidance prompt
|
||||||
server.prompt(
|
server.prompt(
|
||||||
"diagram-workflow",
|
"diagram-workflow",
|
||||||
"Guidelines for creating and editing draw.io diagrams",
|
"Guidelines for creating and editing draw.io diagrams",
|
||||||
@@ -77,26 +48,22 @@ server.prompt(
|
|||||||
role: "user",
|
role: "user",
|
||||||
content: {
|
content: {
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `# Draw.io Diagram Workflow Guidelines
|
text: `# Draw.io Diagram Workflow
|
||||||
|
|
||||||
## Creating a New Diagram
|
## Creating a New Diagram
|
||||||
1. Call start_session to open the browser preview
|
1. Call start_session to open browser preview
|
||||||
2. Use display_diagram with complete mxGraphModel XML to create a new diagram
|
2. Use display_diagram with mxGraphModel XML
|
||||||
|
|
||||||
## Adding Elements to Existing Diagram
|
## Adding Elements
|
||||||
1. Use edit_diagram with "add" operation
|
Use edit_diagram with "add" operation - provide cell_id and new_xml
|
||||||
2. Provide a unique cell_id and complete mxCell XML
|
|
||||||
3. No need to call get_diagram first - the server fetches latest state automatically
|
|
||||||
|
|
||||||
## Modifying or Deleting Existing Elements
|
## Modifying/Deleting Elements
|
||||||
1. FIRST call get_diagram to see current cell IDs and structure
|
1. Call get_diagram to see current cell IDs
|
||||||
2. THEN call edit_diagram with "update" or "delete" operations
|
2. Use edit_diagram with "update" or "delete" operations
|
||||||
3. For update, provide the cell_id and complete new mxCell XML
|
|
||||||
|
|
||||||
## Important Notes
|
## Notes
|
||||||
- display_diagram REPLACES the entire diagram - only use for new diagrams
|
- display_diagram REPLACES entire diagram
|
||||||
- edit_diagram PRESERVES user's manual changes (fetches browser state first)
|
- edit_diagram PRESERVES user's manual changes`,
|
||||||
- Always use unique cell_ids when adding elements (e.g., "shape-1", "arrow-2")`,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -108,25 +75,15 @@ server.registerTool(
|
|||||||
"start_session",
|
"start_session",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Start a new diagram session and open the browser for real-time preview. " +
|
"Start a new diagram session and open browser for real-time preview.",
|
||||||
"Starts an embedded server and opens a browser window with draw.io. " +
|
|
||||||
"The browser will show diagram updates as they happen.",
|
|
||||||
inputSchema: {},
|
inputSchema: {},
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
// Start embedded HTTP server
|
|
||||||
const port = await startHttpServer(config.port)
|
const port = await startHttpServer(config.port)
|
||||||
|
|
||||||
// Create session
|
|
||||||
const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`
|
const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`
|
||||||
currentSession = {
|
currentSession = { id: sessionId, xml: "" }
|
||||||
id: sessionId,
|
|
||||||
xml: "",
|
|
||||||
version: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open browser
|
|
||||||
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
|
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
|
||||||
await open(browserUrl)
|
await open(browserUrl)
|
||||||
|
|
||||||
@@ -136,7 +93,7 @@ server.registerTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.`,
|
text: `Session started!\n\nSession ID: ${sessionId}\nBrowser: ${browserUrl}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -158,9 +115,7 @@ server.registerTool(
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Display a NEW draw.io diagram from XML. REPLACES the entire diagram. " +
|
"Display a NEW draw.io diagram from XML. REPLACES the entire diagram. " +
|
||||||
"Use this for creating new diagrams from scratch. " +
|
"Use edit_diagram to add elements to existing diagram.",
|
||||||
"To ADD elements to an existing diagram, use edit_diagram with 'add' operation instead. " +
|
|
||||||
"You should generate valid draw.io/mxGraph XML format.",
|
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
xml: z
|
xml: z
|
||||||
.string()
|
.string()
|
||||||
@@ -174,14 +129,13 @@ server.registerTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Error: No active session. Please call start_session first.",
|
text: "Error: No active session. Call start_session first.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and auto-fix XML
|
|
||||||
let xml = inputXml
|
let xml = inputXml
|
||||||
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
|
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
|
||||||
if (fixed) {
|
if (fixed) {
|
||||||
@@ -189,7 +143,6 @@ server.registerTool(
|
|||||||
log.info(`XML auto-fixed: ${fixes.join(", ")}`)
|
log.info(`XML auto-fixed: ${fixes.join(", ")}`)
|
||||||
}
|
}
|
||||||
if (!valid && error) {
|
if (!valid && error) {
|
||||||
log.error(`XML validation failed: ${error}`)
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -201,47 +154,24 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`Displaying diagram, ${xml.length} chars`)
|
// Save current state before replacing
|
||||||
|
|
||||||
// 1. Save current state to history BEFORE replacing (preserve user's work)
|
|
||||||
if (currentSession.xml) {
|
if (currentSession.xml) {
|
||||||
// Check last entry's source to use correct label
|
addHistory(currentSession.id, currentSession.xml)
|
||||||
const lastEntry = getHistory(currentSession.id, 1)[0]
|
|
||||||
const actualSource = lastEntry?.source || "human"
|
|
||||||
addHistoryEntry(currentSession.id, {
|
|
||||||
xml: currentSession.xml,
|
|
||||||
svg: "",
|
|
||||||
source: actualSource,
|
|
||||||
tool: "display_diagram",
|
|
||||||
timestamp: new Date(),
|
|
||||||
description: "Before AI replaced",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update session state
|
|
||||||
currentSession.xml = xml
|
currentSession.xml = xml
|
||||||
currentSession.version++
|
|
||||||
|
|
||||||
// Push to embedded server state
|
|
||||||
setState(currentSession.id, xml)
|
setState(currentSession.id, xml)
|
||||||
|
|
||||||
// 2. Save new state to history AFTER generation (capture AI result)
|
// Save new state
|
||||||
addHistoryEntry(currentSession.id, {
|
addHistory(currentSession.id, xml)
|
||||||
xml: xml,
|
|
||||||
svg: "",
|
|
||||||
source: "ai",
|
|
||||||
tool: "display_diagram",
|
|
||||||
timestamp: new Date(),
|
|
||||||
description: "AI generated diagram",
|
|
||||||
})
|
|
||||||
|
|
||||||
log.info(`Diagram displayed successfully`)
|
log.info(`Displayed diagram, ${xml.length} chars`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Diagram displayed successfully!\n\nThe diagram is now visible in your browser.\n\nXML length: ${xml.length} characters`,
|
text: `Diagram displayed! (${xml.length} chars)`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -262,33 +192,22 @@ server.registerTool(
|
|||||||
"edit_diagram",
|
"edit_diagram",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Edit the current diagram by ID-based operations (update/add/delete cells). " +
|
"Edit diagram by operations (update/add/delete cells). " +
|
||||||
"ALWAYS fetches the latest state from browser first, so user's manual changes are preserved.\n\n" +
|
"Fetches latest browser state first, preserving user's changes.\n\n" +
|
||||||
"IMPORTANT workflow:\n" +
|
|
||||||
"- For ADD operations: Can use directly - just provide new unique cell_id and new_xml.\n" +
|
|
||||||
"- For UPDATE/DELETE: Call get_diagram FIRST to see current cell IDs, then edit.\n\n" +
|
|
||||||
"Operations:\n" +
|
"Operations:\n" +
|
||||||
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" +
|
"- add: Add new cell (cell_id + new_xml)\n" +
|
||||||
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
|
"- update: Replace cell (cell_id + new_xml)\n" +
|
||||||
"- delete: Remove a cell by its id. Only cell_id is needed.\n\n" +
|
"- delete: Remove cell (cell_id only)",
|
||||||
"For add/update, new_xml must be a complete mxCell element including mxGeometry.",
|
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
operations: z
|
operations: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
type: z
|
type: z.enum(["update", "add", "delete"]),
|
||||||
.enum(["update", "add", "delete"])
|
cell_id: z.string(),
|
||||||
.describe("Operation type"),
|
new_xml: z.string().optional(),
|
||||||
cell_id: z.string().describe("The id of the mxCell"),
|
|
||||||
new_xml: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Complete mxCell XML element (required for update/add)",
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.describe("Array of operations to apply"),
|
.describe("Operations to apply"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ operations }) => {
|
async ({ operations }) => {
|
||||||
@@ -298,18 +217,17 @@ server.registerTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Error: No active session. Please call start_session first.",
|
text: "Error: No active session. Call start_session first.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch latest state from browser
|
// Fetch latest from browser
|
||||||
const browserState = getState(currentSession.id)
|
const browserState = getState(currentSession.id)
|
||||||
if (browserState?.xml) {
|
if (browserState?.xml) {
|
||||||
currentSession.xml = browserState.xml
|
currentSession.xml = browserState.xml
|
||||||
log.info("Fetched latest diagram state from browser")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentSession.xml) {
|
if (!currentSession.xml) {
|
||||||
@@ -317,95 +235,50 @@ server.registerTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Error: No diagram to edit. Please create a diagram first with display_diagram.",
|
text: "Error: No diagram to edit. Use display_diagram first.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`Editing diagram with ${operations.length} operation(s)`)
|
// Save before editing
|
||||||
|
addHistory(currentSession.id, currentSession.xml)
|
||||||
|
|
||||||
// 1. Save current state to history BEFORE editing (preserve user's work)
|
// Validate operations
|
||||||
// Check last entry's source to use correct label
|
|
||||||
const lastEntry = getHistory(currentSession.id, 1)[0]
|
|
||||||
const actualSource = lastEntry?.source || "human"
|
|
||||||
addHistoryEntry(currentSession.id, {
|
|
||||||
xml: currentSession.xml,
|
|
||||||
svg: "",
|
|
||||||
source: actualSource,
|
|
||||||
tool: "edit_diagram",
|
|
||||||
timestamp: new Date(),
|
|
||||||
description: "Before AI edit",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Validate and auto-fix new_xml for each operation
|
|
||||||
const validatedOps = operations.map((op) => {
|
const validatedOps = operations.map((op) => {
|
||||||
if (op.new_xml) {
|
if (op.new_xml) {
|
||||||
const { valid, error, fixed, fixes } = validateAndFixXml(
|
const { fixed, fixes } = validateAndFixXml(op.new_xml)
|
||||||
op.new_xml,
|
|
||||||
)
|
|
||||||
if (fixed) {
|
if (fixed) {
|
||||||
log.info(
|
log.info(
|
||||||
`Operation ${op.type} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`,
|
`${op.type} ${op.cell_id}: auto-fixed: ${fixes.join(", ")}`,
|
||||||
)
|
)
|
||||||
return { ...op, new_xml: fixed }
|
return { ...op, new_xml: fixed }
|
||||||
}
|
}
|
||||||
if (!valid && error) {
|
|
||||||
log.warn(
|
|
||||||
`Operation ${op.type} ${op.cell_id}: XML validation failed: ${error}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return op
|
return op
|
||||||
})
|
})
|
||||||
|
|
||||||
// Apply operations
|
|
||||||
const { result, errors } = applyDiagramOperations(
|
const { result, errors } = applyDiagramOperations(
|
||||||
currentSession.xml,
|
currentSession.xml,
|
||||||
validatedOps as DiagramOperation[],
|
validatedOps as DiagramOperation[],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
const errorMessages = errors
|
|
||||||
.map((e) => `${e.type} ${e.cellId}: ${e.message}`)
|
|
||||||
.join("\n")
|
|
||||||
log.warn(`Edit had ${errors.length} error(s): ${errorMessages}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
currentSession.xml = result
|
currentSession.xml = result
|
||||||
currentSession.version++
|
|
||||||
|
|
||||||
// Push to embedded server
|
|
||||||
setState(currentSession.id, result)
|
setState(currentSession.id, result)
|
||||||
|
|
||||||
// 2. Save new state to history AFTER editing (capture AI result)
|
// Save after editing
|
||||||
addHistoryEntry(currentSession.id, {
|
addHistory(currentSession.id, result)
|
||||||
xml: result,
|
|
||||||
svg: "",
|
|
||||||
source: "ai",
|
|
||||||
tool: "edit_diagram",
|
|
||||||
timestamp: new Date(),
|
|
||||||
description: `AI edit: ${operations.length} operation(s)`,
|
|
||||||
})
|
|
||||||
|
|
||||||
log.info(`Diagram edited successfully`)
|
log.info(`Edited diagram: ${operations.length} operation(s)`)
|
||||||
|
|
||||||
const successMsg = `Diagram edited successfully!\n\nApplied ${operations.length} operation(s).`
|
const msg = `Applied ${operations.length} operation(s).`
|
||||||
const errorMsg =
|
const warn =
|
||||||
errors.length > 0
|
errors.length > 0
|
||||||
? `\n\nWarnings:\n${errors.map((e) => `- ${e.type} ${e.cellId}: ${e.message}`).join("\n")}`
|
? `\nWarnings: ${errors.map((e) => `${e.type} ${e.cellId}: ${e.message}`).join(", ")}`
|
||||||
: ""
|
: ""
|
||||||
|
|
||||||
return {
|
return { content: [{ type: "text", text: msg + warn }] }
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: successMsg + errorMsg,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
@@ -423,9 +296,8 @@ server.registerTool(
|
|||||||
"get_diagram",
|
"get_diagram",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Get the current diagram XML (fetches latest from browser, including user's manual edits). " +
|
"Get current diagram XML (fetches latest from browser). " +
|
||||||
"Call this BEFORE edit_diagram if you need to update or delete existing elements, " +
|
"Call before edit_diagram to see cell IDs.",
|
||||||
"so you can see the current cell IDs and structure.",
|
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
@@ -434,14 +306,13 @@ server.registerTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Error: No active session. Please call start_session first.",
|
text: "Error: No active session. Call start_session first.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch latest state from browser
|
|
||||||
const browserState = getState(currentSession.id)
|
const browserState = getState(currentSession.id)
|
||||||
if (browserState?.xml) {
|
if (browserState?.xml) {
|
||||||
currentSession.xml = browserState.xml
|
currentSession.xml = browserState.xml
|
||||||
@@ -452,7 +323,7 @@ server.registerTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "No diagram exists yet. Use display_diagram to create one.",
|
text: "No diagram yet. Use display_diagram to create one.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -462,14 +333,13 @@ server.registerTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Current diagram XML:\n\n${currentSession.xml}`,
|
text: `Current diagram:\n\n${currentSession.xml}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
log.error("get_diagram failed:", message)
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Error: ${message}` }],
|
content: [{ type: "text", text: `Error: ${message}` }],
|
||||||
isError: true,
|
isError: true,
|
||||||
@@ -482,13 +352,9 @@ server.registerTool(
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
"export_diagram",
|
"export_diagram",
|
||||||
{
|
{
|
||||||
description: "Export the current diagram to a .drawio file.",
|
description: "Export diagram to .drawio file.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
path: z
|
path: z.string().describe("File path (e.g., ./diagram.drawio)"),
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"File path to save the diagram (e.g., ./diagram.drawio)",
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ path }) => {
|
async ({ path }) => {
|
||||||
@@ -498,14 +364,13 @@ server.registerTool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Error: No active session. Please call start_session first.",
|
text: "Error: No active session. Call start_session first.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch latest state
|
|
||||||
const browserState = getState(currentSession.id)
|
const browserState = getState(currentSession.id)
|
||||||
if (browserState?.xml) {
|
if (browserState?.xml) {
|
||||||
currentSession.xml = browserState.xml
|
currentSession.xml = browserState.xml
|
||||||
@@ -514,10 +379,7 @@ server.registerTool(
|
|||||||
if (!currentSession.xml) {
|
if (!currentSession.xml) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{ type: "text", text: "Error: No diagram to export." },
|
||||||
type: "text",
|
|
||||||
text: "Error: No diagram to export. Please create a diagram first.",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
}
|
}
|
||||||
@@ -527,27 +389,24 @@ server.registerTool(
|
|||||||
const nodePath = await import("node:path")
|
const nodePath = await import("node:path")
|
||||||
|
|
||||||
let filePath = path
|
let filePath = path
|
||||||
if (!filePath.endsWith(".drawio")) {
|
if (!filePath.endsWith(".drawio")) filePath += ".drawio"
|
||||||
filePath = `${filePath}.drawio`
|
|
||||||
}
|
|
||||||
|
|
||||||
const absolutePath = nodePath.resolve(filePath)
|
const absolutePath = nodePath.resolve(filePath)
|
||||||
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
|
await fs.writeFile(absolutePath, currentSession.xml, "utf-8")
|
||||||
|
|
||||||
log.info(`Diagram exported to ${absolutePath}`)
|
log.info(`Exported to ${absolutePath}`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Diagram exported successfully!\n\nFile: ${absolutePath}\nSize: ${currentSession.xml.length} characters`,
|
text: `Exported to ${absolutePath}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
log.error("export_diagram failed:", message)
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Error: ${message}` }],
|
content: [{ type: "text", text: `Error: ${message}` }],
|
||||||
isError: true,
|
isError: true,
|
||||||
@@ -556,227 +415,15 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tool: list_history
|
// Start server
|
||||||
server.registerTool(
|
|
||||||
"list_history",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"List diagram version history for the current session. " +
|
|
||||||
"Shows version numbers, who made each change (AI vs human), and timestamps. " +
|
|
||||||
"Use this to find a version to restore.",
|
|
||||||
inputSchema: {
|
|
||||||
limit: z
|
|
||||||
.number()
|
|
||||||
.optional()
|
|
||||||
.describe("Maximum number of entries to return (default: 20)"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ limit = 20 }) => {
|
|
||||||
try {
|
|
||||||
if (!currentSession) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Error: No active session. Please call start_session first.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const history = getHistory(currentSession.id, limit)
|
|
||||||
|
|
||||||
if (history.length === 0) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "No history available yet. Make some changes to create history.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const historyText = history
|
|
||||||
.map((entry) => {
|
|
||||||
const time = entry.timestamp.toLocaleTimeString()
|
|
||||||
const source = entry.source === "ai" ? "AI" : "Human"
|
|
||||||
const desc = entry.description
|
|
||||||
? ` - ${entry.description}`
|
|
||||||
: ""
|
|
||||||
return `v${entry.version} [${source}] ${time}${desc}`
|
|
||||||
})
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Diagram History (${history.length} entries, newest first):\n\n${historyText}\n\nUse restore_version to restore a specific version.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
log.error("list_history failed:", message)
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${message}` }],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tool: restore_version
|
|
||||||
server.registerTool(
|
|
||||||
"restore_version",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Restore diagram to a previous version from history. " +
|
|
||||||
"Use list_history first to see available versions. " +
|
|
||||||
"This creates a NEW history entry (non-destructive).",
|
|
||||||
inputSchema: {
|
|
||||||
version: z.number().describe("Version number to restore"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ version }) => {
|
|
||||||
try {
|
|
||||||
if (!currentSession) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Error: No active session. Please call start_session first.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = getHistoryVersion(currentSession.id, version)
|
|
||||||
if (!entry) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Error: Version ${version} not found in history. Use list_history to see available versions.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore by updating session and state
|
|
||||||
currentSession.xml = entry.xml
|
|
||||||
currentSession.version++
|
|
||||||
setState(currentSession.id, entry.xml)
|
|
||||||
|
|
||||||
// Add history entry for the restore
|
|
||||||
addHistoryEntry(currentSession.id, {
|
|
||||||
xml: entry.xml,
|
|
||||||
svg: entry.svg,
|
|
||||||
source: "ai",
|
|
||||||
tool: "restore_version",
|
|
||||||
timestamp: new Date(),
|
|
||||||
description: `Restored from v${version}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
log.info(`Restored diagram to v${version}`)
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Diagram restored to version ${version} successfully!\n\nThe browser will update automatically.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
log.error("restore_version failed:", message)
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${message}` }],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tool: get_version
|
|
||||||
server.registerTool(
|
|
||||||
"get_version",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Get the XML content of a specific version from history. " +
|
|
||||||
"Use this to inspect what a previous version looked like before restoring.",
|
|
||||||
inputSchema: {
|
|
||||||
version: z.number().describe("Version number to retrieve"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ version }) => {
|
|
||||||
try {
|
|
||||||
if (!currentSession) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Error: No active session. Please call start_session first.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = getHistoryVersion(currentSession.id, version)
|
|
||||||
if (!entry) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Error: Version ${version} not found in history.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = entry.source === "ai" ? "AI" : "Human"
|
|
||||||
const time = entry.timestamp.toISOString()
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Version ${version} (${source} edit at ${time}):\n\n${entry.xml}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
log.error("get_version failed:", message)
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${message}` }],
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Start the MCP server
|
|
||||||
async function main() {
|
async function main() {
|
||||||
log.info("Starting MCP server for Next AI Draw.io (embedded mode)...")
|
log.info("Starting MCP server...")
|
||||||
|
|
||||||
const transport = new StdioServerTransport()
|
const transport = new StdioServerTransport()
|
||||||
await server.connect(transport)
|
await server.connect(transport)
|
||||||
|
log.info("MCP server running")
|
||||||
log.info("MCP server running on stdio")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
log.error("Fatal error:", error)
|
log.error("Fatal:", error)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user