Skip to content
This repository was archived by the owner on May 21, 2026. It is now read-only.

Commit 5f8456e

Browse files
committed
feat: create mcp-ui-adapter + tests
1 parent c6a5894 commit 5f8456e

6 files changed

Lines changed: 830 additions & 23 deletions

File tree

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
/**
2+
* MCP-UI Adapter
3+
*
4+
* Provides an adapter between mcp-use high-level UIResource definitions
5+
* and the low-level @mcp-ui/server resource format.
6+
* Ref: https://mcpui.dev/guide/server/typescript/usage-examples
7+
*/
8+
9+
import { createUIResource } from '@mcp-ui/server'
10+
import type { UIResourceContent, UIResourceDefinition } from '../types/resource.js'
11+
12+
/**
13+
* Content type options for UI resources
14+
*/
15+
export type UIContentType =
16+
| 'externalUrl' // Default: iframe URL for serving widgets
17+
| 'rawHtml' // Direct HTML content
18+
| 'remoteDom' // Remote DOM scripting
19+
20+
/**
21+
* Encoding options for UI resources
22+
*/
23+
export type UIEncoding = 'text' | 'blob'
24+
25+
/**
26+
* Framework options for Remote DOM resources
27+
*/
28+
export type RemoteDomFramework = 'react' | 'webcomponents'
29+
30+
/**
31+
* Extended UI resource definition with content type support
32+
*/
33+
export interface ExtendedUIResourceDefinition extends UIResourceDefinition {
34+
contentType?: UIContentType
35+
encoding?: UIEncoding
36+
htmlContent?: string
37+
remoteDomScript?: string
38+
remoteDomFramework?: RemoteDomFramework
39+
}
40+
41+
/**
42+
* Configuration for the adapter
43+
*/
44+
export interface AdapterConfig {
45+
baseUrl: string
46+
port: number | string
47+
}
48+
49+
/**
50+
* MCP-UI Adapter class
51+
*/
52+
export class McpUiAdapter {
53+
private config: AdapterConfig
54+
55+
constructor(config: AdapterConfig) {
56+
this.config = config
57+
}
58+
59+
/**
60+
* Build the full URL for a widget
61+
*/
62+
private buildWidgetUrl(widget: string, props?: Record<string, any>): string {
63+
const url = new URL(
64+
`/mcp-use/widgets/${widget}`,
65+
`http://localhost:${this.config.port}`
66+
)
67+
68+
if (props) {
69+
Object.entries(props).forEach(([key, value]) => {
70+
if (value !== undefined && value !== null) {
71+
const stringValue = typeof value === 'object'
72+
? JSON.stringify(value)
73+
: String(value)
74+
url.searchParams.set(key, stringValue)
75+
}
76+
})
77+
}
78+
79+
return url.toString()
80+
}
81+
82+
/**
83+
* Create a UIResource for an external URL (default for widgets)
84+
* @param uri - URI of the resource
85+
* @param iframeUrl - URL of the iframe
86+
* @param encoding - Encoding of the resource (text or blob (URL is Base64 encoded))
87+
* @returns UIResourceContent
88+
*/
89+
createExternalUrlResource(
90+
uri: string,
91+
iframeUrl: string,
92+
encoding: UIEncoding = 'text'
93+
): UIResourceContent {
94+
return createUIResource({
95+
uri: uri as `ui://${string}`,
96+
content: { type: 'externalUrl', iframeUrl },
97+
encoding
98+
})
99+
}
100+
101+
/**
102+
* Create a UIResource for raw HTML content
103+
*
104+
* @param uri - URI of the resource
105+
* @param htmlString - HTML string to embed
106+
* @param encoding - Encoding of the resource
107+
* @returns UIResourceContent
108+
*/
109+
createRawHtmlResource(
110+
uri: string,
111+
htmlString: string,
112+
encoding: UIEncoding = 'text'
113+
): UIResourceContent {
114+
return createUIResource({
115+
uri: uri as `ui://${string}`,
116+
content: { type: 'rawHtml', htmlString },
117+
encoding
118+
})
119+
}
120+
121+
/**
122+
* Create a UIResource for Remote DOM scripting
123+
*/
124+
createRemoteDomResource(
125+
uri: string,
126+
script: string,
127+
framework: RemoteDomFramework = 'react',
128+
encoding: UIEncoding = 'text'
129+
): UIResourceContent {
130+
return createUIResource({
131+
uri: uri as `ui://${string}`,
132+
content: { type: 'remoteDom', script, framework },
133+
encoding
134+
})
135+
}
136+
137+
/**
138+
* Create a UIResource from our high-level definition
139+
*/
140+
createWidgetUIResource(
141+
definition: ExtendedUIResourceDefinition,
142+
props?: Record<string, any>
143+
): UIResourceContent {
144+
const uri = `ui://widget/${definition.name}`
145+
const contentType = definition.contentType || 'externalUrl'
146+
const encoding = definition.encoding || 'text'
147+
148+
switch (contentType) {
149+
case 'externalUrl': {
150+
const widgetUrl = this.buildWidgetUrl(definition.widget, props)
151+
return this.createExternalUrlResource(uri, widgetUrl, encoding)
152+
}
153+
154+
case 'rawHtml': {
155+
if (!definition.htmlContent) {
156+
throw new Error(`HTML content required for rawHtml type in widget ${definition.name}`)
157+
}
158+
return this.createRawHtmlResource(uri, definition.htmlContent, encoding)
159+
}
160+
161+
case 'remoteDom': {
162+
if (!definition.remoteDomScript) {
163+
throw new Error(`Remote DOM script required for remoteDom type in widget ${definition.name}`)
164+
}
165+
const framework = definition.remoteDomFramework || 'react'
166+
return this.createRemoteDomResource(
167+
uri,
168+
definition.remoteDomScript,
169+
framework,
170+
encoding
171+
)
172+
}
173+
174+
default:
175+
throw new Error(`Unknown content type: ${contentType}`)
176+
}
177+
}
178+
179+
/**
180+
* Generate HTML content for a widget (for rawHtml type)
181+
*/
182+
generateWidgetHtml(
183+
definition: UIResourceDefinition,
184+
props?: Record<string, any>
185+
): string {
186+
const [width = '100%', height = '400px'] = definition.size || []
187+
const propsJson = props ? JSON.stringify(props) : '{}'
188+
189+
return `
190+
<!DOCTYPE html>
191+
<html>
192+
<head>
193+
<meta charset="UTF-8">
194+
<title>${definition.title || definition.name}</title>
195+
<style>
196+
body {
197+
margin: 0;
198+
padding: 20px;
199+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
200+
}
201+
.widget-container {
202+
width: ${width};
203+
height: ${height};
204+
border: 1px solid #e0e0e0;
205+
border-radius: 8px;
206+
overflow: auto;
207+
padding: 20px;
208+
background: white;
209+
}
210+
.widget-title {
211+
font-size: 1.5em;
212+
font-weight: 600;
213+
margin-bottom: 10px;
214+
}
215+
.widget-description {
216+
color: #666;
217+
margin-bottom: 20px;
218+
}
219+
</style>
220+
</head>
221+
<body>
222+
<div class="widget-container">
223+
<div class="widget-title">${definition.title || definition.name}</div>
224+
${definition.description ? `<div class="widget-description">${definition.description}</div>` : ''}
225+
<div id="widget-root"></div>
226+
</div>
227+
<script>
228+
// Widget props passed from server
229+
window.__WIDGET_PROPS__ = ${propsJson};
230+
231+
// Placeholder for widget initialization
232+
console.log('Widget ${definition.name} loaded with props:', window.__WIDGET_PROPS__);
233+
234+
// Communication with parent window
235+
window.addEventListener('message', (event) => {
236+
console.log('Received message:', event.data);
237+
});
238+
239+
// Example tool call
240+
function callTool(toolName, params) {
241+
window.parent.postMessage({
242+
type: 'tool',
243+
payload: { toolName, params }
244+
}, '*');
245+
}
246+
</script>
247+
</body>
248+
</html>`
249+
}
250+
251+
/**
252+
* Generate a Remote DOM script for a widget
253+
*/
254+
generateRemoteDomScript(
255+
definition: UIResourceDefinition,
256+
props?: Record<string, any>
257+
): string {
258+
return `
259+
// Remote DOM script for ${definition.name}
260+
const container = document.createElement('div');
261+
container.style.padding = '20px';
262+
263+
// Create title
264+
const title = document.createElement('h2');
265+
title.textContent = '${definition.title || definition.name}';
266+
container.appendChild(title);
267+
268+
${definition.description ? `
269+
// Add description
270+
const description = document.createElement('p');
271+
description.textContent = '${definition.description}';
272+
description.style.color = '#666';
273+
container.appendChild(description);
274+
` : ''}
275+
276+
// Widget props
277+
const props = ${JSON.stringify(props || {})};
278+
279+
// Create interactive button
280+
const button = document.createElement('ui-button');
281+
button.setAttribute('label', 'Interact with ${definition.name}');
282+
button.addEventListener('press', () => {
283+
window.parent.postMessage({
284+
type: 'tool',
285+
payload: {
286+
toolName: 'ui_${definition.name}',
287+
params: props
288+
}
289+
}, '*');
290+
});
291+
container.appendChild(button);
292+
293+
// Add custom widget logic here
294+
console.log('Remote DOM widget ${definition.name} initialized with props:', props);
295+
296+
// Append to root
297+
root.appendChild(container);`
298+
}
299+
}
300+
301+
/**
302+
* Factory function to create an adapter instance
303+
*/
304+
export function createMcpUiAdapter(config: AdapterConfig): McpUiAdapter {
305+
return new McpUiAdapter(config)
306+
}

packages/mcp-use/src/server/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,11 @@ export {
44
} from './mcp-server.js'
55

66
export * from './types/index.js'
7+
8+
// MCP-UI adapter exports
9+
export {
10+
McpUiAdapter,
11+
createMcpUiAdapter,
12+
type ExtendedUIResourceDefinition,
13+
type AdapterConfig
14+
} from './adapters/mcp-ui-adapter.js'

packages/mcp-use/src/server/mcp-server.ts

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import express, { type Express } from 'express'
1414
import { existsSync, readdirSync } from 'node:fs'
1515
import { join } from 'node:path'
1616
import { requestLogger } from './logging.js'
17-
import { createUIResource } from '@mcp-ui/server'
17+
import type { createMcpUiAdapter } from './adapters/mcp-ui-adapter.js'
18+
import type { McpUiAdapter } from './adapters/mcp-ui-adapter.js'
1819

1920
export class McpServer {
2021
private server: OfficialMcpServer
@@ -23,6 +24,7 @@ export class McpServer {
2324
private mcpMounted = false
2425
private inspectorMounted = false
2526
private serverPort?: number
27+
private uiAdapter?: McpUiAdapter
2628

2729
/**
2830
* Creates a new MCP server instance with Express integration
@@ -342,11 +344,7 @@ export class McpServer {
342344
inputs: this.convertPropsToInputs(definition.props),
343345
fn: async (params) => {
344346
// Create the UIResource with user-provided params
345-
const uiResource = this.createWidgetUIResource(
346-
definition.widget,
347-
params,
348-
definition.size
349-
)
347+
const uiResource = this.createWidgetUIResource(definition, params)
350348

351349
return {
352350
content: [
@@ -396,29 +394,24 @@ export class McpServer {
396394
* compatible clients.
397395
*
398396
* @private
399-
* @param widget - Widget name/identifier
397+
* @param definition - UIResource definition
400398
* @param params - Parameters to pass to the widget via URL
401-
* @param size - Optional preferred frame size [width, height]
402399
* @returns UIResource object compatible with MCP-UI
403400
*/
404401
private createWidgetUIResource(
405-
widget: string,
406-
params: Record<string, any>,
407-
size?: [string, string]
402+
definition: UIResourceDefinition,
403+
params: Record<string, any>
408404
): any {
409-
const iframeUrl = this.buildWidgetUrl(widget, params)
405+
// Initialize adapter if not already created
406+
if (!this.uiAdapter) {
407+
this.uiAdapter = createMcpUiAdapter({
408+
baseUrl: `http://localhost`,
409+
port: this.serverPort || 3001
410+
})
411+
}
410412

411-
return createUIResource({
412-
uri: `ui://widget/${widget}` as any,
413-
content: {
414-
type: 'externalUrl',
415-
iframeUrl
416-
},
417-
encoding: 'text',
418-
uiMetadata: size ? {
419-
'preferred-frame-size': size
420-
} : undefined
421-
})
413+
// Use the adapter to create the UIResource
414+
return this.uiAdapter.createWidgetUIResource(definition, params)
422415
}
423416

424417
/**

0 commit comments

Comments
 (0)