{"id":292,"date":"2026-08-26T14:23:42","date_gmt":"2026-08-26T14:23:42","guid":{"rendered":"https:\/\/subashselvaraj.com\/?p=292"},"modified":"2026-08-26T14:23:42","modified_gmt":"2026-08-26T14:23:42","slug":"agentic-apps-using-app-builder-and-mcp-part-2","status":"publish","type":"post","link":"https:\/\/subashselvaraj.com\/index.php\/2026\/08\/26\/agentic-apps-using-app-builder-and-mcp-part-2\/","title":{"rendered":"Agentic Apps Using App Builder and MCP\u200a\u2014\u200aPart\u00a02"},"content":{"rendered":"\n<p>Building the Agentic App\u200a\u2014\u200aturning a plain MCP tool into an interactive HTML experience the host renders inline.<\/p>\n\n\n\n<p>In <a href=\"https:\/\/subashselvaraj.com\/index.php\/2026\/07\/16\/agentic-apps-using-app-builder-and-mcp\/\" target=\"_blank\" rel=\"noopener\" title=\"\">Part 1<\/a>, we scaffolded an MCP server with <code>aio app init<\/code> using the <code>@adobe\/generator-app-remote-mcp-server-generic<\/code> template. That template ships three sample tools &#8211; <code>echo<\/code>, <code>calculator<\/code>, and <code>weather<\/code> &#8211; but <code>weather<\/code> only returns a text summary, there&#8217;s no UI.<\/p>\n\n\n\n<p>This part turns <code>weather<\/code> into an <strong>Agentic App<\/strong>: a tool that still returns structured data, but also renders an interactive HTML card inside the MCP host &#8211; themed and animated to match the reported condition (sun, clouds, rain, snow), with a search box that lets you check another city without leaving the card.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>1. Add MCP Apps Dependencies<\/h3>\n\n\n\n<p>Part 1 already gave you <code>@adobe\/aio-sdk<\/code>, <code>@modelcontextprotocol\/sdk<\/code>, and <code>zod<\/code> from the generator template. Add the MCP Apps extension and the dev dependencies needed to build the UI:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>npm install @modelcontextprotocol\/ext-apps\nnpm install -D vite vite-plugin-singlefile typescript<\/code><\/pre>\n\n\n\n<p>Your <code>package.json<\/code> should now include:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"dependencies\": {\n    \"@adobe\/aio-sdk\": \"^5.0.0\",\n    \"@modelcontextprotocol\/ext-apps\": \"^1.7.5\",\n    \"@modelcontextprotocol\/sdk\": \"^1.24.0\",\n    \"zod\": \"^3.23.8\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"^8.1.5\",\n    \"vite-plugin-singlefile\": \"^2.3.3\",\n    \"typescript\": \"^7.0.2\"\n  }\n}<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>2. Project Layout for the&nbsp;UI<\/h3>\n\n\n\n<p>Add an <code>app-ui<\/code> source folder alongside your server action:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>actions\/mcp-server\/\n\u251c\u2500\u2500 index.js\n\u251c\u2500\u2500 tools.js\n\u251c\u2500\u2500 validator.js\n\u251c\u2500\u2500 embedded-ui.js             # Generated - do not edit (npm run embed:ui)\n\u251c\u2500\u2500 app-ui\/                    # MCP App UI source\n\u2502   \u251c\u2500\u2500 vite.config.mjs\n\u2502   \u251c\u2500\u2500 tsconfig.json\n\u2502   \u2514\u2500\u2500 src\/\n\u2502       \u251c\u2500\u2500 global.css\n\u2502       \u2514\u2500\u2500 weather\/\n\u2502           \u251c\u2500\u2500 weather.html\n\u2502           \u251c\u2500\u2500 weather.ts\n\u2502           \u2514\u2500\u2500 weather.css\n\u2514\u2500\u2500 static\/                    # Generated - single-file HTML bundles (gitignored)\n    \u2514\u2500\u2500 weather.html<\/code><\/pre>\n\n\n\n<p>Each MCP App view gets its own subfolder under <code>app-ui\/src\/<\/code>. <code>weather<\/code> is the only one the template ships with, but the build is set up so adding more is just a matter of adding another folder &#8211; see <a href=\"#extending\">Extending to Multiple Views<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>3. Build the UI with\u00a0Vite<\/h3>\n\n\n\n<p>Create <code>actions\/mcp-server\/app-ui\/vite.config.mjs<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import path from 'path'\nimport { fileURLToPath } from 'url'\nimport { defineConfig } from 'vite'\nimport { viteSingleFile } from 'vite-plugin-singlefile'\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url))\n\/\/ APP selects which UI to build. Each MCP App UI lives under src\/&lt;app-name>\/&lt;app-name>.html\n\/\/ (see scripts\/build-ui.js, which builds every one of them by setting this env var in turn).\nconst APP = process.env.APP || 'weather'\nconst appRoot = path.join(__dirname, 'src', APP)\nexport default defineConfig({\n  root: appRoot,\n  plugins: &#91;viteSingleFile()],\n  build: {\n    outDir: path.join(__dirname, '..\/static'),\n    emptyOutDir: false,\n    rollupOptions: {\n      input: path.join(appRoot, `${APP}.html`)\n    }\n  }\n})<\/code><\/pre>\n\n\n\n<p><code>vite-plugin-singlefile<\/code> inlines all CSS and JavaScript into one HTML file. That matters on I\/O Runtime: the deployed action bundle is the only artifact that ships to production.<\/p>\n\n\n\n<p>Instead of hardcoding a build command per view, <code>scripts\/build-ui.js<\/code> discovers every folder under <code>app-ui\/src\/<\/code> and builds each one by setting <code>APP<\/code> in turn:<\/p>\n\n\n\n<p>scripts\/build-ui.js<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const appUiSrcDir = path.join(__dirname, '..\/actions\/mcp-server\/app-ui\/src')\nconst configPath = path.join(__dirname, '..\/actions\/mcp-server\/app-ui\/vite.config.mjs')\nconst viteBin = path.join(__dirname, '..\/node_modules\/.bin\/vite')\n\nconst appNames = fs.readdirSync(appUiSrcDir, { withFileTypes: true })\n  .filter((entry) => entry.isDirectory())\n  .map((entry) => entry.name)\n  .filter((name) => fs.existsSync(path.join(appUiSrcDir, name, `${name}.html`)))\n  .sort()\nfor (const name of appNames) {\n  console.log(`\\n> Building ${name}`)\n  execFileSync(viteBin, &#91;'build', '--config', configPath], {\n    stdio: 'inherit',\n    env: { ...process.env, APP: name }\n  })\n}<\/code><\/pre>\n\n\n\n<p>Add build scripts to package.json:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"scripts\": {\n    \"build\": \"npm run build:ui &amp;&amp; npm run embed:ui &amp;&amp; webpack\",\n    \"build:ui\": \"node scripts\/build-ui.js\",\n    \"embed:ui\": \"node scripts\/embed-ui.js\",\n    \"deploy\": \"npm run build &amp;&amp; aio app deploy\",\n    \"start\": \"node local-server.js\"\n  }\n}<\/code><\/pre>\n\n\n\n<h4>Why an embed&nbsp;script?<\/h4>\n\n\n\n<p>Adobe I\/O Runtime deploys a single webpack bundle. Static files in <code>actions\/mcp-server\/static\/<\/code> are <strong>not<\/strong> deployed alongside the action. <code>scripts\/embed-ui.js<\/code> solves this by discovering every built HTML file and inlining it into a JS module the server can <code>require()<\/code>:<\/p>\n\n\n\n<p>scripts\/embed-ui.js<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const staticDir = path.join(__dirname, '..\/actions\/mcp-server\/static')\nconst outPath = path.join(__dirname, '..\/actions\/mcp-server\/embedded-ui.js')\nconst toCamelCase = (stem) => stem.replace(\/-(&#91;a-z0-9])\/gi, (_, c) => c.toUpperCase())\n\nconst embedded = {}\nfor (const filename of fs.readdirSync(staticDir).filter((f) => f.endsWith('.html'))) {\n  const key = `${toCamelCase(filename.replace(\/\\.html$\/, ''))}Html`\n  embedded&#91;key] = fs.readFileSync(path.join(staticDir, filename), 'utf8')\n}\nfs.writeFileSync(outPath, `module.exports = ${JSON.stringify(embedded, null, 2)}\\n`)<\/code><\/pre>\n\n\n\n<p><code>weather.html<\/code> becomes the key <code>weatherHtml<\/code> in <code>embedded-ui.js<\/code> &#8211; the same key <code>tools.js<\/code> passes to <code>loadUiHtml()<\/code> (Step 6). At request time, the server prefers this embedded HTML; during local dev it falls back to reading files from <code>static\/<\/code> if the embed step hasn&#8217;t run yet.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>4. Create the HTML&nbsp;Shell<\/h3>\n\n\n\n<p><code>weather.html<\/code> is a minimal shell. The TypeScript entry point owns all dynamic behavior:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;!DOCTYPE html>\n&lt;html lang=\"en\">\n&lt;head>\n  &lt;meta charset=\"UTF-8\">\n  &lt;meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  &lt;meta name=\"color-scheme\" content=\"light dark\">\n  &lt;title>Weather&lt;\/title>\n&lt;\/head>\n&lt;body>\n  &lt;main class=\"main\" id=\"main\" data-category=\"loading\">\n    &lt;div class=\"weather-fx\" id=\"fx\" aria-hidden=\"true\">&lt;\/div>\n\n    &lt;div class=\"search-row\">\n      &lt;input type=\"text\" id=\"city-input\" class=\"city-input\" placeholder=\"Check another city\u2026\" aria-label=\"City name\">\n      &lt;button type=\"button\" id=\"search-btn\" class=\"btn-search\">Search&lt;\/button>\n    &lt;\/div>\n\n    &lt;section class=\"card\" id=\"card\" data-state=\"loading\">\n      &lt;header class=\"card-header\">\n        &lt;div class=\"location\">\n          &lt;h1 class=\"city-name\" id=\"city-name\">\u2014&lt;\/h1>\n          &lt;p class=\"country-name\" id=\"country-name\">&lt;\/p>\n        &lt;\/div>\n        &lt;div class=\"icon-wrap\" id=\"icon-wrap\">\n          &lt;!-- one .icon per condition (sun\/cloud\/rain\/snow\/loading) - CSS shows only\n               the one matching main&#91;data-category]; see weather.css -->\n        &lt;\/div>\n      &lt;\/header>\n\n      &lt;div class=\"temp-row\">\n        &lt;span class=\"temperature\" id=\"temperature\">--\u00b0&lt;\/span>\n        &lt;span class=\"condition\" id=\"condition\">\u2014&lt;\/span>\n      &lt;\/div>\n\n      &lt;dl class=\"details-grid\" id=\"details-grid\">\n        &lt;div class=\"detail\">&lt;dt>Humidity&lt;\/dt>&lt;dd id=\"detail-humidity\">\u2014&lt;\/dd>&lt;\/div>\n        &lt;div class=\"detail\">&lt;dt>Wind&lt;\/dt>&lt;dd id=\"detail-wind\">\u2014&lt;\/dd>&lt;\/div>\n        &lt;div class=\"detail\">&lt;dt>Pressure&lt;\/dt>&lt;dd id=\"detail-pressure\">\u2014&lt;\/dd>&lt;\/div>\n        &lt;div class=\"detail\">&lt;dt>Visibility&lt;\/dt>&lt;dd id=\"detail-visibility\">\u2014&lt;\/dd>&lt;\/div>\n        &lt;div class=\"detail\">&lt;dt>UV Index&lt;\/dt>&lt;dd id=\"detail-uv\">\u2014&lt;\/dd>&lt;\/div>\n      &lt;\/dl>\n\n      &lt;p class=\"updated\" id=\"updated\">&lt;\/p>\n    &lt;\/section>\n\n    &lt;p class=\"error-message\" id=\"error-message\" hidden>&lt;\/p>\n  &lt;\/main>\n  &lt;script type=\"module\" src=\".\/weather.ts\">&lt;\/script>\n&lt;\/body>\n&lt;\/html><\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>5. Implement the MCP App&nbsp;Client<\/h3>\n\n\n\n<p>The client-side code uses the <code>App<\/code> class from <code>@modelcontextprotocol\/ext-apps<\/code>. Register lifecycle handlers <strong>before<\/strong> calling <code>connect()<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import {\n  App,\n  applyDocumentTheme,\n  applyHostFonts,\n  applyHostStyleVariables,\n  type McpUiHostContext,\n} from \"@modelcontextprotocol\/ext-apps\";\nimport type { CallToolResult } from \"@modelcontextprotocol\/sdk\/types.js\";\nimport \"..\/global.css\";\nimport \".\/weather.css\";\n\nconst mainEl = document.getElementById(\"main\") as HTMLElement;\nconst cardEl = document.getElementById(\"card\") as HTMLElement;\nconst errorMessage = document.getElementById(\"error-message\") as HTMLParagraphElement;\nconst cityInput = document.getElementById(\"city-input\") as HTMLInputElement;\nconst searchBtn = document.getElementById(\"search-btn\") as HTMLButtonElement;\n\/\/ ...remaining detail-grid element refs (temperature, humidity, wind, pressure, etc.)\n\ntype WeatherStructured = {\n  city: string;\n  country: string;\n  category: \"sunny\" | \"cloudy\" | \"rain\" | \"snow\";\n  condition: string;\n  temperature: number;\n  humidity: number;\n  windSpeed: number;\n  pressure: number;\n  visibility: number;\n  uvIndex: number;\n  lastUpdated: string;\n};\n\n\/\/ Default\/reset state - shown from first paint and reapplied before any re-fetch, so a\n\/\/ request in flight always looks like \"loading\", never like stale or unrelated data.\nfunction setLoadingState() {\n  errorMessage.hidden = true;\n  cardEl.hidden = false;\n  cardEl.dataset.state = \"loading\";\n  mainEl.dataset.category = \"loading\";\n}\n\nfunction renderWeather(structured: WeatherStructured) {\n  errorMessage.hidden = true;\n  cardEl.hidden = false;\n  mainEl.dataset.category = structured.category; \/\/ CSS themes sun\/cloud\/rain\/snow off this\n  \/\/ ...populate temperature\/condition\/humidity\/wind\/pressure\/visibility\/uv text content\n  cardEl.dataset.state = \"ready\"; \/\/ flips the loading skeleton off\n}\n\nfunction showError(message: string) {\n  cardEl.hidden = true;\n  errorMessage.textContent = message;\n  errorMessage.hidden = false;\n}\n\nfunction applyToolResult(result: CallToolResult) {\n  if (result.isError) {\n    const content = result.content ?? &#91;];\n    const textItem = content.find((c): c is { type: \"text\"; text: string } => c.type === \"text\");\n    showError(textItem?.text ?? \"An error occurred.\");\n    return;\n  }\n  const structured = result.structuredContent as WeatherStructured | undefined;\n  if (structured) renderWeather(structured);\n}\n\nfunction handleHostContextChanged(ctx: McpUiHostContext) {\n  if (ctx.theme) applyDocumentTheme(ctx.theme);\n  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);\n  if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);\n}\n\nconst app = new App({ name: \"Weather App\", version: \"1.0.0\" });\n\napp.onteardown = async () => ({});\napp.ontoolinput = () => setLoadingState();\napp.ontoolresult = applyToolResult;\napp.onerror = console.error;\napp.onhostcontextchanged = handleHostContextChanged;\n\nasync function searchCity() {\n  const city = cityInput.value.trim();\n  if (!city) return;\n  setLoadingState();\n  try {\n    const result = await app.callServerTool({ name: \"weather\", arguments: { city } });\n    applyToolResult(result);\n  } catch (e) {\n    showError(e instanceof Error ? e.message : \"Weather request failed.\");\n  }\n}\n\nsearchBtn.addEventListener(\"click\", () => void searchCity());\ncityInput.addEventListener(\"keydown\", (e) => { if (e.key === \"Enter\") searchBtn.click(); });\n\n\/\/ Connect to the host - triggers the initial tool invocation\napp.connect().then(() => {\n  const ctx = app.getHostContext();\n  if (ctx) handleHostContextChanged(ctx);\n});<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>How the UI talks to the&nbsp;server<\/h3>\n\n\n\n<figure class=\"wp-block-image\"><img decoding=\"async\" src=\"https:\/\/cdn-images-1.medium.com\/max\/1600\/1*X2fHDhiugqhub1OuFZMSVQ.png\" alt=\"\"\/><\/figure>\n\n\n\n<p><code>callServerTool<\/code> is for UI actions that should re-run a tool without involving the agent. <code>sendMessage<\/code> becomes useful once your Agentic App has more than one view and needs the agent to navigate between them.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>6. Register the Tool on the&nbsp;Server<\/h3>\n\n\n\n<p>In <code>actions\/mcp-server\/tools.js<\/code>, the <code>weather<\/code> tool from Part 1 gets a <code>_meta.ui.resourceUri<\/code> link and an <code>outputSchema:<\/code><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const fs = require('fs\/promises')\nconst path = require('path')\nconst { z } = require('zod')\n\n\/\/ Adobe I\/O Runtime only deploys the bundled index.js - no sibling static\/ files - so the\n\/\/ weather UI must ride inside the bundle as a required JS string (see scripts\/embed-ui.js,\n\/\/ run via `npm run embed:ui` as part of `npm run build`). Falls back to reading\n\/\/ actions\/mcp-server\/static\/ on disk for local dev when the embed step hasn't run yet.\nlet embeddedUi = null\ntry {\n  embeddedUi = require('.\/embedded-ui.js')\n} catch {\n  \/\/ Local dev without embed step\n}\nasync function loadUiHtml(filePath, embeddedKey) {\n  const embedded = embeddedUi?.&#91;embeddedKey]\n  if (typeof embedded === 'string' &amp;&amp; embedded.length > 0) return embedded\n  return fs.readFile(filePath, 'utf-8')\n}\nconst WEATHER_RESOURCE_URI = 'ui:\/\/weather\/mcp-app.html'\nconst WEATHER_RESOURCE_MIME_TYPE = 'text\/html;profile=mcp-app'\nfunction registerTools(server) {\n  \/\/ ...echo and calculator tools from Part 1 stay as-is...\n  server.registerTool(\n    'weather',\n    {\n      title: 'Weather',\n      description: 'Get current weather information for any city.',\n      inputSchema: {\n        city: z.string().describe('Name of the city to get weather for (e.g., \"London\")')\n      },\n      outputSchema: {\n        city: z.string(),\n        country: z.string(),\n        category: z.enum(&#91;'sunny', 'cloudy', 'rain', 'snow']),\n        condition: z.string(),\n        temperature: z.number(),\n        humidity: z.number(),\n        windSpeed: z.number(),\n        pressure: z.number(),\n        visibility: z.number(),\n        uvIndex: z.number(),\n        lastUpdated: z.string()\n      },\n      \/\/ Links this tool's result to the weather app-ui resource registered below.\n      \/\/ Both keys point at the same URI - `ui\/resourceUri` is kept for hosts that\n      \/\/ haven't picked up the nested `ui.resourceUri` shape yet.\n      _meta: {\n        ui: { resourceUri: WEATHER_RESOURCE_URI },\n        'ui\/resourceUri': WEATHER_RESOURCE_URI\n      }\n    },\n    async ({ city = 'Unknown City' }) => {\n      \/\/ CUSTOMIZE: replace with a real weather API call. This generates mock data -\n      \/\/ a category (sunny\/cloudy\/rain\/snow, weighted so sun\/clouds are most common)\n      \/\/ and a plausible temperature\/humidity\/wind\/etc. within that category's range.\n      const structuredContent = generateMockWeather(city)\n      return {\n        content: &#91;{ type: 'text', text: `Weather for ${city}: ${structuredContent.condition}, ${structuredContent.temperature}\u00b0C` }],\n        structuredContent\n      }\n    }\n  )\n}<\/code><\/pre>\n\n\n\n<p>The critical part is <code>_meta.ui.resourceUri<\/code>. Without it, the host returns text only &#8211; no UI panel.<\/p>\n\n\n\n<p>Return both:<\/p>\n\n\n\n<ul>\n<li><code><strong>content<\/strong><\/code> &#8211; human-readable text for the LLM conversation (and for hosts without MCP App UI support)<\/li>\n\n\n\n<li><code><strong>structuredContent<\/strong><\/code> &#8211; typed data consumed by your HTML UI via <code>ontoolresult<\/code><\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>7. Register the UI&nbsp;Resource<\/h3>\n\n\n\n<p>Register the HTML resource that the host renders:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>function registerResources(server) {\n  \/\/ ...other resources from Part 1 (example-resource-1, docs:\/\/api, config:\/\/settings)...\n  \/\/ Weather app-ui - serves the bundled HTML\/CSS\/JS card that the `weather` tool\n  \/\/ links to via `_meta.ui.resourceUri` (see registerTools above).\n  server.registerResource(\n    WEATHER_RESOURCE_URI,\n    WEATHER_RESOURCE_URI,\n    { mimeType: WEATHER_RESOURCE_MIME_TYPE },\n    async () => {\n      const html = await loadUiHtml(\n        path.join(__dirname, 'static', 'weather.html'),\n        'weatherHtml'\n      )\n      return {\n        contents: &#91;{\n          uri: WEATHER_RESOURCE_URI,\n          mimeType: WEATHER_RESOURCE_MIME_TYPE,\n          text: html\n        }]\n      }\n    }\n  )\n}<\/code><\/pre>\n\n\n\n<p>This card is pure CSS\/JS with no external images, so the resource doesn\u2019t need a CSP allowlist. If your own MCP App UI loads external images or fonts, declare the allowed origins under <code>_meta.ui.csp.resourceDomains<\/code> on <code>contents[0]<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>8. Build and Run&nbsp;Locally<\/h3>\n\n\n\n<p>Build the full pipeline:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">npm run build<br>npm start<\/pre>\n\n\n\n<p><code>local-server.js<\/code> wraps your I\/O Runtime action in a plain HTTP server on port <strong>9080<\/strong>, translating each request into the <code>__ow_*<\/code> params shape <code>main()<\/code> expects and loading <code>LOG_LEVEL<\/code>, <code>SERVICE_API_KEY<\/code>, and <code>AUTH_VALIDATE_IMS<\/code> from&nbsp;<code>.env<\/code>.<\/p>\n\n\n\n<h3>Connect Cursor<\/h3>\n\n\n\n<p>Add to your Cursor MCP settings:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"mcpServers\": {\n    \"agentic-app-local\": {\n      \"url\": \"http:\/\/localhost:9080\",\n      \"type\": \"streamable-http\"\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p>Restart Cursor, then ask:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p><em>What\u2019s the weather in Tokyo?<\/em><\/p>\n<\/blockquote>\n\n\n\n<p>The host invokes <code>weather<\/code>, your server generates the data, and the themed weather card renders inside the conversation.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" loading=\"lazy\" width=\"1024\" height=\"1014\" src=\"https:\/\/subashselvaraj.com\/wp-content\/uploads\/2026\/08\/image-1024x1014.png\" alt=\"\" class=\"wp-image-293\" srcset=\"https:\/\/subashselvaraj.com\/wp-content\/uploads\/2026\/08\/image-1024x1014.png 1024w, https:\/\/subashselvaraj.com\/wp-content\/uploads\/2026\/08\/image-300x297.png 300w, https:\/\/subashselvaraj.com\/wp-content\/uploads\/2026\/08\/image-150x150.png 150w, https:\/\/subashselvaraj.com\/wp-content\/uploads\/2026\/08\/image-768x761.png 768w, https:\/\/subashselvaraj.com\/wp-content\/uploads\/2026\/08\/image.png 1486w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">Conversation Screenshot from Cursor\u00a0<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>9. Deploy to Adobe I\/O&nbsp;Runtime<\/h3>\n\n\n\n<p>The build pipeline now bundles the weather UI along with the action code. Redeploy the same way you did in Part 1:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>npm run deploy<\/code><\/pre>\n\n\n\n<p>This re-runs <code>build:ui<\/code> \u2192 <code>embed:ui<\/code> \u2192 <code>webpack<\/code>, then <code>aio app deploy<\/code> uploads the updated bundle. The deployed URL is unchanged from Part 1:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>https:\/\/&lt;namespace>.adobeioruntime.net\/api\/v1\/web\/&lt;package-name>\/mcp-server<\/code><\/pre>\n\n\n\n<p>Point Cursor at the deployed URL instead of <code>localhost:9080<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"mcpServers\": {\n    \"my-agentic-app\": {\n      \"url\": \"https:\/\/&lt;namespace>.adobeioruntime.net\/api\/v1\/web\/&lt;package-name>\/mcp-server\",\n      \"type\": \"streamable-http\"\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p>Or Claude Desktop, via <code>mcp-remote<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"mcpServers\": {\n    \"my-agentic-app\": {\n      \"command\": \"npx\",\n      \"args\": &#91;\n        \"mcp-remote\",\n        \"https:\/\/&lt;namespace>.adobeioruntime.net\/api\/v1\/web\/&lt;package-name>\/mcp-server\"\n      ]\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<h3>ChatGPT<\/h3>\n\n\n\n<p>ChatGPT doesn\u2019t take a JSON config file\u200a\u2014\u200aconnectors are added through the UI, and only over HTTPS, which is why this is the first host we can wire up ChatGPT for:<\/p>\n\n\n\n<ol>\n<li>In ChatGPT, go to <strong>Settings \u2192 Plugins \u2192 Advanced settings<\/strong> (some accounts show this under <strong>Settings \u2192 Security and login<\/strong>) and turn on <strong>Developer mode<\/strong>. This unlocks custom, unverified connectors.<\/li>\n\n\n\n<li>Back in <strong>Settings \u2192 Plugins<\/strong>, click <strong>Create<\/strong>.<\/li>\n\n\n\n<li>Enter a name and description, then paste your deployed MCP server URL:<code>https:\/\/&lt;namespace&gt;.adobeioruntime.net\/api\/v1\/web\/&lt;package-name&gt;\/mcp-server<\/code><\/li>\n\n\n\n<li>For <strong>Authentication<\/strong>, choose <strong>No authentication<\/strong>\u200a\u2014\u200ait matches the unset <code>SERVICE_API_KEY<\/code> \/ <code>AUTH_VALIDATE_IMS<\/code> from this part.<\/li>\n\n\n\n<li>Save, then enable the connector from the <strong>+<\/strong> menu in a chat (or under Deep Research) so <code>weather<\/code> is available as a tool.<\/li>\n<\/ol>\n\n\n\n<p>ChatGPT implements the same MCP Apps standard this server already uses (<code>_meta.ui.resourceUri<\/code>), so the themed card should render there too, not just the text summary.<\/p>\n\n\n\n<p>Ask the same test prompt again in each host\u200a\u2014\u200athe live server should render the identical weather card:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<p>What\u2019s the weather in Tokyo?<\/p>\n<\/blockquote>\n\n\n\n<figure class=\"wp-block-image\"><img decoding=\"async\" src=\"https:\/\/cdn-images-1.medium.com\/max\/1600\/1*A7ZLwyTgcF8P9FEWYU4I0g.png\" alt=\"\"\/><figcaption class=\"wp-element-caption\">Conversation Screenshot from ChatGPT<\/figcaption><\/figure>\n\n\n\n<p>None of these configs send credentials, because <code>SERVICE_API_KEY<\/code> and <code>AUTH_VALIDATE_IMS<\/code> are still unset &#8211; the deployed URL is open to anyone who has it. That&#8217;s fine for testing, but not for production. <strong>Part 3<\/strong> covers turning on authentication before you rely on this deployment.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>End-to-End Flow<\/h3>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" loading=\"lazy\" width=\"1024\" height=\"830\" src=\"https:\/\/subashselvaraj.com\/wp-content\/uploads\/2026\/08\/image-1-1024x830.png\" alt=\"\" class=\"wp-image-294\" srcset=\"https:\/\/subashselvaraj.com\/wp-content\/uploads\/2026\/08\/image-1-1024x830.png 1024w, https:\/\/subashselvaraj.com\/wp-content\/uploads\/2026\/08\/image-1-300x243.png 300w, https:\/\/subashselvaraj.com\/wp-content\/uploads\/2026\/08\/image-1-768x623.png 768w, https:\/\/subashselvaraj.com\/wp-content\/uploads\/2026\/08\/image-1.png 1344w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">The second search stays entirely between the UI and the server\u200a\u2014\u200athe host and user aren\u2019t involved again until the next conversation turn.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>Extending to Multiple&nbsp;Views<\/h3>\n\n\n\n<p>Because <code>scripts\/build-ui.js<\/code> discovers every folder under <code>app-ui\/src\/<\/code>, adding a second view is just a matter of:<\/p>\n\n\n\n<ol>\n<li>Adding <code>app-ui\/src\/&lt;name&gt;\/&lt;name&gt;.{html,ts,css}<\/code> &#8211; for example <code>app-ui\/src\/forecast\/forecast.html<\/code><\/li>\n\n\n\n<li>Registering a matching resource, e.g. <code>ui:\/\/forecast\/mcp-app.html<\/code><\/li>\n\n\n\n<li>Pointing a tool\u2019s <code>_meta.ui.resourceUri<\/code> at that resource<\/li>\n<\/ol>\n\n\n\n<p>No changes to <code>vite.config.mjs<\/code>, <code>build-ui.js<\/code>, or <code>embed-ui.js<\/code> are needed &#8211; they pick up the new app by folder name and camelCase the embedded key automatically (<code>forecast.html<\/code> \u2192 <code>forecastHtml<\/code>).<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>Common Pitfalls<\/h3>\n\n\n\n<figure class=\"wp-block-image\"><img decoding=\"async\" src=\"https:\/\/cdn-images-1.medium.com\/max\/1600\/1*pr7MuGFd_ZeTJv4DixdB1A.png\" alt=\"\"\/><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3>Summary<\/h3>\n\n\n\n<p>In Part 2, we:<\/p>\n\n\n\n<ol>\n<li>Added <code><strong>@modelcontextprotocol\/ext-apps<\/strong><\/code> for interactive UI support<\/li>\n\n\n\n<li>Laid out an <code><strong>app-ui\/<\/strong><\/code> source folder and a Vite build that inlines it into a single HTML file<\/li>\n\n\n\n<li>Built the <strong>weather card UI<\/strong>\u200a\u2014\u200a<code>weather.html<\/code>, <code>weather.ts<\/code>, <code>weather.css<\/code><\/li>\n\n\n\n<li class=\"ticss-caf8259e\">Created <strong>embed scripts<\/strong> so HTML ships inside the serverless bundle<\/li>\n\n\n\n<li>Linked the <code><strong>weather<\/strong><\/code><strong> tool<\/strong> to its UI with <code>_meta.ui.resourceUri<\/code><\/li>\n\n\n\n<li>Registered the <strong>UI resource<\/strong> the host renders<\/li>\n\n\n\n<li>Implemented the <strong>MCP App client lifecycle<\/strong> (<code>ontoolresult<\/code>, <code>callServerTool<\/code>, <code>onhostcontextchanged<\/code>)<\/li>\n\n\n\n<li>Ran locally and tested in <strong>Cursor<\/strong><\/li>\n\n\n\n<li><strong>Deployed<\/strong> the updated bundle to Adobe I\/O Runtime and reconnected Cursor, Claude Desktop, and ChatGPT to the live URL<\/li>\n<\/ol>\n\n\n\n<p>You now have a working Agentic App deployed to I\/O Runtime\u200a\u2014\u200abut open to anyone with the URL. Part 3 covers locking it down with API key or IMS authentication.<\/p>\n\n\n\n<h3>Resources<\/h3>\n\n\n\n<ul>\n<li><a href=\"https:\/\/github.com\/modelcontextprotocol\/ext-apps\" rel=\"noreferrer noopener\" target=\"_blank\">MCP Apps Extension\u200a\u2014\u200aServer API<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/modelcontextprotocol.github.io\/ext-apps\/\" rel=\"noreferrer noopener\" target=\"_blank\">MCP Apps\u200a\u2014\u200aApp lifecycle and handlers<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developers.openai.com\/apps-sdk\/mcp-apps-in-chatgpt\" rel=\"noreferrer noopener\" target=\"_blank\">OpenAI Apps SDK\u200a\u2014\u200aMCP Apps in ChatGPT<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/github.com\/richardtallent\/vite-plugin-singlefile\" rel=\"noreferrer noopener\" target=\"_blank\">Vite Single File Plugin<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/github.com\/adobe\/generator-app-remote-mcp-server-generic\" rel=\"noreferrer noopener\" target=\"_blank\">Remote MCP Server Generator Template<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.adobe.com\/app-builder\/docs\/resources\/ai-use-cases\" rel=\"noreferrer noopener\" target=\"_blank\">Adobe App Builder\u200a\u2014\u200aAI Use Cases<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/subashselvaraj.com\/index.php\/2026\/07\/16\/agentic-apps-using-app-builder-and-mcp\/\" target=\"_blank\" rel=\"noopener\" title=\"\">Part 1: Getting Started<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Building the Agentic App\u200a\u2014\u200aturning a plain MCP tool into an interactive HTML experience the host renders inline. In Part 1, we scaffolded an MCP server&#8230;<\/p>\n","protected":false},"author":1,"featured_media":296,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"om_disable_all_campaigns":false,"_mi_skip_tracking":false,"_themeisle_gutenberg_block_has_review":false},"categories":[36,37,34,38,39,1],"tags":[33,31,32],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/subashselvaraj.com\/index.php\/wp-json\/wp\/v2\/posts\/292"}],"collection":[{"href":"https:\/\/subashselvaraj.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/subashselvaraj.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/subashselvaraj.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/subashselvaraj.com\/index.php\/wp-json\/wp\/v2\/comments?post=292"}],"version-history":[{"count":2,"href":"https:\/\/subashselvaraj.com\/index.php\/wp-json\/wp\/v2\/posts\/292\/revisions"}],"predecessor-version":[{"id":297,"href":"https:\/\/subashselvaraj.com\/index.php\/wp-json\/wp\/v2\/posts\/292\/revisions\/297"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/subashselvaraj.com\/index.php\/wp-json\/wp\/v2\/media\/296"}],"wp:attachment":[{"href":"https:\/\/subashselvaraj.com\/index.php\/wp-json\/wp\/v2\/media?parent=292"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/subashselvaraj.com\/index.php\/wp-json\/wp\/v2\/categories?post=292"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/subashselvaraj.com\/index.php\/wp-json\/wp\/v2\/tags?post=292"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}