mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
V0.1.2 (#19)
* 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:
parent
3eca24ac5a
commit
ced993c388
37 changed files with 1771 additions and 453 deletions
|
|
@ -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):
|
||||
|
|
|
|||
54
README.md
54
README.md
|
|
@ -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
|
||||
|
||||
| | |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
135
server/__tests__/security.test.ts
Normal file
135
server/__tests__/security.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
15
server/api/mcp/document.get.ts
Normal file
15
server/api/mcp/document.get.ts
Normal 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 }
|
||||
})
|
||||
29
server/api/mcp/document.post.ts
Normal file
29
server/api/mcp/document.post.ts
Normal 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 }
|
||||
})
|
||||
44
server/api/mcp/events.get.ts
Normal file
44
server/api/mcp/events.get.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'],
|
||||
})
|
||||
|
||||
|
|
|
|||
48
server/utils/mcp-sync-state.ts
Normal file
48
server/utils/mcp-sync-state.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
94
src/hooks/use-mcp-sync.ts
Normal 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()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
166
src/mcp/__tests__/security.test.ts
Normal file
166
src/mcp/__tests__/security.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
80
src/mcp/tools/design-prompt.ts
Normal file
80
src/mcp/tools/design-prompt.ts
Normal 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.`
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
22
src/mcp/utils/sanitize.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export type MCPCliTool =
|
|||
| 'opencode-cli'
|
||||
| 'kiro-cli'
|
||||
|
||||
export type MCPTransportMode = 'stdio' | 'http' | 'both'
|
||||
|
||||
export interface MCPCliIntegration {
|
||||
tool: MCPCliTool
|
||||
displayName: string
|
||||
|
|
|
|||
129
src/utils/__tests__/security.test.ts
Normal file
129
src/utils/__tests__/security.test.ts
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue