11#!/usr/bin/env node
22/**
33 * Demonstrates that the ext-apps (mcp-ui) pattern is fully implementable on top of the v2
4- * SDK's custom-method-handler API , without extending Protocol or relying on the v1 generic
4+ * SDK's `extension()` registrar , without extending Protocol or relying on the v1 generic
55 * type parameters.
66 *
77 * In v1, ext-apps defined `class ProtocolWithEvents<...> extends Protocol<SendRequestT, ...>` to
8- * widen the request/notification type unions. In v2, the same is achieved by composing
9- * setCustomRequestHandler / setCustomNotificationHandler / sendCustomRequest / sendCustomNotification
10- * on top of the standard Client and Server classes.
8+ * widen the request/notification type unions. In v2, the same is achieved by composing a
9+ * `Client`/`Server`, declaring an SEP-2133 extension via `.extension(id, settings)`, and using the
10+ * returned `ExtensionHandle` for all `ui/*` methods. Capability negotiation happens via the
11+ * standard MCP `initialize` exchange — no separate `ui/initialize` round-trip needed.
1112 */
1213
13- import { Client } from '@modelcontextprotocol/client' ;
14+ import { Client , type ExtensionHandle } from '@modelcontextprotocol/client' ;
1415import { InMemoryTransport , Server } from '@modelcontextprotocol/server' ;
1516import { z } from 'zod' ;
1617
18+ const EXT_ID = 'io.modelcontextprotocol/ui' ;
19+
1720// ───────────────────────────────────────────────────────────────────────────────
18- // Custom method schemas (mirror the ext-apps spec.types.ts pattern )
21+ // SEP-2133 extension capability shapes (asymmetric — App and Host advertise different things )
1922// ───────────────────────────────────────────────────────────────────────────────
2023
21- const InitializeParams = z . object ( {
22- protocolVersion : z . string ( ) ,
23- appInfo : z . object ( { name : z . string ( ) , version : z . string ( ) } )
24+ const AppCapabilities = z . object ( {
25+ availableDisplayModes : z . array ( z . enum ( [ 'inline' , 'fullscreen' ] ) )
2426} ) ;
25- const InitializeResult = z . object ( {
26- protocolVersion : z . string ( ) ,
27- hostInfo : z . object ( { name : z . string ( ) , version : z . string ( ) } ) ,
27+ type AppCapabilities = z . infer < typeof AppCapabilities > ;
28+
29+ const HostCapabilities = z . object ( {
30+ openLinks : z . boolean ( ) ,
2831 hostContext : z . object ( { theme : z . enum ( [ 'light' , 'dark' ] ) , locale : z . string ( ) } )
2932} ) ;
33+ type HostCapabilities = z . infer < typeof HostCapabilities > ;
34+
35+ // ───────────────────────────────────────────────────────────────────────────────
36+ // Custom method schemas (mirror the ext-apps spec.types.ts pattern)
37+ // ───────────────────────────────────────────────────────────────────────────────
3038
3139const OpenLinkParams = z . object ( { url : z . url ( ) } ) ;
3240const OpenLinkResult = z . object ( { opened : z . boolean ( ) } ) ;
@@ -43,32 +51,32 @@ type AppEventMap = {
4351} ;
4452
4553// ───────────────────────────────────────────────────────────────────────────────
46- // App: wraps Client, exposes typed mcp-ui/* methods + DOM-style events
54+ // App: wraps Client + ExtensionHandle , exposes typed mcp-ui/* methods + DOM-style events
4755// (replaces v1's `class App extends ProtocolWithEvents<AppRequest, AppNotification, AppResult, AppEventMap>`)
4856// ───────────────────────────────────────────────────────────────────────────────
4957
5058class App {
5159 readonly client : Client ;
60+ readonly ui : ExtensionHandle < AppCapabilities , HostCapabilities > ;
5261 private _listeners : { [ K in keyof AppEventMap ] : ( ( p : AppEventMap [ K ] ) => void ) [ ] } = {
5362 toolresult : [ ] ,
5463 hostcontextchanged : [ ]
5564 } ;
56- private _hostContext ?: z . infer < typeof InitializeResult > [ 'hostContext' ] ;
65+ private _hostContext ?: HostCapabilities [ 'hostContext' ] ;
5766
5867 onTeardown ?: ( params : z . infer < typeof TeardownParams > ) => void | Promise < void > ;
5968
60- constructor ( appInfo : { name : string ; version : string } ) {
69+ constructor ( appInfo : { name : string ; version : string } , caps : AppCapabilities ) {
6170 this . client = new Client ( appInfo , { capabilities : { } } ) ;
71+ this . ui = this . client . extension ( EXT_ID , caps , { peerSchema : HostCapabilities } ) ;
6272
63- // Incoming custom request from host
64- this . client . setCustomRequestHandler ( 'mcp-ui/resourceTeardown' , TeardownParams , async params => {
73+ this . ui . setRequestHandler ( 'mcp-ui/resourceTeardown' , TeardownParams , async params => {
6574 await this . onTeardown ?. ( params ) ;
6675 return { } ;
6776 } ) ;
6877
69- // Incoming custom notifications from host -> DOM-style event slots
70- this . client . setCustomNotificationHandler ( 'mcp-ui/toolResult' , ToolResultParams , p => this . _dispatch ( 'toolresult' , p ) ) ;
71- this . client . setCustomNotificationHandler ( 'mcp-ui/hostContextChanged' , HostContextChangedParams , p => {
78+ this . ui . setNotificationHandler ( 'mcp-ui/toolResult' , ToolResultParams , p => this . _dispatch ( 'toolresult' , p ) ) ;
79+ this . ui . setNotificationHandler ( 'mcp-ui/hostContextChanged' , HostContextChangedParams , p => {
7280 this . _hostContext = { ...this . _hostContext ! , ...p } ;
7381 this . _dispatch ( 'hostcontextchanged' , p ) ;
7482 } ) ;
@@ -89,77 +97,74 @@ class App {
8997 }
9098
9199 async connect(transport: Parameters< Client [ 'connect' ] > [ 0 ] ) : Promise < void > {
100+ // MCP `initialize` carries capabilities.extensions[EXT_ID] both ways — no separate
101+ // mcp-ui/initialize round-trip needed.
92102 await this . client . connect ( transport ) ;
93- const result = await this . client . sendCustomRequest (
94- 'mcp-ui/initialize' ,
95- { protocolVersion : '2026-01-26' , appInfo : { name : 'demo-app' , version : '1.0.0' } } ,
96- InitializeResult
97- ) ;
98- this . _hostContext = result . hostContext ;
99- await this . client . sendCustomNotification ( 'mcp-ui/initialized' , { } ) ;
103+ this . _hostContext = this . ui . getPeerSettings ( ) ?. hostContext ;
104+ }
105+
106+ get hostCapabilities ( ) : HostCapabilities | undefined {
107+ return this . ui . getPeerSettings ( ) ;
100108 }
101109
102110 getHostContext ( ) {
103111 return this . _hostContext ;
104112 }
105113
106114 openLink ( url : string ) {
107- return this . client . sendCustomRequest ( 'mcp-ui/openLink' , { url } , OpenLinkResult ) ;
115+ return this . ui . sendRequest ( 'mcp-ui/openLink' , { url } , OpenLinkResult ) ;
108116 }
109117
110118 notifySizeChanged ( width : number , height : number ) {
111- return this . client . sendCustomNotification ( 'mcp-ui/sizeChanged' , { width, height } ) ;
119+ return this . ui . sendNotification ( 'mcp-ui/sizeChanged' , { width, height } ) ;
112120 }
113121}
114122
115123// ───────────────────────────────────────────────────────────────────────────────
116- // Host: wraps Server, handles mcp-ui/* requests and emits mcp-ui/* notifications
124+ // Host: wraps Server + ExtensionHandle , handles mcp-ui/* requests and emits mcp-ui/* notifications
117125// ───────────────────────────────────────────────────────────────────────────────
118126
119127class Host {
120128 readonly server : Server ;
129+ readonly ui : ExtensionHandle < HostCapabilities , AppCapabilities > ;
121130 onSizeChanged ?: ( p : z . infer < typeof SizeChangedParams > ) => void ;
122131
123132 constructor ( ) {
124133 this . server = new Server ( { name : 'demo-host' , version : '1.0.0' } , { capabilities : { } } ) ;
134+ this . ui = this . server . extension (
135+ EXT_ID ,
136+ { openLinks : true , hostContext : { theme : 'dark' , locale : 'en-US' } } ,
137+ { peerSchema : AppCapabilities }
138+ ) ;
125139
126- this . server . setCustomRequestHandler ( 'mcp-ui/initialize' , InitializeParams , params => {
127- console . log ( `[host] mcp-ui/initialize from ${ params . appInfo . name } @${ params . appInfo . version } ` ) ;
128- return {
129- protocolVersion : params . protocolVersion ,
130- hostInfo : { name : 'demo-host' , version : '1.0.0' } ,
131- hostContext : { theme : 'dark' , locale : 'en-US' }
132- } ;
133- } ) ;
134-
135- this . server . setCustomRequestHandler ( 'mcp-ui/openLink' , OpenLinkParams , params => {
140+ this . ui . setRequestHandler ( 'mcp-ui/openLink' , OpenLinkParams , params => {
136141 console . log ( `[host] mcp-ui/openLink url=${ params . url } ` ) ;
137142 return { opened : true } ;
138143 } ) ;
139144
140- this . server . setCustomNotificationHandler ( 'mcp-ui/initialized' , z . object ( { } ) . optional ( ) , ( ) => {
141- console . log ( '[host] mcp-ui/initialized' ) ;
142- } ) ;
143-
144- this . server . setCustomNotificationHandler ( 'mcp-ui/sizeChanged' , SizeChangedParams , p => {
145+ this . ui . setNotificationHandler ( 'mcp-ui/sizeChanged' , SizeChangedParams , p => {
145146 console . log ( `[host] mcp-ui/sizeChanged ${ p . width } x${ p . height } ` ) ;
146147 this . onSizeChanged ?. ( p ) ;
147148 } ) ;
148149 }
149150
151+ get appCapabilities ( ) : AppCapabilities | undefined {
152+ return this . ui . getPeerSettings ( ) ;
153+ }
154+
150155 notifyToolResult ( toolName : string , text : string ) {
151- return this . server . sendCustomNotification ( 'mcp-ui/toolResult' , {
156+ return this . ui . sendNotification ( 'mcp-ui/toolResult' , {
152157 toolName,
153158 content : [ { type : 'text' , text } ]
154159 } ) ;
155160 }
156161
157162 notifyHostContextChanged ( patch : z . infer < typeof HostContextChangedParams > ) {
158- return this . server . sendCustomNotification ( 'mcp-ui/hostContextChanged' , patch ) ;
163+ return this . ui . sendNotification ( 'mcp-ui/hostContextChanged' , patch ) ;
159164 }
160165
161166 requestTeardown ( reason : string ) {
162- return this . server . sendCustomRequest ( 'mcp-ui/resourceTeardown' , { reason } , z . object ( { } ) ) ;
167+ return this . ui . sendRequest ( 'mcp-ui/resourceTeardown' , { reason } , z . object ( { } ) ) ;
163168 }
164169}
165170
@@ -169,7 +174,7 @@ class Host {
169174
170175async function main ( ) {
171176 const host = new Host ( ) ;
172- const app = new App ( { name : 'demo-app' , version : '1.0.0' } ) ;
177+ const app = new App ( { name : 'demo-app' , version : '1.0.0' } , { availableDisplayModes : [ 'inline' , 'fullscreen' ] } ) ;
173178
174179 app . addEventListener ( 'toolresult' , p => console . log ( `[app] toolresult: ${ p . toolName } -> "${ p . content [ 0 ] ?. text } "` ) ) ;
175180 app . addEventListener ( 'hostcontextchanged' , p => console . log ( `[app] hostcontextchanged: ${ JSON . stringify ( p ) } ` ) ) ;
@@ -180,6 +185,9 @@ async function main() {
180185 await host . server . connect ( serverTransport ) ;
181186 await app . connect ( clientTransport ) ;
182187
188+ // Capability negotiation via MCP initialize — both sides see each other's extensions[EXT_ID]
189+ console . log ( `[app] hostCapabilities: ${ JSON . stringify ( app . hostCapabilities ) } ` ) ;
190+ console . log ( `[host] appCapabilities: ${ JSON . stringify ( host . appCapabilities ) } ` ) ;
183191 console . log ( `[app] hostContext after init: ${ JSON . stringify ( app . getHostContext ( ) ) } ` ) ;
184192
185193 // App -> Host: custom request
0 commit comments