Setup and context: How MCP Is Transforming Mobile App Development
When Anthropic introduced MCP (Model Context Protocol) in late 2024, it established a universal standard for connecting AI models to external tools and data sources. By 2026, MCP adoption has exploded on the desktop and server side — but mobile app integration remains largely uncharted territory for most developers.
Bringing MCP into Rork-powered mobile apps unlocks some genuinely powerful capabilities:
- Dynamic tool integration: AI autonomously invokes database queries, web search, code execution, and more
- Beyond RAG: Real-time data access that static retrieval pipelines simply can't match
- Multi-provider flexibility: Switch between Claude, Gemini, and GPT-4o through a single interface
- Custom tool surfaces: Expose your own APIs as MCP servers and let AI call them on demand
This guide gives you everything you need to integrate MCP into a Rork app — from architecture design and working implementation code to production security patterns. If you've already worked through Building an AI Assistant App with Rork × Claude API and Intelligent Assistant Apps with Rork × AI Function Calling, you're ready for this next level.
Understanding MCP: Architecture and Core Concepts
The Three-Layer Model
MCP defines three distinct roles:
- MCP Host: The application that drives the AI — in our case, the Rork app itself
- MCP Client: A library embedded in the host that manages communication with MCP servers
- MCP Server: A service that exposes tools, resources, and prompts to AI models
[Rork App (MCP Host)]
↓ MCP Client
[MCP Server Pool]
├── Brave Search MCP Server
├── Supabase MCP Server
├── GitHub MCP Server
└── Your Custom API Server
Transport Options
MCP supports several transport mechanisms:
- stdio: Inter-process communication (desktop and server-side only)
- SSE (Server-Sent Events): HTTP-based event streaming — ideal for mobile apps
- Streamable HTTP: Introduced in 2025 as the preferred server-side option
Mobile apps communicate over the network, so SSE or Streamable HTTP is the practical choice.
How Tool Definitions Work
MCP servers expose their capabilities through a tools/list endpoint using JSON Schema. The client passes these definitions to the AI model, which then calls tools/call whenever it decides a tool is relevant.
{
"name": "search_products",
"description": "Search the product database for matching items",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" },
"maxResults": { "type": "number", "default": 5 }
},
"required": ["query"]
}
}Environment Setup and Prerequisites
What You'll Need
- Rork Max (required for custom code editing and native module support)
- Node.js 20+ (for local MCP server testing)
- Anthropic API key (Claude claude-3-5-sonnet or claude-opus-4 recommended)
Installing Required Packages
Add these to your Rork app:
# MCP client SDK (React Native compatible)
npm install @modelcontextprotocol/sdk
# EventSource polyfill for React Native
npm install eventsource
# Anthropic SDK
npm install @anthropic-ai/sdkSetting Up Your MCP Server (Cloudflare Workers)
For this guide, we'll deploy a custom MCP server on Cloudflare Workers. If you're connecting to existing public MCP servers (Brave Search, Supabase, etc.), you can skip this section.
npm create cloudflare@latest my-mcp-server -- --template worker
cd my-mcp-server
npm install @modelcontextprotocol/sdkBuilding the MCP Server (Cloudflare Workers)
Core Server Structure
// src/index.ts (Cloudflare Workers MCP Server)
import { McpAgent } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export class MyMCPServer extends McpAgent {
server = new McpServer({
name: "my-mobile-mcp-server",
version: "1.0.0",
});
async init() {
// Tool 1: Database search
this.server.tool(
"search_products",
"Search the product database",
{
query: z.string().describe("Search keyword"),
category: z.string().optional().describe("Category filter"),
limit: z.number().default(10).describe("Maximum results"),
},
async ({ query, category, limit }) => {
const results = await searchProductsFromDB(query, category, limit);
return {
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
};
}
);
// Tool 2: Stock check
this.server.tool(
"check_stock",
"Check the inventory count for a product",
{
productId: z.string().describe("Product ID"),
},
async ({ productId }) => {
const stock = await getStockFromDB(productId);
return {
content: [
{ type: "text", text: `Product ${productId} stock: ${stock} units` },
],
};
}
);
}
}
export default new OAuthProvider({
apiHandlers: { "/mcp": MyMCPServer.mount("/mcp") },
});Deploying to Cloudflare Workers
npx wrangler deploy
# → Available at https://mcp.yourdomain.comImplementing the Rork App Side
Step 1: MCP Client Initialization
// lib/mcp-client.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
export interface MCPClientConfig {
serverUrl: string;
apiKey?: string;
timeout?: number;
}
export class MobileMCPClient {
private client: Client;
private transport: SSEClientTransport;
private isConnected: boolean = false;
constructor(private config: MCPClientConfig) {
this.transport = new SSEClientTransport(new URL(config.serverUrl), {
requestInit: {
headers: {
Authorization: `Bearer ${config.apiKey}`,
"X-App-Version": "1.0.0",
},
},
});
this.client = new Client(
{ name: "rork-mobile-app", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
}
async connect(): Promise<void> {
if (this.isConnected) return;
await this.client.connect(this.transport);
this.isConnected = true;
console.log("✅ Connected to MCP Server");
}
async listTools() {
await this.connect();
const { tools } = await this.client.listTools();
return tools;
}
async callTool(name: string, args: Record<string, unknown>) {
await this.connect();
return await this.client.callTool({ name, arguments: args });
}
async disconnect(): Promise<void> {
if (!this.isConnected) return;
await this.client.close();
this.isConnected = false;
}
}
export const mcpClient = new MobileMCPClient({
serverUrl: process.env.EXPO_PUBLIC_MCP_SERVER_URL!,
apiKey: process.env.EXPO_PUBLIC_MCP_API_KEY,
timeout: 30000,
});Step 2: Integrating Anthropic with MCP
// lib/ai-mcp-service.ts
import Anthropic from "@anthropic-ai/sdk";
import { mcpClient } from "./mcp-client";
const anthropic = new Anthropic({
apiKey: process.env.EXPO_PUBLIC_ANTHROPIC_API_KEY,
});
export interface ConversationMessage {
role: "user" | "assistant";
content: string;
}
export async function* streamWithMCP(
userMessage: string,
conversationHistory: ConversationMessage[]
): AsyncGenerator<string> {
// Step 1: Fetch available tools from the MCP server
const mcpTools = await mcpClient.listTools();
// Step 2: Convert MCP tool definitions to Anthropic format
const anthropicTools: Anthropic.Tool[] = mcpTools.map((tool) => ({
name: tool.name,
description: tool.description || "",
input_schema: tool.inputSchema as Anthropic.Tool.InputSchema,
}));
const messages: Anthropic.MessageParam[] = [
...conversationHistory.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
{ role: "user", content: userMessage },
];
// Step 3: Send message to Claude with tool use enabled
let response = await anthropic.messages.create({
model: "claude-opus-4-5",
max_tokens: 4096,
tools: anthropicTools,
messages,
stream: false,
});
// Step 4: Tool use loop — keep executing until Claude is done calling tools
while (response.stop_reason === "tool_use") {
const toolUseBlocks = response.content.filter(
(block): block is Anthropic.ToolUseBlock => block.type === "tool_use"
);
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const toolUse of toolUseBlocks) {
console.log(`🔧 Calling tool: ${toolUse.name}`, toolUse.input);
// Execute the tool via MCP
const mcpResult = await mcpClient.callTool(
toolUse.name,
toolUse.input as Record<string, unknown>
);
const resultText =
mcpResult.content
.filter(
(c): c is { type: "text"; text: string } => c.type === "text"
)
.map((c) => c.text)
.join("\n") || "Tool execution complete";
toolResults.push({
type: "tool_result",
tool_use_id: toolUse.id,
content: resultText,
});
}
// Return tool results to Claude to continue generation
messages.push(
{ role: "assistant", content: response.content },
{ role: "user", content: toolResults }
);
response = await anthropic.messages.create({
model: "claude-opus-4-5",
max_tokens: 4096,
tools: anthropicTools,
messages,
stream: false,
});
}
// Step 5: Stream the final text response
const finalText = response.content
.filter((block): block is Anthropic.TextBlock => block.type === "text")
.map((block) => block.text)
.join("");
const chunkSize = 10;
for (let i = 0; i < finalText.length; i += chunkSize) {
yield finalText.slice(i, i + chunkSize);
await new Promise((resolve) => setTimeout(resolve, 0));
}
}Step 3: The React Native Chat UI
// components/MCPChatScreen.tsx
import React, { useState, useRef, useCallback } from "react";
import {
View, Text, TextInput, FlatList, TouchableOpacity,
ActivityIndicator, StyleSheet, KeyboardAvoidingView, Platform,
} from "react-native";
import { streamWithMCP, ConversationMessage } from "../lib/ai-mcp-service";
interface Message extends ConversationMessage {
id: string;
isStreaming?: boolean;
}
export const MCPChatScreen: React.FC = () => {
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const flatListRef = useRef<FlatList>(null);
const sendMessage = useCallback(async () => {
if (!inputText.trim() || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
role: "user",
content: inputText.trim(),
};
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: "",
isStreaming: true,
};
const history: ConversationMessage[] = messages.map((m) => ({
role: m.role,
content: m.content,
}));
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setInputText("");
setIsLoading(true);
try {
for await (const chunk of streamWithMCP(userMessage.content, history)) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: m.content + chunk }
: m
)
);
flatListRef.current?.scrollToEnd({ animated: false });
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id ? { ...m, isStreaming: false } : m
)
);
} catch (error) {
console.error("MCP chat error:", error);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: "An error occurred. Please try again.", isStreaming: false }
: m
)
);
} finally {
setIsLoading(false);
}
}, [inputText, isLoading, messages]);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View
style={[
styles.bubble,
item.role === "user" ? styles.userBubble : styles.aiBubble,
]}
>
<Text style={styles.bubbleText}>{item.content}</Text>
{item.isStreaming && <ActivityIndicator size="small" color="#666" />}
</View>
)}
contentContainerStyle={styles.list}
onContentSizeChange={() =>
flatListRef.current?.scrollToEnd({ animated: true })
}
/>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={inputText}
onChangeText={setInputText}
placeholder="Type a message..."
multiline
editable={!isLoading}
/>
<TouchableOpacity
style={[styles.sendBtn, isLoading && styles.disabled]}
onPress={sendMessage}
disabled={isLoading}
>
<Text style={styles.sendBtnText}>Send</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#f5f5f5" },
list: { padding: 16, gap: 8 },
bubble: { maxWidth: "80%", padding: 12, borderRadius: 16 },
userBubble: { alignSelf: "flex-end", backgroundColor: "#007AFF" },
aiBubble: { alignSelf: "flex-start", backgroundColor: "#fff", elevation: 2 },
bubbleText: { fontSize: 15, lineHeight: 22 },
inputRow: {
flexDirection: "row",
padding: 12,
backgroundColor: "#fff",
borderTopWidth: 1,
borderTopColor: "#e0e0e0",
gap: 8,
},
input: {
flex: 1,
borderWidth: 1,
borderColor: "#e0e0e0",
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 8,
maxHeight: 100,
fontSize: 15,
},
sendBtn: {
backgroundColor: "#007AFF",
borderRadius: 20,
paddingHorizontal: 16,
justifyContent: "center",
},
disabled: { opacity: 0.5 },
sendBtnText: { color: "#fff", fontWeight: "600" },
});Authentication and Security
Never Expose API Keys in the Client
// ❌ Never do this — API keys in client code are leaked in build artifacts
const mcpClient = new MobileMCPClient({
serverUrl: "https://mcp.yourdomain.com/mcp",
apiKey: "sk-ant-XXXX", // Critically insecure
});
// ✅ Route through your authenticated backend instead
// Rork App → Your Backend (auth check) → MCP ServerJWT-Based Auth Flow
// lib/mcp-auth.ts
import * as SecureStore from "expo-secure-store";
export async function getMCPToken(): Promise<string> {
const cached = await SecureStore.getItemAsync("mcp_token");
if (cached) {
const { token, expiresAt } = JSON.parse(cached);
// Reuse if more than 5 minutes remain
if (Date.now() < expiresAt - 300000) return token;
}
const supabaseToken = await getSupabaseToken();
const response = await fetch(
`${process.env.EXPO_PUBLIC_API_BASE_URL}/api/mcp-token`,
{
method: "POST",
headers: {
Authorization: `Bearer ${supabaseToken}`,
"Content-Type": "application/json",
},
}
);
const { token, expiresIn } = await response.json();
await SecureStore.setItemAsync(
"mcp_token",
JSON.stringify({ token, expiresAt: Date.now() + expiresIn * 1000 })
);
return token;
}Rate Limiting
// lib/mcp-rate-limiter.ts
export class MCPRateLimiter {
private requestCount = 0;
private resetTime = Date.now() + 60000;
constructor(private readonly maxPerMinute = 20) {}
async throttle(): Promise<void> {
if (Date.now() > this.resetTime) {
this.requestCount = 0;
this.resetTime = Date.now() + 60000;
}
if (this.requestCount >= this.maxPerMinute) {
const wait = this.resetTime - Date.now();
await new Promise((r) => setTimeout(r, wait));
this.requestCount = 0;
this.resetTime = Date.now() + 60000;
}
this.requestCount++;
}
}Real-World Use Case: AI Shopping Advisor in an E-Commerce App
Here's how MCP plays out in a concrete scenario. A user opens your shopping app and types: "I'm looking for a birthday gift for my girlfriend, budget around $80."
Without any additional code changes, the AI autonomously:
- Calls
search_products({ query: "birthday gift women", maxPrice: 80, limit: 5 }) - Calls
check_stock({ productId: "P001" })for each result - Calls
get_reviews({ productId: "P001", sortBy: "rating" }) - Synthesizes everything into a natural-language recommendation
// The entire flow above happens automatically through the tool-use loop.
// No hardcoded logic — just tool definitions on the MCP server.
//
// AI response example:
// "Based on top-rated options under $80, I'd recommend the Rose Gold Jewelry Set
// ($72, in stock, ★4.9). Reviewers consistently call it 'elegant and gift-ready,'
// making it a great birthday pick."The real power: adding a new tool to the MCP server instantly makes it available to the AI — no app update required.
Troubleshooting Common Issues
Issue 1: SSE Connection Drops Frequently
Mobile apps lose SSE connections when backgrounded or when switching networks. Add automatic reconnection logic:
class ReconnectingMCPClient extends MobileMCPClient {
async callToolWithRetry(name: string, args: Record<string, unknown>, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await this.callTool(name, args);
} catch (error: unknown) {
const isConnectionError =
error instanceof Error && error.message.includes("connection");
if (isConnectionError && i < retries - 1) {
console.log(`Reconnect attempt ${i + 1}/${retries}...`);
this.isConnected = false;
await new Promise((r) => setTimeout(r, 1000 * (i + 1)));
continue;
}
throw error;
}
}
}
}Issue 2: Tool not found Error
Verify that tool names match exactly between server and client:
const tools = await mcpClient.listTools();
console.log("Available tools:", tools.map((t) => t.name));
// → ["search_products", "check_stock", "get_reviews"]Issue 3: EventSource is not defined on Android
React Native doesn't include EventSource by default. Add the polyfill at the very top of App.tsx:
import "eventsource-polyfill";
// or
import EventSource from "react-native-event-source";
global.EventSource = EventSource;Summary
Integrating MCP into your Rork app transforms it from a standard AI chatbot into an autonomous agent that can access live data, call external APIs, and reason across multiple tool results — all without you writing a new code path for every capability.
Key takeaways from this guide:
- Architecture: Three-layer Host/Client/Server model with SSE transport for mobile
- Core loop: Convert MCP tool definitions to Anthropic format, run the tool-use loop, stream the final response
- Production hardening: JWT auth, rate limiting, and auto-reconnect for mobile network realities
- Scalability: Add tools to your MCP server; the app picks them up automatically
For a strong foundation in AI-powered mobile features, revisit Rork × Gemini 2.0 Flash Complete Guide alongside this article — combining multiple AI providers through MCP is where things get truly interesting.