MCP server integration (for developers)
This guide is for developers and technical partners. It explains how to build an MCP server that Bonnie can discover and call as external tools, so you can extend what Bonnie does on the phone and over WhatsApp.
If you are a restaurant looking to connect a reservation system, you do not need this page. See Reservation systems and Connect your reservation system instead.
Overview
- Purpose: help partners and developers build an MCP server that Bonnie can discover and call as external tools to augment voice and chat assistants.
- Who should read this: backend engineers or integrators familiar with building HTTP/JSON services. Basic knowledge of JSON Schema is helpful.
- What you will build: an MCP server that exposes tools over JSON-RPC, with optional UI discovery, scoping by call state and channel, and a minimal config contract.
What is an MCP server, and why it matters for Bonnie
A Model Context Protocol (MCP) server is a lightweight service that exposes callable "tools" over JSON-RPC, discoverable via tools/list and executable via tools/call. In our setup, Bonnie acts as the MCP client: at the start of a call or chat she lists the available tools, applies scope rules, and then invokes the right tool in-flow. This lets you cleanly package capabilities (reservations, CRM actions, payments, delivery lookups, calendars, or custom business logic) behind clear JSON Schemas and predictable responses.
How this expands Bonnie's phone and WhatsApp skills
By connecting Bonnie to one or more MCP servers, you can safely extend what she does across third-party platforms without changing Bonnie's core. Tools can be scoped to a call state (setup, in-progress, completed) and a channel (phone, whatsapp), so the right actions are available at the right moment. For example: auto-fetching customer context during setup, checking table availability mid-call, or sending a WhatsApp confirmation after a booking. Results can be structured JSON, so Bonnie can reliably summarise outcomes to the caller, update systems, or trigger follow-up messages. Authentication and headers carry restaurant- and conversation-specific context, keeping integrations secure and targeted while staying simple to adopt.
Concepts
- MCP in brief: servers expose tools over JSON-RPC.
tools/list: enumerate available tools and their input schema.tools/call: execute a tool with arguments.
- Bonnie's role: she acts as the MCP client. She discovers tools at runtime and invokes them during conversations.
- Transports supported:
streamable_http(default) andsse. - Authentication: optional Bearer token via
Authorization: Bearer <token>. Additional static headers are also supported.
Architecture and end-to-end flow
- Assistant configuration registers one or more MCP servers.
- On startup of an interaction, Bonnie lists tools from each configured server and normalises them to OpenAI-style function tools.
- Availability is computed by merging server-level defaults (from the assistant config) with per-tool
metascoping (from your server). - At runtime, Bonnie selects the eligible tools for the current call state and channel, and calls them via
tools/call. - Responses can be plain text or structured data. Structured data is preferred when available.
Building a server (requirements)
- Mandatory endpoints:
tools/list: provide your tools withname,description, andinputSchema(a JSON Schema object).tools/call: execute a tool by its simplenamewith anargumentsobject.
- Tool schema essentials:
name: unique within your server.description: a concise, human-readable explanation.inputSchema: JSON Schema Draft-07 (object). Usepropertiesandrequiredas needed.
- Returning results:
- Text: return textual content.
- Structured: return a JSON object (preferred). Bonnie surfaces
data/ structured content when provided.
- Transport and hosting: serve over streamable HTTP (a single endpoint) or SSE. Ensure public reachability and TLS if internet-facing.
- Authentication: if needed, require Bearer tokens and/or custom headers. Bonnie forwards the configured headers.
Scopes for tools (call state and channel)
- Supported call states:
setup,in-progress,completed. - Supported channels:
phone,whatsapp. - Per-tool scopes: in your
tools/list, include an optionalmeta:meta.bonnie_states: a subset ofsetup,in-progress,completed.meta.bonnie_channels: a subset ofphone,whatsapp.meta.bonnie_feedback: a list of strings (for example["One moment please", "Let me check that"]). One is randomly selected and played to the caller before the tool executes, which helps manage expectations on slower operations. Always write these in English. Bonnie auto-translates other languages.
- Server-level defaults: in the assistant config you can set
call-statesandchannelsfor the whole server. Per-toolmetacan further constrain these. - Pre-call (setup) tools:
- Tools with
meta.bonnie_states: ["setup"]are auto-executed during call setup when allowed for the current channel. - They are invoked with no arguments. Read context from the headers instead.
- If such a tool returns
{ "prompt_variables": { ... } }, Bonnie merges those into the prompt context under the namespacemcp_<alias>(for examplemcp_example.first_name).
- Tools with
Adding tool metadata (without FastMCP)
When building an MCP server without FastMCP, you can add Bonnie-specific metadata by including a meta field in the tool definitions returned by tools/list. This enables scoping and pre-execution feedback.
JSON-RPC response format
In your tools/list response, include a meta object on each tool:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "check_reservation",
"description": "Check an existing reservation by confirmation number",
"inputSchema": {
"type": "object",
"properties": {
"confirmation_number": {
"type": "string",
"description": "The reservation confirmation number"
}
},
"required": ["confirmation_number"]
},
"meta": {
"bonnie_feedback": [
"Let me look that up for you",
"One moment while I check your reservation"
],
"bonnie_states": ["in-progress"],
"bonnie_channels": ["phone", "whatsapp"]
}
}
]
}
}
Meta field reference
| Field | Type | Description |
|---|---|---|
bonnie_states |
string[] |
Limit tool availability to specific call states: setup, in-progress, completed. If omitted, server-level defaults apply (see assistant config). |
bonnie_channels |
string[] |
Limit tool availability to specific channels: phone, whatsapp. If omitted, server-level defaults apply. |
bonnie_feedback |
string[] |
A list of feedback messages. One is randomly selected and may be played to the caller before the tool executes. Always provide messages in English. Bonnie auto-translates as needed. Playback is not guaranteed if the model already includes feedback in its response. |
Notes
- The
metafield is entirely optional. Tools without it inherit the server-level defaults from the assistant configuration. - Per-tool
metacan only further constrain availability. It cannot expand beyond what the server-level config allows. - Keep feedback messages short and conversational. Providing multiple options adds natural variation.
Request context your server receives
Bonnie sends request context as HTTP headers.
- Always:
x-bonnie-assistant-id: string.x-bonnie-call-state:setup,in-progress, orcompleted.x-bonnie-channel:phoneorwhatsapp.x-bonnie-config: a JSON string of the assistant's per-serverconfigobject.
- On tool calls:
x-bonnie-conversation-id: string, if available.x-bonnie-caller-id: caller identifier or phone, if available.x-bonnie-destination-id: destination identifier or phone, if available.
Servers should ignore headers they do not recognise.
Discovery for UI configuration
- Well-known endpoint:
/.well-known/mcp-server-info(optional, used by the UI only). - Fields:
name(required): a human-readable server name.config_schema(optional): JSON Schema for server-specific config. Top-levelrequired: [].auth.required(optional): whether tool calls require an Authorization header.
- The UI may cache discovery results. Runtime tool listing and invocation do not depend on this endpoint.
Configure Bonnie to use your server (without UI / discovery)
Add an mcp_servers entry to your assistant configuration:
{
"mcp_servers": [
{
"url": "https://mcp.example.com/mcp",
"alias": "example",
"type": "streamable_http",
"auth_token": "abcd1234",
"headers": { "X-Tenant": "acme-eu" },
"config": { "region": "eu-west" },
"call-states": ["in-progress"],
"channels": ["phone", "whatsapp"],
"timeout-ms": 1500
}
],
"mcp_global_timeout_ms": 15000
}
Field reference:
url(required): the base MCP endpoint.alias(optional): a label for logs and UI, and the prompt variable namespace.type(optional):streamable_http(default) orsse.auth_token(optional): sendsAuthorization: Bearer <token>.headers(optional): extra static headers added to every request.config(optional): arbitrary JSON forwarded viax-bonnie-config.call-states(optional): default allowed call states for this server.channels(optional): default allowed channels for this server.timeout-ms(optional): the client per-server timeout (default 1500ms).mcp_global_timeout_ms(optional, top-level): the global cap for discovery and tool calls (default 15000ms).
Example server walkthrough
Location: docs/mcp/server/example/python/app.py.
current_datetime: returns an ISO string. Demonstrates a basic tool with optional arguments.request_context: returns the current request headers. Shows how to read Bonnie's headers.validate_api_key: checks the Bearer token presence and value.get_weather: calls an external API with validated input, returns structured content, and demonstrates pre-execution feedback viameta.bonnie_feedback.get_random_number: demonstrates channel scoping viameta.bonnie_channels.get_user_profile: demonstrates setup scoping viameta.bonnie_states: ["setup"]and returningprompt_variables.- Discovery route:
/.well-known/mcp-server-infoprovides minimal UI metadata and an optionalconfig_schema.
Run locally (FastMCP Python example):
python -m pip install fastmcp pytz
python docs/mcp/server/example/python/app.py
Testing with MCP Inspector
UI mode:
npx @modelcontextprotocol/inspector --transport http --serverUrl http://localhost:8080/mcp
CLI mode:
# List tools
npx @modelcontextprotocol/inspector --cli --transport http http://localhost:8080/mcp --method tools/list | cat
# Call a tool
npx @modelcontextprotocol/inspector --cli --transport http http://localhost:8080/mcp --method tools/call \
--tool-name current_datetime --tool-arg timezone=Europe/Amsterdam | cat
Best practices
- Prefer structured JSON results for machine readability, and keep payloads concise.
- Keep tool names stable. Evolve
inputSchemaadditively when possible. - Validate inputs against your schema, and return clear error messages.
- Handle auth failures gracefully, and avoid logging sensitive header values.
- Respect timeouts and avoid very large responses.
Appendices
Example well-known discovery JSON:
{
"name": "Bonnie MCP Example Server",
"endpoints": { "mcp": "https://example.com/mcp" },
"auth": { "required": false },
"config_schema": {
"type": "object",
"properties": {
"restaurant_id": { "type": "string", "description": "Unique identifier of the restaurant" }
},
"required": ["restaurant_id"]
}
}
Scope defaults: if no per-tool meta is provided, Bonnie defaults to state in-progress and channels phone and whatsapp. The assistant config can further constrain availability.
Example app (Python)
from datetime import datetime, timezone as dt_timezone
from typing import Optional, Any, Dict
import json
from urllib.parse import urlencode
from urllib.request import urlopen
import pytz
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_http_request, get_http_headers
from starlette.requests import Request
from starlette.responses import JSONResponse
mcp = FastMCP("Bonnie MCP Example Server (Python)")
@mcp.tool
def current_datetime(timezone: Optional[str] = None) -> str:
"""Return the current date/time in ISO format."""
if timezone:
try:
tz = pytz.timezone(timezone)
now = datetime.now(tz)
except Exception:
now = datetime.now(dt_timezone.utc)
else:
now = datetime.now(dt_timezone.utc)
return now.isoformat()
@mcp.tool
def request_context() -> Dict[str, Any]:
"""Return request headers."""
try:
req = get_http_request()
headers = dict(req.headers) if req else {}
return {"headers": headers}
except Exception:
return {"error": "no_request"}
@mcp.tool
def validate_api_key() -> Dict[str, Any]:
"""Validate API key by checking if Bearer token matches 'test-token'."""
try:
headers = get_http_headers()
auth_header = headers.get("authorization", "")
if not isinstance(auth_header, str) or not auth_header.lower().startswith("bearer "):
return {
"valid": False,
"error": "missing_bearer_token",
"message": "Authorization header with Bearer token is required."
}
# Extract token from "Bearer <token>"
token = auth_header.split(" ", 1)[1].strip()
if token == "test-token":
return {
"valid": True,
"message": "API key is valid.",
"token": token
}
else:
return {
"valid": False,
"error": "invalid_token",
"message": "Invalid API key provided.",
"received_token": token
}
except Exception as exc: # noqa: BLE001
return {
"valid": False,
"error": "validation_failed",
"message": f"Failed to validate API key: {str(exc)}"
}
def _get_city_coordinates(city: str) -> Optional[Dict[str, Any]]:
"""Get latitude and longitude for a city using Open-Meteo geocoding."""
params = {"name": city.strip(), "count": 1, "language": "en", "format": "json"}
url = "https://geocoding-api.open-meteo.com/v1/search?" + urlencode(params)
try:
with urlopen(url, timeout=10) as resp:
body = resp.read().decode("utf-8")
data = json.loads(body)
if data.get("results") and len(data["results"]) > 0:
result = data["results"][0]
return {
"latitude": result.get("latitude"),
"longitude": result.get("longitude"),
"name": result.get("name"),
"country": result.get("country"),
}
except Exception:
pass
return None
@mcp.tool(
meta={
"bonnie_feedback": [
"Let me check the weather for you",
"One moment while I get the forecast",
]
}
)
def get_weather(city: Optional[str] = None) -> Dict[str, Any]:
"""Get current weather for a city using Open-Meteo API."""
# - Free API, no API key required
# - Uses Open-Meteo weather service: https://open-meteo.com/
# - Only parameter is `city` (e.g., "Amsterdam")
if not city or not isinstance(city, str) or not city.strip():
return {"error": "missing_city", "message": "Provide a city name."}
# Get coordinates for the city
location_data = _get_city_coordinates(city.strip())
if not location_data:
return {"error": "city_not_found", "message": f"Could not find coordinates for city: {city.strip()}"}
# Get weather data using coordinates
params = {
"latitude": location_data["latitude"],
"longitude": location_data["longitude"],
"current": (
"temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,"
"showers,snowfall,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_speed_10m,"
"wind_direction_10m,wind_gusts_10m"
),
"timezone": "auto"
}
url = "https://api.open-meteo.com/v1/forecast?" + urlencode(params)
try:
with urlopen(url, timeout=10) as resp:
body = resp.read().decode("utf-8")
raw = json.loads(body)
except Exception as exc: # noqa: BLE001
return {"error": "weather_fetch_failed", "message": str(exc)}
current = raw.get("current") or {}
# Map weather codes to descriptions
weather_code = current.get("weather_code", 0)
weather_descriptions = {
0: "Clear sky",
1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
45: "Fog", 48: "Depositing rime fog",
51: "Light drizzle", 53: "Moderate drizzle", 55: "Dense drizzle",
56: "Light freezing drizzle", 57: "Dense freezing drizzle",
61: "Slight rain", 63: "Moderate rain", 65: "Heavy rain",
66: "Light freezing rain", 67: "Heavy freezing rain",
71: "Slight snow fall", 73: "Moderate snow fall", 75: "Heavy snow fall",
77: "Snow grains",
80: "Slight rain showers", 81: "Moderate rain showers", 82: "Violent rain showers",
85: "Slight snow showers", 86: "Heavy snow showers",
95: "Thunderstorm", 96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail"
}
condition = weather_descriptions.get(weather_code, "Unknown")
return {
"city": location_data["name"],
"country": location_data["country"],
"temperature_c": current.get("temperature_2m"),
"apparent_temperature_c": current.get("apparent_temperature"),
"humidity": current.get("relative_humidity_2m"),
"condition": condition,
"weather_code": weather_code,
"wind_speed_kmh": current.get("wind_speed_10m"),
"wind_direction": current.get("wind_direction_10m"),
"pressure_hpa": current.get("pressure_msl"),
"is_day": bool(current.get("is_day", 0)),
"precipitation_mm": current.get("precipitation", 0),
"cloud_cover": current.get("cloud_cover"),
}
@mcp.tool(
meta={
"bonnie_channels": ["whatsapp"],
}
)
def get_random_number(min_value: int = 0, max_value: int = 100) -> Dict[str, Any]:
"""Return a random integer between min_value and max_value (inclusive)."""
# - Example of a tool that will only be listed for WhatsApp
if min_value > max_value:
min_value, max_value = max_value, min_value
import random
return {"number": random.randint(min_value, max_value)}
@mcp.tool(
meta={
"bonnie_states": ["setup"],
}
)
def get_user_profile() -> Dict[str, Any]:
"""Return static prompt variables for pre-call setup."""
req = get_http_request()
headers = dict(req.headers) if req else {}
return {
"prompt_variables": {
"first_name": "Bonnie",
"last_name": "Clyde",
"caller_id": headers.get("x-bonnie-caller-id", ""),
"destination_id": headers.get("x-bonnie-destination-id", ""),
}
}
# Well-known discovery endpoint
@mcp.custom_route(path="/.well-known/mcp-server-info", methods=["GET"])
async def mcp_server_info(request: Request) -> JSONResponse: # noqa: ARG001
return JSONResponse(
{
"name": "Bonnie MCP Example Server (Python)",
"description": (
"This is an example MCP server for Bonnie using Open-Meteo weather API. "
"It is used to test the MCP integration."
),
"alias": "bonnie-mcp",
"author": "Bonnie",
"icon": "https://bonnie.co/wp-content/uploads/2025/08/logomark-bonnie-default.svg",
"tags": ["mcp", "example", "python"],
"config_schema": {
"type": "object",
"properties": {
"restaurant_id": {
"type": "string",
"description": "Unique identifier of the restaurant",
}
},
"required": ["restaurant_id"],
},
"endpoints": {"mcp": "/mcp"},
"auth": {"required": False},
}
)
if __name__ == "__main__":
# Streamable HTTP transport (single MCP endpoint)
mcp.run(transport="http", host="0.0.0.0", port=8080, path="/mcp")
Need help or want this enabled?
If you want to register an MCP server for your assistant, or you are building one and need a hand, reach our team from Get help.