Skip to content

Commit 14420e6

Browse files
authored
Shrink MCP App HTML by sharing language templates and bundling the Zama icon (#838)
1 parent 6eb89bc commit 14420e6

43 files changed

Lines changed: 445 additions & 142 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/shrink-mcp-apps.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'ui': patch
3+
'@openzeppelin/contracts-mcp': patch
4+
---
5+
6+
Shrink MCP App package size by sharing one HTML template per language.
7+
- Inject contract kind at serve time instead of shipping near-duplicate HTML per tool.
8+
- Scope MCP App Tailwind content away from web-only Wizard shells.
9+
- Bundle the Zama logo so it renders in self-contained MCP Apps.

packages/mcp/src/apps/register.test.ts

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import test from 'ava';
22
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
33
import { testMcpInfo } from '../helpers.test';
4-
import { appResourceUri, readAppHtml, registerWizardAppTool, RESOURCE_MIME_TYPE, wizardAppResult } from './register';
4+
import {
5+
appResourceUri,
6+
getToolAppSpec,
7+
MCP_KIND_PLACEHOLDER,
8+
readAppHtml,
9+
registerWizardAppTool,
10+
RESOURCE_MIME_TYPE,
11+
TOOL_APP_SPECS,
12+
wizardAppResult,
13+
} from './register';
514
import { registerSolidityERC20 } from '../solidity/tools/erc20';
615

716
test('solidity-erc20 registers UI metadata', t => {
@@ -12,25 +21,42 @@ test('solidity-erc20 registers UI metadata', t => {
1221
t.is(tool._meta?.['ui/resourceUri'], appResourceUri('solidity-erc20'));
1322
});
1423

15-
test('MCP App HTML artifacts exist for Wizard-backed tools', async t => {
16-
// One tool per language; registerWizardAppTool fails closed for the rest at server start.
17-
const tools = [
18-
'solidity-erc20',
19-
'solidity-erc721',
20-
'cairo-erc20',
21-
'stellar-fungible',
22-
'stylus-erc20',
23-
'confidential-erc7984',
24-
'uniswap-hooks',
25-
];
24+
test('TOOL_APP_SPECS entries have distinct per-tool URIs and known overrides', t => {
25+
const tools = Object.keys(TOOL_APP_SPECS);
26+
t.true(tools.length >= 26, `expected at least 26 tools, got ${tools.length}`);
2627
for (const tool of tools) {
28+
const spec = getToolAppSpec(tool);
29+
t.truthy(spec.template, `${tool} missing template`);
30+
t.truthy(spec.kind, `${tool} missing kind`);
31+
t.is(appResourceUri(tool), `ui://openzeppelin/${tool}.html`);
32+
}
33+
t.is(TOOL_APP_SPECS['solidity-rwa']?.kind, 'RealWorldAsset');
34+
t.is(TOOL_APP_SPECS['uniswap-hooks']?.kind, 'Hooks');
35+
});
36+
37+
test('MCP App HTML artifacts exist and inject kind for Wizard-backed tools', async t => {
38+
const tools = Object.keys(TOOL_APP_SPECS).sort();
39+
for (const tool of tools) {
40+
const spec = getToolAppSpec(tool);
2741
const html = await readAppHtml(tool);
2842
t.true(html.includes('<!DOCTYPE html>'), `${tool} missing doctype`);
2943
t.true(html.includes('<script>'), `${tool} missing script`);
3044
t.true(html.length > 10_000, `${tool} HTML unexpectedly small`);
45+
t.false(html.includes(MCP_KIND_PLACEHOLDER), `${tool} still contains kind placeholder`);
46+
t.true(html.includes(spec.kind), `${tool} missing injected kind ${spec.kind}`);
3147
}
3248
});
3349

50+
test('kind injection differentiates tools that share a language template', async t => {
51+
const erc20 = await readAppHtml('solidity-erc20');
52+
const erc721 = await readAppHtml('solidity-erc721');
53+
t.not(erc20, erc721, 'shared language template must produce distinct per-tool HTML after kind inject');
54+
t.false(erc20.includes(MCP_KIND_PLACEHOLDER));
55+
t.false(erc721.includes(MCP_KIND_PLACEHOLDER));
56+
// Kind names also appear as Controls keys in the full language bundle; the mount argument is what differs.
57+
t.true(Math.abs(erc20.length - erc721.length) < 32, 'injected HTML should only differ by kind string length');
58+
});
59+
3460
/**
3561
* Guards the two properties the hosted server depends on. mcp.openzeppelin.com consumes this
3662
* package as a library, so one long-lived process serves many sessions: the read must not block
@@ -50,14 +76,24 @@ test('RESOURCE_MIME_TYPE is the MCP Apps profile', t => {
5076
t.is(RESOURCE_MIME_TYPE, 'text/html;profile=mcp-app');
5177
});
5278

53-
test('missing App HTML fails closed with build:apps guidance', t => {
79+
test('kind placeholder stays in sync across mcp, ui, and packaging script', async t => {
80+
const { readFile } = await import('fs/promises');
81+
const { join } = await import('path');
82+
const uiPlaceholderSrc = await readFile(join(__dirname, '../../../ui/src/mcp-apps/kind-placeholder.ts'), 'utf-8');
83+
const packageScript = await readFile(join(__dirname, '../../../ui/scripts/package-mcp-apps.mjs'), 'utf-8');
84+
const assignment = new RegExp(`MCP_KIND_PLACEHOLDER\\s*=\\s*'${MCP_KIND_PLACEHOLDER}'`);
85+
t.regex(uiPlaceholderSrc, assignment);
86+
t.regex(packageScript, assignment);
87+
});
88+
89+
test('missing App mapping fails closed with guidance', t => {
5490
const err = t.throws(() => readAppHtml('definitely-missing-tool-xyz'));
5591
t.true(err instanceof Error);
56-
t.regex((err as Error).message, /MCP App HTML missing/);
57-
t.regex((err as Error).message, /build:apps/);
92+
t.regex((err as Error).message, /MCP App mapping missing/);
93+
t.regex((err as Error).message, /TOOL_APP_SPECS|build:apps/);
5894
});
5995

60-
test('registerWizardAppTool fails closed when HTML missing', t => {
96+
test('registerWizardAppTool fails closed when mapping missing', t => {
6197
const server = new McpServer(testMcpInfo);
6298
const err = t.throws(() =>
6399
registerWizardAppTool(
@@ -73,8 +109,7 @@ test('registerWizardAppTool fails closed when HTML missing', t => {
73109
),
74110
);
75111
t.true(err instanceof Error);
76-
t.regex((err as Error).message, /MCP App HTML missing/);
77-
t.regex((err as Error).message, /build:apps/);
112+
t.regex((err as Error).message, /MCP App mapping missing/);
78113
});
79114

80115
test('wizardAppResult sets top-level isError on failure', t => {
@@ -88,3 +123,27 @@ test('wizardAppResult sets top-level isError on failure', t => {
88123
t.is(fail.structuredContent.code, undefined);
89124
t.is(fail.structuredContent.error, 'bad options');
90125
});
126+
127+
/**
128+
* Live registry check via compiled createServer (ava/ts-node cannot load server.ts .js imports).
129+
* Ensures every mapped tool got registerWizardAppTool UI metadata and a registered UI resource
130+
* (not a bare registerTool with hand-copied _meta).
131+
*/
132+
test('createServer wires MCP App UI metadata for every TOOL_APP_SPECS tool', t => {
133+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- ava/ts-node cannot load server.ts .js imports
134+
const { createServer } = require('../../dist/server.js') as { createServer: () => McpServer };
135+
const server = createServer();
136+
const { _registeredTools: tools, _registeredResources: resources } = server as unknown as {
137+
_registeredTools: Record<string, { _meta?: { ui?: { resourceUri?: string }; [key: string]: unknown } }>;
138+
_registeredResources: Record<string, unknown>;
139+
};
140+
141+
for (const toolName of Object.keys(TOOL_APP_SPECS)) {
142+
const tool = tools[toolName];
143+
const uri = appResourceUri(toolName);
144+
t.truthy(tool, `createServer did not register '${toolName}'`);
145+
t.is(tool!._meta?.ui?.resourceUri, uri, `${toolName} missing UI resourceUri`);
146+
t.is(tool!._meta?.['ui/resourceUri'], uri, `${toolName} missing ui/resourceUri`);
147+
t.true(uri in resources, `${toolName} advertises ${uri} but createServer did not registerResource that URI`);
148+
}
149+
});

packages/mcp/src/apps/register.ts

Lines changed: 119 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,115 @@ const RESOURCE_URI_META_KEY = 'ui/resourceUri';
1111

1212
const 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+
1467
export 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
*/
44130
const 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> {
60167
function 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

Comments
 (0)