Skip to main content
Glama

PlayCanvas Editor MCP Server

Official
by playcanvas
main.js22.3 kB
(() => { if (!window.editor) { throw new Error('PlayCanvas Editor not found'); } /** * @param {string} msg - The message to log. */ const log = (msg) => { console.log(`%c[WSC] ${msg}`, 'color:#f60'); }; /** * @param {string} msg - The message to log. */ const error = (msg) => { console.error(`%c[WSC] ${msg}`, 'color:#f60'); }; /** * PlayCanvas Editor API observer package. */ const observer = window.editor.observer; /** * PlayCanvas Editor API wrapper. */ const api = window.editor.api.globals; /** * PlayCanvas REST API wrapper. * * @param {'GET' | 'POST' | 'PUT' | 'DELETE'} method - The HTTP method to use. * @param {string} path - The path to the API endpoint. * @param {FormData | Object} data - The data to send. * @param {boolean} auth - Whether to use authentication. * @returns {Promise<Object>} The response data. */ const rest = (method, path, data, auth = false) => { const init = { method, headers: { Authorization: auth ? `Bearer ${api.accessToken}` : undefined } }; if (data instanceof FormData) { init.body = data; } else { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(data); } return fetch(`/api/${path}`, init).then((res) => res.json()); }; /** * @param {Object} obj - The object to iterate. * @param {Function} callback - The callback to call for each key-value pair. * @param {string} currentPath - The current path of the object. */ const iterateObject = (obj, callback, currentPath = '') => { Object.entries(obj).forEach(([key, value]) => { const path = currentPath ? `${currentPath}.${key}` : key; if (value && typeof value === 'object' && !Array.isArray(value)) { iterateObject(value, callback, path); } else { callback(path, value); } }); }; class WSC extends observer.Events { static STATUS_CONNECTING = 'connecting'; static STATUS_CONNECTED = 'connected'; static STATUS_DISCONNECTED = 'disconnected'; /** * @type {WebSocket} * @private */ _ws; /** * @type {Map<string, Function} * @private */ _methods = new Map(); /** * @type {ReturnType<typeof setTimeout> | null} * @private */ _connectTimeout = null; /** * @type {WSC.STATUS_CONNECTING | WSC.STATUS_CONNECTED | WSC.STATUS_DISCONNECTED} * @private */ _status = WSC.STATUS_DISCONNECTED; /** * @type {WSC.STATUS_CONNECTING | WSC.STATUS_CONNECTED | WSC.STATUS_DISCONNECTED} */ get status() { return this._status; } /** * @param {string} address - The address to connect to. * @param {Function} resolve - The function to call when the connection is established. * @param {number} [retryTimeout] - The timeout to retry the connection. */ connect(address, retryTimeout = 1000) { this._status = WSC.STATUS_CONNECTING; this.emit('status', this._status); log(`Connecting to ${address}`); if (this._connectTimeout) { clearTimeout(this._connectTimeout); } this._connect(address, retryTimeout, () => { this._ws.onclose = (evt) => { if (evt.reason === 'FORCE') { return; } this._status = WSC.STATUS_DISCONNECTED; this.emit('status', this._status); log('Disconnected'); }; this._status = WSC.STATUS_CONNECTED; this.emit('status', this._status); log('Connected'); }); } /** * @param {string} address - The address to connect to. * @param {number} retryTimeout - The timeout to retry the connection. * @param {Function} resolve - The function to call when the connection is established * @private */ _connect(address, retryTimeout, resolve) { this._ws = new WebSocket(address); this._ws.onopen = () => { resolve(); }; this._ws.onmessage = async (event) => { try { const { id, name, args } = JSON.parse(event.data); const res = await this.call(name, ...args); this._ws.send(JSON.stringify({ id, res })); } catch (e) { error(e); } }; this._ws.onclose = () => { this._connectTimeout = setTimeout(() => { this._connectTimeout = null; this._connect(address, retryTimeout, resolve); }, retryTimeout); }; } disconnect() { if (this._connectTimeout) { clearTimeout(this._connectTimeout); } if (this._ws) { this._ws.close(1000, 'FORCE'); this._ws = null; } this._status = WSC.STATUS_DISCONNECTED; this.emit('status', this._status); log('Disconnected'); } /** * @param {string} name - The name of the method to add. * @param {(...args: any[]) => { data?: any, error?: string }} fn - The function to call when the method is called. */ method(name, fn) { if (this._methods.get(name)) { error(`Method already exists: ${name}`); return; } this._methods.set(name, fn); } /** * @param {string} name - The name of the method to call. * @param {...*} args - The arguments to pass to the method. * @returns {{ data?: any, error?: string }} The response data. */ call(name, ...args) { return this._methods.get(name)?.(...args); } } class Messenger extends observer.Events { constructor() { super(); window.addEventListener('message', (event) => { if (event.data?.ctx !== 'isolated') { return; } const { name, args } = event.data; this.emit(name, ...args); }); } send(name, ...args) { window.postMessage({ name, args, ctx: 'main' }); } } const wsc = new WSC(); const messenger = new Messenger('main'); // sync messenger.on('sync', () => { messenger.send('status', wsc.status); }); messenger.on('connect', ({ port = 52000 }) => { wsc.connect(`ws://localhost:${port}`); }); messenger.on('disconnect', () => { wsc.disconnect(); }); wsc.on('status', (status) => { messenger.send('status', status); }); // general wsc.method('ping', () => 'pong'); // entities wsc.method('entities:create', (entityDataArray) => { const entities = []; entityDataArray.forEach((entityData) => { if (Object.hasOwn(entityData, 'parent')) { const parent = api.entities.get(entityData.parent); if (!parent) { return { error: `Parent entity not found: ${entityData.parent}` }; } entityData.entity.parent = parent; } const entity = api.entities.create(entityData.entity); if (!entity) { return { error: 'Failed to create entity' }; } entities.push(entity); log(`Created entity(${entity.get('resource_id')})`); }); return { data: entities.map((entity) => entity.json()) }; }); wsc.method('entities:modify', (edits) => { edits.forEach(({ id, path, value }) => { const entity = api.entities.get(id); if (!entity) { return { error: 'Entity not found' }; } entity.set(path, value); log(`Set property(${path}) of entity(${id}) to: ${JSON.stringify(value)}`); }); return { data: true }; }); wsc.method('entities:duplicate', async (ids, options = {}) => { const entities = ids.map((id) => api.entities.get(id)); if (!entities.length) { return { error: 'Entities not found' }; } const res = await api.entities.duplicate(entities, options); log(`Duplicated entities: ${res.map((entity) => entity.get('resource_id')).join(', ')}`); return { data: res.map((entity) => entity.json()) }; }); wsc.method('entities:reparent', (options) => { const entity = api.entities.get(options.id); if (!entity) { return { error: 'Entity not found' }; } const parent = api.entities.get(options.parent); if (!parent) { return { error: 'Parent entity not found' }; } entity.reparent(parent, options.index, { preserveTransform: options.preserveTransform }); log(`Reparented entity(${options.id}) to entity(${options.parent})`); return { data: entity.json() }; }); wsc.method('entities:delete', async (ids) => { const entities = ids.map((id) => api.entities.get(id)).filter((entity) => entity !== api.entities.root); if (!entities.length) { return { error: 'No entities to delete' }; } await api.entities.delete(entities); log(`Deleted entities: ${ids.join(', ')}`); return { data: true }; }); wsc.method('entities:list', () => { const entities = api.entities.list(); if (!entities.length) { return { error: 'No entities found' }; } log('Listed entities'); return { data: entities.map((entity) => entity.json()) }; }); wsc.method('entities:components:add', (id, components) => { const entity = api.entities.get(id); if (!entity) { return { error: 'Entity not found' }; } Object.entries(components).forEach(([name, data]) => { entity.addComponent(name, data); }); log(`Added components(${Object.keys(components).join(', ')}) to entity(${id})`); return { data: entity.json() }; }); wsc.method('entities:components:remove', (id, components) => { const entity = api.entities.get(id); if (!entity) { return { error: 'Entity not found' }; } components.forEach((component) => { entity.removeComponent(component); }); log(`Removed components(${components.join(', ')}) from entity(${id})`); return { data: entity.json() }; }); wsc.method('entities:components:script:add', (id, scriptName) => { const entity = api.entities.get(id); if (!entity) { return { error: 'Entity not found' }; } if (!entity.get('components.script')) { return { error: 'Script component not found' }; } entity.addScript(scriptName); log(`Added script(${scriptName}) to component(script) of entity(${id})`); return { data: entity.get('components.script') }; }); // assets wsc.method('assets:create', async (assets) => { try { // Map each asset definition to a promise that handles its creation const assetCreationPromises = assets.map(async ({ type, options }) => { if (options?.folder) { options.folder = api.assets.get(options.folder); } let createPromise; // Determine the correct API call based on the asset type switch (type) { case 'css': createPromise = api.assets.createCss(options); break; case 'folder': createPromise = api.assets.createFolder(options); break; case 'html': createPromise = api.assets.createHtml(options); break; case 'material': if (options?.data?.name) { options.name = options.data.name; } createPromise = api.assets.createMaterial(options); break; case 'script': createPromise = api.assets.createScript(options); break; case 'shader': createPromise = api.assets.createShader(options); break; case 'template': if (options?.entity) { options.entity = api.entities.get(options.entity); } createPromise = api.assets.createTemplate(options); break; case 'text': createPromise = api.assets.createText(options); break; default: // Throw an error for this specific promise if type is invalid throw new Error(`Invalid asset type: ${type}`); } // Await the specific asset creation promise const asset = await createPromise; // Check for creation failure and throw an error if (!asset) { throw new Error(`Failed to create asset of type ${type}`); } // Log success and return the asset data for this promise log(`Created asset(${asset.get('id')}) - Type: ${type}`); return asset.json(); }); // Wait for all creation promises to resolve concurrently const createdAssetsData = await Promise.all(assetCreationPromises); // Return the collected data if all promises succeeded return { data: createdAssetsData }; } catch (error) { // Catch any error thrown during the mapping or from Promise.all const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred during asset creation.'; log(`Error creating assets: ${errorMessage}`); return { error: errorMessage }; } }); wsc.method('assets:delete', (ids) => { const assets = ids.map((id) => api.assets.get(id)); if (!assets.length) { return { error: 'Assets not found' }; } api.assets.delete(assets); log(`Deleted assets: ${ids.join(', ')}`); return { data: true }; }); wsc.method('assets:list', (type) => { let assets = api.assets.list(); if (type) { assets = assets.filter((asset) => asset.get('type') === type); } log('Listed assets'); return { data: assets.map((asset) => asset.json()) }; }); wsc.method('assets:instantiate', async (ids) => { const assets = ids.map((id) => api.assets.get(id)); if (!assets.length) { return { error: 'Assets not found' }; } if (assets.some((asset) => asset.get('type') !== 'template')) { return { error: 'Invalid template asset' }; } const entities = await api.assets.instantiateTemplates(assets); log(`Instantiated assets: ${ids.join(', ')}`); return { data: entities.map((entity) => entity.json()) }; }); wsc.method('assets:property:set', (id, prop, value) => { const asset = api.assets.get(id); if (!asset) { return { error: 'Asset not found' }; } asset.set(`data.${prop}`, value); log(`Set asset(${id}) property(${prop}) to: ${JSON.stringify(value)}`); return { data: asset.json() }; }); wsc.method('assets:script:text:set', async (id, text) => { const asset = api.assets.get(id); if (!asset) { return { error: 'Asset not found' }; } const form = new FormData(); form.append('filename', asset.get('file.filename')); form.append('file', new Blob([text], { type: 'text/plain' }), asset.get('file.filename')); form.append('branchId', window.config.self.branch.id); try { const data = await rest('PUT', `assets/${id}`, form, true); if (data.error) { return { error: data.error }; } log(`Set asset(${id}) script text`); return { data }; } catch (e) { return { error: e.message }; } }); wsc.method('assets:script:parse', async (id) => { const asset = api.assets.get(id); if (!asset) { return { error: 'Asset not found' }; } // FIXME: This is a hacky way to get the parsed script data. Expose a proper API for this. const [error, data] = await new Promise((resolve) => { window.editor.call('scripts:parse', asset.observer, (...data) => resolve(data)); }); if (error) { return { error }; } if (Object.keys(data.scripts).length === 0) { return { error: 'Failed to parse script' }; } log(`Parsed asset(${id}) script`); return { data }; }); // scenes wsc.method('scene:settings:modify', (settings) => { const scene = api.settings.scene; iterateObject(settings, (path, value) => { scene.set(path, value); }); log('Modified scene settings'); return { data: scene.json() }; }); // store // playcanvas wsc.method('store:playcanvas:list', async (options = {}) => { const params = []; if (options.search) { params.push(`search=${options.search}`); } params.push('regexp=true'); if (options.order) { params.push(`order=${options.order}`); } if (options.skip) { params.push(`skip=${options.skip}`); } if (options.limit) { params.push(`limit=${options.limit}`); } try { const data = await rest('GET', `store?${params.join('&')}`); if (data.error) { return { error: data.error }; } log(`Searched store: ${JSON.stringify(options)}`); return { data }; } catch (e) { return { error: e.message }; } }); wsc.method('store:playcanvas:get', async (id) => { try { const data = await rest('GET', `store/${id}`); if (data.error) { return { error: data.error }; } log(`Got store item(${id})`); return { data }; } catch (e) { return { error: e.message }; } }); wsc.method('store:playcanvas:clone', async (id, name, license) => { try { const data = await rest('POST', `store/${id}/clone`, { scope: { type: 'project', id: window.config.project.id }, name, store: 'playcanvas', targetFolderId: null, license }); if (data.error) { return { error: data.error }; } log(`Cloned store item(${id})`); return { data }; } catch (e) { return { error: e.message }; } }); // sketchfab wsc.method('store:sketchfab:list', async (options = {}) => { const params = ['restricted=0', 'type=models', 'downloadable=true']; if (options.search) { params.push(`q=${options.search}`); } if (options.order) { params.push(`sort_by=${options.order}`); } if (options.skip) { params.push(`cursor=${options.skip}`); } if (options.limit) { params.push(`count=${Math.min(options.limit ?? 0, 24)}`); } try { const res = await fetch(`https://api.sketchfab.com/v3/search?${params.join('&')}`); const data = await res.json(); if (data.error) { return { error: data.error }; } log(`Searched Sketchfab: ${JSON.stringify(options)}`); return { data }; } catch (e) { return { error: e.message }; } }); wsc.method('store:sketchfab:get', async (uid) => { try { const res = await fetch(`https://api.sketchfab.com/v3/models/${uid}`); const data = await res.json(); if (data.error) { return { error: data.error }; } log(`Got Sketchfab model(${uid})`); return { data }; } catch (e) { return { error: e.message }; } }); wsc.method('store:sketchfab:clone', async (uid, name, license) => { try { const data = await rest('POST', `store/${uid}/clone`, { scope: { type: 'project', id: window.config.project.id }, name, store: 'sketchfab', targetFolderId: null, license }); if (data.error) { return { error: data.error }; } log(`Cloned sketchfab item(${uid})`); return { data }; } catch (e) { return { error: e.message }; } }); })();

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/playcanvas/editor-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server