@@ -11,29 +11,115 @@ const RESOURCE_URI_META_KEY = 'ui/resourceUri';
1111
1212const APPS_DIR = path . join ( __dirname , '..' , '..' , 'apps' ) ;
1313
14+ /**
15+ * Kind placeholder baked into language HTML templates by the UI build.
16+ * Duplicated in packages/ui (kind-placeholder.ts, package-mcp-apps.mjs); kept aligned by
17+ * `kind placeholder stays in sync` in register.test.ts.
18+ */
19+ export const MCP_KIND_PLACEHOLDER = '__OZ_MCP_KIND__' ;
20+ export type AppTemplate = 'solidity' | 'cairo' | 'stellar' | 'stylus' | 'confidential' | 'uniswap-hooks' ;
21+
22+ export type ToolAppSpec = {
23+ template : AppTemplate ;
24+ kind : string ;
25+ } ;
26+
27+ /**
28+ * Exhaustive tool → language template + kind map.
29+ * Kept in lockstep with packages/core kinds by `TOOL_APP_SPECS matches every core kind and UI language entry`
30+ * in server.test.ts — do not add tools here without that test (and the UI entries/<language>.ts) passing.
31+ * Overrides match MCP_TOOL_NAME_OVERRIDES in server.test.ts: solidity-rwa ← RealWorldAsset, uniswap-hooks ← Hooks.
32+ */
33+ export const TOOL_APP_SPECS : Readonly < Record < string , ToolAppSpec > > = {
34+ 'solidity-erc20' : { template : 'solidity' , kind : 'ERC20' } ,
35+ 'solidity-erc721' : { template : 'solidity' , kind : 'ERC721' } ,
36+ 'solidity-erc1155' : { template : 'solidity' , kind : 'ERC1155' } ,
37+ 'solidity-stablecoin' : { template : 'solidity' , kind : 'Stablecoin' } ,
38+ 'solidity-rwa' : { template : 'solidity' , kind : 'RealWorldAsset' } ,
39+ 'solidity-account' : { template : 'solidity' , kind : 'Account' } ,
40+ 'solidity-governor' : { template : 'solidity' , kind : 'Governor' } ,
41+ 'solidity-custom' : { template : 'solidity' , kind : 'Custom' } ,
42+
43+ 'cairo-erc20' : { template : 'cairo' , kind : 'ERC20' } ,
44+ 'cairo-erc721' : { template : 'cairo' , kind : 'ERC721' } ,
45+ 'cairo-erc1155' : { template : 'cairo' , kind : 'ERC1155' } ,
46+ 'cairo-account' : { template : 'cairo' , kind : 'Account' } ,
47+ 'cairo-multisig' : { template : 'cairo' , kind : 'Multisig' } ,
48+ 'cairo-governor' : { template : 'cairo' , kind : 'Governor' } ,
49+ 'cairo-vesting' : { template : 'cairo' , kind : 'Vesting' } ,
50+ 'cairo-custom' : { template : 'cairo' , kind : 'Custom' } ,
51+
52+ 'stellar-fungible' : { template : 'stellar' , kind : 'Fungible' } ,
53+ 'stellar-non-fungible' : { template : 'stellar' , kind : 'NonFungible' } ,
54+ 'stellar-stablecoin' : { template : 'stellar' , kind : 'Stablecoin' } ,
55+ 'stellar-governor' : { template : 'stellar' , kind : 'Governor' } ,
56+ 'stellar-vault' : { template : 'stellar' , kind : 'Vault' } ,
57+
58+ 'stylus-erc20' : { template : 'stylus' , kind : 'ERC20' } ,
59+ 'stylus-erc721' : { template : 'stylus' , kind : 'ERC721' } ,
60+ 'stylus-erc1155' : { template : 'stylus' , kind : 'ERC1155' } ,
61+
62+ 'confidential-erc7984' : { template : 'confidential' , kind : 'ERC7984' } ,
63+
64+ 'uniswap-hooks' : { template : 'uniswap-hooks' , kind : 'Hooks' } ,
65+ } ;
66+
1467export function appResourceUri ( toolName : string ) : string {
1568 return `ui://openzeppelin/${ toolName } .html` ;
1669}
1770
18- /** Resolve HTML for a tool, throwing build guidance when the artifact is missing. */
19- function resolveAppHtmlPath ( toolName : string ) : string {
20- const toolPath = path . join ( APPS_DIR , `${ toolName } .html` ) ;
21- if ( fs . existsSync ( toolPath ) ) {
22- return toolPath ;
71+ export function getToolAppSpec ( toolName : string ) : ToolAppSpec {
72+ const spec = TOOL_APP_SPECS [ toolName ] ;
73+ if ( spec === undefined ) {
74+ throw new Error (
75+ `MCP App mapping missing for ${ toolName } . ` +
76+ `Add it to TOOL_APP_SPECS in packages/mcp/src/apps/register.ts, then run: ` +
77+ `yarn --cwd packages/mcp build:apps` ,
78+ ) ;
79+ }
80+ return spec ;
81+ }
82+
83+ /** Resolve language HTML template path, throwing build guidance when missing. */
84+ function resolveAppHtmlPath ( template : AppTemplate , toolName : string ) : string {
85+ const templatePath = path . join ( APPS_DIR , `${ template } .html` ) ;
86+ if ( fs . existsSync ( templatePath ) ) {
87+ return templatePath ;
2388 }
2489 throw new Error (
25- `MCP App HTML missing for ${ toolName } (looked in ${ APPS_DIR } ). ` +
90+ `MCP App HTML missing for ${ toolName } (looked for template ${ template } .html in ${ APPS_DIR } ). ` +
2691 `Run: yarn --cwd packages/mcp build:apps ` +
2792 `(npm consumers: reinstall the package or report a packaging bug).` ,
2893 ) ;
2994}
3095
96+ function injectKind ( templateHtml : string , kind : string , toolName : string ) : string {
97+ // Replace the bare placeholder so either "__OZ_MCP_KIND__" or '__OZ_MCP_KIND__' becomes the kind.
98+ const matches = templateHtml . split ( MCP_KIND_PLACEHOLDER ) . length - 1 ;
99+ if ( matches !== 1 ) {
100+ throw new Error (
101+ `MCP App template for ${ toolName } must contain exactly one ${ MCP_KIND_PLACEHOLDER } ` +
102+ `kind placeholder (found ${ matches } ). Rebuild with yarn --cwd packages/mcp build:apps.` ,
103+ ) ;
104+ }
105+ const html = templateHtml . split ( MCP_KIND_PLACEHOLDER ) . join ( kind ) ;
106+ if ( html . includes ( MCP_KIND_PLACEHOLDER ) ) {
107+ throw new Error ( `MCP App kind injection left a kind placeholder in HTML for ${ toolName } ` ) ;
108+ }
109+ if ( ! html . includes ( kind ) ) {
110+ throw new Error ( `MCP App kind injection failed to embed kind ${ kind } for ${ toolName } ` ) ;
111+ }
112+ return html ;
113+ }
114+
31115/**
32116 * App HTML is an immutable build artifact, so it is read from disk at most once per tool and then
33117 * served from memory. Keyed by tool name rather than per request or per session, so the hosted
34118 * server holds one copy no matter how many sessions open the same app; memory is bounded by the
35119 * number of tools, not by traffic.
36120 *
121+ * Language templates are shared on disk; kind is injected once per tool on first read.
122+ *
37123 * The in-flight promise is cached so concurrent first reads share a single disk read, and a failed
38124 * read is evicted so a transient error cannot poison a tool for the process lifetime.
39125 *
@@ -42,16 +128,37 @@ function resolveAppHtmlPath(toolName: string): string {
42128 * refactor. See that test for why the hosted server depends on them.
43129 */
44130const htmlCache = new Map < string , Promise < string > > ( ) ;
131+ const templateCache = new Map < AppTemplate , Promise < string > > ( ) ;
45132
46- export function readAppHtml ( toolName : string ) : Promise < string > {
47- const cached = htmlCache . get ( toolName ) ;
133+ function readTemplateHtml ( template : AppTemplate , toolName : string ) : Promise < string > {
134+ const cached = templateCache . get ( template ) ;
48135 if ( cached !== undefined ) {
49136 return cached ;
50137 }
51- const pending = fs . promises . readFile ( resolveAppHtmlPath ( toolName ) , 'utf-8' ) . catch ( ( e : unknown ) => {
52- htmlCache . delete ( toolName ) ;
138+ const pending = fs . promises . readFile ( resolveAppHtmlPath ( template , toolName ) , 'utf-8' ) . catch ( ( e : unknown ) => {
139+ templateCache . delete ( template ) ;
53140 throw e ;
54141 } ) ;
142+ templateCache . set ( template , pending ) ;
143+ return pending ;
144+ }
145+
146+ export function readAppHtml ( toolName : string ) : Promise < string > {
147+ const cached = htmlCache . get ( toolName ) ;
148+ if ( cached !== undefined ) {
149+ return cached ;
150+ }
151+ // Resolve mapping synchronously so unknown tools fail before advertising UI.
152+ const spec = getToolAppSpec ( toolName ) ;
153+ // Fail closed on missing template before caching a doomed read.
154+ resolveAppHtmlPath ( spec . template , toolName ) ;
155+
156+ const pending = readTemplateHtml ( spec . template , toolName )
157+ . then ( templateHtml => injectKind ( templateHtml , spec . kind , toolName ) )
158+ . catch ( ( e : unknown ) => {
159+ htmlCache . delete ( toolName ) ;
160+ throw e ;
161+ } ) ;
55162 htmlCache . set ( toolName , pending ) ;
56163 return pending ;
57164}
@@ -60,7 +167,8 @@ export function readAppHtml(toolName: string): Promise<string> {
60167function registerWizardAppResource ( server : McpServer , toolName : string , options ?: { title ?: string } ) : void {
61168 const uri = appResourceUri ( toolName ) ;
62169 // Fail closed: do not register a UI resource that cannot be served.
63- resolveAppHtmlPath ( toolName ) ;
170+ const spec = getToolAppSpec ( toolName ) ;
171+ resolveAppHtmlPath ( spec . template , toolName ) ;
64172 server . registerResource (
65173 options ?. title ?? `${ toolName } UI` ,
66174 uri ,
0 commit comments