Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions sass/editor/_editor-mcp.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/editor/blank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';
1 change: 1 addition & 0 deletions src/editor/driver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import './animation';
import './animstategraph';
import './scene';
import './project';
import './projects';
import './viewport';
import './processing';
import './build';
Expand Down
47 changes: 47 additions & 0 deletions src/editor/driver/project-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const TYPES: Record<string, string> = { 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<string, unknown> | 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<string, unknown> = 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 };
133 changes: 133 additions & 0 deletions src/editor/driver/projects.ts
Original file line number Diff line number Diff line change
@@ -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<any>;

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<string, unknown>) => {
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<number>();
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<boolean>((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<string, unknown>) => {
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 } };
});
17 changes: 17 additions & 0 deletions src/editor/mcp/blank.ts
Original file line number Diff line number Diff line change
@@ -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);
});
31 changes: 30 additions & 1 deletion src/editor/mcp/connection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Events } from '@playcanvas/observer';

import { config } from '@/editor/config';

import { handleRequest } from './request';

const DEFAULT_PORT = 52000;
Expand All @@ -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'];
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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<void>((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.
*
Expand Down
Loading
Loading