Skip to content

How to Create MCP Apps with Spring AI: Embed Rich UIs in Chat Interfaces

The Problem: Text-Only Chat Is Limiting

I was building an AI assistant that needed to let users select locations on a map. Describing coordinates in natural language is clumsy: “I want the location around latitude 37.7749, longitude -122.4194” — not ideal. What I really needed was an interactive map UI embedded directly in the chat.

Traditional chat interfaces only support text-based interactions. While natural language is flexible, some actions are better served by visual UIs — clicking a map location is more precise than describing coordinates, selecting dates from a calendar is easier than typing them, and choosing from a list is faster than describing preferences.

This is where MCP Apps come in.

What Are MCP Apps?

MCP Apps let you embed rich HTML/JavaScript UIs directly in AI chat interfaces. When the AI assistant needs user input that’s better handled visually, it can trigger an MCP App that renders an interactive UI within the chat context.

The architecture looks like this:

+------------------+ +------------------+ +------------------+
| AI Assistant |---->| MCP Server |---->| HTML/JS UI |
| (Chat Client) | | (Spring AI) | | (MCP App) |
+------------------+ +------------------+ +------------------+
| | |
| 1. Call @McpTool | |
|------------------------>| |
| | 2. Return metadata |
| | with resourceUri |
| |<-------------------------|
| | |
| 3. Fetch HTML from | |
| resourceUri | |
|------------------------>| |
| | 4. Return HTML/CSS/JS |
| |<-------------------------|
| |
| 5. Render UI in chat context |
|---------------------------------------------------|
| |
| 6. User interacts with UI |
|---------------------------------------------------|
| |
| 7. UI sends results back via ext-apps.ts |
|<---------------------------------------------------|

An MCP App has two primary components:

  1. Tool method (@McpTool): Triggers the app, provides metadata linking to the HTML resource
  2. Resource method (@McpResource): Serves the HTML/CSS/JavaScript UI

My First Attempt: Missing Version Requirement

I started with Spring AI 1.0.0-M4 and tried to use @McpTool annotation:

@Service
public class DiceApp {
@McpTool(name = "roll-the-dice", description = "Rolls the dice")
public String rollTheDice() {
return "Opening dice roller app.";
}
}

But the annotation wasn’t recognized:

error: cannot find symbol
@McpTool(name = "roll-the-dice", description = "Rolls the dice")
^
symbol: class McpTool

The fix: MCP annotations require Spring AI 2.0.0-M3 or later. I updated my pom.xml:

pom.xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-server-webmvc-spring-boot-starter</artifactId>
<version>2.0.0-M3</version>
</dependency>

Creating the MCP Resource (HTML UI)

First, I created the HTML resource that will be served by the MCP server. This is the interactive UI that appears in the chat:

src/main/resources/app/dice-app.html
<!DOCTYPE html>
<html>
<head>
<title>Dice Roller</title>
<style>
body {
font-family: system-ui, sans-serif;
padding: 20px;
text-align: center;
}
.dice {
font-size: 48px;
margin: 20px 0;
}
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
</style>
</head>
<body>
<h2>Roll the Dice</h2>
<div class="dice" id="result">Click to roll!</div>
<button onclick="rollDice()">Roll</button>
<script type="module">
import { App } from "https://unpkg.com/@modelcontextprotocol/[email protected]/app-with-deps";
const app = new App({ name: "roll-the-dice", version: "1.0.0" });
// Connect to the assistant host
await app.connect();
window.rollDice = async function() {
const die1 = Math.floor(Math.random() * 6) + 1;
const die2 = Math.floor(Math.random() * 6) + 1;
const result = `${die1} and ${die2}`;
document.getElementById('result').textContent = result;
// Send the result back to the AI assistant
await app.updateModelContext({
content: [{ type: "text", text: `Dice roll: ${result}` }],
});
};
</script>
</body>
</html>

Key points about this HTML:

  • Uses ext-apps.ts module from CDN for communication with the AI host
  • Calls app.connect() before any UI interactions
  • Uses app.updateModelContext() to send results back to the assistant

Serving the HTML with @McpResource

Now I needed to serve this HTML from the MCP server using the @McpResource annotation:

src/main/java/com/example/mcp/DiceApp.java
package com.example.mcp;
import org.springframework.ai.tool.annotation.McpResource;
import org.springframework.ai.tool.annotation.providers.CspMetaProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Map;
@Service
public class DiceApp {
@Value("classpath:/app/dice-app.html")
private Resource diceAppResource;
@McpResource(
name = "Dice App Resource",
uri = "ui://dice/dice-app.html",
mimeType = "text/html;profile=mcp-app",
metaProvider = CspMetaProvider.class
)
public String getDiceAppResource() throws IOException {
return diceAppResource.getContentAsString(Charset.defaultCharset());
}
}

Important details:

  • uri must use a custom scheme like ui:// — this is how the client knows where to fetch the HTML
  • mimeType must be text/html;profile=mcp-app — this signals it’s an MCP App, not regular HTML
  • metaProvider = CspMetaProvider.class sets Content Security Policy headers for external resources

The Second Problem: How Does the Tool Know About the Resource?

I had the HTML resource, but how does the AI assistant know to trigger it? I needed a tool that references the resource:

