Skip to content
Open
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
115 changes: 114 additions & 1 deletion src/editor/mcp/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ const HOST = '127.0.0.1';
const RETRY_TIMEOUT = 1000;
const PROTOCOL_VERSION = 1;

// A blocked socket is indistinguishable from "nothing is listening", so probe the permission
// when we never reach 'open'. The name changed with the Chrome 145 loopback/LAN split, so try
// each and use the first the browser recognises.
const LOCAL_ACCESS_PERMISSIONS = ['loopback-network', 'local-network', 'local-network-access'];

type Status = 'connecting' | 'connected' | 'disconnected';
type Role = 'editor' | 'runtime';
type MethodResult = { data?: any; error?: string; meta?: Record<string, any> };
Expand All @@ -17,6 +22,33 @@ type Method = (...args: any[]) => MethodResult | Promise<MethodResult>;
const log = (msg: string) => console.log(`[MCP] ${msg}`);
const error = (msg: unknown) => console.error(`[MCP] ${msg}`);

// 'denied' or 'prompt' when the permission may be the cause, null when it can't be. A
// never-asked site reads 'prompt', which is also what a missing server looks like.
// the server's opening frame advertises its capabilities; tool requests always carry an id
const greetingOf = (data: unknown) => {
if (typeof data !== 'string' || !data.includes('"hello"')) {
return null;
}
try {
return JSON.parse(data)?.hello ?? null;
} catch {
return null;
}
};

const localAccessState = async () => {
for (const name of LOCAL_ACCESS_PERMISSIONS) {
const state = await navigator.permissions?.query({ name: name as PermissionName }).then(
(status) => status.state,
() => null
);
if (state) {
return state === 'granted' ? null : state;
}
}
return null;
};

/**
* WebSocket client that connects the page to the external MCP server and dispatches its
* tool requests to registered handlers. Same wire protocol as the former Chrome extension:
Expand All @@ -39,10 +71,32 @@ class MCPConnection extends Events {

private _forceClosed = false;

private _blocked: string | null = null;

private _serverRelay = false;

private _fallback: ((name: string, args: any[]) => MethodResult | Promise<MethodResult> | null) | null = null;

get status() {
return this._status;
}

/**
* Whether the server can route `runtime:*` through this page instead of the launch page
* opening its own socket.
*/
get serverRelay() {
return this._serverRelay;
}

get methodNames() {
return Array.from(this._methods.keys()).sort();
}

get blocked() {
return this._blocked;
}

get port() {
return this._port;
}
Expand All @@ -52,6 +106,23 @@ class MCPConnection extends Events {
this.emit('status', status);
}

private _setBlocked(state: string | null) {
if (this._blocked === state) {
return;
}
this._blocked = state;
if (state === 'denied') {
error(
'The browser is blocking this page from reaching the MCP server. Allow local access for this site in its site settings ("Apps on device" in Chrome), then reconnect.'
);
} else if (state === 'prompt') {
error(
'This page has not been allowed to reach local servers yet. Accept the browser prompt, or allow local access for this site in its site settings ("Apps on device" in Chrome). Otherwise check that the MCP server is running.'
);
}
this.emit('blocked', state);
}

