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.
// After rolling dice, tell the AI so it can answer follow-upsconst 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 knowsIn 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.
// User clicks submit - send as user messageconst 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.
// Call a server tool to persist dataconst 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:
<!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">
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 historyawait app.sendMessage({ content: [{ type: "text", text: "Dice result: 4 and 3" }]});// User sees this in chat as their own message
// CORRECT: Updates AI context invisiblyawait app.updateModelContext({ content: [{ type: "text", text: "Dice result: 4 and 3" }]});// AI knows, but chat stays cleanMistake 2: Forgetting to await
// WRONG: Fire and forget - might fail silentlyapp.updateModelContext({ content: [...] });
// CORRECT: Handle errors properlytry { await app.updateModelContext({ content: [...] });} catch (error) { console.error("Failed to update context:", error);}Mistake 3: Not connecting first
// WRONG: Communication methods fail silentlyconst app = new App({ name: "my-app", version: "1.0.0" });await app.updateModelContext({...}); // Doesn't work!
// CORRECT: Connect before communicatingconst app = new App({ name: "my-app", version: "1.0.0" });await app.connect(); // MUST call this firstawait app.updateModelContext({...}); // Now it worksMistake 4: Calling non-existent server tools
// WRONG: Tool doesn't exist on serverawait 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
| Method | Purpose | Visible in Chat? | Use Case |
|---|---|---|---|
updateModelContext | AI state awareness | No | ”Remember this for later” |
sendMessage | User-initiated action | Yes | ”Do something with this” |
callServerTool | Backend operation | No | ”Process/save this” |
Quick Decision Guide
Ask yourself:
- Should the AI remember this for future questions? Use
updateModelContext - Should this appear in chat as a user action? Use
sendMessage - Do you need server-side processing? Use
callServerTool
You can combine them:
// Roll dice, update context, AND save to databaseawait app.updateModelContext({...}); // AI remembersawait app.callServerTool("save", {...}); // Backend saves// Don't use sendMessage unless you want it visibleRelated Knowledge
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)
// Or install via npm// npm install @modelcontextprotocol/ext-appsSpring AI Integration
If you’re using Spring AI 2.0.0-M3+, you can define tools that your UI can call:
@Servicepublic 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:
@Overridepublic 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