Bring Your Own MCP

Bring Your Own MCP

AI assistants should do more than chat, they should actively assist users in performing tasks. Embedding MCP in the front end allows you to provide an AI agent the tools your users need when they need it.

AI agents become more useful when they can execute actions, not just generate text. The Model Context Protocol (MCP) gives AI agents access to external tools and data sources. This has opened up powerful new workflows for desktop applications, where AI assistants can directly manipulate local files, query databases, and integrate with development tools.

What about web applications? Consider a user shopping for a car on your website. When browsing available models, an AI assistant needs tools to select specific cars or filter options that meet certain criteria. After the user selects a car, it needs different capabilities: tools to configure the model, arrange a test drive, or handle financing. By embedding MCP into your frontend, the same AI agent can adapt its available tools based on where the user is in their journey.

Here's how it works in practice:

Website View:

Choose Your Car

Honda CR-V
$32,000
Ford F-150
$38,000
BMW M3
$68,000
Tesla Model S
$75,000

AI Assistant:

Available Tools:
filter_cars select_car
Hi! I can help you pick and configure a car. Try the prompts below.

How Does It Work?

Normally, AI agents initiate connections to MCP tool providers. But when the MCP server lives in a browser, this creates a connectivity challenge. Web applications can't accept inbound connections from backend services. The solution is to have the browser initiate a bidirectional WebSocket connection that handles client-initiated conversations as well as agent-initiated MCP calls.

Client

UI Component
Handles user interactions and displays results
MCP Server
Exposes tools and handles execution requests
↕ WebSocket

Transfers prompts, responses, and MCP protocol messages bidirectionally between client and agent.

AI Agent

Maintains tool registry, provides context to LLM, forwards tool execution requests
↓ LLM API

Agent queries the LLM for reasoning and gets back structured tool call instructions.

Large Language Model

General purpose LLM that can generate text responses or decide to call tools

Frontend: Browser MCP Client

Building a client-side MCP system starts with a browser-based MCP client that registers tools and handles execution requests from the AI agent.

First, the client establishes a WebSocket connection to enable bidirectional communication with the agent:

connectToAgent() {
    this.ws = new WebSocket(this.agentUrl);
    
    this.ws.onopen = () => {
        console.log('Connected to agent via WebSocket');
    };
    
    this.ws.onmessage = (event) => {
        const data = JSON.parse(event.data);
        this.handleAgentResponse(data);
    };
}

handleAgentResponse(data) {
    // Check if this is an MCP request from the agent
    if (data.jsonrpc && data.method) {
        const mcpResponse = this.handleMCPRequest(data);
        this.ws.send(JSON.stringify(mcpResponse));
        return;
    }
    
    // Handle other agent responses (chat, etc.)
    if (data.result) {
        console.log('Agent response:', data.result);
    }
}

Next, tools are defined with a name, description, and input schema:

this.tools['fill_form_data'] = {
    name: 'fill_form_data',
    description: 'Fill form fields with the provided data',
    inputSchema: {
        type: 'object',
        properties: {
            formData: {
                type: 'object',
                description: 'Object containing form field names and values'
            }
        },
        required: ['formData']
    }
};

Finally, the client handles MCP requests from the agent, responding to tool discovery and execution:

handleMCPRequest(message) {
    const { method, params, id } = message;
    
    switch (method) {
        case 'tools/list':
            return {
                jsonrpc: '2.0',
                id: id,
                result: { tools: Object.values(this.tools) }
            };
            
        case 'tools/call':
            const { name, arguments: args } = params;
            
            if (name === 'fill_form_data') {
                this.fillFormWithData(args.formData);
                return {
                    jsonrpc: '2.0',
                    id: id,
                    result: {
                        content: [{ type: 'text', text: 'Form filled successfully' }]
                    }
                };
            }
    }
}

This shows the complete implementation flow: connect via WebSocket, define available tools, then handle incoming MCP requests and execute tools locally with results sent back to the agent.

Backend: AI Agent Architecture

The backend agent bridges the browser and LLM. When a browser connects, the agent discovers available tools, creates an AI assistant with access to those tools, and coordinates the conversation flow.

First, each WebSocket connection gets its own manager that tracks pending requests and coordinates async responses:

class ConnectionManager:
    def __init__(self, websocket):
        self.websocket = websocket
        self.mcp_request_id = 0
        self.pending_mcp_requests = {}
        
    async def send_mcp_request(self, method, params=None):
        self.mcp_request_id += 1
        request_id = self.mcp_request_id
        
        future = asyncio.Future()
        self.pending_mcp_requests[request_id] = future
        
        await self.websocket.send_text(json.dumps({
            "jsonrpc": "2.0", "method": method, 
            "id": request_id, "params": params
        }))
        
        return await asyncio.wait_for(future, timeout=15.0)
    

Next, the agent discovers what tools are available and converts them into LangChain tools:

async def discover_and_create_tools(manager):
    response = await manager.send_mcp_request("tools/list")
    tools_data = response.get("result", {}).get("tools", [])
    
    tools = []
    for tool_info in tools_data:
        tool = DynamicMCPTool(
            name=tool_info["name"],
            description=tool_info["description"],
            manager=manager,
            args_schema=jsonschema_to_pydantic(tool_info["inputSchema"])
        )
        tools.append(tool)
    
    return tools
    

When the LLM calls a tool, the DynamicMCPTool wrapper serializes arguments and forwards the request to the browser:

class DynamicMCPTool(BaseTool):
    async def _arun(self, **kwargs):
        serializable_kwargs = to_jsonable_python(kwargs)
        params = {"name": self.name, "arguments": serializable_kwargs}
        
        response = await self.manager.send_mcp_request("tools/call", params)
        
        if "error" in response:
            return f"Error calling {self.name}: {response['error']}"
        
        return str(response.get("result", {}))
    

Finally, the agent coordinates two message types: user chat messages for LLM processing, and MCP responses from tool calls. A listener routes these appropriately:

async def listen(self):
    while True:
        data = await self.websocket.receive_text()
        message_data = json.loads(data)
        
        if "jsonrpc" in message_data and "method" not in message_data:
            # Route MCP response to waiting request
            await self.handle_mcp_response(message_data)
        else:
            # Route user message to LLM
            message = message_data.get("message", "")
            asyncio.create_task(self.process_agent_message(message))
    

Conclusion

AI implementations often fall into two extremes: weak chatbots that can only respond to questions, or overly ambitious autonomous agents trying to replace human workflows entirely. The middle ground - AI that augments rather than replaces - requires giving agents the right tools at the right time. Context-aware tool access lets AI assist users exactly where they are in their workflow without trying to take over the entire process.

The code samples above have been shortened for clarity. You can find the complete implementation at https://github.com/JonWoodlief/byomcp.