Created
February 8, 2026 21:03
-
-
Save thekid/7696c1189dbfdbdb9e1ea88667caf60a to your computer and use it in GitHub Desktop.
MCP Apps
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | |
| } | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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']}`); | |
| } | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
See xp-forge/mcp#19