Skip to content

MCP App Communication: updateModelContext vs sendMessage vs callServerTool

The Problem

I was building an MCP App with a dice roller UI. After rolling the dice, I wanted the AI to know the result so users could ask follow-up questions like “What were the dice?” I tried sendMessage(), but the dice results showed up in chat as if the user typed them - that was wrong.

Then I tried callServerTool() to save the results, but the AI still didn’t know about them. I was confused about when to use each communication method.

What I Learned

MCP Apps provide three distinct communication channels via the ext-apps.ts App class:

┌─────────────────────────────────────────────────────────────┐
│ MCP App UI │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ User Interaction │ │ Your Code │ │
│ │ (button click) │──▶│ │ │
│ └──────────────────┘ └────────┬─────────┘ │
│ │ │
│ ┌─────────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │updateModel │ │ sendMessage │ │callServerTool│ │
│ │Context │ │ │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
└────────────────┼────────────────┼────────────────┼──────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ AI Model │ │ Chat as │ │ MCP Server │
│ Context │ │ User Msg │ │ Backend │
│ │ │ │ │ │
│ (invisible) │ │ (visible) │ │ (transparent)│
└──────────────┘ └──────────────┘ └──────────────┘

Each serves a different purpose. Using the wrong one causes confusing behavior.

The Three Communication Channels

1. updateModelContext() - AI State Awareness

updateModelContext() injects information into the chat history as system context. The AI knows about it, but it doesn’t appear as a user message.

When to use: State changes the AI should remember for future interactions.

dice-roller.js
// After rolling dice, tell the AI so it can answer follow-ups
const updateDiceContext = async (result) => {
await app.updateModelContext({
content: [{
type: "text",
text: `Dice roll result: Die 1 = ${result.die1}, Die 2 = ${result.die2}`
}],
});
};
// Now user can ask "What were the dice?" and AI knows

In my dice roller, this is exactly what I needed. The AI becomes aware of the dice state without polluting the chat.

2. sendMessage() - User-Visible Actions

sendMessage() posts a message as if the user typed it. It shows up in the chat history.

When to use: Triggering follow-up actions or workflows that should be visible.

form-submission.js
// User clicks submit - send as user message
const submitForm = async (formData) => {
await app.sendMessage({
content: [{
type: "text",
text: `I submitted the form with: ${JSON.stringify(formData)}`
}]
});
// This appears in chat as if user typed it
};

The key difference: this is visible in chat. Use it when the action should trigger a visible AI response.

3. callServerTool() - Backend Operations

callServerTool() invokes tools defined on the MCP server from your JavaScript UI.

When to use: Server-side processing, API calls, database operations.

save-results.js
// Call a server tool to persist data
const saveDiceResult = async (result) => {
await app.callServerTool("save-dice-result", {
die1: result.die1,
die2: result.die2,
timestamp: Date.now()
});
// Server handles persistence - no chat pollution
};

This is transparent to the chat - useful for backend operations that don’t need user visibility.

Putting It All Together

Here’s a complete example showing all three methods:

dice-app.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dice Roller</title>
<style>
body { font-family: system-ui; max-width: 400px; margin: 2rem auto; padding: 1rem; }
.dice { font-size: 3rem; text-align: center; margin: 1rem 0; }
button { width: 100%; padding: 1rem; margin: 0.5rem 0; cursor: pointer; }
</style>
</head>
<body>
<h1>Dice Roller</h1>
<div class="dice" id="result">Roll to start</div>
<button onclick="rollDice()">Roll Dice</button>
<button onclick="submitResult()">Submit Result</button>
<button onclick="saveToDatabase()">Save to Database</button>
<script type="module">
import { App } from "https://unpkg.com/@modelcontextprotocol/[email protected]/app-with-deps";
const app = new App({ name: "dice-app", version: "1.0.0" });
// CRITICAL: Must connect before using any communication method
await app.connect();
let currentResult = { die1: 0, die2: 0 };
window.rollDice = async () => {
currentResult = {
die1: Math.floor(Math.random() * 6) + 1,
die2: Math.floor(Math.random() * 6) + 1
};
const total = currentResult.die1 + currentResult.die2;
document.getElementById('result').textContent = `${currentResult.die1} + ${currentResult.die2} = ${total}`;
// Method 1: Tell AI about the state (invisible to chat)
await app.updateModelContext({
content: [{
type: "text",
text: `Dice rolled: ${currentResult.die1} and ${currentResult.die2} (total: ${total})`
}],
});
};
window.submitResult = async () => {
if (currentResult.die1 === 0) {
alert("Roll the dice first!");
return;
}
// Method 2: Send as user message (visible in chat)
await app.sendMessage({
content: [{
type: "text",
text: `I rolled ${currentResult.die1} and ${currentResult.die2} for a total of ${currentResult.die1 + currentResult.die2}`
}]
});
};
window.saveToDatabase = async () => {
if (currentResult.die1 === 0) {
alert("Roll the dice first!");
return;
}
// Method 3: Call server tool (backend operation)
try {
await app.callServerTool("save-dice-result", {
die1: currentResult.die1,
die2: currentResult.die2,
total: currentResult.die1 + currentResult.die2,
timestamp: new Date().toISOString()
});
alert("Saved to database!");
} catch (error) {
alert(`Error: ${error.message}`);
}
};
</script>
</body>
</html>

