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
1 change: 1 addition & 0 deletions class.pth
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
src/main/php/
src/main/resources/
src/test/php/
23 changes: 22 additions & 1 deletion src/main/php/io/modelcontextprotocol/McpServer.class.php
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<?php namespace io\modelcontextprotocol;

use io\modelcontextprotocol\server\{Delegate, JsonRpc, Response, Result};
use lang\FormatException;
use lang\{FormatException, ClassLoader};
use text\json\Json;
use util\NoSuchElementException;
use util\log\Traceable;
use web\Handler;
use web\io\StaticContent;

/**
* MCP Server implementation
Expand Down Expand Up @@ -94,6 +95,26 @@ public function setTrace($cat) {
$this->rpc->setTrace($cat);
}

/**
* Serves resources
*
* @param [:string]|function(util.URI, io.File, string): iterable $headers
* @return function(web.Request, web.Response)
*/
public function resources($headers= []) {
$cl= ClassLoader::getDefault();
$content= (new StaticContent())->with($headers);

return function($request, $response) use($content, $cl) {
$resource= basename($request->uri()->path());
return $content->serve(
$request,
$response,
$cl->providesResource($resource) ? $cl->getResourceAsStream($resource) : null
);
};
}

/**
* Handle requests
*
Expand Down
82 changes: 82 additions & 0 deletions src/main/resources/mcp-app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
class McpApp {
#nextId = 1;
#pending = {};
#handlers = {
'ui/notifications/host-context-changed': params => this.#apply(params.styles),
};

/** Creates a new MCP app */
constructor(name, version= '1.0.0') {
this.name = name;
this.version = version;

// Match events to pending promises
window.addEventListener('message', (event) => {
if ('2.0' !== event.data?.jsonrpc) return;

if ('result' in event.data) {
const promise = this.#pending[event.data.id] ?? null;
if (event.data?.result) {
promise.resolve(event.data.result);
} else if (event.data?.error) {
promise.reject(new Error(event.data.error.message));
} else {
promise.reject(new Error(`Unsupported message: ${JSON.stringify(event.data)}`));
}
} else if (event.data.method in this.#handlers) {
this.#handlers[event.data.method](event.data.params);
} else {
console.log('Unhandled', event.data);
}
});
}

#apply(styles) {
const $root = document.documentElement.style;
for (const [property, value] of Object.entries(styles?.variables)) {
value === undefined || $root.setProperty(property, value);
}
}

async #send(method, params) {
const id = this.#nextId++;

this.#pending[id] = Promise.withResolvers();
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');

return this.#pending[id].promise;
}

/** Adds an event handler for a given message type, e.g. `ui/notifications/tool-result` */
on(event, handler) {
this.#handlers[event] = handler;
}

/** Initialize the app using the initialize/initialized handshake */
async initialize() {
const result = await this.#send('ui/initialize', {
appCapabilities: {},
appInfo: {name: this.name, version: this.version},
protocolVersion: '2026-01-26',
});
this.#apply(result.hostContext.styles);

window.parent.postMessage({ jsonrpc: '2.0', method: 'ui/notifications/initialized' }, '*');
return Promise.resolve(result);
}

/** Send message content to the host's chat interface */
async send(text) {
return this.#send('ui/message', { role: 'user', content: [{ type: 'text', text }] });
}

/** Tells host to open a given link */
async open(link) {
return this.#send('ui/open-link', { url: link });
}

/** Makes host proxy an MCP tool call and return its result */
async call(tool, args) {
return this.#send('tools/call', { name: tool, arguments: args });
}
}
209 changes: 209 additions & 0 deletions src/main/resources/mcp-host.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
class McpHost {
#nextId = 1;
#server = null;
#pending = {};
messages = console.warn;
links = console.warn;

constructor(frame, endpoint, name, version) {
this.frame = frame;
this.endpoint = endpoint;
this.name = name;
this.version = version;
}

async #send(payload) {
return await fetch(this.endpoint, {
method: 'POST',
body: JSON.stringify({ jsonrpc: '2.0', id: this.#nextId++, ...payload }),
headers: {
'Content-Type' : 'application/json',
'Accept': 'text/event-stream, application/json',
...this.authorization
},
});
}

async *linesIn(reader) {
const decoder = new TextDecoder();

let buffer = '';
let n = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
if (buffer.length) yield buffer;
return;
}

buffer += decoder.decode(value, { stream: true });
while (-1 !== (n = buffer.indexOf('\n'))) {
yield buffer.slice(0, n);
buffer = buffer.slice(n + 1);
}
}
}

async #read(response) {
const type = response.headers.get('Content-Type');

