* Security hardening: fix critical and high-severity vulnerabilities (#18)

* feat(mcp): add sanitizeObject utility to strip prototype pollution keys

Recursively removes __proto__, constructor, and prototype keys from
parsed JSON objects to prevent prototype pollution attacks via
malicious .op files or batch_design DSL input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(electron): validate file path in saveToPath IPC handler

Prevent path traversal attacks by checking for null bytes and restricting
file extensions to .op and .pen only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mcp): guard against prototype pollution in document parsing and batch design

Sanitize JSON.parse output in openDocument() and parseJsonArg() to strip
__proto__, constructor, and prototype keys before processing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ai): sanitize debug logs and harden temp file handling

Filter credential patterns from debug tail before sending to client.
Set restrictive 0o700 permissions on temp directory. Validate attachment
media types against allowlist to prevent extension spoofing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ai): restrict environment variables passed to codex subprocess

Replace full process.env with explicit allowlist of PATH, HOME, TERM,
LANG, SHELL, TMPDIR, and OPENAI_*/CODEX_* prefixed vars only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(figma): add decompression size limits to prevent zip bombs

Enforce 100MB total unzipped size and 50MB per-image limits during
.fig file extraction to guard against malicious archives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(canvas): add dangerous SVG tags to skip list and fix ReDoS in getAttr

Strip script, foreignObject, animate, animateMotion, and set elements
during SVG import. Escape regex-special characters in style attribute
name lookup to prevent ReDoS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(security): add unit tests for security hardening fixes

27 tests covering: sanitizeObject prototype pollution stripping,
document-manager sanitization, batch-design DSL sanitization,
codex env allowlist, debug tail credential filtering, media type
validation, SVG skip tags, and ReDoS safety.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(mcp): add live canvas sync and HTTP transport support

Introduce real-time MCP ↔ renderer sync via SSE (server/api/mcp endpoints,
use-mcp-sync hook, mcp-sync-state). Add StreamableHTTPServerTransport for
HTTP and dual stdio+http modes. Electron writes ~/.openpencil/.port for MCP
discovery. New design_prompt tool. Agent settings dialog gains transport
mode selector. Security: restrict Electron file writes to home/temp dirs.
Bump version to 0.1.2.

---------

Co-authored-by: RolandSherwin <RolandSherwin@protonmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kayshen Xu 2026-03-03 21:08:52 +08:00 committed by GitHub
parent 3eca24ac5a
commit ced993c388
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1771 additions and 453 deletions

View file

@ -156,7 +156,7 @@ PenDocument (source of truth)
- `component-browser-panel.tsx` / `component-browser-grid.tsx` / `component-browser-card.tsx` — Resizable floating panel for browsing, importing, and inserting UIKit components with category tabs and search
- `variables-panel.tsx` — Design variables management: theme axes as tabs, variant columns, resizable floating panel, add/rename/delete themes and variants
- `variable-row.tsx` — Individual variable row: type icon, editable name, per-theme-variant value cells (color picker, number input, text input), context menu
- **`src/components/shared/`** — Reusable UI (10 files): ColorPicker, NumberInput, DropdownSelect, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog, VariablePicker, FigmaImportDialog
- **`src/components/shared/`** — Reusable UI (9 files): ColorPicker, NumberInput, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog, VariablePicker, FigmaImportDialog
- **`src/components/icons/`** — Provider/brand logos: ClaudeLogo, OpenAILogo, OpenCodeLogo, FigmaLogo
- **`src/components/ui/`** — shadcn/ui primitives: Button, Select, Separator, Slider, Switch, Toggle, Tooltip
- **`src/services/ai/`** — AI services (20 files):

View file