Common Mistakes I Made

Mistake 1: Using sendMessage for AI awareness

// WRONG: Pollutes chat history
await app.sendMessage({
content: [{ type: "text", text: "Dice result: 4 and 3" }]
});
// User sees this in chat as their own message
// CORRECT: Updates AI context invisibly
await app.updateModelContext({
content: [{ type: "text", text: "Dice result: 4 and 3" }]
});
// AI knows, but chat stays clean

Mistake 2: Forgetting to await

// WRONG: Fire and forget - might fail silently
app.updateModelContext({ content: [...] });
// CORRECT: Handle errors properly
try {
await app.updateModelContext({ content: [...] });
} catch (error) {
console.error("Failed to update context:", error);
}

Mistake 3: Not connecting first

// WRONG: Communication methods fail silently
const app = new App({ name: "my-app", version: "1.0.0" });
await app.updateModelContext({...}); // Doesn't work!
// CORRECT: Connect before communicating
const app = new App({ name: "my-app", version: "1.0.0" });
await app.connect(); // MUST call this first
await app.updateModelContext({...}); // Now it works

Mistake 4: Calling non-existent server tools

// WRONG: Tool doesn't exist on server
await app.callServerTool("non-existent-tool", {...});
// Throws error
// CORRECT: Define tool on MCP server first
// In your Spring AI backend:
@McpTool(name = "save-dice-result", description = "Saves dice result")
public String saveDiceResult(Map<String, Object> params) {
// Implementation
}

When to Use Each Method

MethodPurposeVisible in Chat?Use Case
updateModelContextAI state awarenessNo”Remember this for later”
sendMessageUser-initiated actionYes”Do something with this”
callServerToolBackend operationNo”Process/save this”

Quick Decision Guide

Ask yourself:

  1. Should the AI remember this for future questions? Use updateModelContext
  2. Should this appear in chat as a user action? Use sendMessage
  3. Do you need server-side processing? Use callServerTool

You can combine them:

// Roll dice, update context, AND save to database
await app.updateModelContext({...}); // AI remembers
await app.callServerTool("save", {...}); // Backend saves
// Don't use sendMessage unless you want it visible

How ext-apps.ts Works

The ext-apps.ts module enables JSON-RPC communication between your UI and the MCP server/host. It’s provided by the @modelcontextprotocol/ext-apps package:

// Load from CDN (easiest)
import { App } from "https://unpkg.com/@modelcontextprotocol/[email protected]/app-with-deps";
// Or install via npm
// npm install @modelcontextprotocol/ext-apps

Spring AI Integration

If you’re using Spring AI 2.0.0-M3+, you can define tools that your UI can call:

DiceTools.java
@Service
public class DiceTools {
@McpTool(
name = "save-dice-result",
description = "Saves a dice roll result to the database"
)
public String saveDiceResult(
@ToolParam(description = "First die value") int die1,
@ToolParam(description = "Second die value") int die2,
@ToolParam(description = "Total value") int total,
@ToolParam(description = "Timestamp") String timestamp
) {
// Save to database
diceRepository.save(new DiceRoll(die1, die2, total, timestamp));
return "Saved dice roll: " + die1 + " + " + die2 + " = " + total;
}
}

Content Security Policy

When loading ext-apps.ts from a CDN, make sure your CSP allows it:

CspMetaProvider.java
@Override
public List<CspPolicy> getCspPolicies() {
return List.of(
CspPolicy.builder()
.defaultSrc("'self'")
.scriptSrc("'self' 'unsafe-inline' https://unpkg.com")
.build()
);
}

Summary

I wasted time using the wrong communication methods because I didn’t understand their purposes:

  • updateModelContext: For AI awareness (invisible to chat)
  • sendMessage: For user-visible actions (shows in chat)
  • callServerTool: For backend operations (transparent)

Always await app.connect() before using any method. Handle errors properly. And choose the right method based on visibility and purpose.

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