if (type.startsWith('application/json')) {
return response.json();
} else if (type.startsWith('text/event-stream')) {
for await (const line of this.linesIn(response.body.getReader())) {
if (line.startsWith('data: ')) {
return Promise.resolve(JSON.parse(line.slice(6)));
}
}
return Promise.resolve(null);
}

throw new Error('Cannot handle mime type ' + type);
}

authorize(authorization) {
this.authorization = authorization ? { 'Authorization' : authorization } : {};
}

async initialize(auth= undefined) {
const cached = `mcp-auth:${this.endpoint}`;

// Cache authorization per endpoint in session storage
this.authorize(window.sessionStorage.getItem(cached) ?? await auth.authorize());

// Perform initialization, refreshing authorization if necessary
let initialize;
do {
initialize = await this.#send({ method: 'initialize', params: {
protocolVersion: '2026-01-26',
clientInfo: { name: this.name, version: this.version },
capabilities: {},
}});
if (200 === initialize.status) {
this.#server = await this.#read(initialize);
return true;
} else if (401 === initialize.status && auth) {
const authorization = await auth.authorize();
if (authorization) {
window.sessionStorage.setItem(cached, authorization);
this.authorize(authorization);
continue;
}
throw new Error('Unauthorized');
} else {
throw new Error('Unexpected ' + initialize.status);
}
} while (true);
}

/** Launches the given app */
async launch(app, args = {}) {
const call = await this.#read(await this.#send({ method: 'tools/call', params: {
name: app.name,
arguments: args,
}}));
const contents = await this.#read(await this.#send({ method: 'resources/read', params: {
uri : app._meta.ui.resourceUri,
}}));

this.#pending[call.id] = { tool: app, input: args, result: call.result };

// Render the app into the application iframe
this.frame.dataset.call = call.id;

//
const inner = this.frame.contentDocument.createElement('iframe');
inner.style = 'width: 100%; height: 100%; border: none;';
inner.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
// inner.src = '/static/mcp-proxy.html';
inner.srcdoc = contents.result.contents[0].text;
this.frame.contentDocument.documentElement.style = 'margin: 0; height: 100%';
this.frame.contentDocument.body.style = 'margin: 0; height: 100%';
this.frame.contentDocument.body.appendChild(inner);

this.frame.contentWindow.onmessage = async e => {
if ('2.0' !== e.data?.jsonrpc) return;

switch (e.data.method) {
case 'ui/initialize':
const styles = getComputedStyle(document.documentElement);
const variables = {};
for (let prop of styles) {
if (prop.startsWith('--')) {
variables[prop] = styles.getPropertyValue(prop);
}
}

inner.contentWindow.postMessage({ jsonrpc: '2.0', id: e.data.id, result: {
protocolVersion: '2026-01-26',
hostInfo: { name: this.name, version: this.version },
hostCapabilities: { /* TODO */ },
hostContext: {
toolInfo: {
id: this.frame.dataset.call,
tool: this.#pending[this.frame.dataset.call].tool,
},
platform: 'web',
userAgent: navigator.userAgent,
// TODO: locale
// TODO: timeZone
deviceCapabilities: { hover: true, touch: true },
displayMode: 'inline',
availableDisplayModes: ['inline'],
safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 },
containerDimensions: {
maxWidth: this.frame.contentWindow.innerWidth,
maxHeight: this.frame.contentWindow.innerHeight,
},
theme: 'light',
styles: { variables },
},
}});
break;

case 'ui/notifications/initialized':
inner.contentWindow.postMessage({
jsonrpc: '2.0',
method: 'ui/notifications/tool-input',
params: this.#pending[this.frame.dataset.call].input
});
inner.contentWindow.postMessage({
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: this.#pending[this.frame.dataset.call].result
});
delete this.#pending[this.frame.dataset.call];
break;

case 'ui/notifications/size-changed':
console.warn('Not yet implemented', e.data);
break;

case 'ui/open-link':
this.links(e.data.params.url, e.data.id);
inner.contentWindow.postMessage({ jsonrpc: '2.0', id: e.data.id, result: {} });
break;

case 'ui/message':
this.messages(e.data.params.content, e.data.id);
inner.contentWindow.postMessage({ jsonrpc: '2.0', id: e.data.id, result: {} });
break;

default: // Proxy MCP
inner.contentWindow.postMessage(await this.#read(await this.#send(e.data)));
break;
}
};
}

/** Returns all MCP apps for the given server */
async *apps() {
const tools = await this.#read(await this.#send({ method: 'tools/list' }));

for (const tool of tools.result.tools) {
if (tool._meta && 'ui' in tool._meta) {
yield tool;
}
}
}
}
Loading
Loading