@Service
public class DiceApp {
// ... resource method from above ...
@McpTool(
title = "Roll the Dice",
name = "roll-the-dice",
description = "Rolls virtual dice and returns the result",
metaProvider = DiceMetaProvider.class
)
public String rollTheDice() {
return "Opening dice roller app.";
}
}

But I was missing a critical piece: the MetaProvider that links the tool to the HTML resource.

Creating the MetaProvider

The MetaProvider is the bridge between the tool and the resource. It provides metadata that tells the AI client where to find the HTML:

src/main/java/com/example/mcp/DiceMetaProvider.java
package com.example.mcp;
import org.springframework.ai.tool.annotation.MetaProvider;
import java.util.Map;
public static final class DiceMetaProvider implements MetaProvider {
@Override
public Map&lt;String, Object&gt; getMeta() {
return Map.of(
"ui", Map.of(
"resourceUri", "ui://dice/dice-app.html"
)
);
}
}

This metadata tells the AI client: “When this tool is invoked, fetch the HTML from ui://dice/dice-app.html and render it inline.”

The Complete Solution

Here’s the complete working implementation:

src/main/java/com/example/mcp/DiceApp.java
package com.example.mcp;
import org.springframework.ai.tool.annotation.McpResource;
import org.springframework.ai.tool.annotation.McpTool;
import org.springframework.ai.tool.annotation.MetaProvider;
import org.springframework.ai.tool.annotation.providers.CspMetaProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Map;
@Service
public class DiceApp {
@Value("classpath:/app/dice-app.html")
private Resource diceAppResource;
@McpResource(
name = "Dice App Resource",
uri = "ui://dice/dice-app.html",
mimeType = "text/html;profile=mcp-app",
metaProvider = CspMetaProvider.class
)
public String getDiceAppResource() throws IOException {
return diceAppResource.getContentAsString(Charset.defaultCharset());
}
@McpTool(
title = "Roll the Dice",
name = "roll-the-dice",
description = "Rolls virtual dice and returns the result",
metaProvider = DiceMetaProvider.class
)
public String rollTheDice() {
return "Opening dice roller app.";
}
public static final class DiceMetaProvider implements MetaProvider {
@Override
public Map&lt;String, Object&gt; getMeta() {
return Map.of(
"ui", Map.of(
"resourceUri", "ui://dice/dice-app.html"
)
);
}
}
}

Bidirectional Communication

The real power of MCP Apps is bidirectional communication. The UI can:

Update the model context:

await app.updateModelContext({
content: [{ type: "text", text: "Dice roll: 4 and 6" }],
});

Call server-side tools:

const result = await app.callServerTool("analyze-dice-roll", {
roll: [4, 6]
});

Send messages to the conversation:

await app.sendMessage({
role: "user",
content: [{ type: "text", text: "I rolled a 4 and 6!" }],
});

This creates a hybrid interaction model: users get the flexibility of natural language chat combined with the precision of traditional UIs — all within the same conversation context. The model stays aware of UI state through context updates.

Common Mistakes to Avoid

Mistake 1: Wrong Spring AI Version

<!-- WRONG: Too old, no MCP annotations -->
<version>1.0.0-M4</version>
<!-- CORRECT: MCP annotations available -->
<version>2.0.0-M3</version>

Mistake 2: Missing CSP Metadata

// WRONG: No CSP provider, external scripts blocked
@McpResource(name = "App", uri = "ui://app.html")
// CORRECT: Include CSP metadata for external resources
@McpResource(
name = "App",
uri = "ui://app.html",
metaProvider = CspMetaProvider.class
)

Mistake 3: Forgetting to Connect

// WRONG: Calling updateModelContext before connect
const app = new App({ name: "my-app" });
await app.updateModelContext({...}); // Fails silently
// CORRECT: Connect first
const app = new App({ name: "my-app" });
await app.connect();
await app.updateModelContext({...}); // Works

Mistake 4: Wrong MIME Type

// WRONG: Regular HTML, not recognized as MCP App
mimeType = "text/html"
// CORRECT: Signals MCP App to client
mimeType = "text/html;profile=mcp-app"

Transport Protocol Considerations

Different AI clients support different transport protocols:

TransportUse CaseClient Support
stdioLocal development, CLI toolsMost clients
SSE (Server-Sent Events)Web-based clientsClaude Desktop, Cursor
HTTP with SSEProduction deploymentsVaries by client

For web deployment, configure the transport:

application.yml
spring:
ai:
mcp:
server:
type: sse
sse-message-endpoint: /mcp/message

Summary

MCP Apps with Spring AI bridge the gap between chat flexibility and UI precision. Here’s what I learned:

  • Use @McpResource to serve HTML with mimeType = "text/html;profile=mcp-app"
  • Use @McpTool with a MetaProvider that sets ui.resourceUri to link the tool to the resource
  • Include CspMetaProvider.class when loading external scripts or styles
  • Use ext-apps.ts module for bidirectional communication (updateModelContext, callServerTool, sendMessage)
  • Always call app.connect() before UI interactions
  • Spring AI 2.0.0-M3+ is required for MCP annotations

The hybrid interaction model — natural language when it makes sense, visual UIs when they’re better — all within the same chat context — makes MCP Apps a powerful tool for building sophisticated AI assistants.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments