/**
* Circuitry MCP Server v2
*
* Lightweight bridge to Circuitry with permission flow and agent delegation.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema
} from '@modelcontextprotocol/sdk/types.js'
import { isConfigured, getAccessKey, getEServerUrl } from './config.js'
import { getClient } from './eserver-client.js'
import { allToolDefinitions } from './tools.js'
// Use console.error for logging since stdout is reserved for MCP JSON-RPC
const log = (...args: unknown[]) => console.error('[circuitry-mcp]', ...args)
// Connection state
let connectionApproved = false
/**
* Create and start the MCP server
*/
export async function startServer(): Promise<void> {
log('Starting Circuitry MCP Server v2...')
// Check configuration
if (!isConfigured()) {
log('Server not configured. Run "npx @circuitry/mcp-server setup" first.')
}
// Create server
const server = new Server(
{
name: 'circuitry-mcp-server',
version: '2.0.0'
},
{
capabilities: {
tools: {}
}
}
)
// Get EServer client
const client = getClient()
// Handle list_tools request
server.setRequestHandler(ListToolsRequestSchema, async () => {
log('Received list_tools request')
const tools = allToolDefinitions.map(tool => {
const properties: Record<string, unknown> = {}
const required: string[] = []
for (const param of tool.parameters) {
const prop: Record<string, unknown> = {
description: param.description
}
switch (param.type) {
case 'string':
prop.type = 'string'
if (param.enum) {
prop.enum = param.enum
}
break
case 'number':
prop.type = 'number'
break
case 'boolean':
prop.type = 'boolean'
break
case 'array':
prop.type = 'array'
prop.items = param.items || { type: 'string' }
break
case 'object':
prop.type = 'object'
prop.additionalProperties = true
break
default:
prop.type = 'string'
}
properties[param.name] = prop
if (param.required) {
required.push(param.name)
}
}
return {
name: tool.name,
description: tool.description,
inputSchema: {
type: 'object' as const,
properties,
required
}
}
})
return { tools }
})
// Handle call_tool request
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const toolStart = performance.now()
const { name, arguments: args } = request.params
const argsSize = JSON.stringify(args || {}).length
log(`Received call_tool request: ${name} (args: ${argsSize} bytes)`)
// Check if configured
if (!isConfigured()) {
return errorResponse(
'Circuitry MCP Server is not configured.\n\nRun this command to set up:\n npx @circuitry/mcp-server setup'
)
}
// Check connection to EServer
const connected = await client.ping()
if (!connected) {
return errorResponse(
`Cannot connect to EServer at ${getEServerUrl()}\n\nMake sure:\n1. Circuitry Electron app is running\n2. EServer is enabled (check system tray)`
)
}
try {
// Handle circuitry.status - always allowed
if (name === 'circuitry.status') {
const status = await client.getStatus()
return successResponse({
...status,
approved: connectionApproved
})
}
// Handle circuitry.connect - request permission
if (name === 'circuitry.connect') {
if (connectionApproved) {
return successResponse({
approved: true,
message: 'Already connected and approved'
})
}
// Request connection permission from Circuitry
const result = await client.requestConnection()
connectionApproved = result.approved
return successResponse({
approved: result.approved,
message: result.approved
? 'Connection approved. Chat panel opened in agent+mcp mode.'
: 'Connection denied by user.'
})
}
// Handle circuitry.disconnect - end session
if (name === 'circuitry.disconnect') {
const result = await client.disconnect()
connectionApproved = false
return successResponse({
disconnected: result.success,
message: result.message || 'Session disconnected. Call circuitry.connect to reconnect.'
})
}
// All other tools require approved connection
if (!connectionApproved) {
// Try to auto-approve if already approved in a previous session
const status = await client.getConnectionStatus()
if (status.approved) {
connectionApproved = true
} else {
return errorResponse(
'Connection not approved.\n\nCall circuitry.connect first to request permission from the user.'
)
}
}
// Handle agent delegation tools
if (name === 'agent.chat') {
const { message, context } = args as { message: string; context?: Record<string, unknown> }
const result = await client.sendAgentChat(message, context)
return successResponse(result)
}
if (name === 'agent.createFlowchart') {
const { description, style } = args as { description: string; style?: string }
const message = style
? `Create a ${style} flowchart: ${description}`
: `Create a flowchart: ${description}`
const result = await client.sendAgentChat(message, { intent: 'flowchart', style })
return successResponse(result)
}
if (name === 'agent.poll') {
const { chatId } = args as { chatId: string }
const result = await client.pollAgentResponse(chatId)
return successResponse(result)
}
// Handle workflow tools via Circuitry MCP API
if (name === 'workflow.getActive') {
// Get full workflow structure which includes workflow info
const result = await client.callApi('mcp.getWorkflowStructure', {})
const structure = result as { workflowId: string | null; workflowName: string | null; nodeCount: number; edgeCount: number }
return successResponse({
id: structure.workflowId,
name: structure.workflowName,
nodeCount: structure.nodeCount,
edgeCount: structure.edgeCount
})
}
if (name === 'workflow.getStructure') {
const result = await client.callApi('mcp.getWorkflowStructure', {})
return successResponse(result)
}
if (name === 'workflow.resolveFlow') {
const { userMessage } = args as { userMessage: string }
const result = await client.callApi('mcp.resolveFlow', { userMessage })
return successResponse(result)
}
if (name === 'workflow.getNodeSummary') {
const { nodeIds } = args as { nodeIds?: string[] }
const result = await client.callApi('mcp.getNodeSummary', { nodeIds: nodeIds || [] })
return successResponse(result)
}
if (name === 'workflow.getFlowcharts') {
const result = await client.callApi('mcp.getFlowcharts', {})
return successResponse(result)
}
if (name === 'workflow.layoutNodes') {
const { nodeIds, direction, spacing } = args as {
nodeIds?: string[]
direction?: 'vertical' | 'horizontal'
spacing?: number
}
const result = await client.callApi('mcp.layoutNodes', { nodeIds, direction, spacing })
return successResponse(result)
}
if (name === 'workflow.undo') {
const result = await client.callApi('mcp.undo', {})
return successResponse(result)
}
if (name === 'workflow.redo') {
const result = await client.callApi('mcp.redo', {})
return successResponse(result)
}
if (name === 'workflow.canUndo') {
const result = await client.callApi('mcp.canUndo', {})
return successResponse(result)
}
if (name === 'workflow.canRedo') {
const result = await client.callApi('mcp.canRedo', {})
return successResponse(result)
}
if (name === 'workflow.getSelectionContext') {
const result = await client.callApi('mcp.getSelectionContext', {})
return successResponse(result)
}
// Handle code tools - can use file path OR direct content
if (name === 'code.create') {
const { filePath, name: nodeName, content, position } = args as {
filePath?: string
name?: string
content?: string
position?: { x: number; y: number }
}
// If filePath provided, use file sync feature
if (filePath) {
const result = await client.createCodeNodeFromFile(filePath, nodeName, position)
return successResponse(result)
}
// Otherwise use direct API (name + content)
const result = await client.callApi('code.create', { name: nodeName, content, position })
return successResponse(result)
}
if (name === 'code.createBatch') {
const { filePaths, layout } = args as { filePaths: string[]; layout?: string }
const result = await client.createCodeNodesFromFiles(filePaths, layout)
return successResponse(result)
}
if (name === 'code.createBatchGrouped') {
const { groups, layout } = args as {
groups: Array<{
name: string
files: Array<{ path: string; name?: string }>
color?: string
}>
layout?: string
}
const result = await client.createCodeNodesGrouped(groups, layout)
return successResponse(result)
}
// Handle drawing.getImage - return image in a format Claude can see
if (name === 'drawing.getImage') {
const result = await client.callApi(name, args as Record<string, unknown>) as {
imageData?: string
width?: number
height?: number
strokeCount?: number
}
if (result.imageData && result.imageData.startsWith('data:image/png;base64,')) {
// Extract base64 data without the data URL prefix
const base64Data = result.imageData.replace('data:image/png;base64,', '')
return {
content: [
{
type: 'image' as const,
data: base64Data,
mimeType: 'image/png'
},
{
type: 'text' as const,
text: `Drawing captured: ${result.strokeCount} strokes, ${Math.round(result.width || 0)}x${Math.round(result.height || 0)}px`
}
]
}
}
// No drawing data
return successResponse({ message: 'No drawing found on canvas', strokeCount: 0 })
}
// Handle screen.capture - return screen image in a format Claude can see
if (name === 'screen.capture') {
const result = await client.callApi(name, args as Record<string, unknown>) as {
imageData?: string
width?: number
height?: number
screenId?: string
screenName?: string
} | null
if (result?.imageData && result.imageData.startsWith('data:image/png;base64,')) {
// Extract base64 data without the data URL prefix
const base64Data = result.imageData.replace('data:image/png;base64,', '')
return {
content: [
{
type: 'image' as const,
data: base64Data,
mimeType: 'image/png'
},
{
type: 'text' as const,
text: `Screen captured: "${result.screenName}" (${result.screenId}), ${result.width}x${result.height}px`
}
]
}
}
// No screen captured
return successResponse({ message: 'Failed to capture screen. Make sure you are in Designer mode with a screen visible.', screenId: null })
}
// Handle html.create with file-based params
if (name === 'html.create') {
const { htmlFile, cssFile, html, css, ...restArgs } = args as {
htmlFile?: string
cssFile?: string
html?: string
css?: string
[key: string]: unknown
}
let finalHtml = html
let finalCss = css
// Read HTML from file if provided
if (htmlFile) {
try {
const fs = await import('fs/promises')
finalHtml = await fs.readFile(htmlFile, 'utf-8')
log(`[html.create] Read HTML from file: ${htmlFile} (${finalHtml.length} chars)`)
} catch (err) {
return errorResponse(`Failed to read HTML file: ${htmlFile} - ${err instanceof Error ? err.message : String(err)}`)
}
}
// Read CSS from file if provided
if (cssFile) {
try {
const fs = await import('fs/promises')
finalCss = await fs.readFile(cssFile, 'utf-8')
log(`[html.create] Read CSS from file: ${cssFile} (${finalCss.length} chars)`)
} catch (err) {
return errorResponse(`Failed to read CSS file: ${cssFile} - ${err instanceof Error ? err.message : String(err)}`)
}
}
// Validate we have HTML and CSS
if (!finalHtml) {
return errorResponse('html.create requires either "html" or "htmlFile" parameter')
}
if (!finalCss) {
return errorResponse('html.create requires either "css" or "cssFile" parameter')
}
// Call API with resolved content
const result = await client.callApi('html.create', {
...restArgs,
html: finalHtml,
css: finalCss
})
const toolElapsed = performance.now() - toolStart
log(`[TIMING] Tool ${name} (file-based) completed in ${toolElapsed.toFixed(0)}ms`)
return successResponse(result)
}
// For all other tools, relay to Circuitry API
// The EServer bridge passes args as an object to the Circuitry API
// API methods support both direct args: nodes.get("id") and object args: nodes.get({ nodeId: "id" })
const result = await client.callApi(name, args as Record<string, unknown>)
const toolElapsed = performance.now() - toolStart
log(`[TIMING] Tool ${name} completed in ${toolElapsed.toFixed(0)}ms`)
return successResponse(result)
} catch (error) {
const toolElapsed = performance.now() - toolStart
const errorMessage = error instanceof Error ? error.message : String(error)
log(`Tool error: ${errorMessage}`)
log(`[TIMING] Tool ${name} failed in ${toolElapsed.toFixed(0)}ms`)
return errorResponse(errorMessage)
}
})
// Connect to stdio transport
const transport = new StdioServerTransport()
log('Connecting to stdio transport...')
await server.connect(transport)
log('Server started successfully')
// Keep the process running
process.on('SIGINT', () => {
log('Shutting down...')
client.disconnectWebSocket()
process.exit(0)
})
process.on('SIGTERM', () => {
log('Shutting down...')
client.disconnectWebSocket()
process.exit(0)
})
}
/**
* Create a success response
*/
function successResponse(data: unknown) {
let text: string
if (data === undefined || data === null) {
text = 'Success (no return value)'
} else if (typeof data === 'object') {
text = JSON.stringify(data, null, 2)
} else {
text = String(data)
}
return {
content: [{ type: 'text', text }]
}
}
/**
* Create an error response
*/
function errorResponse(message: string) {
return {
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true
}
}