/**
* Connect to the MCP server and keep the connection alive. If the socket drops
* unexpectedly (server restart, transient network, background-tab throttling) we retry
Expand Down Expand Up @@ -82,8 +153,12 @@ class MCPConnection extends Events {
private _open() {
const ws = new WebSocket(`ws://${HOST}:${this._port}`);
this._ws = ws;
this._serverRelay = false;
let opened = false;

ws.onopen = () => {
opened = true;
this._setBlocked(null);
ws.send(
JSON.stringify({
register: this._role,
Expand All @@ -95,6 +170,13 @@ class MCPConnection extends Events {
log('Connected');
};
ws.onmessage = async (event) => {
const greeting = greetingOf(event.data);
if (greeting) {
this._serverRelay = !!greeting.relay;
log(`Server relay ${this._serverRelay ? 'available' : 'unavailable'}`);
this.emit('hello');
return;
}
const msg = await handleRequest(event.data, (name, ...args) => this.call(name, ...args));
if ('id' in msg) {
ws.send(JSON.stringify(msg));
Expand All @@ -113,6 +195,9 @@ class MCPConnection extends Events {
if (this._forceClosed || evt?.reason === 'FORCE') {
return;
}
if (!opened) {
localAccessState().then((state) => this._setBlocked(state));
}
this._setStatus('connecting');
log('Disconnected; reconnecting');
if (this._connectTimeout) {
Expand Down Expand Up @@ -140,6 +225,27 @@ class MCPConnection extends Events {
log('Disconnected');
}

/**
* Send a raw frame outside the request/response flow, for relay announcements.
*
* @param msg - The frame to send; dropped if the socket isn't open.
*/
send(msg: Record<string, any>) {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send(JSON.stringify(msg));
}
}

/**
* Handle methods this page doesn't implement. Returning null declines, leaving the caller
* with the usual unknown-method error.
*
* @param fn - The handler, called with the method name and its arguments.
*/
fallback(fn: (name: string, args: any[]) => MethodResult | Promise<MethodResult> | null) {
this._fallback = fn;
}

/**
* @param name - The name of the method to register.
* @param fn - The handler to call when the method is requested.
Expand All @@ -160,7 +266,11 @@ class MCPConnection extends Events {
call(name: string, ...args: any[]): MethodResult | Promise<MethodResult> {
const fn = this._methods.get(name);
if (!fn) {
return { error: `Unknown method: ${name}. The editor may be outdated; reload the page and reconnect.` };
return (
this._fallback?.(name, args) ?? {
error: `Unknown method: ${name}. The editor may be outdated; reload the page and reconnect.`
}
);
}
return fn(...args);
}
Expand All @@ -172,6 +282,9 @@ editor.method('mcp:connect', (port?: number, role?: Role) => mcp.connect(port, r
editor.method('mcp:disconnect', () => mcp.disconnect());
editor.method('mcp:status', () => mcp.status);
editor.method('mcp:port', () => mcp.port);
editor.method('mcp:blocked', () => mcp.blocked);
editor.method('mcp:relay', () => mcp.serverRelay);
mcp.on('status', (status: Status) => editor.emit('mcp:status', status));
mcp.on('blocked', (state: string | null) => editor.emit('mcp:blocked', state));

export { mcp, DEFAULT_PORT, PROTOCOL_VERSION };
104 changes: 88 additions & 16 deletions src/editor/mcp/launch.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,73 @@
import { config } from '@/editor/config';

import { mcp } from './connection';
import { relay } from './relay';

const log = (msg: string) => console.log(`[MCP] ${msg}`);

// how long an already-open launch window gets to answer the relay offer before we relaunch
const ADOPT_TIMEOUT = 5000;
const CLOSE_POLL = 100;
const CLOSE_ATTEMPTS = 10;

// remember a handle to the launched runtime window so we can stop it later
let runtimeWindow: Window | null = null;

/**
* Close the current launch window — ours, or one handed to the relay by the Launch button —
* and confirm it went. The page closes itself where we can't.
*
* @returns True if a window was open and is now closed.
*/
const closeCurrent = async () => {
const target = runtimeWindow && !runtimeWindow.closed ? runtimeWindow : relay.peer?.window;
runtimeWindow = null;
if (!target || target.closed) {
relay.detach();
return false;
}
let closed = await relay.close();
if (!closed) {
target.close();
for (let i = 0; i < CLOSE_ATTEMPTS && !target.closed; i++) {
await new Promise((resolve) => setTimeout(resolve, CLOSE_POLL));
}
closed = target.closed;
}
relay.detach();
if (!closed) {
log('Runtime window would not close');
}
return closed;
};

