Skip to content

Instantly share code, notes, and snippets.

@thekid
Created February 8, 2026 21:03
Show Gist options
  • Select an option

  • Save thekid/7696c1189dbfdbdb9e1ea88667caf60a to your computer and use it in GitHub Desktop.

Select an option

Save thekid/7696c1189dbfdbdb9e1ea88667caf60a to your computer and use it in GitHub Desktop.
MCP Apps
class McpApp {
#nextId = 1;
#pending = {};
/** 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 ('ui/notifications/host-context-changed' === event.data.method) {
this.#apply(event.data.params.styles);
} else {
console.log(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;
}
/** 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 new Promise((resolve, reject) => resolve());
}
/** 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 });
}
}
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;
window.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);
}
}
this.#post({ 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':
this.#post({
jsonrpc: '2.0',
method: 'ui/notifications/tool-input',
params: this.#pending[this.frame.dataset.call].input
});
this.#post({
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);
this.#post({ jsonrpc: '2.0', id: e.data.id, result: {} });
break;
case 'ui/message':
this.messages(e.data.params.content, e.data.id);
this.#post({ jsonrpc: '2.0', id: e.data.id, result: {} });
break;
default: // Proxy MCP
this.#post(await this.#read(await this.#send(e.data)));
break;
}
};
}
#post(message) {
this.frame.contentWindow.postMessage(message);
}
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;
this.frame.srcdoc = contents.result.contents[0].text;
}
/** 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;
}
}
}
}
class McpOAuth {
#UNRESERVED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
constructor() {
const cached = window.sessionStorage.getItem('mcp-oauth');
this.client = cached ? JSON.parse(cached) : {
client_name: 'XP/MCP Host',
redirect_uris: [window.location.href],
grant_types: ['authorization_code'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
};
}
/** Generates random code verifier */
#verifier() {
const random = new Uint8Array(64);
crypto.getRandomValues(random);
let verifier = '';
for (let i = 0; i < 64; i++) {
verifier += this.#UNRESERVED[random[i] % 66];
}
return verifier;
}
/** URL-safe base64 encoded challenge for verifier */
async #challenge(verifier) {
const sha = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const b64 = btoa(new Uint8Array(sha).reduce((data, byte) => data + String.fromCharCode(byte), ''));
return b64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
/** Performs authorization */
async authorize() {
const url = new URL(window.location.href);
const code = url.searchParams.get('code');
if (null === code) {
let response;
// Check for OAuth flow
response = await fetch('/.well-known/oauth-protected-resource');
if (!response.ok) {
throw new Error('Unauthorized, and no OAuth flow possible');
}
const resource = await response.json();
response = await fetch(resource['authorization_servers'][0] + '/.well-known/oauth-authorization-server');
if (!response.ok) {
throw new Error('Unauthorized, and no OAuth meta data from ' + response.url);
}
// Register OAuth client if necessary
const meta = await response.json();
if ('client_id' in this.client) {
console.warn('Reusing OAuth client', this.client);
} else {
response = await fetch(meta['registration_endpoint'], {
method: 'POST',
body: JSON.stringify(this.client),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error('Client registration failed from ' + response.url);
}
this.client = await response.json();
console.warn('Registered OAuth client', this.client);
window.sessionStorage.setItem('mcp-oauth', JSON.stringify(this.client));
}
// Start OAuth flow by redirecting to authorization endpoint
const verifier = this.#verifier();
const challenge = await this.#challenge(verifier);
const state = crypto.randomUUID();
window.sessionStorage.setItem(`mcp-${state}`, JSON.stringify({ verifier, meta }));
window.location.replace(`${meta['authorization_endpoint']}?client_id=${this.client['client_id']}&response_type=code&state=${state}&code_challenge=${challenge}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(this.client['redirect_uris'][0])}`);
return Promise.resolve(null);
} else {
const state = url.searchParams.get('state');
const verify = JSON.parse(window.sessionStorage.getItem(`mcp-${state}`));
window.sessionStorage.removeItem(`mcp-${state}`);
// Exchange code for access token
const response = await fetch(verify.meta['token_endpoint'], {
method: 'POST',
body: new URLSearchParams({
code,
state,
grant_type: 'authorization_code',
client_id: this.client['client_id'],
redirect_uri: this.client['redirect_uris'][0],
code_verifier: verify.verifier,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
if (!response.ok) {
throw new Error('Token exchange failed for ' + response.url);
}
const token = await response.json();
// Remove query string and state, then return authorization
url.search = '';
window.history.replaceState({}, document.title, url.href);
return Promise.resolve(`${token['token_type']} ${token['access_token']}`);
}
}
}
@thekid
Copy link
Author

thekid commented Feb 8, 2026

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment