diff --git a/sass/editor/_editor-mcp.scss b/sass/editor/_editor-mcp.scss index a3523ad76..e2f85497e 100644 --- a/sass/editor/_editor-mcp.scss +++ b/sass/editor/_editor-mcp.scss @@ -10,6 +10,35 @@ height: 22px; } +// project manager header on the blank /editor page +.header-utils > .pcui-button.mcp-header { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + min-width: 0; + margin: 0; + padding: 0; + background: none; + border: none; + box-shadow: none; + cursor: pointer; + + .mcp-icon { + width: 18px; + height: 18px; + } + + &.active { + color: #4ea86a; + } +} + +.mcp-popover.mcp-popover-below { + z-index: 10002; // above the project manager overlay (10000) +} + .mcp-popover { position: fixed; // centered on the toolbar button in toolbar.ts width: 220px; diff --git a/src/editor/blank.ts b/src/editor/blank.ts index 6c3af8001..9aadbcdf6 100644 --- a/src/editor/blank.ts +++ b/src/editor/blank.ts @@ -15,6 +15,9 @@ import './editor'; import './layout/layout'; import './messenger/messenger'; import './drop/drop'; + +// storage (the MCP button saves its connected flag and port here) +import './storage/localstorage'; import './search/search'; // users @@ -46,3 +49,6 @@ import './pickers/picker-scene'; // team management picker import './pickers/picker-team-management'; + +// mcp (project tools only; the scene driver needs an open project) +import './mcp/blank'; diff --git a/src/editor/driver/index.ts b/src/editor/driver/index.ts index 59b6a04b0..30babeb65 100644 --- a/src/editor/driver/index.ts +++ b/src/editor/driver/index.ts @@ -4,6 +4,7 @@ import './animation'; import './animstategraph'; import './scene'; import './project'; +import './projects'; import './viewport'; import './processing'; import './build'; diff --git a/src/editor/driver/project-fields.ts b/src/editor/driver/project-fields.ts new file mode 100644 index 000000000..71ac94e43 --- /dev/null +++ b/src/editor/driver/project-fields.ts @@ -0,0 +1,47 @@ +const TYPES: Record = { name: 'string', description: 'string', private: 'boolean', owner: 'string' }; + +const ALLOWED = { + create: ['name', 'description', 'private', 'owner'], + update: ['name', 'description', 'private'] +}; + +const valid = (key: string, value: unknown) => typeof value === TYPES[key]; + +/** + * Picks the project fields an agent may set over MCP. Anything else (forks, settings, images, + * tags, ...) is rejected by name rather than silently dropped. + * + * @param input - The tool input. + * @param mode - Whether the fields are for a new project or an update. + * @returns The accepted fields, or an error. + */ +const pickProjectFields = (input: Record | null | undefined, mode: 'create' | 'update') => { + const allowed = ALLOWED[mode]; + const entries = Object.entries(input ?? {}).filter(([, value]) => value !== undefined); + + const rejected = entries.filter(([key]) => !allowed.includes(key)).map(([key]) => key); + if (rejected.length) { + return { error: `Unsupported project field(s): ${rejected.join(', ')}. Allowed: ${allowed.join(', ')}.` }; + } + + const invalid = entries.filter(([key, value]) => !valid(key, value)).map(([key]) => key); + if (invalid.length) { + return { + error: `Invalid project field(s): ${invalid.map((key) => `${key} (expected ${TYPES[key]})`).join(', ')}.` + }; + } + + const fields: Record = Object.fromEntries(entries); + if (typeof fields.name === 'string' && !fields.name.trim()) { + return { error: 'Project name cannot be empty.' }; + } + if (mode === 'create' && fields.name === undefined) { + return { error: 'Project name is required.' }; + } + if (mode === 'update' && !entries.length) { + return { error: `Provide at least one of: ${allowed.join(', ')}.` }; + } + return { fields }; +}; + +export { pickProjectFields }; diff --git a/src/editor/driver/projects.ts b/src/editor/driver/projects.ts new file mode 100644 index 000000000..74b9bfb0a --- /dev/null +++ b/src/editor/driver/projects.ts @@ -0,0 +1,133 @@ +import { config } from '@/editor/config'; +import { mcp } from '@/editor/mcp/connection'; +import { message } from '@/editor/mcp/request'; + +import { driver } from './driver'; +import { pickProjectFields } from './project-fields'; + +const api = editor.api.globals; + +// a new project can't be opened until the backend has set it up (messenger project.create) +const CREATE_READY_MS = 30_000; + +const NO_PROJECT = 'No project is open. Pass a project id, or call list_projects and open_project first.'; + +const currentId = () => config.project?.id ?? null; + +const summary = (p: any) => ({ + id: p.id, + name: p.name, + description: p.description ?? '', + private: !!p.private, + owner: p.owner, + ownerId: p.owner_id, + modified: p.modified +}); + +const getProject = (projectId: number) => + api.rest.projects.projectGet({ projectId } as any).promisify() as Promise; + +driver.method('project:list', async (ownerId?: number) => { + try { + const res: any = await api.rest.users.userProjects(ownerId ?? config.self.id, 'profile').promisify(); + const result = (res?.result ?? []).map(summary); + return { data: result, meta: { total: result.length, count: result.length } }; + } catch (e) { + return { error: message(e) }; + } +}); + +driver.method('project:get', async (projectId?: number) => { + const id = projectId ?? currentId(); + if (id === null) { + return { error: NO_PROJECT }; + } + try { + const project = await getProject(id); + return { data: { ...summary(project), open: project.id === currentId() } }; + } catch (e) { + return { error: message(e) }; + } +}); + +driver.method('project:create', async (input: Record) => { + const { fields, error } = pickProjectFields(input, 'create'); + if (error) { + return { error }; + } + + // listen before creating so a fast setup event isn't missed + const ready = new Set(); + let onReady: ((id: number) => void) | null = null; + const evt = editor.on('messenger:project.create', (msg: any) => { + ready.add(msg.project_id); + onReady?.(msg.project_id); + }); + + try { + // match the New Project dialog: owned by username, using the current script system + const project: any = await api.rest.projects + .projectCreate({ + owner: config.self.username, + ...fields, + settings: { useLegacyScripts: false, vr: false } + } as any) + .promisify(); + + const initialized = + ready.has(project.id) || + (await new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), CREATE_READY_MS); + onReady = (id) => { + if (id === project.id) { + clearTimeout(timer); + resolve(true); + } + }; + })); + return { data: summary(project), meta: initialized ? {} : { initializing: true } }; + } catch (e) { + return { error: message(e) }; + } finally { + evt.unbind(); + } +}); + +driver.method('project:update', async (projectId: number, input: Record) => { + const { fields, error } = pickProjectFields(input, 'update'); + if (error) { + return { error }; + } + try { + const project: any = await api.rest.projects.projectUpdate({ projectId, ...fields } as any).promisify(); + editor.call('picker:project:cms:refreshProjects'); + return { data: summary(project) }; + } catch (e) { + return { error: message(e) }; + } +}); + +driver.method('project:open', async (projectId: number) => { + if (projectId === currentId()) { + return { data: { id: projectId, reloading: false } }; + } + try { + await getProject(projectId); + } catch (e) { + return { error: message(e) }; + } + + // a local build must open the local page, like the Launch button + const search = new URLSearchParams(window.location.search); + const params = new URLSearchParams(); + for (const flag of ['use_local_frontend', 'use_local_engine']) { + if (search.has(flag)) { + params.set(flag, search.get(flag) ?? ''); + } + } + const query = params.size ? `?${params.toString()}` : ''; + + // reply first: navigating drops the socket, and the editor reconnects on load + mcp.flushed().then(() => window.location.assign(`/editor/project/${projectId}${query}`)); + return { data: { id: projectId, reloading: true } }; +}); diff --git a/src/editor/mcp/blank.ts b/src/editor/mcp/blank.ts new file mode 100644 index 000000000..ed134237d --- /dev/null +++ b/src/editor/mcp/blank.ts @@ -0,0 +1,17 @@ +import { driver } from '@/editor/driver/driver'; +import '@/editor/driver/projects'; + +import { mcp } from './connection'; +import { createMcpButton } from './popover'; + +mcp.method('ping', () => ({ data: 'pong' })); + +for (const [name, fn] of driver.methods) { + mcp.method(name, fn); +} + +editor.once('load', () => { + const button = createMcpButton('below'); + button.class.add('mcp-header'); + editor.call('picker:project:cms:addHeaderButton', button); +}); diff --git a/src/editor/mcp/connection.ts b/src/editor/mcp/connection.ts index ec9951ef9..0fa6e4a7f 100644 --- a/src/editor/mcp/connection.ts +++ b/src/editor/mcp/connection.ts @@ -1,5 +1,7 @@ import { Events } from '@playcanvas/observer'; +import { config } from '@/editor/config'; + import { handleRequest } from './request'; const DEFAULT_PORT = 52000; @@ -9,6 +11,9 @@ const HOST = '127.0.0.1'; const RETRY_TIMEOUT = 1000; const PROTOCOL_VERSION = 1; +// how long flushed() waits for queued frames to leave the socket +const FLUSH_TIMEOUT = 1000; + // a blocked socket looks like "nothing listening"; probe the permission on a failed open. // the name changed across the Chrome 145 loopback/LAN split, so try each, first match wins. const LOCAL_ACCESS_PERMISSIONS = ['loopback-network', 'local-network', 'local-network-access']; @@ -138,7 +143,10 @@ class MCPConnection extends Events { JSON.stringify({ register: this._role, protocolVersion: PROTOCOL_VERSION, - methods: Array.from(this._methods.keys()).sort() + methods: Array.from(this._methods.keys()).sort(), + + // null on the blank /editor page, so the server can say no project is open + project: config?.project?.id ?? null }) ); this._setStatus('connected'); @@ -193,6 +201,27 @@ class MCPConnection extends Events { log('Disconnected'); } + /** + * Resolve once queued frames have left the socket, so a reply is not lost when the page + * navigates right after a handler returns. Waits a macrotask first because the reply is + * sent in the microtask after the handler resolves. + * + * @returns Resolves when the send buffer is empty, the socket is gone, or after a timeout. + */ + flushed() { + const start = Date.now(); + return new Promise((resolve) => { + const check = () => { + if (!this._ws || this._ws.bufferedAmount === 0 || Date.now() - start >= FLUSH_TIMEOUT) { + resolve(); + return; + } + setTimeout(check, 10); + }; + setTimeout(check); + }); + } + /** * Send a raw frame outside the request/response flow, for relay announcements. * diff --git a/src/editor/mcp/popover.ts b/src/editor/mcp/popover.ts new file mode 100644 index 000000000..325d5ffc6 --- /dev/null +++ b/src/editor/mcp/popover.ts @@ -0,0 +1,144 @@ +import { Button, Container, Label, Panel, TextInput } from '@playcanvas/pcui'; + +import { TooltipHandle } from '@/common/tooltips'; + +import { DEFAULT_PORT } from './connection'; + +// MCP mark from Hugeicons (mcp-server-solid-sharp), fills set to currentColor. +const MCP_ICON = + ''; + +// popover hint: why the socket can't connect +const BLOCKED_HINTS: Record = { + denied: 'The browser is blocking this page — allow "Apps on device" for this site, then reconnect', + prompt: 'Allow "Apps on device" for this site if the browser asks, or check the server is running' +}; + +/** + * Create the MCP button and its connect popover, and reconnect if the page was connected + * before it reloaded. Callers place the returned button. + * + * @param placement - Open the popover to the right of the button (toolbar) or below it (header). + * @returns The MCP button. + */ +const createMcpButton = (placement: 'right' | 'below') => { + const root = editor.call('layout.root'); + + const button = new Button({ class: ['pc-icon', 'mcp'] }); + button.dom.appendChild(new DOMParser().parseFromString(MCP_ICON, 'image/svg+xml').documentElement); + + const tooltip = TooltipHandle.attach({ + target: button.dom, + text: 'MCP Server', + align: placement === 'below' ? 'top' : 'left', + root + }); + + // connect popover + const popover = new Panel({ + class: ['mcp-popover', `mcp-popover-${placement}`], + headerText: 'MCP SERVER', + collapsible: true, + hidden: true + }); + + const statusValue = new Label({ class: 'mcp-status-value', text: 'Disconnected' }); + const statusRow = new Container({ class: 'mcp-row' }); + statusRow.append(new Label({ class: 'mcp-row-label', text: 'Status' })); + statusRow.append(statusValue); + + const portField = new TextInput({ class: 'mcp-field', value: String(DEFAULT_PORT) }); + const portRow = new Container({ class: 'mcp-row' }); + portRow.append(new Label({ class: 'mcp-row-label', text: 'Port' })); + portRow.append(portField); + + const toggle = new Button({ class: 'mcp-toggle', text: 'CONNECT' }); + + const hint = new Label({ class: 'mcp-hint', hidden: true }); + + popover.append(statusRow); + popover.append(portRow); + popover.append(hint); + popover.append(toggle); + root.append(popover); + + const updateHint = () => { + const blocked = BLOCKED_HINTS[editor.call('mcp:blocked')]; + hint.text = blocked ?? ''; + hint.hidden = !blocked; + }; + + const render = (state: string) => { + statusValue.text = state.charAt(0).toUpperCase() + state.slice(1); + statusValue.class.remove('mcp-connecting', 'mcp-connected'); + if (state === 'connecting' || state === 'connected') { + statusValue.class.add(`mcp-${state}`); + } + button.class[state === 'connected' ? 'add' : 'remove']('active'); + portField.enabled = state === 'disconnected'; + toggle.text = state === 'connecting' ? 'CANCEL' : state === 'connected' ? 'DISCONNECT' : 'CONNECT'; + updateHint(); + }; + render(editor.call('mcp:status') || 'disconnected'); + editor.on('mcp:status', render); + editor.on('mcp:blocked', updateHint); + + toggle.on('click', () => { + if (editor.call('mcp:status') === 'disconnected') { + const port = parseInt(portField.value, 10) || DEFAULT_PORT; + editor.call('localStorage:set', 'editor:mcp:connected', true); + editor.call('localStorage:set', 'editor:mcp:port', port); + editor.call('mcp:connect', port); + } else { + editor.call('localStorage:set', 'editor:mcp:connected', false); + editor.call('mcp:disconnect'); + } + }); + + // toggle popover (centered on the button), close on outside click + const onOutside = (evt: MouseEvent) => { + const target = evt.target as Node; + if (popover.dom.contains(target) || button.dom.contains(target)) { + return; + } + popover.hidden = true; + }; + button.on('click', () => { + if (!popover.hidden) { + popover.hidden = true; + return; + } + popover.hidden = false; + const rect = button.dom.getBoundingClientRect(); + if (placement === 'below') { + popover.dom.style.left = `${Math.max(4, rect.right - popover.dom.offsetWidth)}px`; + popover.dom.style.top = `${rect.bottom + 4}px`; + } else { + const top = rect.top + rect.height / 2 - popover.dom.offsetHeight / 2; + popover.dom.style.left = `${rect.right + 4}px`; + popover.dom.style.top = `${Math.max(4, top)}px`; + } + }); + popover.on('show', () => { + // hide the hover tooltip while open so its arrow doesn't poke through + tooltip.detach(); + tooltip.hidden = true; + updateHint(); + window.addEventListener('mousedown', onOutside); + }); + popover.on('hide', () => { + tooltip.attach(button.dom); + window.removeEventListener('mousedown', onOutside); + }); + + // auto-reconnect if connected before a reload (branch switch, checkpoint restore, project open) + if (editor.call('localStorage:get', 'editor:mcp:connected')) { + const port = editor.call('localStorage:get', 'editor:mcp:port') || DEFAULT_PORT; + portField.value = String(port); + editor.call('mcp:connect', port); + } + + return button; +}; + +export { createMcpButton }; diff --git a/src/editor/mcp/toolbar.ts b/src/editor/mcp/toolbar.ts index d9c19946d..c06c38d77 100644 --- a/src/editor/mcp/toolbar.ts +++ b/src/editor/mcp/toolbar.ts @@ -1,127 +1,8 @@ -import { Button, Container, Label, Panel, TextInput } from '@playcanvas/pcui'; - -import { TooltipHandle } from '@/common/tooltips'; - -import { DEFAULT_PORT } from './connection'; - -// MCP mark from Hugeicons (mcp-server-solid-sharp), fills set to currentColor. -const MCP_ICON = - ''; - -// popover hint: why the socket can't connect -const BLOCKED_HINTS: Record = { - denied: 'The browser is blocking this page — allow "Apps on device" for this site, then reconnect', - prompt: 'Allow "Apps on device" for this site if the browser asks, or check the server is running' -}; +import { createMcpButton } from './popover'; editor.once('load', () => { - const root = editor.call('layout.root'); - const toolbar = editor.call('layout.toolbar'); - // toolbar button, below publish (last in the top group) - const button = new Button({ class: ['pc-icon', 'mcp'] }); - button.dom.appendChild(new DOMParser().parseFromString(MCP_ICON, 'image/svg+xml').documentElement); - toolbar.append(button); + const button = createMcpButton('right'); + editor.call('layout.toolbar').append(button); editor.call('toolbar:register', { id: 'mcp', label: 'MCP Server', group: 'main', button }); - - const tooltip = TooltipHandle.attach({ - target: button.dom, - text: 'MCP Server', - align: 'left', - root - }); - - // connect popover - const popover = new Panel({ class: 'mcp-popover', headerText: 'MCP SERVER', collapsible: true, hidden: true }); - - const statusValue = new Label({ class: 'mcp-status-value', text: 'Disconnected' }); - const statusRow = new Container({ class: 'mcp-row' }); - statusRow.append(new Label({ class: 'mcp-row-label', text: 'Status' })); - statusRow.append(statusValue); - - const portField = new TextInput({ class: 'mcp-field', value: String(DEFAULT_PORT) }); - const portRow = new Container({ class: 'mcp-row' }); - portRow.append(new Label({ class: 'mcp-row-label', text: 'Port' })); - portRow.append(portField); - - const toggle = new Button({ class: 'mcp-toggle', text: 'CONNECT' }); - - const hint = new Label({ class: 'mcp-hint', hidden: true }); - - popover.append(statusRow); - popover.append(portRow); - popover.append(hint); - popover.append(toggle); - root.append(popover); - - const updateHint = () => { - const blocked = BLOCKED_HINTS[editor.call('mcp:blocked')]; - hint.text = blocked ?? ''; - hint.hidden = !blocked; - }; - - const render = (state: string) => { - statusValue.text = state.charAt(0).toUpperCase() + state.slice(1); - statusValue.class.remove('mcp-connecting', 'mcp-connected'); - if (state === 'connecting' || state === 'connected') { - statusValue.class.add(`mcp-${state}`); - } - button.class[state === 'connected' ? 'add' : 'remove']('active'); - portField.enabled = state === 'disconnected'; - toggle.text = state === 'connecting' ? 'CANCEL' : state === 'connected' ? 'DISCONNECT' : 'CONNECT'; - updateHint(); - }; - render(editor.call('mcp:status') || 'disconnected'); - editor.on('mcp:status', render); - editor.on('mcp:blocked', updateHint); - - toggle.on('click', () => { - if (editor.call('mcp:status') === 'disconnected') { - const port = parseInt(portField.value, 10) || DEFAULT_PORT; - editor.call('localStorage:set', 'editor:mcp:connected', true); - editor.call('localStorage:set', 'editor:mcp:port', port); - editor.call('mcp:connect', port); - } else { - editor.call('localStorage:set', 'editor:mcp:connected', false); - editor.call('mcp:disconnect'); - } - }); - - // toggle popover (centered on the button), close on outside click - const onOutside = (evt: MouseEvent) => { - const target = evt.target as Node; - if (popover.dom.contains(target) || button.dom.contains(target)) { - return; - } - popover.hidden = true; - }; - button.on('click', () => { - if (!popover.hidden) { - popover.hidden = true; - return; - } - popover.hidden = false; - const rect = button.dom.getBoundingClientRect(); - const top = rect.top + rect.height / 2 - popover.dom.offsetHeight / 2; - popover.dom.style.left = `${rect.right + 4}px`; - popover.dom.style.top = `${Math.max(4, top)}px`; - }); - popover.on('show', () => { - // hide the hover tooltip while open so its arrow doesn't poke through - tooltip.detach(); - tooltip.hidden = true; - updateHint(); - window.addEventListener('mousedown', onOutside); - }); - popover.on('hide', () => { - tooltip.attach(button.dom); - window.removeEventListener('mousedown', onOutside); - }); - - // auto-reconnect if connected before a reload (branch switch / checkpoint restore drop the socket) - if (editor.call('localStorage:get', 'editor:mcp:connected')) { - const port = editor.call('localStorage:get', 'editor:mcp:port') || DEFAULT_PORT; - portField.value = String(port); - editor.call('mcp:connect', port); - } }); diff --git a/src/editor/pickers/project-management/picker-cms.ts b/src/editor/pickers/project-management/picker-cms.ts index 1448ffe0a..074145d39 100644 --- a/src/editor/pickers/project-management/picker-cms.ts +++ b/src/editor/pickers/project-management/picker-cms.ts @@ -533,6 +533,11 @@ editor.once('load', () => { window.open(`${config.url.home}/account`, '_blank'); }); + // lets the blank page add its MCP button before the user icon + editor.method('picker:project:cms:addHeaderButton', (button: Element) => { + headerUtils.appendBefore(button, userIcon); + }); + // left panel const leftPanel = new Element({ class: 'cms-left-panel'