TL;DR: Learn how to add interactive forms, charts, visualization & PDF viewer - natively in your AI Agent chat window using MCP Apps.
AI Agents are getting smarter with each passing day. But, their interfaces? Not so much. But, what if there is a way to turn the AI chat from a place where you converse into a place where you can actually work?
That's where MCP Apps, an extension of Model Context Protocol, comes to our rescue.
In my previous article, we explored the foundational building blocks of MCP Apps in detail and witnessed how it provides a way to deliver rich, bidirectional UIs. Through a series of minimal examples, we covered various concepts such as the handshake protocol and host-aware theming, to CSP policies and calling tools from within an iframe.
Now, it's time to stitch those pieces together into something real.
In this article, we'll walk through a complete, production-style MCP Apps chatflow (chat + workflow) and build an interactive sales analytics UI where a user can select sales region, pick a metric like revenue or conversion rate, fetch live data, visualize it with charts, and create & download a PDF report - all without ever leaving the AI Agent chat window. We will also learn how to deploy the developed TypeScript MCP server powering our sales analytics MCP Apps on Amazon Bedrock AgentCore Runtime.
You can find the entire source code of this article in the Github repo link provided below 👇
Sample MCP Apps Chatflow
Sample MCP server that renders interactive sales analytics UIs inside an MCP Apps-compatible chat client. It includes a sales metric selector, chart-based visualization, and PDF report generation.
Articles
This project is part of the MCP Apps article series published on dev.to:
- Part 1: How I render interactive UI in my AI Agent chatflows using MCP Apps - Covers the core architectural patterns for declaring UI resources, practical design principles, and how to handle sandboxed host–server communication.
- Part 2: How I built MCP Apps based Sales Analytics Agentic UI & deployed it on Amazon Bedrock AgentCore - Walks through a complete, production-style MCP Apps chatflow (chat + workflow) and build an interactive sales analytics UI where a user can select sales region, pick a metric like revenue or conversion rate, fetch live data, visualize it with charts, and create & download a PDF report - all without…
The Problem: When chat isn't enough!
It all started when my friend, who is a sales analyst, started exploring AI Agents after reading everywhere that agents can unlock productivity by automating workflows and are also capable of performing data analysis.
He prepared sales analytics reports on a monthly and quarterly basis and felt AI Agents could accelerate this workflow greatly. He started his Agentic journey by uploading CSVs, interacting with the AI Agent & assigning it tasks. The agent would frequently ask him clarifying questions, like the required sales metrics, sales regions he was interested in and more follow-up questions. Often, the AI agent would assume certain parameters and produce random results which meant he had to start the entire workflow all over again.
Although the resulting analysis was accurate, but from the presentation standpoint a simple markdown result was quite underwhelming. After discussing his entire Agentic Experience (AX), I realised that the core friction was due to the limitations of plain chat. While my friend kept answering and re-prompting, the model kept guessing and re-analyzing. The experience never quite graduated from "assistant response" to an actual "working interface".
And, MCP Apps solved exactly this gap!
Instead of returning only text, the server returns a rich, interactive HTML interface rendered directly inside the chat which lets the user drive the experience like an application, not just an answer.
Let's see how.
The Solution: A Chatflow powered by MCP Apps
An end-to-end demo of the complete chatflow is shown below:
Our chatflow involves three MCP Apps (UI resources) orchestrated by four tools. Each MCP App is a self-contained HTML page served by the MCP server, running in a sandboxed iframe, communicating with the host via postMessage JSON-RPC.
The new Agentic experience (AX) unfolds like a real workflow:
- User asks for sales insights.
- AI agent opens a Sales Metric Selector MCP App which allows user to easily select the sales metric, reporting period, reporting year & sales regions, instead of forcing the user to specify everything in text.
- On clicking Submit, the app fetches data through an internal MCP tool and adds it as a structured JSON data in the context.
- With the relevant data added in context, user can prompt to generate a visualization or prepare a PDF report.
- Upon requesting for a visualization, the Sales Visualization MCP App is rendered which presents the entire data in an insightful & interactive visualization dashboard.
- Upon requesting for a PDF report, the AI agent triggers a tool which creates a PDF report server side, and then displays it via a PDF Report Viewer MCP App. The MCP app displays the pdf in the form of an interactive PDF and allows the user to download the report.
The most important thing about this whole new experience is not just that these steps happen, but they happen inside one continuous chat experience.
Let us now go through the individual tools and take a look at some of their salient features:
1. select-sales-metric tool
Code - Tool Registration and MCP App
This is where the interactive chatflow begins. When the user prompts - "get sales data", instead of asking the user to specify every parameter via text, the agent calls this tool. VS Code (Host) then reads the linked resource and renders a rich HTML form with dropdown menus, toggle buttons, search region & multi-select state cards inside an iframe.
The Sales metric selector MCP App resides in sales-form.ts, where server-side data such as the list of valid sales regions (Indian states), sales metric and top sales regions are serialized directly into the HTML template:
...
<select id="metricSelect">${metricOptions}</select>
..
..
const indianStates = ${JSON.stringify(indianStates)};
const topStates = ${JSON.stringify(topStates)};
The makes the UI entirely self-contained as the page arrives already primed with the valid states and metrics. And, no further external API calls are required just to render the form or for data validation.
1.1. get-sales-data tool
Code - Tool Registration
When the user clicks "Submit" button in the Sales metric selector MCP App, the form doesn't just update the context — it calls another MCP tool from within the iframe itself by executing the below communication:
const toolResult = await sendRequest("tools/call", {
name: "get-sales-data",
arguments: {
states: Array.from(selectedStates),
metric: currentMetric,
period: currentPeriod,
year: currentYear,
},
});
report = toolResult.structuredContent || null;
We trigger the data fetch manually via this tool as it allows us to have a deterministic control over the most critical element ("data") and we can explicitly define inputs without relying on agent reasoning. The tool is only visible to the app, and not the agent so it cannot call it accidentally. Only the MCP App iframe can invoke it via tools/call. This is a pattern I introduced in my previous article, and here you can see how powerful it becomes in a real workflow.
After fetching the result, ui/update-model-context request is sent to the host with the entire structured data (user input + data obtained for report) is added into the conversation context, so the agent can use it in subsequent turns.
await sendRequest("ui/update-model-context", {
structuredContent: {
selections: {
metric: currentMetric,
period: currentPeriod,
year: currentYear,
states: Array.from(selectedStates),
selectedStateNames: selectedStateNames,
},
report: report,
},
});
updateStatus("✅ Context updated successfully.");
2. visualize-sales-data tool
Code - Tool Registration & MCP App
Once the Agent has the structured data in its context, when the user prompts "visualize", the agent calls visualize-sales-data tool which renders a visualization dashboard inside the chat highlighting some key stats. Three additional complementary chart views are also presented in the dashboard for identifying deeper trends:
- A stacked bar or line view for period-by-period comparison.
- A doughnut chart to gauge a state's contribution share (%) in the sales metric.
- A horizontal bar chart for ranked state comparison.
Two implementation details are especially worth noticing:
i) CSP is not a footnote, it is part of the Security Contract
The visualization MCP App depends on chart.js from a CDN:
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"><\/script>
As we discussed it in our previous article, the resource domain dependency is declared explicitly in the UI resource metadata while registering it.
csp: {
resourceDomains: ["https://cdn.jsdelivr.net"],
},
Without this declaration, the host sandbox will block the script. This demonstrates one of the most practical aspects of MCP Apps - the app states what it needs, and the host enforces that contract. An app cannot silently inherit broad network access just because it is an embedded HTML.
ii) MCP App receives tool input through notification
When the host invokes the visualize-sales-data tool, it forwards the structured payload to the iframe:
if (msg.method === "ui/notifications/tool-input") {
const sc = msg.params?.structuredContent || msg.params?.arguments;
if (sc && sc.report) {
reportData = sc.report;
selections = sc.selections;
renderDashboard();
}
}
This hydrates the UI with all the data it needs to render summary cards and charts. There is no string parsing or scraping of the tool response text. Just clean structured data flowing from tool to host to app.
3. show-sales-pdf-report tool
Code - Tool Registration & MCP App
Once the user has explored the interactive visualization dashboard, a PDF report generation can be triggered via show-sales-pdf-report tool using "generate pdf" prompt.
The following actions happen after the tool is triggered:
- The server generates a PDF report using
jsPDFandjspdf-autotable, and sends it to the client as abase64 stringviastructuredContent. - The PDF viewer MCP App decodes the base64 payload and renders it using PDF.js (from CDN). This interactive PDF viewer includes page navigation (previous/next) & zoom controls (keyboard shortcuts included).
The user also has the option to save the PDF report via a "Download PDF" button as shown below:
On clicking the "Download PDF" button a ui/download-file request is triggered which sends the resource (PDF) to VS Code which displays the save dialog.
const result = await request("ui/download-file", {
contents: [
{
type: "resource",
resource: {
uri: "file:///" + pdfFileName,
mimeType: "application/pdf",
blob: pdfBase64,
},
},
],
});
That pattern is important as sandboxed iframes should not get to ability to save any file without informing the host which thereby informs the end user. Thus, the host stays in control, and the user gets a normal, expected download flow.
Serving MCP Apps Over HTTP
Currently, we are running the MCP server locally using Express with the Streamable HTTP transport - a departure from the stdio approach demonstrated in the previous article. This lets us run the server as an HTTP endpoint as we are moving closer to a deployable shape, making it ideal for web-based clients and remote deployments.
We can run the MCP server locally on node by executing:
cd /path/to/sample-mcp-apps-chatflow
# Install dependencies
npm install
# Development mode (with hot reload)
npm run dev
# Test with MCP Inspector
npm run inspector:http
which successfully displays
> sample-mcp-apps-chatflow@1.0.0 dev
> tsx src/index.ts
🚀 MCP Apps Chatflow server running at http://localhost:3000
📡 MCP endpoint: http://localhost:3000/mcp
To connect to this endpoint from VS Code Insiders, add the following config to .vscode/mcp.json:
{
"servers": {
"sample-mcp-apps-chatflow": {
"type": "http",
"url": "http://localhost:3000/mcp"
}
}
}
Deploying to Amazon Bedrock AgentCore Runtime (AgentCore CLI)
What is the value of building an awesome MCP server if you can't take it beyond the localhost? To be truly useful, it needs to be hosted so that your team or your users can connect to it from anywhere, without having to run it on their own system.
That's where Amazon Bedrock AgentCore Runtime comes in. It provides a managed, scalable runtime for your MCP server with built-in OAuth and session isolation.
Let us go ahead and deploy our TypeScript MCP server to Amazon Bedrock AgentCore Runtime.
Note: This guide uses the AgentCore CLI (
@aws/agentcore), the current Node.js-based CLI. If you have the older Python-basedbedrock-agentcore-starter-toolkitinstalled, uninstall it first to avoid command conflicts -- both tools use theagentcorecommand name.pip uninstall bedrock-agentcore-starter-toolkit # if installed via pip pipx uninstall bedrock-agentcore-starter-toolkit # if installed via pipx
Software prerequisites
Make sure you have the following softwares installed in your system:
-
AWS CLI with configured credentials (
aws sts get-caller-identitymust work) - Node.js 20+ and npm
- Docker (or podman/finch) for container builds
-
AWS CDK bootstrapped in your target account/region (
npx cdk bootstrap aws://ACCOUNT_ID/REGION) - VS Code or any other MCP Apps compatible Host or even terminal for testing the deployed MCP server
Step 1 - Prepare the server for AgentCore Runtime
AgentCore Runtime expects your MCP server to bind to 0.0.0.0:8000 and expose a POST /mcp endpoint using Streamable HTTP transport. The container must target linux/arm64.
In src/index.ts, update the port and host:
const port = parseInt(process.env.PORT || "8000");
const host = process.env.HOST || "0.0.0.0";
app.listen(port, host, () => {
console.log(`🚀 MCP Apps Chatflow server running at http://${host}:${port}`);
console.log(`📡 MCP endpoint: http://${host}:${port}/mcp`);
});
Step 2: Add a Dockerfile
Create a Dockerfile in the project root:
# Build stage
FROM --platform=linux/arm64 node:22-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Production stage
FROM --platform=linux/arm64 node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
COPY --from=builder /app/dist/ ./dist/
ENV PORT=8000
ENV HOST=0.0.0.0
EXPOSE 8000
CMD ["node", "dist/index.js"]
Add a .dockerignore:
node_modules
dist
agentcore
.git
*.md
Verify it builds and starts locally:
docker build --platform linux/arm64 -t sales-mcp-apps .
docker run --platform linux/arm64 -p 8000:8000 sales-mcp-apps
# Test with MCP Inspector:
npx @modelcontextprotocol/inspector http://localhost:8000/mcp
Step 3: Install the AgentCore CLI
npm install -g @aws/agentcore
agentcore --version
Step 4: Create the AgentCore project
From the project root, scaffold the AgentCore configuration:
agentcore create \
--name salesmcpapps \
--protocol MCP \
--build Container \
--no-agent \
--skip-python-setup \
--skip-git
This generates:
agentcore/
agentcore.json # Project and runtime configuration
aws-targets.json # AWS account and region target
.cli/ # CLI state (deployed-state.json)
cdk/ # CDK app for deployment
Configure the runtime
The agentcore create --no-agent command creates an empty runtimes array. You must manually add the runtime configuration to agentcore/agentcore.json. The important fields are:
-
codeLocation- path to the directory containing your Dockerfile, relative to the project root (the parent ofagentcore/). If your Dockerfile is in the project root, use".". If you isolate your app code in a subdirectory (recommended to avoid CDK bundling issues), use that path (e.g.,"app"). -
entrypoint- the file that starts your server (e.g.,"dist/index.js"). Required even for Container builds. -
runtimeVersion- must be"NODE_22"for Node.js projects. -
protocol-"MCP"for MCP servers.
Example agentcore/agentcore.json:
{
"$schema": "https://schema.agentcore.aws.dev/v1/agentcore.json",
"name": "salesmcpapps",
"version": 1,
"managedBy": "CDK",
"tags": {
"agentcore:created-by": "agentcore-cli",
"agentcore:project-name": "salesmcpapps"
},
"runtimes": [
{
"name": "salesmcpapps",
"build": "Container",
"entrypoint": "dist/index.js",
"codeLocation": ".",
"runtimeVersion": "NODE_22",
"protocol": "MCP",
"tags": {}
}
],
"memories": [],
"credentials": [],
"evaluators": [],
"onlineEvalConfigs": [],
"agentCoreGateways": [],
"policyEngines": []
}
Configure deployment target
Edit agentcore/aws-targets.json with your account ID and region:
[
{
"name": "default",
"description": "Production deployment",
"account": "YOUR_ACCOUNT_ID",
"region": "us-east-1"
}
]
Tip: If your Dockerfile is in the project root alongside
agentcore/, the CDK asset bundler may hit path-length errors because it recursively copies thecdk.outdirectory into itself. To avoid this, put your application code (Dockerfile, package.json, src/, tsconfig.json) in a subdirectory (e.g.,app/) and set"codeLocation": "app".
Step 5: Set up Cognito authentication
The AgentCore CLI does not have a built-in command to set up Cognito (unlike the deprecated toolkit's agentcore identity setup-cognito). You need to create the Cognito resources manually.
Create a Cognito User Pool
export REGION=us-east-1
# Create user pool
POOL_ID=$(aws cognito-idp create-user-pool \
--pool-name "salesmcpapps-pool" \
--region $REGION \
--query 'UserPool.Id' --output text)
echo "Pool ID: $POOL_ID"
# Add a domain for the OAuth token endpoint
aws cognito-idp create-user-pool-domain \
--user-pool-id $POOL_ID \
--domain "salesmcpapps-auth" \
--region $REGION
Create a Resource Server and App Client
For machine-to-machine authentication (client_credentials flow):
# Create resource server with a custom scope
aws cognito-idp create-resource-server \
--user-pool-id $POOL_ID \
--identifier "salesmcpapps-auth" \
--name "Sales MCP Apps" \
--scopes '[{"ScopeName":"invoke","ScopeDescription":"Invoke MCP server"}]' \
--region $REGION
# Create app client with client_credentials flow
CLIENT_OUTPUT=$(aws cognito-idp create-user-pool-client \
--user-pool-id $POOL_ID \
--client-name "salesmcpapps-client" \
--generate-secret \
--explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_REFRESH_TOKEN_AUTH \
--allowed-o-auth-flows client_credentials \
--allowed-o-auth-scopes "salesmcpapps-auth/invoke" \
--allowed-o-auth-flows-user-pool-client \
--supported-identity-providers COGNITO \
--region $REGION)
CLIENT_ID=$(echo $CLIENT_OUTPUT | python3 -c "import sys,json; print(json.load(sys.stdin)['UserPoolClient']['ClientId'])")
CLIENT_SECRET=$(echo $CLIENT_OUTPUT | python3 -c "import sys,json; print(json.load(sys.stdin)['UserPoolClient']['ClientSecret'])")
echo "Client ID: $CLIENT_ID"
echo "Client Secret: $CLIENT_SECRET"
echo "Discovery URL: https://cognito-idp.$REGION.amazonaws.com/$POOL_ID/.well-known/openid-configuration"
Add JWT auth to agentcore.json
Update the runtime in agentcore/agentcore.json to add authorizerType and authorizerConfiguration:
{
"name": "salesmcpapps",
"build": "Container",
"entrypoint": "dist/index.js",
"codeLocation": ".",
"runtimeVersion": "NODE_22",
"protocol": "MCP",
"authorizerType": "CUSTOM_JWT",
"authorizerConfiguration": {
"customJwtAuthorizer": {
"discoveryUrl": "https://cognito-idp.us-east-1.amazonaws.com/YOUR_POOL_ID/.well-known/openid-configuration",
"allowedClients": ["YOUR_CLIENT_ID"]
}
},
"tags": {}
}
Step 6 - Deploy
agentcore deploy --target default --yes
This will:
- Synthesize a CloudFormation template via CDK
- Build your Docker container via AWS CodeBuild and push it to Amazon ECR
- Create the AgentCore Runtime
The deployment takes roughly 5-10 minutes. On completion, the Runtime ARN is saved to agentcore/.cli/deployed-state.json.
Step 7 - Test the deployment
Get a Bearer token
TOKEN=$(curl -s -X POST \
"https://salesmcpapps-auth.auth.$REGION.amazoncognito.com/oauth2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&scope=salesmcpapps-auth/invoke" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
Invoke the MCP server
URL-encode the Runtime ARN and call the invocations endpoint:
# Get runtime ARN from deployed state
RUNTIME_ARN=$(python3 -c "
import json
with open('agentcore/.cli/deployed-state.json') as f:
state = json.load(f)
rt = list(state['targets']['default']['resources']['runtimes'].values())[0]
print(rt['runtimeArn'])
")
# URL-encode the ARN
ENCODED_ARN=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$RUNTIME_ARN', safe=''))")
# Test MCP initialize
curl -s -X POST \
"https://bedrock-agentcore.$REGION.amazonaws.com/runtimes/${ENCODED_ARN}/invocations" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "User-Agent: test-client/1.0" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' \
| python3 -m json.tool
You should see a response with serverInfo, capabilities including resources and tools.
List tools and resources
After initialize, you can send tools/list and resources/list requests (include the mcp-session-id header from the initialize response).
Step 8 - Connect from VS Code
In VS Code with an MCP Apps-compatible client, create a .vscode/mcp.json in your workspace:
{
"servers": {
"sales-mcp-apps": {
"type": "http",
"url": "https://bedrock-agentcore.REGION.amazonaws.com/runtimes/DOUBLE_ENCODED_ARN/invocations",
"headers": {
"Authorization": "Bearer YOUR_TOKEN"
}
}
}
}
Important: double-encode the
/in the ARN. VS Code's URL parser decodes%2Fback to/before sending the request, which breaks the ARN path segment. Use%252Finstead of%2Fso that after VS Code decodes once, the server receives the correct%2F.For example, if your ARN is:
arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/my-server-abc123The URL in
mcp.jsonshould be:https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A123456789012%3Aruntime%252Fmy-server-abc123/invocationsNote
%252F(not%2F) before the runtime name.
Once connected, VS Code will discover the tools and render the MCP Apps UIs (sales metric selector, visualization charts, PDF report viewer) as interactive HTML panels in the chat as shown below:
Clean up
To remove deployed resources, delete the CloudFormation stack:
aws cloudformation delete-stack \
--stack-name AgentCore-salesmcpapps-default \
--region $REGION
Also clean up Cognito:
aws cognito-idp delete-user-pool-domain \
--user-pool-id $POOL_ID \
--domain "salesmcpapps-auth" \
--region $REGION
aws cognito-idp delete-user-pool \
--user-pool-id $POOL_ID \
--region $REGION
Final Words
MCP Apps is no longer a theoretical specification - it's a practical framework for building rich, interactive experiences inside AI chatflows. This sales analytics case study demonstrates the full spectrum: forms for structured input, Chart.js for visualization, jsPDF for document generation, and the MCP Apps protocol gluing it all together.
The patterns described here — app-only tools, structured content flow, CSP declarations, server-side PDF generation - are directly transferable to your own use cases. Whether you're building a CRM dashboard, a data annotation tool, or an interactive report builder, the approach remains the same. We also saw how Amazon Bedrock AgentCore can be used for deploying MCP remote servers securely.
I would love to hear your experience with MCP Apps or about any issues you faced while going through this article. Please mention it in the comment section below and I will definitely address it. Also, in case you have any other suggestion, feel free to add it in the comments.
by Ashita Prasad (GitHub, LinkedIn, X, Instagram)
Disclaimer: The opinions expressed here are my own and do not necessarily represent those of current or past employers. Please note that you are solely responsible for your judgement on checking facts. This post does not monetize via any advertising.








Top comments (0)