@ -5,8 +5,8 @@
<h1 align="center">OpenPencil</h1>
<p align="center">
<strong>Open-source design tool with a Design-as-Code philosophy.</strong><br />
Design on canvas. Generate code. Let AI build screens from a prompt.
<strong>AI-native open-source design tool. Design-as-Code.</strong><br />
Prompt to UI on canvas. Multi-agent orchestration. Built-in MCP server. Code generation.
</p>
<p align="center">
@ -18,8 +18,8 @@
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="#ai-native-design">AI</a> ·
<a href="#features">Features</a> ·
<a href="#ai-agents">AI Agents</a> ·
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
<a href="#contributing">Contributing</a>
</p>
@ -53,6 +53,33 @@ bun run electron:dev
> **Prerequisites:** [Bun](https://bun.sh/) >= 1.0 and [Node.js](https://nodejs.org/) >= 18
## AI-Native Design
OpenPencil is built around AI from the ground up — not as a plugin, but as a core workflow.
**Prompt to UI**
- **Text-to-design** — describe a page, get it generated on canvas in real-time with streaming animation
- **Orchestrator** — decomposes complex pages into spatial sub-tasks for parallel generation
- **Design modification** — select elements, then describe changes in natural language
- **Vision input** — attach screenshots or mockups for reference-based design
**Multi-Agent Support**
| Agent | Setup |
| --- | --- |
| **Claude Code** | No config — uses Claude Agent SDK with local OAuth |
| **Codex CLI** | Connect in Agent Settings (`Cmd+,`) |
| **OpenCode** | Connect in Agent Settings (`Cmd+,`) |
**MCP Server**
- Built-in MCP server — one-click install into Claude Code / Codex / Gemini / OpenCode / Kiro CLIs
- Design automation from terminal: read, create, and modify `.op` files via any MCP-compatible agent
**Code Generation**
- React + Tailwind CSS
- HTML + CSS
- CSS Variables from design tokens
## Features
**Canvas & Drawing**
@ -68,11 +95,6 @@ bun run electron:dev
- Component system — reusable components with instances and overrides
- CSS sync — auto-generated custom properties, `var(--name)` in code output
**Code Generation**
- React + Tailwind CSS
- HTML + CSS
- CSS Variables from design tokens
**Figma Import**
- Import `.fig` files with layout, fills, strokes, effects, text, images, and vectors preserved
@ -81,22 +103,6 @@ bun run electron:dev
- Auto-update from GitHub Releases
- Native application menu and file dialogs
## AI Agents
OpenPencil integrates with multiple AI coding agents to generate full-page designs from a single prompt — directly on canvas with streaming animation.
| Agent | Setup |
| --- | --- |
| **Claude Code** | No config — uses Claude Agent SDK with local OAuth |
| **Codex CLI** | Connect in Agent Settings (`Cmd+,`) |
| **OpenCode** | Connect in Agent Settings (`Cmd+,`) |
- **Text-to-design** — describe a page, get it generated on canvas in real-time
- **Image attachments** — attach screenshots or mockups for vision-based analysis
- **Orchestrator** — decomposes complex pages into spatial sub-tasks for parallel generation
- **Design modification** — select elements, then describe changes in natural language
- **MCP server** — install into Claude Code / Codex / Gemini / OpenCode / Kiro CLIs for design automation from terminal
## Tech Stack
| | |

View file

@ -11,8 +11,9 @@ import { GitHubProvider } from 'electron-updater/out/providers/GitHubProvider'
import { execSync } from 'node:child_process'
import { fork, type ChildProcess } from 'node:child_process'
import { createServer } from 'node:net'
import { join } from 'node:path'
import { readFile, writeFile } from 'node:fs/promises'
import { join, resolve, extname } from 'node:path'
import { homedir } from 'node:os'
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'
let mainWindow: BrowserWindow | null = null
let nitroProcess: ChildProcess | null = null
@ -191,6 +192,34 @@ function fixPath(): void {
}
}
// ---------------------------------------------------------------------------
// Port file for MCP sync discovery (~/.openpencil/.port)
// ---------------------------------------------------------------------------
const PORT_FILE_DIR = join(homedir(), '.openpencil')
const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port')
async function writePortFile(port: number): Promise<void> {
try {
await mkdir(PORT_FILE_DIR, { recursive: true })
await writeFile(
PORT_FILE_PATH,
JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }),
'utf-8',
)
} catch {
// Non-critical — MCP sync will fall back to file I/O
}
}
async function cleanupPortFile(): Promise<void> {
try {
await unlink(PORT_FILE_PATH)
} catch {
// Ignore if already removed
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@ -375,8 +404,24 @@ function setupIPC(): void {
ipcMain.handle(
'dialog:saveToPath',
async (_event, payload: { filePath: string; content: string }) => {
await writeFile(payload.filePath, payload.content, 'utf-8')
return payload.filePath
const resolved = resolve(payload.filePath)
if (resolved.includes('\0')) {
throw new Error('Invalid file path')
}
const ext = extname(resolved).toLowerCase()
if (ext !== '.op' && ext !== '.pen') {
throw new Error('Only .op and .pen file extensions are allowed')
}
// Directory allowlist: only allow writes under user home or OS temp
const allowedRoots = [app.getPath('home'), app.getPath('temp')]
const inAllowedDir = allowedRoots.some(
(root) => resolved === root || resolved.startsWith(root + '/') || resolved.startsWith(root + '\\'),
)
if (!inAllowedDir) {
throw new Error('File path must be within the user home or temp directory')
}
await writeFile(resolved, payload.content, 'utf-8')
return resolved
},
)
@ -532,11 +577,15 @@ app.on('ready', async () => {
try {
serverPort = await startNitroServer()
console.log(`Nitro server started on port ${serverPort}`)
await writePortFile(serverPort)
} catch (err) {
console.error('Failed to start Nitro server:', err)
app.quit()
return
}
} else {
// Dev mode: Vite dev server runs on port 3000
await writePortFile(3000)
}
createWindow()
@ -558,7 +607,8 @@ app.on('activate', () => {
}
})
app.on('before-quit', () => {
app.on('before-quit', async () => {
await cleanupPortFile()
if (nitroProcess) {
nitroProcess.kill()
nitroProcess = null

View file

@ -1,6 +1,6 @@
{
"name": "openpencil",
"version": "0.1.1",
"version": "0.1.2",
"description": "Open-source vector design tool with Design-as-Code philosophy",
"author": {
"name": "ZSeven-W",

View file

@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest'
import { filterCodexEnv } from '../utils/codex-client'
import { SENSITIVE_LOG_PATTERN, ALLOWED_MEDIA_TYPES, resolveMediaExtension } from '../api/ai/chat'
// ---------------------------------------------------------------------------
// 1. Codex client env allowlist
// ---------------------------------------------------------------------------
describe('codex client env allowlist', () => {
it('should strip dangerous env vars', () => {
const env = {
PATH: '/usr/bin',
HOME: '/home/user',
AWS_SECRET_KEY: 'supersecret',
DATABASE_URL: 'postgres://...',
ANTHROPIC_API_KEY: 'sk-ant-xxx',
GITHUB_TOKEN: 'ghp_xxx',
}
const filtered = filterCodexEnv(env)
expect(filtered).not.toHaveProperty('AWS_SECRET_KEY')
expect(filtered).not.toHaveProperty('DATABASE_URL')
expect(filtered).not.toHaveProperty('ANTHROPIC_API_KEY')
expect(filtered).not.toHaveProperty('GITHUB_TOKEN')
})
it('should keep PATH, HOME, and shell vars', () => {
const env = {
PATH: '/usr/bin',
HOME: '/home/user',
TERM: 'xterm-256color',
LANG: 'en_US.UTF-8',
SHELL: '/bin/zsh',
TMPDIR: '/tmp',
}
const filtered = filterCodexEnv(env)
expect(filtered.PATH).toBe('/usr/bin')
expect(filtered.HOME).toBe('/home/user')
expect(filtered.TERM).toBe('xterm-256color')
expect(filtered.LANG).toBe('en_US.UTF-8')
expect(filtered.SHELL).toBe('/bin/zsh')
expect(filtered.TMPDIR).toBe('/tmp')
})
it('should keep OPENAI_* and CODEX_* vars', () => {
const env = {
PATH: '/usr/bin',
HOME: '/home/user',
OPENAI_API_KEY: 'sk-openai-xxx',
OPENAI_ORG_ID: 'org-xxx',
CODEX_TOKEN: 'codex-xxx',
CODEX_SANDBOX: 'read-only',
}
const filtered = filterCodexEnv(env)
expect(filtered.OPENAI_API_KEY).toBe('sk-openai-xxx')
expect(filtered.OPENAI_ORG_ID).toBe('org-xxx')
expect(filtered.CODEX_TOKEN).toBe('codex-xxx')
expect(filtered.CODEX_SANDBOX).toBe('read-only')
})
it('should not leak vars with similar prefixes', () => {
const env = {
PATH: '/usr/bin',
HOME: '/home/user',
OPENAI_API_KEY: 'ok',
OPENAI_COMPAT: 'ok',
OPEN_SECRET: 'bad',
CODEX_MODE: 'ok',
CODE_SECRET: 'bad',
}
const filtered = filterCodexEnv(env)
expect(filtered).not.toHaveProperty('OPEN_SECRET')
expect(filtered).not.toHaveProperty('CODE_SECRET')
expect(filtered.OPENAI_API_KEY).toBe('ok')
expect(filtered.CODEX_MODE).toBe('ok')
})
})
// ---------------------------------------------------------------------------
// 2. Debug tail sanitization
// ---------------------------------------------------------------------------
describe('debug tail sanitization', () => {
it('should match ANTHROPIC_API_KEY leak', () => {
expect(SENSITIVE_LOG_PATTERN.test('ANTHROPIC_API_KEY=sk-ant-abc123')).toBe(true)
})
it('should match Authorization Bearer header', () => {
expect(SENSITIVE_LOG_PATTERN.test('Authorization: Bearer token123')).toBe(true)
expect(SENSITIVE_LOG_PATTERN.test('authorization: Bearer xyz')).toBe(true)
})
it('should match api_key and api-key patterns', () => {
expect(SENSITIVE_LOG_PATTERN.test('api_key=secret')).toBe(true)
expect(SENSITIVE_LOG_PATTERN.test('api-key: secret')).toBe(true)
expect(SENSITIVE_LOG_PATTERN.test('apikey=secret')).toBe(true)
})
it('should NOT match normal log lines', () => {
expect(SENSITIVE_LOG_PATTERN.test('Using API endpoint https://api.anthropic.com')).toBe(false)
expect(SENSITIVE_LOG_PATTERN.test('Model: claude-sonnet-4-5-20250929')).toBe(false)
expect(SENSITIVE_LOG_PATTERN.test('Request completed in 1200ms')).toBe(false)
expect(SENSITIVE_LOG_PATTERN.test('Connecting to upstream server...')).toBe(false)
})
})
// ---------------------------------------------------------------------------
// 3. Media type allowlist
// ---------------------------------------------------------------------------
describe('media type allowlist', () => {
it('should allow standard image types', () => {
expect(ALLOWED_MEDIA_TYPES.has('image/png')).toBe(true)
expect(ALLOWED_MEDIA_TYPES.has('image/jpeg')).toBe(true)
expect(ALLOWED_MEDIA_TYPES.has('image/gif')).toBe(true)
expect(ALLOWED_MEDIA_TYPES.has('image/webp')).toBe(true)
})
it('should reject non-image types', () => {
expect(ALLOWED_MEDIA_TYPES.has('image/svg+xml')).toBe(false)
expect(ALLOWED_MEDIA_TYPES.has('application/pdf')).toBe(false)
expect(ALLOWED_MEDIA_TYPES.has('text/html')).toBe(false)
})
it('should resolve extensions correctly', () => {
expect(resolveMediaExtension('image/png')).toBe('png')
expect(resolveMediaExtension('image/jpeg')).toBe('jpeg')
expect(resolveMediaExtension('image/gif')).toBe('gif')
expect(resolveMediaExtension('image/webp')).toBe('webp')
})
it('should fall back to png for disallowed types', () => {
expect(resolveMediaExtension('image/x-sh')).toBe('png')
expect(resolveMediaExtension('image/svg+xml')).toBe('png')
expect(resolveMediaExtension('application/pdf')).toBe('png')
expect(resolveMediaExtension('text/html')).toBe('png')
expect(resolveMediaExtension('')).toBe('png')
})
})

View file

@ -9,6 +9,17 @@ import {
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
/** Pattern for detecting sensitive data in debug log output */
export const SENSITIVE_LOG_PATTERN = /ANTHROPIC_API_KEY=|Authorization:\s*Bearer|api[_-]?key\s*[:=]/i
/** Allowed media types for image attachments */
export const ALLOWED_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
/** Resolve file extension from media type, falling back to 'png' for disallowed types */
export function resolveMediaExtension(mediaType: string): string {
return ALLOWED_MEDIA_TYPES.has(mediaType) ? mediaType.split('/')[1] : 'png'
}
interface ChatAttachmentWire {
name: string
mediaType: string
@ -30,7 +41,8 @@ async function readDebugTail(path?: string, maxLines = 40): Promise<string[] | u
try {
const raw = await readFile(path, 'utf-8')
const lines = raw.split('\n').filter((l) => l.trim().length > 0)
return lines.slice(-maxLines)
const sanitized = lines.filter(l => !SENSITIVE_LOG_PATTERN.test(l))
return sanitized.slice(-maxLines)
} catch {
return undefined
}
@ -118,16 +130,17 @@ async function saveAttachmentsToTempFiles(
): Promise<{ tempDir: string; files: string[] }> {
let tempDir: string
if (insideProject) {
const { mkdirSync } = await import('node:fs')
const { mkdirSync, chmodSync } = await import('node:fs')
const baseDir = join(process.cwd(), '.openpencil-tmp')
mkdirSync(baseDir, { recursive: true })
mkdirSync(baseDir, { recursive: true, mode: 0o700 })
chmodSync(baseDir, 0o700)
tempDir = await mkdtemp(join(baseDir, 'attach-'))
} else {
tempDir = await mkdtemp(join(tmpdir(), 'openpencil-attach-'))
}
const files: string[] = []
for (const att of attachments) {
const ext = att.mediaType.split('/')[1] || 'png'
const ext = resolveMediaExtension(att.mediaType)
const filePath = join(tempDir, `${files.length}.${ext}`)
await writeFile(filePath, Buffer.from(att.data, 'base64'))
files.push(filePath)

View file

@ -7,6 +7,8 @@ import { existsSync } from 'node:fs'
interface InstallBody {
tool: string
action: 'install' | 'uninstall'
transportMode?: 'stdio' | 'http' | 'both'
httpPort?: number
}
interface InstallResult {
@ -38,107 +40,74 @@ function resolveMcpServerPath(): string {
return projectDist // Return expected path even if not yet compiled
}
/** Config file locations and formats for each CLI tool. */
const CLI_CONFIGS: Record<
string,
{
configPath: () => string
read: (filePath: string) => Promise<Record<string, any>>
write: (filePath: string, config: Record<string, any>) => Promise<void>
install: (config: Record<string, any>, serverPath: string) => Record<string, any>
uninstall: (config: Record<string, any>) => Record<string, any>
function buildMcpServerEntry(
serverPath: string,
transportMode: 'stdio' | 'http' | 'both' = 'stdio',
httpPort = 3100,
): { command: string; args: string[] } {
switch (transportMode) {
case 'http':
return { command: 'node', args: [serverPath, '--http', '--port', String(httpPort)] }
case 'both':
return { command: 'node', args: [serverPath, '--http', '--port', String(httpPort), '--stdio'] }
default:
return { command: 'node', args: [serverPath] }
}
> = {
}
/** Config file locations and formats for each CLI tool. */
interface CliConfigDef {
configPath: () => string
read: (filePath: string) => Promise<Record<string, any>>
write: (filePath: string, config: Record<string, any>) => Promise<void>
}
function installMcpServer(
config: Record<string, any>,
serverPath: string,
transportMode?: 'stdio' | 'http' | 'both',
httpPort?: number,
): Record<string, any> {
return {
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: buildMcpServerEntry(serverPath, transportMode, httpPort),
},
}
}
function uninstallMcpServer(config: Record<string, any>): Record<string, any> {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
}
const CLI_CONFIGS: Record<string, CliConfigDef> = {
'claude-code': {
configPath: () => join(homedir(), '.claude.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return {
...config,
mcpServers: Object.keys(servers).length > 0 ? servers : undefined,
}
},
},
'codex-cli': {
configPath: () => join(homedir(), '.codex', 'config.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
'gemini-cli': {
configPath: () => join(homedir(), '.gemini', 'settings.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: {
command: 'node',
args: [serverPath],
},
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
'opencode-cli': {
configPath: () => join(homedir(), '.opencode', 'config.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
'kiro-cli': {
configPath: () => join(homedir(), '.kiro', 'settings.json'),
read: readJsonConfig,
write: writeJsonConfig,
install: (config, serverPath) => ({
...config,
mcpServers: {
...(config.mcpServers ?? {}),
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
},
}),
uninstall: (config) => {
const servers = { ...(config.mcpServers ?? {}) }
delete servers[MCP_SERVER_NAME]
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
},
},
}
@ -186,8 +155,8 @@ export default defineEventHandler(async (event) => {
const updated =
body.action === 'install'
? cliConfig.install(config, serverPath)
: cliConfig.uninstall(config)
? installMcpServer(config, serverPath, body.transportMode, body.httpPort)
: uninstallMcpServer(config)
await cliConfig.write(configPath, updated)

View file

@ -0,0 +1,15 @@
import { defineEventHandler, setResponseHeaders } from 'h3'
import { getSyncDocument } from '../../utils/mcp-sync-state'
/** GET /api/mcp/document — Returns the current canvas document for MCP to read. */
export default defineEventHandler((event) => {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
const { doc, version } = getSyncDocument()
if (!doc) {
return new Response(JSON.stringify({ error: 'No document loaded in editor' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})
}
return { version, document: doc }
})

View file

@ -0,0 +1,29 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { setSyncDocument } from '../../utils/mcp-sync-state'
import type { PenDocument } from '../../../src/types/pen'
interface PostBody {
document: PenDocument
sourceClientId?: string
}
/** POST /api/mcp/document — Receives document update from MCP or renderer, triggers SSE broadcast. */
export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
const body = await readBody<PostBody>(event)
if (!body?.document) {
return new Response(JSON.stringify({ error: 'Missing document in request body' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const doc = body.document
if (!doc.version || (!Array.isArray(doc.children) && !Array.isArray(doc.pages))) {
return new Response(JSON.stringify({ error: 'Invalid document format' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const version = setSyncDocument(doc, body.sourceClientId)
return { ok: true, version }
})

View file

@ -0,0 +1,44 @@
import { defineEventHandler } from 'h3'
import { randomUUID } from 'node:crypto'
import type { ServerResponse } from 'node:http'
import { registerSSEClient, unregisterSSEClient, getSyncDocument } from '../../utils/mcp-sync-state'
/** GET /api/mcp/events — SSE stream for renderer to subscribe to live document changes. */
export default defineEventHandler((event) => {
const clientId = randomUUID()
// Write headers directly on the raw Node.js response for h3 v2 compatibility.
// h3 v2 no longer supports `_handled = true` to keep connections open.
const res = event.node!.res! as ServerResponse
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
})
// Send client ID so renderer can use it as sourceClientId when pushing back
res.write(`data: ${JSON.stringify({ type: 'client:id', clientId })}\n\n`)
// Send current document as initial state (if any)
const { doc, version } = getSyncDocument()
if (doc) {
res.write(`data: ${JSON.stringify({ type: 'document:init', version, document: doc })}\n\n`)
}
registerSSEClient(clientId, res)
// Keep-alive heartbeat
const heartbeat = setInterval(() => {
if (!res.closed) res.write(': heartbeat\n\n')
}, 30_000)
res.on('close', () => {
clearInterval(heartbeat)
unregisterSSEClient(clientId)
})
// Return a promise that resolves on close to prevent h3 from ending the response
return new Promise<void>((resolve) => {
res.on('close', resolve)
})
})

View file

@ -24,6 +24,25 @@ interface CodexCliResult {
const DEFAULT_CODEX_TIMEOUT_MS = 15 * 60 * 1000
/**
* Allowlist-based env filter for Codex CLI subprocess.
* Only passes through safe system vars and provider-specific prefixes.
* Prevents leaking secrets like ANTHROPIC_API_KEY, AWS_SECRET_KEY, GITHUB_TOKEN, etc.
*/
const CODEX_ENV_ALLOWLIST = new Set(['PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR'])
export function filterCodexEnv(
env: Record<string, string | undefined>,
): Record<string, string | undefined> {
const result: Record<string, string | undefined> = {}
for (const [k, v] of Object.entries(env)) {
if (CODEX_ENV_ALLOWLIST.has(k) || k.startsWith('OPENAI_') || k.startsWith('CODEX_')) {
result[k] = v
}
}
return result
}
export async function runCodexExec(
userPrompt: string,
options: CodexExecOptions = {},
@ -125,7 +144,7 @@ async function executeCodexCommand(
): Promise<{ text: string; errors: string[] }> {
return await new Promise((resolve, reject) => {
const child = spawn('codex', args, {
env: process.env,
env: filterCodexEnv(process.env as Record<string, string | undefined>),
stdio: ['ignore', 'pipe', 'pipe'],
})

View file

@ -0,0 +1,48 @@
/**
* In-memory sync state for MCP Renderer real-time communication.
* Shared across Nitro API endpoints: GET/POST /api/mcp/document, GET /api/mcp/events.
*/
import type { PenDocument } from '../../src/types/pen'
import type { ServerResponse } from 'node:http'
let currentDocument: PenDocument | null = null
let documentVersion = 0
interface SSEClient {
id: string
res: ServerResponse
}
const clients = new Map<string, SSEClient>()
export function getSyncDocument(): { doc: PenDocument | null; version: number } {
return { doc: currentDocument, version: documentVersion }
}
export function setSyncDocument(doc: PenDocument, sourceClientId?: string): number {
currentDocument = doc
documentVersion++
broadcast({ type: 'document:update', version: documentVersion, document: doc }, sourceClientId)
return documentVersion
}
export function registerSSEClient(id: string, res: ServerResponse): void {
clients.set(id, { id, res })
}
export function unregisterSSEClient(id: string): void {
clients.delete(id)
}
function broadcast(payload: Record<string, unknown>, excludeClientId?: string): void {
const data = `data: ${JSON.stringify(payload)}\n\n`
for (const [id, client] of clients) {
if (id === excludeClientId) continue
try {
if (!client.res.closed) client.res.write(data)
} catch {
clients.delete(id)
}
}
}

View file

@ -21,6 +21,7 @@ import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import { useUIKitStore } from '@/stores/uikit-store'
import { useElectronMenu } from '@/hooks/use-electron-menu'
import { useFigmaPaste } from '@/hooks/use-figma-paste'
import { useMcpSync } from '@/hooks/use-mcp-sync'
const FabricCanvas = lazy(() => import('@/canvas/fabric-canvas'))
@ -112,6 +113,9 @@ export default function EditorLayout() {
// Handle Figma clipboard paste
useFigmaPaste()
// MCP ↔ canvas real-time sync
useMcpSync()
// Hydrate persisted settings
useEffect(() => {
useAgentSettingsStore.getState().hydrate()

View file

@ -3,7 +3,7 @@ import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { exportLayerToRaster, type RasterFormat } from '@/utils/export'
import SectionHeader from '@/components/shared/section-header'
import DropdownSelect from '@/components/shared/dropdown-select'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
const SCALE_OPTIONS = [
@ -53,18 +53,30 @@ export default function ExportSection({ nodeId, nodeName }: ExportSectionProps)
<div className="space-y-1.5">
<SectionHeader title="Export" />
<div className="flex gap-1.5">
<DropdownSelect
value={scale}
options={SCALE_OPTIONS}
onChange={setScale}
className="flex-1"
/>
<DropdownSelect
value={format}
options={FORMAT_OPTIONS}
onChange={setFormat}
className="flex-1"
/>
<Select value={scale} onValueChange={setScale}>
<SelectTrigger className="flex-1 h-6 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCALE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={format} onValueChange={setFormat}>
<SelectTrigger className="flex-1 h-6 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"

View file

@ -1,9 +1,9 @@
import { useState } from 'react'
import ColorPicker from '@/components/shared/color-picker'
import NumberInput from '@/components/shared/number-input'
import DropdownSelect from '@/components/shared/dropdown-select'
import SectionHeader from '@/components/shared/section-header'
import VariablePicker from '@/components/shared/variable-picker'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Plus, X } from 'lucide-react'
import { isVariableRef } from '@/variables/resolve-variables'
@ -160,11 +160,18 @@ export default function FillSection({
/>
{showTypeSelector && (
<DropdownSelect
value={fillType}
options={FILL_TYPE_OPTIONS}
onChange={handleTypeChange}
/>
<Select value={fillType} onValueChange={handleTypeChange}>
<SelectTrigger className="h-6 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILL_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{fillType === 'solid' && (

View file

@ -1,6 +1,6 @@
import type { ImageNode, ImageFitMode } from '@/types/pen'
import SectionHeader from '@/components/shared/section-header'
import DropdownSelect from '@/components/shared/dropdown-select'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
const FIT_MODE_OPTIONS: { value: string; label: string }[] = [
{ value: 'fill', label: 'Fill' },
@ -18,12 +18,21 @@ export default function ImageSection({ node, onUpdate }: ImageSectionProps) {
return (
<div className="space-y-1.5">
<SectionHeader title="Image" />
<DropdownSelect
label="Fit"
value={node.objectFit ?? 'fill'}
options={FIT_MODE_OPTIONS}
onChange={(v) => onUpdate({ objectFit: v as ImageFitMode })}
/>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground shrink-0">Fit</span>
<Select value={node.objectFit ?? 'fill'} onValueChange={(v) => onUpdate({ objectFit: v as ImageFitMode })}>
<SelectTrigger className="flex-1 h-6 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIT_MODE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}

View file

@ -3,9 +3,10 @@ import type { ComponentType, SVGProps } from 'react'
import { X, Check, Loader2, Unplug, AlertCircle, Zap, Terminal } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
import type { AIProviderType, GroupedModel } from '@/types/agent-settings'
import type { AIProviderType, MCPTransportMode, GroupedModel } from '@/types/agent-settings'
import ClaudeLogo from '@/components/icons/claude-logo'
import OpenAILogo from '@/components/icons/openai-logo'
import OpenCodeLogo from '@/components/icons/opencode-logo'
@ -51,6 +52,20 @@ async function connectAgent(
}
}
async function callMcpInstall(
tool: string,
action: 'install' | 'uninstall',
transportMode?: MCPTransportMode,
httpPort?: number,
): Promise<{ success: boolean; error?: string }> {
const res = await fetch('/api/ai/mcp-install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool, action, transportMode, httpPort }),
})
return res.json()
}
function ProviderRow({ type }: { type: AIProviderType }) {
const provider = useAgentSettingsStore((s) => s.providers[type])
const connect = useAgentSettingsStore((s) => s.connectProvider)
@ -150,11 +165,20 @@ function ProviderRow({ type }: { type: AIProviderType }) {
)
}
const TRANSPORT_OPTIONS: { value: MCPTransportMode; label: string }[] = [
{ value: 'stdio', label: 'stdio' },
{ value: 'http', label: 'http' },
{ value: 'both', label: 'stdio + http' },
]
export default function AgentSettingsDialog() {
const open = useAgentSettingsStore((s) => s.dialogOpen)
const setDialogOpen = useAgentSettingsStore((s) => s.setDialogOpen)
const mcpIntegrations = useAgentSettingsStore((s) => s.mcpIntegrations)
const mcpTransportMode = useAgentSettingsStore((s) => s.mcpTransportMode)
const mcpHttpPort = useAgentSettingsStore((s) => s.mcpHttpPort)
const toggleMCP = useAgentSettingsStore((s) => s.toggleMCPIntegration)
const setMCPTransport = useAgentSettingsStore((s) => s.setMCPTransport)
const persist = useAgentSettingsStore((s) => s.persist)
const dialogRef = useRef<HTMLDivElement>(null)
@ -170,6 +194,35 @@ export default function AgentSettingsDialog() {
const [mcpInstalling, setMcpInstalling] = useState<string | null>(null)
const [mcpError, setMcpError] = useState<string | null>(null)
/** Re-install all enabled CLIs with the current global transport settings */
const reinstallEnabled = useCallback(
async (mode: MCPTransportMode, port: number) => {
const enabled = mcpIntegrations.filter((m) => m.enabled)
if (enabled.length === 0) return
setMcpError(null)
setMcpInstalling('__transport__')
try {
for (const m of enabled) {
const result = await callMcpInstall(
m.tool,
'install',
mode,
mode !== 'stdio' ? port : undefined,
)
if (!result.success) {
setMcpError(result.error ?? 'Failed to update transport')
return
}
}
} catch {
setMcpError('Failed to update MCP transport')
} finally {
setMcpInstalling(null)
}
},
[mcpIntegrations],
)
const handleToggleMCP = useCallback(
async (tool: string) => {
const current = mcpIntegrations.find((m) => m.tool === tool)
@ -179,12 +232,12 @@ export default function AgentSettingsDialog() {
setMcpInstalling(tool)
setMcpError(null)
try {
const res = await fetch('/api/ai/mcp-install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool, action }),
})
const result = await res.json()
const result = await callMcpInstall(
tool,
action,
action === 'install' ? mcpTransportMode : undefined,
action === 'install' && mcpTransportMode !== 'stdio' ? mcpHttpPort : undefined,
)
if (result.success) {
toggleMCP(tool)
persist()
@ -197,11 +250,34 @@ export default function AgentSettingsDialog() {
setMcpInstalling(null)
}
},
[mcpIntegrations, toggleMCP, persist],
[mcpIntegrations, mcpTransportMode, mcpHttpPort, toggleMCP, persist],
)
const handleTransportChange = useCallback(
async (mode: MCPTransportMode) => {
if (mode === mcpTransportMode) return
setMCPTransport(mode)
persist()
await reinstallEnabled(mode, mcpHttpPort)
},
[mcpTransportMode, mcpHttpPort, setMCPTransport, persist, reinstallEnabled],
)
const handlePortBlur = useCallback(
async (value: string) => {
const port = parseInt(value, 10)
if (isNaN(port) || port < 1 || port > 65535 || port === mcpHttpPort) return
setMCPTransport(mcpTransportMode, port)
persist()
await reinstallEnabled(mcpTransportMode, port)
},
[mcpTransportMode, mcpHttpPort, setMCPTransport, persist, reinstallEnabled],
)
if (!open) return null
const isBusy = mcpInstalling !== null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
@ -254,6 +330,45 @@ export default function AgentSettingsDialog() {
MCP Integrations in Terminal
</h4>
</div>
{/* Global transport selector */}
<div className="flex items-center gap-2 mb-3 px-1">
<span className="text-[11px] text-muted-foreground shrink-0">Transport</span>
<Select
value={mcpTransportMode}
onValueChange={(v) => handleTransportChange(v as MCPTransportMode)}
disabled={isBusy}
>
<SelectTrigger className="h-6 w-[100px] text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRANSPORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{mcpTransportMode !== 'stdio' && (
<>
<span className="text-[11px] text-muted-foreground shrink-0">Port</span>
<input
type="text"
inputMode="numeric"
defaultValue={mcpHttpPort}
key={mcpHttpPort}
onBlur={(e) => handlePortBlur(e.target.value)}
disabled={isBusy}
className="h-6 w-[52px] text-[11px] text-center tabular-nums bg-secondary text-foreground rounded border border-input focus:border-ring outline-none transition-colors"
/>
</>
)}
{mcpInstalling === '__transport__' && (
<Loader2 size={10} className="animate-spin text-muted-foreground shrink-0" />
)}
</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5">
{mcpIntegrations.map((m) => (
<div
@ -278,7 +393,7 @@ export default function AgentSettingsDialog() {
</div>
<Switch
checked={m.enabled}
disabled={mcpInstalling !== null}
disabled={isBusy}
onCheckedChange={() => handleToggleMCP(m.tool)}
className="shrink-0 ml-2"
/>

View file

@ -1,38 +0,0 @@
import { cn } from '@/lib/utils'
interface DropdownSelectProps {
value: string
options: { value: string; label: string }[]
onChange: (value: string) => void
label?: string
className?: string
}
export default function DropdownSelect({
value,
options,
onChange,
label,
className = '',
}: DropdownSelectProps) {
return (
<div className={cn('flex items-center gap-1.5', className)}>
{label && (
<span className="text-[10px] text-muted-foreground shrink-0">
{label}
</span>
)}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1 h-6 bg-secondary text-foreground text-[11px] px-1.5 rounded border border-transparent hover:border-input focus:border-ring focus:outline-none cursor-pointer transition-colors"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)
}

94
src/hooks/use-mcp-sync.ts Normal file
View file

@ -0,0 +1,94 @@
import { useEffect, useRef } from 'react'
import { useDocumentStore } from '@/stores/document-store'
import type { PenDocument } from '@/types/pen'
const PUSH_DEBOUNCE_MS = 2000
const RECONNECT_DELAY_MS = 3000
function getBaseUrl(): string {
return window.location.origin
}
function pushDocumentToServer(clientId: string | null) {
const doc = useDocumentStore.getState().document
fetch(`${getBaseUrl()}/api/mcp/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document: doc, sourceClientId: clientId }),
}).catch(() => {})
}
/**
* Subscribes the renderer to MCP sync events via SSE.
* - Receives document updates from MCP and applies them to the canvas.
* - Pushes local document changes to Nitro so MCP can read them.
*/
export function useMcpSync() {
const clientIdRef = useRef<string | null>(null)
const pushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Skip the next debounce push when we just applied an external document
const skipNextPushRef = useRef(false)
useEffect(() => {
const baseUrl = getBaseUrl()
let eventSource: EventSource | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let disposed = false
function connect() {
if (disposed) return
eventSource = new EventSource(`${baseUrl}/api/mcp/events`)
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'client:id') {
clientIdRef.current = data.clientId
// Push current document so MCP can read it immediately
pushDocumentToServer(data.clientId)
} else if (data.type === 'document:update') {
const doc = data.document as PenDocument
const childCount = doc.pages?.[0]?.children?.length ?? doc.children?.length ?? 0
console.log('[mcp-sync] Received document:update, top-level children:', childCount)
// Mark to skip the push triggered by applyExternalDocument
skipNextPushRef.current = true
useDocumentStore.getState().applyExternalDocument(doc)
}
} catch {
// Ignore malformed events
}
}
eventSource.onerror = () => {
eventSource?.close()
eventSource = null
if (!disposed) {
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS)
}
}
}
connect()
// Push local document changes to Nitro (debounced)
const unsubDoc = useDocumentStore.subscribe(() => {
if (skipNextPushRef.current) {
skipNextPushRef.current = false
return
}
if (pushTimerRef.current) clearTimeout(pushTimerRef.current)
pushTimerRef.current = setTimeout(() => {
pushDocumentToServer(clientIdRef.current)
}, PUSH_DEBOUNCE_MS)
})
return () => {
disposed = true
eventSource?.close()
if (reconnectTimer) clearTimeout(reconnectTimer)
if (pushTimerRef.current) clearTimeout(pushTimerRef.current)
unsubDoc()
}
}, [])
}

View file

@ -0,0 +1,166 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { writeFile, unlink, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { sanitizeObject } from '../utils/sanitize'
import { openDocument, invalidateCache } from '../document-manager'
import { handleBatchDesign } from '../tools/batch-design'
const TMP_DIR = '/tmp/openpencil-security-tests'
beforeEach(async () => {
await mkdir(TMP_DIR, { recursive: true })
})
afterEach(async () => {
// Clean up any test files
const files = ['proto.op', 'normal.op', 'batch.op', 'batch-proto.op']
for (const f of files) {
try {
const fp = join(TMP_DIR, f)
invalidateCache(fp)
await unlink(fp)
} catch {}
}
})
// ---------- sanitizeObject ----------
describe('sanitizeObject', () => {
it('strips __proto__ key', () => {
const input = JSON.parse('{"__proto__": {"polluted": true}, "safe": 1}')
const result = sanitizeObject(input)
expect(result).toEqual({ safe: 1 })
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false)
})
it('strips constructor key', () => {
const result = sanitizeObject({ constructor: 'bad', ok: true })
expect(result).toEqual({ ok: true })
})
it('strips prototype key', () => {
const result = sanitizeObject({ prototype: {}, keep: 'yes' })
expect(result).toEqual({ keep: 'yes' })
})
it('works recursively on nested objects', () => {
const input = JSON.parse(
'{"a": {"__proto__": {"x": 1}, "b": {"constructor": "c", "d": 2}}}',
)
const result = sanitizeObject(input)
expect(result).toEqual({ a: { b: { d: 2 } } })
})
it('preserves arrays', () => {
const result = sanitizeObject([1, { __proto__: 'x', a: 2 }, 'three'])
expect(result).toEqual([1, { a: 2 }, 'three'])
})
it('preserves primitives', () => {
expect(sanitizeObject('hello')).toBe('hello')
expect(sanitizeObject(42)).toBe(42)
expect(sanitizeObject(null)).toBe(null)
expect(sanitizeObject(undefined)).toBe(undefined)
})
it('preserves normal object keys', () => {
const obj = { type: 'frame', x: 10, y: 20, children: [] }
expect(sanitizeObject(obj)).toEqual(obj)
})
})
// ---------- document-manager openDocument ----------
describe('openDocument', () => {
it('does not pollute Object.prototype from __proto__ in file', async () => {
const fp = join(TMP_DIR, 'proto.op')
const malicious = JSON.stringify({
version: '1.0.0',
__proto__: { polluted: true },
children: [
{
id: 'n1',
type: 'rectangle',
__proto__: { evil: true },
x: 0,
y: 0,
width: 100,
height: 100,
},
],
})
await writeFile(fp, malicious, 'utf-8')
const doc = await openDocument(fp)
// Object.prototype should not be polluted
expect(({} as any).polluted).toBeUndefined()
expect(({} as any).evil).toBeUndefined()
// Doc should still be valid
expect(doc.version).toBe('1.0.0')
expect(doc.children.length).toBe(1)
})
it('loads normal documents correctly after sanitization', async () => {
const fp = join(TMP_DIR, 'normal.op')
const doc = {
version: '1.0.0',
children: [
{ id: 'r1', type: 'rectangle', x: 10, y: 20, width: 50, height: 60 },
],
}
await writeFile(fp, JSON.stringify(doc), 'utf-8')
const loaded = await openDocument(fp)
expect(loaded.version).toBe('1.0.0')
expect(loaded.children[0]).toMatchObject({
id: 'r1',
type: 'rectangle',
x: 10,
y: 20,
})
})
})
// ---------- batch-design (parseJsonArg sanitization) ----------
describe('handleBatchDesign', () => {
it('executes a normal insert operation', async () => {
const fp = join(TMP_DIR, 'batch.op')
await writeFile(
fp,
JSON.stringify({ version: '1.0.0', children: [] }),
'utf-8',
)
const result = await handleBatchDesign({
filePath: fp,
operations:
'myRect=I(null, { type: "rectangle", x: 0, y: 0, width: 100, height: 100 })',
})
expect(result.results.length).toBe(1)
expect(result.results[0].binding).toBe('myRect')
expect(result.nodeCount).toBe(1)
})
it('strips __proto__ keys from node data in operations', async () => {
const fp = join(TMP_DIR, 'batch-proto.op')
await writeFile(
fp,
JSON.stringify({ version: '1.0.0', children: [] }),
'utf-8',
)
const result = await handleBatchDesign({
filePath: fp,
operations:
'bad=I(null, { "type": "rectangle", "__proto__": {"polluted": true}, "x": 0, "y": 0, "width": 50, "height": 50 })',
})
expect(result.results.length).toBe(1)
// Object.prototype must not be polluted
expect(({} as any).polluted).toBeUndefined()
expect(result.nodeCount).toBe(1)
})
})

View file

@ -1,9 +1,86 @@
import { readFile, writeFile, access } from 'node:fs/promises'
import { constants } from 'node:fs'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import type { PenDocument } from '../types/pen'
import { sanitizeObject } from './utils/sanitize'
const cache = new Map<string, { doc: PenDocument; mtime: number }>()
/** Special path indicating the MCP should operate on the live Electron canvas. */
export const LIVE_CANVAS_PATH = 'live://canvas'
/** Resolve filePath for MCP tools — passes through live://canvas, resolves file paths normally. */
export function resolveDocPath(filePath: string): string {
if (filePath === LIVE_CANVAS_PATH) return LIVE_CANVAS_PATH
return resolve(filePath)
}
const PORT_FILE_PATH = join(homedir(), '.openpencil', '.port')
// ---------------------------------------------------------------------------
// Sync URL discovery
// ---------------------------------------------------------------------------
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err: any) {
return err.code === 'EPERM' // process exists but we lack permission
}
}
/** Read the port file and return the Nitro sync base URL, or null if unavailable. */
export async function getSyncUrl(): Promise<string | null> {
try {
const raw = await readFile(PORT_FILE_PATH, 'utf-8')
const { port, pid } = JSON.parse(raw) as { port: number; pid: number }
if (!isPidAlive(pid)) return null
return `http://127.0.0.1:${port}`
} catch {
return null
}
}
/** Fetch the current document from the live Electron canvas. */
async function fetchLiveDocument(): Promise<PenDocument> {
const syncUrl = await getSyncUrl()
if (!syncUrl) {
throw new Error(
'No running OpenPencil instance found. Start the Electron app or dev server first.',
)
}
const res = await fetch(`${syncUrl}/api/mcp/document`)
if (!res.ok) {
const body = await res.json().catch(() => ({} as Record<string, unknown>))
throw new Error(
(body as { error?: string }).error ?? `Failed to fetch live document: ${res.status}`,
)
}
const data = (await res.json()) as { document: PenDocument }
return data.document
}
/** Push document to the live Electron canvas. Fails silently if unavailable. */
async function pushLiveDocument(doc: PenDocument): Promise<void> {
const syncUrl = await getSyncUrl()
if (!syncUrl) return
try {
await fetch(`${syncUrl}/api/mcp/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document: doc }),
})
} catch {
// Network error — Electron might have quit between check and request
}
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
/** Validate that a parsed object looks like a PenDocument. */
function validate(doc: unknown): doc is PenDocument {
if (!doc || typeof doc !== 'object') return false
@ -12,19 +89,33 @@ function validate(doc: unknown): doc is PenDocument {
return typeof d.version === 'string' && (Array.isArray(d.children) || Array.isArray(d.pages))
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Read and parse a .op / .pen file, returning a PenDocument. Uses cache. */
export async function openDocument(filePath: string): Promise<PenDocument> {
// Live canvas mode: fetch from running Electron/dev server
if (filePath === LIVE_CANVAS_PATH) {
const cached = cache.get(LIVE_CANVAS_PATH)
if (cached) return cached.doc
const doc = await fetchLiveDocument()
cache.set(LIVE_CANVAS_PATH, { doc, mtime: Date.now() })
return doc
}
const cached = cache.get(filePath)
if (cached) return cached.doc
await access(filePath, constants.R_OK)
const text = await readFile(filePath, 'utf-8')
const raw = JSON.parse(text)
if (!validate(raw)) {
const sanitized = sanitizeObject(raw)
if (!validate(sanitized)) {
throw new Error(`Invalid document format: ${filePath}`)
}
cache.set(filePath, { doc: raw, mtime: Date.now() })
return raw
cache.set(filePath, { doc: sanitized, mtime: Date.now() })
return sanitized
}
/** Create a new empty document (not saved to disk yet). */
@ -35,14 +126,25 @@ export function createEmptyDocument(): PenDocument {
}
}
/** Write a PenDocument to disk and update cache. */
/** Write a PenDocument to disk and update cache. Also pushes to live canvas if available. */
export async function saveDocument(
filePath: string,
doc: PenDocument,
): Promise<void> {
if (filePath === LIVE_CANVAS_PATH) {
// Live canvas mode: push to Electron, no disk write
cache.set(LIVE_CANVAS_PATH, { doc, mtime: Date.now() })
await pushLiveDocument(doc)
return
}
// File-based: write to disk
const json = JSON.stringify(doc, null, 2)
await writeFile(filePath, json, 'utf-8')
cache.set(filePath, { doc, mtime: Date.now() })
// Also push to live canvas (dual-write so canvas updates even for file-based MCP use)
await pushLiveDocument(doc)
}
/** Get document from cache (for tools that operate on the active doc). */

View file

@ -1,12 +1,16 @@
#!/usr/bin/env node
import { createServer } from 'node:http'
import { randomUUID } from 'node:crypto'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import pkg from '../../package.json'
import { handleOpenDocument } from './tools/open-document'
import { handleBatchGet } from './tools/batch-get'
import { handleBatchDesign } from './tools/batch-design'
@ -14,77 +18,56 @@ import { handleGetVariables, handleSetVariables } from './tools/variables'
import { handleSnapshotLayout } from './tools/snapshot-layout'
import { handleFindEmptySpace } from './tools/find-empty-space'
const server = new Server(
{ name: 'openpencil', version: '0.1.0' },
{ capabilities: { tools: {} } },
)
// --- Tool definitions (shared across all Server instances) ---
// --- Tool definitions ---
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'open_document',
description:
'Open an existing .op file or create a new empty document. Returns document metadata.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file to open or create',
},
const TOOL_DEFINITIONS = [
{
name: 'open_document',
description:
'Open an existing .op file or connect to the live Electron canvas. Returns document metadata, context summary, and design prompt. Always call this first. Omit filePath to connect to the live canvas.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description:
'Absolute path to the .op file to open or create. Omit to connect to the live Electron canvas, or pass "live://canvas" explicitly.',
},
required: ['filePath'],
},
required: [],
},
{
name: 'batch_get',
description:
'Search and read nodes from an .op file. Search by patterns (type, name regex, reusable flag) or read specific node IDs. Control depth with readDepth and searchDepth.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
patterns: {
type: 'array',
description: 'Search patterns to match nodes',
items: {
type: 'object',
properties: {
type: { type: 'string', description: 'Node type (frame, text, rectangle, etc.)' },
name: { type: 'string', description: 'Regex pattern to match node name' },
reusable: { type: 'boolean', description: 'Match reusable components' },
},
},
{
name: 'batch_get',
description:
'Search and read nodes from an .op file. Search by patterns (type, name regex, reusable flag) or read specific node IDs. Control depth with readDepth and searchDepth.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
patterns: {
type: 'array',
description: 'Search patterns to match nodes',
items: {
type: 'object',
properties: {
type: { type: 'string', description: 'Node type (frame, text, rectangle, etc.)' },
name: { type: 'string', description: 'Regex pattern to match node name' },
reusable: { type: 'boolean', description: 'Match reusable components' },
},
},
nodeIds: {
type: 'array',
items: { type: 'string' },
description: 'Specific node IDs to read',
},
parentId: {
type: 'string',
description: 'Limit search to children of this parent node',
},
readDepth: {
type: 'number',
description: 'How deep to include children in results (default 1)',
},
searchDepth: {
type: 'number',
description: 'How deep to search for matching nodes (default unlimited)',
},
},
required: ['filePath'],
nodeIds: { type: 'array', items: { type: 'string' }, description: 'Specific node IDs to read' },
parentId: { type: 'string', description: 'Limit search to children of this parent node' },
readDepth: { type: 'number', description: 'How deep to include children in results (default 1)' },
searchDepth: { type: 'number', description: 'How deep to search for matching nodes (default unlimited)' },
},
required: ['filePath'],
},
{
name: 'batch_design',
description: `Execute design operations on an .op file using a DSL. Each line is one operation:
},
{
name: 'batch_design',
description: `Execute design operations on an .op file using a DSL. Each line is one operation:
- Insert: binding=I(parent, { type: "frame", ... })
- Copy: binding=C(sourceId, parent, { ...overrides })
- Update: U(nodeId, { fill: [...] })
@ -92,180 +75,212 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
- Move: M(nodeId, newParent, index)
- Delete: D(nodeId)
Bindings can reference earlier results: U(myFrame+"/childId", { ... })`,
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
operations: {
type: 'string',
description: 'Operations DSL (one operation per line)',
},
},
required: ['filePath', 'operations'],
},
},
{
name: 'get_variables',
description: 'Get all design variables and themes defined in an .op file.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
},
required: ['filePath'],
},
},
{
name: 'set_variables',
description:
'Add or update design variables in an .op file. By default merges with existing variables.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
variables: {
type: 'object',
description: 'Variables to set (name → { type, value })',
},
replace: {
type: 'boolean',
description: 'Replace all variables instead of merging (default false)',
},
},
required: ['filePath', 'variables'],
},
},
{
name: 'snapshot_layout',
description:
'Get the hierarchical bounding box layout tree of an .op file. Useful for understanding spatial arrangement.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
parentId: {
type: 'string',
description: 'Only return layout under this parent node',
},
maxDepth: {
type: 'number',
description: 'Max depth to traverse (default 1)',
},
},
required: ['filePath'],
},
},
{
name: 'find_empty_space',
description:
'Find empty canvas space in a given direction for placing new content.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Absolute path to the .op file',
},
width: {
type: 'number',
description: 'Required width of empty space',
},
height: {
type: 'number',
description: 'Required height of empty space',
},
padding: {
type: 'number',
description: 'Minimum padding from other elements (default 50)',
},
direction: {
type: 'string',
enum: ['top', 'right', 'bottom', 'left'],
description: 'Direction to search for empty space',
},
nodeId: {
type: 'string',
description: 'Search relative to this node (default: entire canvas)',
},
},
required: ['filePath', 'width', 'height', 'direction'],
},
},
],
}))
Bindings can reference earlier results: U(myFrame+"/childId", { ... })
// --- Tool execution ---
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
try {
switch (name) {
case 'open_document': {
const result = await handleOpenDocument(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'batch_get': {
const result = await handleBatchGet(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'batch_design': {
const result = await handleBatchDesign(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'get_variables': {
const result = await handleGetVariables(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'set_variables': {
const result = await handleSetVariables(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'snapshot_layout': {
const result = await handleSnapshotLayout(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
case 'find_empty_space': {
const result = await handleFindEmptySpace(args as any)
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}
default:
return {
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
isError: true,
}
}
} catch (err) {
return {
content: [
{
type: 'text',
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
Set postProcess=true to automatically apply role defaults, icon resolution, and sanitization after operations complete.`,
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
operations: { type: 'string', description: 'Operations DSL (one operation per line)' },
postProcess: {
type: 'boolean',
description: 'Apply post-processing (role defaults, icon resolution, sanitization) after operations. Always use when generating designs.',
},
],
isError: true,
}
canvasWidth: {
type: 'number',
description: 'Canvas width for post-processing layout calculations (default 1200, use 375 for mobile). Only used when postProcess=true.',
},
},
required: ['filePath', 'operations'],
},
},
{
name: 'get_variables',
description: 'Get all design variables and themes defined in an .op file.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
},
required: ['filePath'],
},
},
{
name: 'set_variables',
description: 'Add or update design variables in an .op file. By default merges with existing variables.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
variables: { type: 'object', description: 'Variables to set (name → { type, value })' },
replace: { type: 'boolean', description: 'Replace all variables instead of merging (default false)' },
},
required: ['filePath', 'variables'],
},
},
{
name: 'snapshot_layout',
description: 'Get the hierarchical bounding box layout tree of an .op file. Useful for understanding spatial arrangement.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
parentId: { type: 'string', description: 'Only return layout under this parent node' },
maxDepth: { type: 'number', description: 'Max depth to traverse (default 1)' },
},
required: ['filePath'],
},
},
{
name: 'find_empty_space',
description: 'Find empty canvas space in a given direction for placing new content.',
inputSchema: {
type: 'object' as const,
properties: {
filePath: { type: 'string', description: 'Absolute path to the .op file' },
width: { type: 'number', description: 'Required width of empty space' },
height: { type: 'number', description: 'Required height of empty space' },
padding: { type: 'number', description: 'Minimum padding from other elements (default 50)' },
direction: { type: 'string', enum: ['top', 'right', 'bottom', 'left'], description: 'Direction to search for empty space' },
nodeId: { type: 'string', description: 'Search relative to this node (default: entire canvas)' },
},
required: ['filePath', 'width', 'height', 'direction'],
},
},
]
// --- Tool execution handler ---
async function handleToolCall(name: string, args: any) {
switch (name) {
case 'open_document':
return JSON.stringify(await handleOpenDocument(args), null, 2)
case 'batch_get':
return JSON.stringify(await handleBatchGet(args), null, 2)
case 'batch_design':
return JSON.stringify(await handleBatchDesign(args), null, 2)
case 'get_variables':
return JSON.stringify(await handleGetVariables(args), null, 2)
case 'set_variables':
return JSON.stringify(await handleSetVariables(args), null, 2)
case 'snapshot_layout':
return JSON.stringify(await handleSnapshotLayout(args), null, 2)
case 'find_empty_space':
return JSON.stringify(await handleFindEmptySpace(args), null, 2)
default:
throw new Error(`Unknown tool: ${name}`)
}
})
}
/** Register tool handlers on a Server instance. */
function registerTools(server: Server): void {
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOL_DEFINITIONS,
}))
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
try {
const text = await handleToolCall(name, args)
return { content: [{ type: 'text', text }] }
} catch (err) {
return {
content: [{ type: 'text', text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
}
}
})
}
// --- HTTP server helper ---
function startHttpServer(server: Server, port: number): void {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
})
server.connect(transport)
const httpServer = createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id')
res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
if (req.url === '/mcp') {
if (req.method === 'POST') {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
const body = JSON.parse(Buffer.concat(chunks).toString())
await transport.handleRequest(req, res, body)
} else {
await transport.handleRequest(req, res)
}
} else {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found. Use /mcp endpoint.' }))
}
})
httpServer.listen(port, '127.0.0.1', () => {
console.error(`OpenPencil MCP server listening on http://127.0.0.1:${port}/mcp`)
})
}
// --- Start ---
function parseArgs(): { stdio: boolean; http: boolean; port: number } {
const args = process.argv.slice(2)
const hasHttp = args.includes('--http')
const hasStdio = args.includes('--stdio')
const portIdx = args.indexOf('--port')
const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : 3100
if (hasHttp && hasStdio) return { stdio: true, http: true, port: isNaN(port) ? 3100 : port }
if (hasHttp) return { stdio: false, http: true, port: isNaN(port) ? 3100 : port }
return { stdio: true, http: false, port: 3100 }
}
async function main() {
const transport = new StdioServerTransport()
await server.connect(transport)
const { stdio, http, port } = parseArgs()
if (stdio && http) {
// Both: two Server instances sharing the same tool handlers
const stdioServer = new Server(
{ name: pkg.name, version: pkg.version },
{ capabilities: { tools: {} } },
)
registerTools(stdioServer)
await stdioServer.connect(new StdioServerTransport())
const httpServer = new Server(
{ name: pkg.name, version: pkg.version },
{ capabilities: { tools: {} } },
)
registerTools(httpServer)
startHttpServer(httpServer, port)
} else if (http) {
const server = new Server(
{ name: pkg.name, version: pkg.version },
{ capabilities: { tools: {} } },
)
registerTools(server)
startHttpServer(server, port)
} else {
const server = new Server(
{ name: pkg.name, version: pkg.version },
{ capabilities: { tools: {} } },
)
registerTools(server)
await server.connect(new StdioServerTransport())
}
}
main().catch((err) => {

View file

@ -1,20 +1,34 @@
import { resolve } from 'node:path'
import { openDocument, saveDocument } from '../document-manager'
import { openDocument, saveDocument, resolveDocPath } from '../document-manager'
import {
findNodeInTree,
insertNodeInTree,
updateNodeInTree,
removeNodeFromTree,
cloneNodeWithNewIds,
flattenNodes,
getDocChildren,
setDocChildren,
} from '../utils/node-operations'
import { generateId } from '../utils/id'
import { sanitizeObject } from '../utils/sanitize'
import { resolveTreeRoles, resolveTreePostPass } from '../../services/ai/role-resolver'
import '../../services/ai/role-definitions/index'
import {
applyIconPathResolution,
applyNoEmojiIconHeuristic,
} from '../../services/ai/icon-resolver'
import {
ensureUniqueNodeIds,
sanitizeLayoutChildPositions,
sanitizeScreenFrameBounds,
} from '../../services/ai/design-node-sanitization'
import type { PenDocument, PenNode } from '../../types/pen'
export interface BatchDesignParams {
filePath: string
operations: string
postProcess?: boolean
canvasWidth?: number
}
interface OpResult {
@ -35,8 +49,8 @@ interface OpResult {
*/
export async function handleBatchDesign(
params: BatchDesignParams,
): Promise<{ results: OpResult[]; nodeCount: number }> {
const filePath = resolve(params.filePath)
): Promise<{ results: OpResult[]; nodeCount: number; postProcessed?: boolean }> {
const filePath = resolveDocPath(params.filePath)
let doc = await openDocument(filePath)
doc = structuredClone(doc)
@ -57,11 +71,53 @@ export async function handleBatchDesign(
}
}
// --- Post-processing ---
let postProcessed = false
if (params.postProcess) {
const canvasWidth = params.canvasWidth ?? 1200
const children = getDocChildren(doc)
// Find root nodes that were inserted (first binding is typically the root)
const insertedIds = new Set(results.map((r) => r.nodeId))
const rootTargets: PenNode[] = []
for (const id of insertedIds) {
const node = findNodeInTree(children, id)
if (node) rootTargets.push(node)
}
// If no specific roots found, process all top-level children
const targets = rootTargets.length > 0 ? rootTargets : children
for (const target of targets) {
// 1. Role resolution
resolveTreeRoles(target, canvasWidth)
// 2. Tree post-pass (cross-node fixes)
resolveTreePostPass(target, canvasWidth)
// 3. Icon resolution + emoji removal
const flat = flattenNodes([target])
for (const node of flat) {
if (node.type === 'path') applyIconPathResolution(node)
if (node.type === 'text') applyNoEmojiIconHeuristic(node)
}
// 4. Sanitization
const usedIds = new Set<string>()
const idCounters = new Map<string, number>()
ensureUniqueNodeIds(target, usedIds, idCounters)
sanitizeLayoutChildPositions(target, false)
sanitizeScreenFrameBounds(target)
}
postProcessed = true
}
await saveDocument(filePath, doc)
return {
results,
nodeCount: countNodes(getDocChildren(doc)),
postProcessed: postProcessed || undefined,
}
}
@ -81,6 +137,26 @@ function executeLine(
case 'I': {
const { parent, data } = parseInsertArgs(argsStr, bindings)
const node = { ...data, id: generateId() } as PenNode
// Auto-replace: when inserting a frame at root level and an empty
// root frame exists, replace it instead of creating a sibling.
if (parent === null && data.type === 'frame') {
const children = getDocChildren(doc)
const emptyIdx = children.findIndex((n) => isEmptyFrame(n))
if (emptyIdx !== -1) {
const emptyFrame = children[emptyIdx]
// Inherit position from the empty frame so the design lands in the right spot
if (emptyFrame.x !== undefined) (node as any).x = emptyFrame.x
if (emptyFrame.y !== undefined) (node as any).y = emptyFrame.y
let updated = removeNodeFromTree(children, emptyFrame.id)
updated = insertNodeInTree(updated, null, node, emptyIdx)
setDocChildren(doc, updated)
bindings.set(binding, node.id)
results.push({ binding, nodeId: node.id })
break
}
}
setDocChildren(doc, insertNodeInTree(getDocChildren(doc), parent, node))
bindings.set(binding, node.id)
results.push({ binding, nodeId: node.id })
@ -369,7 +445,7 @@ function parseJsonArg(str: string): Record<string, any> {
// Handle single-quoted strings → double-quoted
normalized = normalized.replace(/'/g, '"')
try {
return JSON.parse(normalized)
return sanitizeObject(JSON.parse(normalized))
} catch {
throw new Error(`Failed to parse JSON: ${str}`)
}
@ -445,3 +521,11 @@ function countNodes(nodes: PenNode[]): number {
return count
}
/** A root frame is "empty" if it has no children. */
function isEmptyFrame(node: PenNode): boolean {
return (
node.type === 'frame' &&
(!('children' in node) || !node.children || node.children.length === 0)
)
}

View file

@ -1,5 +1,4 @@
import { resolve } from 'node:path'
import { openDocument } from '../document-manager'
import { openDocument, resolveDocPath } from '../document-manager'
import {
findNodeInTree,
searchNodes,
@ -26,7 +25,7 @@ export interface BatchGetParams {
export async function handleBatchGet(
params: BatchGetParams,
): Promise<{ nodes: Record<string, unknown>[] }> {
const filePath = resolve(params.filePath)
const filePath = resolveDocPath(params.filePath)
const doc = await openDocument(filePath)
const readDepth = params.readDepth ?? 1

View file

@ -0,0 +1,80 @@
import {
PEN_NODE_SCHEMA,
ADAPTIVE_STYLE_POLICY,
DESIGN_EXAMPLES,
} from '../../services/ai/ai-prompts'
/**
* Build the design knowledge prompt for AI-assisted design generation.
*
* This is the MCP equivalent of the CHAT_SYSTEM_PROMPT it provides the
* same schema, style policy, roles, typography, icons, and layout rules
* so that an external AI (e.g. Claude Code) can generate high-quality
* batch_design operations.
*/
export function buildDesignPrompt(): string {
return `You are generating designs for OpenPencil, a vector design tool.
Use the batch_design tool to insert nodes. Each node must follow the PenNode schema below.
${PEN_NODE_SCHEMA}
${ADAPTIVE_STYLE_POLICY}
${DESIGN_EXAMPLES}
LAYOUT ENGINE (flexbox-based):
- Frames with layout: "vertical"/"horizontal" auto-position children via gap, padding, justifyContent, alignItems
- NEVER set x/y on children inside layout containers the engine positions them automatically
- CHILD SIZE RULE: child width must be parent content area. Use "fill_container" when in doubt.
- SIZING: width/height accept: number (px), "fill_container" (stretch to fill parent), "fit_content" (shrink-wrap to content size).
In vertical layout: "fill_container" width stretches horizontally; "fill_container" height fills remaining vertical space.
In horizontal layout: "fill_container" width fills remaining horizontal space; "fill_container" height stretches vertically.
- PADDING: number (uniform), [vertical, horizontal] (e.g. [0, 80]), or [top, right, bottom, left].
- CLIP CONTENT: set clipContent: true to clip children that overflow the frame. ALWAYS use on cards with cornerRadius + image children.
- FLEX DISTRIBUTION via justifyContent:
"space_between" = push items to edges with equal gaps between (ideal for navbars: logo | links | CTA)
"space_around" = equal space around each item
"center" = center-pack items
"start"/"end" = pack to start/end
- ALL nodes must be descendants of the root frame no floating/orphan elements
- WIDTH CONSISTENCY: siblings in a vertical layout must use the SAME width strategy. If one uses "fill_container", ALL siblings must too.
- NEVER use "fill_container" on children of a "fit_content" parent circular dependency.
- TEXT IN LAYOUTS: in vertical layouts, body text textGrowth="fixed-width" + width="fill_container". In horizontal rows, labels textGrowth="auto" + width="fit_content". NEVER use fixed pixel width on text inside a layout.
- TEXT HEIGHT: NEVER set explicit pixel height on text nodes. OMIT height the engine auto-calculates.
- CJK BUTTONS/BADGES: each CJK char fontSize wide. Ensure container width (charCount × fontSize) + padding.
COPYWRITING:
- Headlines: 2-6 words. Subtitles: 1 sentence 15 words. Buttons: 1-3 words. Card text: 2 sentences.
- NEVER generate placeholder paragraphs with 3+ sentences. Distill to essence.
DESIGN GUIDELINES:
- Mobile: root frame 375x812 at x:0,y:0. Web: 1200x800 (single screen) or 1200x3000-5000 (landing page).
- Use unique descriptive IDs. All elements INSIDE root frame as children.
- Max 3-4 levels of nesting. Consistent centered content container (~1040-1160px) for web.
- Buttons: height 44-52px, cornerRadius 8-12, padding [12, 24]. Icon+text: layout="horizontal", gap=8, alignItems="center".
- Inputs: height 44px, light bg, subtle border, width="fill_container" in forms.
- Cards: cornerRadius 12-16, clipContent: true, subtle shadows. Cards in a horizontal row: ALL use height="fill_container".
- Icons: "path" nodes with Feather icon names (PascalCase + "Icon" suffix). Size 16-24px. System auto-resolves names to SVG paths.
- Never use emoji as icons. Never use ellipse for decorative shapes.
- Phone mockup: ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke.
- Default to light neutral styling unless user asks for dark.
DESIGN VARIABLES:
- When document has variables, use "$variableName" references instead of hardcoded values.
- Color variables: [{ "type": "solid", "color": "$primary" }]
- Number variables: "gap": "$spacing-md"
EMPTY FRAME AUTO-REPLACEMENT:
- When inserting a root-level frame via I(null, {...}), if an empty root frame (no children) already exists on the canvas, it is automatically replaced no need to delete or move into it manually.
- The new frame inherits the position (x/y) of the replaced empty frame, so find_empty_space is unnecessary when an empty root frame exists.
- Always use I(null, {...}) for root-level designs the tool handles reuse of empty frames automatically.
POST-PROCESSING (automatic):
- batch_design with postProcess=true automatically applies after insertion:
- Semantic role defaults (button padding, card corners, input styling, etc.)
- Icon name SVG path resolution
- Emoji removal
- Layout child position sanitization
- Unique ID enforcement
Always set postProcess=true when generating designs for best visual quality.`
}

View file

@ -1,5 +1,4 @@
import { resolve } from 'node:path'
import { openDocument } from '../document-manager'
import { openDocument, resolveDocPath } from '../document-manager'
import { getNodeBounds, findNodeInTree, getDocChildren } from '../utils/node-operations'
import type { PenNode } from '../../types/pen'
@ -20,7 +19,7 @@ export interface FindEmptySpaceResult {
export async function handleFindEmptySpace(
params: FindEmptySpaceParams,
): Promise<FindEmptySpaceResult> {
const filePath = resolve(params.filePath)
const filePath = resolveDocPath(params.filePath)
const doc = await openDocument(filePath)
const padding = params.padding ?? 50

View file

@ -1,11 +1,15 @@
import { resolve } from 'node:path'
import {
openDocument,
createEmptyDocument,
saveDocument,
fileExists,
getSyncUrl,
resolveDocPath,
LIVE_CANVAS_PATH,
} from '../document-manager'
import type { PenDocument } from '../../types/pen'
import { flattenNodes, getDocChildren } from '../utils/node-operations'
import { buildDesignPrompt } from './design-prompt'
import type { PenDocument, PenNode } from '../../types/pen'
export interface OpenDocumentParams {
filePath?: string
@ -22,6 +26,8 @@ export interface OpenDocumentResult {
hasVariables: boolean
hasThemes: boolean
}
context: string
designPrompt: string
}
export async function handleOpenDocument(
@ -30,8 +36,23 @@ export async function handleOpenDocument(
let filePath: string
let doc: PenDocument
if (params.filePath) {
filePath = resolve(params.filePath)
if (!params.filePath || params.filePath === LIVE_CANVAS_PATH) {
// Live canvas mode: connect to the running Electron/dev server
const syncUrl = await getSyncUrl()
if (syncUrl) {
filePath = LIVE_CANVAS_PATH
doc = await openDocument(LIVE_CANVAS_PATH)
} else if (params.filePath === LIVE_CANVAS_PATH) {
throw new Error(
'No running OpenPencil instance found. Start the Electron app or dev server first.',
)
} else {
throw new Error(
'filePath is required when no OpenPencil instance is running. Provide a path to an existing .op file or a new file to create.',
)
}
} else {
filePath = resolveDocPath(params.filePath)
const exists = await fileExists(filePath)
if (exists) {
doc = await openDocument(filePath)
@ -40,10 +61,6 @@ export async function handleOpenDocument(
doc = createEmptyDocument()
await saveDocument(filePath, doc)
}
} else {
throw new Error(
'filePath is required. Provide a path to an existing .op file or a new file to create.',
)
}
const pages = doc.pages?.map((p) => ({
@ -66,5 +83,116 @@ export async function handleOpenDocument(
hasVariables: !!doc.variables && Object.keys(doc.variables).length > 0,
hasThemes: !!doc.themes && Object.keys(doc.themes).length > 0,
},
context: buildDocumentContext(doc),
designPrompt: buildDesignPrompt(),
}
}
// ---------------------------------------------------------------------------
// Document context builder (merged from document-context.ts)
// ---------------------------------------------------------------------------
function buildDocumentContext(doc: PenDocument): string {
const children = getDocChildren(doc)
const allNodes = flattenNodes(children)
if (allNodes.length === 0) {
return 'Empty document. No existing nodes.'
}
const nodeSummary = allNodes
.slice(0, 20)
.map((n) => `${n.type}:${n.name ?? n.id}`)
.join(', ')
const canvasSize = estimateCanvasSize(children)
const parts: string[] = []
parts.push(`DOCUMENT SUMMARY:`)
parts.push(`- Total nodes: ${allNodes.length}`)
parts.push(`- Canvas size: ${canvasSize.width}x${canvasSize.height}`)
if (nodeSummary) {
parts.push(`- Nodes (first 20): ${nodeSummary}`)
}
// Hint about empty root frames that will be auto-replaced
const emptyRootFrames = children.filter(
(n) =>
n.type === 'frame' &&
(!('children' in n) || !(n as any).children || (n as any).children.length === 0),
)
if (emptyRootFrames.length > 0) {
const info = emptyRootFrames
.map((f) => {
const w = typeof (f as any).width === 'number' ? (f as any).width : 1200
const h = typeof (f as any).height === 'number' ? (f as any).height : 800
return `"${f.name ?? f.id}" (id: ${f.id}, ${w}x${h})`
})
.join(', ')
parts.push(
`- Empty root frame(s): ${info} — will be auto-replaced when you insert a root-level frame via I(null, ...)`,
)
}
const pageLines = buildPageContext(doc)
if (pageLines) parts.push('', pageLines)
const variableLines = buildVariableContext(doc)
if (variableLines) parts.push('', variableLines)
const themeLines = buildThemeContext(doc)
if (themeLines) parts.push('', themeLines)
return parts.join('\n')
}
function estimateCanvasSize(children: PenNode[]): {
width: number
height: number
} {
for (const node of children) {
if (
node.type === 'frame' &&
typeof node.width === 'number' &&
typeof node.height === 'number'
) {
if (node.width <= 500 && node.height >= 700) {
return { width: 375, height: 812 }
}
return { width: node.width, height: node.height }
}
}
return { width: 1200, height: 800 }
}
function buildVariableContext(doc: PenDocument): string {
if (!doc.variables || Object.keys(doc.variables).length === 0) return ''
const lines = ['DOCUMENT VARIABLES (use "$name" to reference):']
for (const [name, def] of Object.entries(doc.variables)) {
const themed = Array.isArray(def.value) ? ' [themed]' : ''
const displayValue = Array.isArray(def.value)
? String(def.value[0]?.value ?? '')
: String(def.value)
lines.push(` - ${name} (${def.type}): ${displayValue}${themed}`)
}
return lines.join('\n')
}
function buildThemeContext(doc: PenDocument): string {
if (!doc.themes || Object.keys(doc.themes).length === 0) return ''
const entries = Object.entries(doc.themes)
.map(([axis, variants]) => `${axis}: [${variants.join(',')}]`)
.join('; ')
return `Themes: ${entries}`
}
function buildPageContext(doc: PenDocument): string {
if (!doc.pages || doc.pages.length <= 1) return ''
const pageList = doc.pages
.map((p) => `${p.name} (${p.children.length} nodes)`)
.join(', ')
return `Pages: ${pageList}`
}

View file

@ -1,5 +1,4 @@
import { resolve } from 'node:path'
import { openDocument } from '../document-manager'
import { openDocument, resolveDocPath } from '../document-manager'
import { computeLayoutTree, getDocChildren, type LayoutEntry } from '../utils/node-operations'
export interface SnapshotLayoutParams {
@ -11,7 +10,7 @@ export interface SnapshotLayoutParams {
export async function handleSnapshotLayout(
params: SnapshotLayoutParams,
): Promise<{ layout: LayoutEntry[] }> {
const filePath = resolve(params.filePath)
const filePath = resolveDocPath(params.filePath)
const doc = await openDocument(filePath)
const maxDepth = params.maxDepth ?? 1

View file

@ -1,5 +1,4 @@
import { resolve } from 'node:path'
import { openDocument, saveDocument } from '../document-manager'
import { openDocument, saveDocument, resolveDocPath } from '../document-manager'
import type { VariableDefinition } from '../../types/variables'
export interface GetVariablesParams {
@ -15,7 +14,7 @@ export interface SetVariablesParams {
export async function handleGetVariables(
params: GetVariablesParams,
): Promise<{ variables: Record<string, VariableDefinition>; themes: Record<string, string[]> }> {
const filePath = resolve(params.filePath)
const filePath = resolveDocPath(params.filePath)
const doc = await openDocument(filePath)
return {
variables: doc.variables ?? {},
@ -26,7 +25,7 @@ export async function handleGetVariables(
export async function handleSetVariables(
params: SetVariablesParams,
): Promise<{ variables: Record<string, VariableDefinition> }> {
const filePath = resolve(params.filePath)
const filePath = resolveDocPath(params.filePath)
const doc = await openDocument(filePath)
if (params.replace) {

22
src/mcp/utils/sanitize.ts Normal file
View file

@ -0,0 +1,22 @@
/**
* Recursively strip dangerous prototype-pollution keys from parsed JSON objects.
* Call on any user-supplied or file-parsed JSON before using it in the application.
*/
// '__proto__' and 'prototype' enable classic prototype pollution.
// 'constructor' is stripped because obj.constructor.prototype can also be
// used to reach and mutate Object.prototype in certain exploit chains.
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
export function sanitizeObject<T>(obj: T, seen = new WeakSet<object>()): T {
if (!obj || typeof obj !== 'object') return obj
if (seen.has(obj as object)) return obj
seen.add(obj as object)
if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item, seen)) as T
const clean: Record<string, unknown> = {}
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (DANGEROUS_KEYS.has(k)) continue
clean[k] = sanitizeObject(v, seen)
}
return clean as T
}

View file

@ -4,7 +4,7 @@ import { AVAILABLE_FEATHER_ICONS } from './icon-resolver'
// instantly from the local icon map without any network request.
const FEATHER_ICON_NAMES = AVAILABLE_FEATHER_ICONS.join(', ')
const PEN_NODE_SCHEMA = `
export const PEN_NODE_SCHEMA = `
PenNode types (the ONLY format you output for designs):
- frame: Container. Props: width, height, layout ('none'|'vertical'|'horizontal'), gap, padding, justifyContent ('start'|'center'|'end'|'space_between'|'space_around'), alignItems ('start'|'center'|'end'), clipContent (boolean, clips overflowing children), children[], cornerRadius, fill, stroke, effects
- rectangle: Props: width, height, cornerRadius, fill, stroke, effects
@ -71,7 +71,7 @@ OVERFLOW PREVENTION (CRITICAL — violations cause visual glitches):
- BADGES: use badge/chip style only for short labels (CJK <=8 chars / Latin <=16 chars). If text is longer, do NOT use badge style; use a normal text row or small card.
`
const DESIGN_EXAMPLES = `
export const DESIGN_EXAMPLES = `
EXAMPLES:
Button with icon (role="button" auto-adds padding, height, layout, alignItems if not set):
@ -89,7 +89,7 @@ ICONS & IMAGES:
- Do NOT use random real-world app screenshots or dense mini-app simulations for showcase sections.
`
const ADAPTIVE_STYLE_POLICY = `
export const ADAPTIVE_STYLE_POLICY = `
VISUAL STYLE POLICY:
- Do NOT force a dark black+green palette unless the user explicitly asks for it.
- Infer style from user intent and content:

View file

@ -11,6 +11,11 @@ const ZSTD_MAGIC = [0x28, 0xB5, 0x2F, 0xFD]
const PNG_MAGIC_0 = 137
const PNG_MAGIC_1 = 80
const MAX_COMPRESSED_SIZE = 50 * 1024 * 1024 // 50MB compressed input — guard against oversized uploads before decompression
const MAX_UNZIPPED_SIZE = 100 * 1024 * 1024 // 100MB total decompressed
const MAX_IMAGE_SIZE = 50 * 1024 * 1024 // 50MB per image
const MAX_ZIP_ENTRIES = 10_000 // guard against zip bombs with many small entries
const int32 = new Int32Array(1)
const uint8 = new Uint8Array(int32.buffer)
const uint32 = new Uint32Array(int32.buffer)
@ -90,6 +95,12 @@ function figToBinaryParts(fileBuffer: ArrayBuffer): FigBinaryResult {
// If not starting with "fig-kiwi", it's a ZIP archive containing canvas.fig
if (!hasFigKiwiMagic(fileByte)) {
// Pre-decompression size check: reject oversized compressed input before
// UZIP.parse loads the full archive into memory (mitigates zip bombs).
if (fileBuffer.byteLength > MAX_COMPRESSED_SIZE) {
throw new Error('Compressed .fig file exceeds maximum size limit (50MB)')
}
let unzipped: Record<string, Uint8Array>
try {
unzipped = UZIP.parse(fileBuffer)
@ -99,9 +110,22 @@ function figToBinaryParts(fileBuffer: ArrayBuffer): FigBinaryResult {
)
}
const entryCount = Object.keys(unzipped).length
if (entryCount > MAX_ZIP_ENTRIES) {
throw new Error(`ZIP archive contains too many entries (${entryCount})`)
}
// Extract image files stored under images/ directory (keyed by hex hash)
let totalSize = 0
for (const [path, bytes] of Object.entries(unzipped)) {
totalSize += bytes.length
if (totalSize > MAX_UNZIPPED_SIZE) {
throw new Error('Decompressed file exceeds maximum size limit (100MB)')
}
if (path.startsWith('images/') && bytes.length > 0) {
if (bytes.length > MAX_IMAGE_SIZE) {
throw new Error('Image exceeds maximum size limit (50MB)')
}
const key = path.slice(7) // Remove "images/" prefix
imageFiles.set(key, bytes)
}

View file

@ -3,6 +3,7 @@ import type {
AIProviderType,
AIProviderConfig,
MCPCliIntegration,
MCPTransportMode,
GroupedModel,
} from '@/types/agent-settings'
@ -11,6 +12,8 @@ const STORAGE_KEY = 'openpencil-agent-settings'
interface PersistedState {
providers: Record<AIProviderType, AIProviderConfig>
mcpIntegrations: MCPCliIntegration[]
mcpTransportMode: MCPTransportMode
mcpHttpPort: number
}
interface AgentSettingsState extends PersistedState {
@ -24,6 +27,7 @@ interface AgentSettingsState extends PersistedState {
) => void
disconnectProvider: (provider: AIProviderType) => void
toggleMCPIntegration: (tool: string) => void
setMCPTransport: (mode: MCPTransportMode, port?: number) => void
setDialogOpen: (open: boolean) => void
persist: () => void
hydrate: () => void
@ -64,6 +68,8 @@ const DEFAULT_MCP_INTEGRATIONS: MCPCliIntegration[] = [
export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
providers: { ...DEFAULT_PROVIDERS },
mcpIntegrations: [...DEFAULT_MCP_INTEGRATIONS],
mcpTransportMode: 'stdio',
mcpHttpPort: 3100,
dialogOpen: false,
isHydrated: false,
@ -97,12 +103,21 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
),
})),
setMCPTransport: (mode, port) =>
set({
mcpTransportMode: mode,
...(port != null && { mcpHttpPort: port }),
}),
setDialogOpen: (dialogOpen) => set({ dialogOpen }),
persist: () => {
try {
const { providers, mcpIntegrations } = get()
localStorage.setItem(STORAGE_KEY, JSON.stringify({ providers, mcpIntegrations }))
const { providers, mcpIntegrations, mcpTransportMode, mcpHttpPort } = get()
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ providers, mcpIntegrations, mcpTransportMode, mcpHttpPort }),
)
} catch {
// ignore
}
@ -125,7 +140,15 @@ export const useAgentSettingsStore = create<AgentSettingsState>((set, get) => ({
}
set({ providers: merged })
}
if (data.mcpIntegrations) set({ mcpIntegrations: data.mcpIntegrations })
if (data.mcpIntegrations) {
const mergedMcp = DEFAULT_MCP_INTEGRATIONS.map((def) => {
const saved = data.mcpIntegrations!.find((m) => m.tool === def.tool)
return saved ? { ...def, ...saved } : def
})
set({ mcpIntegrations: mergedMcp })
}
if (data.mcpTransportMode) set({ mcpTransportMode: data.mcpTransportMode })
if (data.mcpHttpPort) set({ mcpHttpPort: data.mcpHttpPort })
} catch {
// ignore
} finally {

View file

@ -85,6 +85,7 @@ interface DocumentStoreState {
reorderPage: (pageId: string, direction: 'left' | 'right') => void
duplicatePage: (pageId: string) => string | null
applyExternalDocument: (doc: PenDocument) => void
applyHistoryState: (doc: PenDocument) => void
loadDocument: (
doc: PenDocument,
@ -682,6 +683,19 @@ export const useDocumentStore = create<DocumentStoreState>(
// --- Page management (extracted to document-store-pages.ts) ---
...createPageActions(set, get),
applyExternalDocument: (doc) => {
// Push current state to history so MCP changes are undoable
useHistoryStore.getState().pushState(get().document)
const migrated = migrateToPages(doc)
set({ document: migrated, isDirty: true })
// Preserve activePageId if page still exists
const activePageId = useCanvasStore.getState().activePageId
const pageExists = migrated.pages?.some((p) => p.id === activePageId)
if (!pageExists && migrated.pages && migrated.pages.length > 0) {
useCanvasStore.getState().setActivePageId(migrated.pages[0].id)
}
},
applyHistoryState: (doc) =>
set({ document: doc, isDirty: true }),

View file

@ -16,6 +16,8 @@ export type MCPCliTool =
| 'opencode-cli'
| 'kiro-cli'
export type MCPTransportMode = 'stdio' | 'http' | 'both'
export interface MCPCliIntegration {
tool: MCPCliTool
displayName: string

View file

@ -0,0 +1,129 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest'
import { parseSvgToNodes } from '@/utils/svg-parser'
// ---------------------------------------------------------------------------
// 1. SVG parser SKIP_TAGS — dangerous elements are stripped
// ---------------------------------------------------------------------------
describe('SVG parser SKIP_TAGS', () => {
/** Recursively collect all node names in the tree */
function collectNames(nodes: { name?: string; children?: any[] }[]): string[] {
const names: string[] = []
for (const n of nodes) {
if (n.name) names.push(n.name)
if (n.children) names.push(...collectNames(n.children))
}
return names
}
/** Recursively collect all node types */
function collectTypes(nodes: { type?: string; children?: any[] }[]): string[] {
const types: string[] = []
for (const n of nodes) {
if (n.type) types.push(n.type)
if (n.children) types.push(...collectTypes(n.children))
}
return types
}
it('strips <script> tags from parsed SVG', () => {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="0" y="0" width="50" height="50" fill="red"/>
<script>alert("xss")</script>
</svg>`
const nodes = parseSvgToNodes(svg)
const names = collectNames(nodes).map(n => n.toLowerCase())
expect(names).not.toContain('script')
// The rect should still be present
expect(nodes.length).toBeGreaterThan(0)
})
it('strips <foreignObject> tags from parsed SVG', () => {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="0" y="0" width="50" height="50" fill="blue"/>
<foreignObject width="100" height="100">
<div xmlns="http://www.w3.org/1999/xhtml">HTML content</div>
</foreignObject>
</svg>`
const nodes = parseSvgToNodes(svg)
const names = collectNames(nodes).map(n => n.toLowerCase())
expect(names).not.toContain('foreignobject')
expect(names).not.toContain('foreignObject')
})
it('strips <animate>, <animateMotion>, and <set> tags', () => {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="0" y="0" width="50" height="50" fill="green">
<animate attributeName="x" from="0" to="100" dur="1s"/>
<animateMotion path="M0,0 L100,100" dur="2s"/>
<set attributeName="fill" to="red"/>
</rect>
</svg>`
const nodes = parseSvgToNodes(svg)
const types = collectTypes(nodes)
// Only rectangle-type nodes should remain, no animation nodes
for (const t of types) {
expect(['rectangle', 'frame', 'path', 'ellipse', 'line', 'text', 'group']).toContain(t)
}
})
it('preserves valid shape elements while stripping dangerous ones', () => {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<rect x="0" y="0" width="50" height="50" fill="red"/>
<circle cx="100" cy="100" r="30" fill="blue"/>
<script>document.cookie</script>
<foreignObject><body xmlns="http://www.w3.org/1999/xhtml"><iframe/></body></foreignObject>
</svg>`
const nodes = parseSvgToNodes(svg)
// Should have a wrapping frame with 2 children (rect + circle)
expect(nodes).toHaveLength(1)
expect(nodes[0].type).toBe('frame')
expect('children' in nodes[0] && nodes[0].children).toHaveLength(2)
})
})
// ---------------------------------------------------------------------------
// 2. SVG parser getAttr — ReDoS-safe regex escaping
// ---------------------------------------------------------------------------
describe('SVG parser getAttr ReDoS safety', () => {
it('parses SVG with regex-special chars in style attribute without hanging', () => {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="0" y="0" width="50" height="50" style="(x+x+)+y: red; fill: blue"/>
</svg>`
const start = performance.now()
const nodes = parseSvgToNodes(svg)
const elapsed = performance.now() - start
// Must complete in under 100ms (would hang without escaping)
expect(elapsed).toBeLessThan(100)
expect(nodes.length).toBeGreaterThan(0)
})
it('handles style with brackets and special characters safely', () => {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="0" y="0" width="50" height="50" style="fill: green; [weird]: val; opacity: 0.5"/>
</svg>`
const start = performance.now()
const nodes = parseSvgToNodes(svg)
const elapsed = performance.now() - start
expect(elapsed).toBeLessThan(100)
expect(nodes.length).toBeGreaterThan(0)
})
})
// NOTE: Figma parser decompression limits (MAX_UNZIPPED_SIZE = 100MB,
// MAX_IMAGE_SIZE = 50MB) are internal constants in fig-parser.ts. They guard
// against zip bombs during .fig file decompression. Testing requires crafting
// valid .fig binaries with oversized payloads, and the WASM-based parser
// hangs vitest's jsdom environment, so these are verified via code review.

View file

@ -65,6 +65,7 @@ const SKIP_TAGS = new Set([
'defs', 'style', 'title', 'desc', 'metadata',
'clippath', 'mask', 'filter', 'lineargradient', 'radialgradient',
'symbol', 'marker', 'pattern',
'script', 'foreignobject', 'animate', 'animatemotion', 'set',
])
function parseChildren(
@ -234,7 +235,8 @@ function getAttr(el: Element, name: string): string | null {
// Check inline style first (higher priority)
const style = el.getAttribute('style')
if (style) {
const m = style.match(new RegExp(`${name}\\s*:\\s*([^;]+)`))
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const m = style.match(new RegExp(`${escaped}\\s*:\\s*([^;]+)`))
if (m) return m[1].trim()
}
return el.getAttribute(name)