// launch (runtime control)
mcp.method('launch:start', (options: any = {}) => {
mcp.method('launch:start', async (options: any = {}) => {
const sceneId = config.scene?.id;
const base = config.url?.launch;
if (!sceneId || !base) {
return { error: 'No scene loaded, or launch URL unavailable. Load a scene in the editor and retry.' };
}
// with no options requested, adopt a running app instead of restarting the session
if (mcp.serverRelay && !Object.keys(options).length) {
const running = relay.peer && !relay.peer.window.closed ? relay.peer : null;
if (running?.sceneId === sceneId) {
log('Adopted the running app');
return { data: { url: running.url, sceneId, adopted: true } };
}
const last = editor.call('launch:window');
if (!running && last?.window && !last.window.closed) {
relay.attach(last.window);
const adopted = await relay.ready(ADOPT_TIMEOUT);
if (adopted?.sceneId === sceneId) {
log('Adopted the app launched from the editor');
return { data: { url: adopted.url, sceneId, adopted: true } };
}
// different scene, or a build without the relay: leave it and relaunch
relay.detach();
}
}

const params = new URLSearchParams();

params.set('debug', String(options.debug ?? true));
Expand All @@ -36,29 +90,47 @@ mcp.method('launch:start', (options: any = {}) => {
params.set('ministats', 'true');
}

// pass the MCP port so the launch page can connect back as the runtime peer
// without any popup UI
params.set('mcp_port', String(mcp.port));
const url = `${base}${sceneId}?${params.toString()}`;
// a local build must launch the local launch page, as the Launch button does
const search = new URLSearchParams(location.search);
for (const flag of ['use_local_frontend', 'use_local_engine']) {
if (search.has(flag)) {
params.set(flag, search.get(flag) ?? '');
}
}

if (runtimeWindow && !runtimeWindow.closed) {
runtimeWindow.close();
// only older servers route to a socket the launch page opens itself
if (!mcp.serverRelay) {
params.set('mcp_port', String(mcp.port));
}
runtimeWindow = window.open(url, '_blank');
const url = `${base}${sceneId}?${params.toString()}`;

await closeCurrent();
// open blank so the opener can be severed before the page loads
runtimeWindow = window.open('', '_blank');
if (!runtimeWindow) {
return {
error: 'Could not open the launch window (popup blocked). Allow popups for the editor origin and retry.'
};
}
if (mcp.serverRelay) {
// project scripts get no handle into the editor. This also makes the window unclosable
// from here, so only sever where closing goes through the page (see closeCurrent).
runtimeWindow.opener = null;
}
runtimeWindow.location = url;
editor.call('launch:window:track', runtimeWindow, true);
if (mcp.serverRelay) {
relay.attach(runtimeWindow);
}
log(`Launched runtime for scene(${sceneId})`);
return { data: { url, sceneId } };
return { data: { url, sceneId, adopted: false } };
});
mcp.method('launch:stop', () => {
const wasOpen = !!(runtimeWindow && !runtimeWindow.closed);
if (runtimeWindow && !runtimeWindow.closed) {
runtimeWindow.close();
mcp.method('launch:stop', async () => {
const wasOpen = !!(runtimeWindow && !runtimeWindow.closed) || !!relay.peer;
const closed = await closeCurrent();
if (wasOpen && !closed) {
return { data: { stopped: false }, error: 'The launch window did not close. Close it manually, then retry.' };
}
runtimeWindow = null;
log('Stopped runtime');
return { data: { stopped: wasOpen } };
log(closed ? 'Stopped runtime' : 'No runtime to stop');
return { data: { stopped: closed } };
});
1 change: 1 addition & 0 deletions src/editor/mcp/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { driver } from '@/editor/driver';

import { mcp } from './connection';
import './launch';
import './relay';

mcp.method('ping', () => ({ data: 'pong' }));

Expand Down
Loading
Loading