feat: show views as the live globe in MCP Apps clients

show_on_globe replaces open_in_gods_eye_view and names an MCP Apps view, a
ui:// panel that loads the app in embed mode and posts each later view to it,
so the globe in the conversation changes without reloading. It also takes a
view another answer returned. Tools can declare a UI resource, and the MCP
adapter serves resources/list and resources/read and lists each tool's
resource in _meta.ui.resourceUri.
This commit is contained in:
Sameh Khamis committed 2026-10-02 15:21:36 -07:00
1 parent 60c40865e9
commit 216a75ac71
17 files changed
+417 -41

No files matched your search

+76
View File
@@ -200,3 +200,79 @@ test('the HTTP transport accepts one JSON message per POST', async () => {
assert.equal(get.status, 405);
assert.equal(get.headers.get('allow'), 'POST');
});
test('resources are listed and read, and tools name their UI resource', async () => {
const shown = defineTool({
name: 'show',
title: 'Show',
description: 'Shows something in a view.',
inputSchema: { type: 'object', properties: {} },
ui: { resourceUri: 'ui://fixture/view' },
run: async () => ({ summary: 'shown', data: {} }),
});
const withUi = createMcpServer({
catalog: composeCatalog({ tools: [shown] }),
name: 'fixture',
version: '1',
resources: [
{
uri: 'ui://fixture/view',
name: 'view',
mimeType: 'text/html;profile=mcp-app',
text: '<!doctype html><p>view</p>',
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
},
],
});
const call = async (method, params) =>
(await withUi.handle({ jsonrpc: '2.0', id: 1, method, params })).result ??
(await withUi.handle({ jsonrpc: '2.0', id: 1, method, params })).error;
assert.deepEqual((await call('initialize', {})).capabilities, {
tools: { listChanged: false },
resources: { listChanged: false },
});
assert.deepEqual((await call('tools/list')).tools[0]._meta, {
ui: { resourceUri: 'ui://fixture/view' },
});
assert.deepEqual((await call('resources/list')).resources, [
{
uri: 'ui://fixture/view',
name: 'view',
mimeType: 'text/html;profile=mcp-app',
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
},
]);
assert.deepEqual(
(await call('resources/read', { uri: 'ui://fixture/view' })).contents,
[
{
uri: 'ui://fixture/view',
mimeType: 'text/html;profile=mcp-app',
text: '<!doctype html><p>view</p>',
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
},
],
);
assert.equal(
(await call('resources/read', { uri: 'ui://fixture/other' })).code,
-32002,
);
// A server without resources does not advertise them.
assert.equal(
(await server.handle({ jsonrpc: '2.0', id: 1, method: 'initialize' }))
.result.capabilities.resources,
undefined,
);
assert.throws(
() =>
defineTool({
name: 'bad',
title: 'Bad',
description: 'Points at a web page.',
inputSchema: { type: 'object' },
ui: { resourceUri: 'https://a.example' },
run: async () => ({ summary: '', data: {} }),
}),
/ui:\/\/ resourceUri/,
);
});
+27 -2
View File
@@ -1,6 +1,8 @@
/**
* Model Context Protocol server for a tool catalog: JSON-RPC 2.0 handling of
* initialize, ping, tools/list and tools/call. Transports (HTTP, stdio) pass
* initialize, ping, tools/list and tools/call, and resources/list and
* resources/read for the resources an application supplies, such as MCP Apps
* views (`ui://` resources) that tools name in `_meta.ui.resourceUri`. Transports (HTTP, stdio) pass
* parsed messages in and send the returned responses out.
*/
@@ -18,6 +20,7 @@ const INVALID_REQUEST = -32600;
const METHOD_NOT_FOUND = -32601;
const INVALID_PARAMS = -32602;
const INTERNAL_ERROR = -32603;
const RESOURCE_NOT_FOUND = -32002;
/**
* Describe catalog tools as MCP tool definitions.
@@ -36,6 +39,9 @@ export function toMcpTools(catalog, { descriptions = {}, decorate } = {}) {
description: override.description ?? tool.description,
inputSchema: tool.inputSchema,
annotations: { title, ...tool.annotations },
...(tool.ui
? { _meta: { ui: { resourceUri: tool.ui.resourceUri } } }
: {}),
};
return decorate
? { ...definition, ...decorate(definition, tool) }
@@ -54,8 +60,12 @@ export function createMcpServer({
instructions,
descriptions,
decorate,
resources = [],
}) {
const tools = () => toMcpTools(catalog, { descriptions, decorate });
const resourceByUri = new Map(
resources.map((resource) => [resource.uri, resource]),
);
const methods = {
initialize(params) {
const requested = params?.protocolVersion;
@@ -63,13 +73,28 @@ export function createMcpServer({
protocolVersion: MCP_PROTOCOL_VERSIONS.includes(requested)
? requested
: MCP_PROTOCOL_VERSIONS[0],
capabilities: { tools: { listChanged: false } },
capabilities: {
tools: { listChanged: false },
...(resources.length ? { resources: { listChanged: false } } : {}),
},
serverInfo: { name, version },
...(instructions ? { instructions } : {}),
};
},
ping: () => ({}),
'tools/list': () => ({ tools: tools() }),
'resources/list': () => ({
resources: resources.map(({ text, ...entry }) => entry),
}),
'resources/read'(params) {
const resource = resourceByUri.get(params?.uri);
if (!resource)
throw rpcError(RESOURCE_NOT_FOUND, `Unknown resource: ${params?.uri}`);
const { uri, mimeType, text, _meta } = resource;
return {
contents: [{ uri, mimeType, text, ...(_meta ? { _meta } : {}) }],
};
},
async 'tools/call'(params, { signal }) {
if (typeof params?.name !== 'string')
throw rpcError(INVALID_PARAMS, 'tools/call needs a tool name');