The Model Context Protocol (MCP) has become the standard way for AI agents to call external tools. Claude, Cursor, and a growing ecosystem of agent frameworks speak MCP natively, they discover tools via a tools/list endpoint and call them via tools/call. Any HTTP service that implements that contract becomes a tool provider the agent can use.
This walkthrough deploys a working MCP server to Telnyx Edge Compute. The whole server is 167 lines of Python in function/func.py, uses Python's standard library for the HTTP layer, and exposes four Telnyx APIs (send_sms, search_numbers, run_inference, list_phone_numbers) as MCP tools. There is no Express, no FastAPI, no SDK, just urllib, json, os, and an ASGI handler.
The canonical code example lives in the Telnyx code examples repo:
https://github.com/team-telnyx/telnyx-code-examples/tree/main/edge-mcp-server-deploy-python
What This Example Builds
A single ASGI function deployed to Telnyx Edge Compute that exposes four MCP tools:
| Tool | Telnyx API | Purpose |
|---|---|---|
send_sms | POST /v2/messages | Send an SMS message to an E.164 number |
search_numbers | GET /v2/available_phone_numbers | Search available phone numbers by country and area code |
run_inference | POST /v2/ai/chat/completions | Run LLM inference via Telnyx AI (OpenAI-compatible) |
list_phone_numbers | GET /v2/phone_numbers | List phone numbers on the account |
The function exposes four HTTP endpoints:
| Method | Path | Purpose |
|---|---|---|
GET, POST | /mcp/tools/list | Return the tool catalog |
POST | /mcp/tools/call | Execute a tool by name with arguments |
GET | /health | Health check (tool count, API key presence) |
GET | / | Service info (name, tool list, endpoint paths) |
Once deployed, an AI agent that supports MCP can be pointed at the deployed URL and immediately call Telnyx APIs as part of its reasoning loop.
Why Edge Compute
Edge Compute runs serverless functions co-located with the Telnyx private network, the same network that handles voice, messaging, and AI inference. For an MCP server that calls Telnyx APIs as tools, that means:
- No public internet round trips for the tool execution path. The function runs in the same network as the Telnyx API endpoints.
- Single API key surface. The Telnyx API key is injected as a function secret at deploy time, never sent over the wire by the AI agent.
- No cold-start container orchestration. Edge Compute is a function runtime, not a container host.
The MCP server sits one network hop away from every Telnyx API it calls. That is the carrier-edge advantage for AI agents: the same private network that carries SMS traffic carries the tool calls.
Products Used
telnyx_products: [Edge Compute, Messaging, Numbers, AI Inference]
Telnyx Edge Compute runs the ASGI function. Messaging powers send_sms. Numbers powers search_numbers and list_phone_numbers. AI Inference powers run_inference via the OpenAI-compatible chat completions endpoint.
Architecture
AI Agent (Claude / Cursor / MCP client)
│
│ HTTPS
▼
┌──────────────────────┐
│ Edge Compute │ function/func.py
│ ASGI handler │ 167 lines, stdlib only
│ │
│ GET /mcp/tools/list │ → returns TOOLS array
│ POST /mcp/tools/call │ → _execute_tool(name, args)
│ GET /health │
└──────────┬────────────┘
│ Bearer TELNYX_API_KEY
│ (injected as secret at deploy time)
▼
https://api.telnyx.com/v2
├── /messages (Messaging)
├── /available_phone_numbers (Numbers)
├── /phone_numbers (Numbers)
└── /ai/chat/completions (AI Inference)
The agent only knows the deployed URL. It never sees the Telnyx API key. The key is injected by the Edge Compute runtime as the TELNYX_API_KEY environment variable when the function boots.
The Code
The full function is 167 lines in function/func.py. Three pieces matter.
The tool catalog
The TOOLS array is the contract the agent reads during tools/list. Each entry has a name, a human-readable description, and a JSON Schema inputSchema. The schema is what the agent uses to construct valid arguments:
TOOLS = [
{
"name": "send_sms",
"description": "Send an SMS message via Telnyx",
"inputSchema": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Destination phone number in E.164 format"},
"from_number": {"type": "string", "description": "Your Telnyx phone number in E.164"},
"text": {"type": "string", "description": "Message body"},
},
"required": ["to", "from_number", "text"],
},
},
{
"name": "search_numbers",
"description": "Search for available phone numbers to purchase",
"inputSchema": {
"type": "object",
"properties": {
"country_code": {"type": "string", "description": "ISO 3166-1 alpha-2 country code", "default": "US"},
"area_code": {"type": "string", "description": "Area code to search in"},
"limit": {"type": "integer", "description": "Max results", "default": 5},
},
},
},
{
"name": "run_inference",
"description": "Run LLM inference via Telnyx AI (OpenAI-compatible)",
"inputSchema": {
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "User prompt"},
"model": {"type": "string", "description": "Model name", "default": "moonshotai/Kimi-K2.6"},
"max_tokens": {"type": "integer", "default": 256},
},
"required": ["prompt"],
},
},
{
"name": "list_phone_numbers",
"description": "List phone numbers on your Telnyx account",
"inputSchema": {
"type": "object",
"properties": {
"page_size": {"type": "integer", "default": 10},
},
},
},
]
The agent reads this array, picks the right tool for the user's intent, fills in the arguments, and POSTs to /mcp/tools/call.
The tool executor
_execute_tool is a flat dispatcher. The MCP layer extracts name and arguments from the request body and hands them to this method, which translates them into Telnyx REST calls. Every tool hits a different endpoint with different body shapes:
def _execute_tool(self, name, args):
if name == "send_sms":
return self._telnyx_request("POST", "/messages", {
"from": args["from_number"], "to": args["to"], "text": args["text"],
})
elif name == "search_numbers":
cc = args.get("country_code", "US")
limit = args.get("limit", 5)
path = f"/available_phone_numbers?filter[country_code]={cc}&filter[limit]={limit}"
if args.get("area_code"):
path += f"&filter[national_destination_code]={args['area_code']}"
return self._telnyx_request("GET", path)
elif name == "run_inference":
return self._telnyx_request("POST", "/ai/chat/completions", {
"model": args.get("model", "moonshotai/Kimi-K2.6"),
"messages": [{"role": "user", "content": args["prompt"]}],
"max_tokens": args.get("max_tokens", 256),
})
elif name == "list_phone_numbers":
size = args.get("page_size", 10)
return self._telnyx_request("GET", f"/phone_numbers?page[size]={size}")
return {"error": f"Unknown tool: {name}"}
Adding a new tool means appending to TOOLS and adding one more branch here. The two stay in sync because both reference the same string names.
The ASGI handler
The whole HTTP surface is one method. Edge Compute calls handle(scope, receive, send) on every request, the ASGI contract. The method routes on path and method, reads the JSON body for tools/call, and returns a JSON response:
async def handle(self, scope, receive, send):
if scope.get("type") != HTTP_SCOPE_TYPE:
await self._respond(send, 400, {"error": "not http"})
return
path = scope.get("path", "/")
method = scope.get("method", "GET")
if path == "/mcp/tools/list" and method in ("GET", "POST"):
await self._respond(send, 200, {"tools": TOOLS})
return
if path == "/mcp/tools/call" and method == "POST":
body = await self._read_body(receive)
try:
req_data = json.loads(body)
except json.JSONDecodeError:
await self._respond(send, 400, {"error": "invalid json"})
return
tool_name = req_data.get("params", {}).get("name", req_data.get("name", ""))
tool_args = req_data.get("params", {}).get("arguments", req_data.get("arguments", {}))
result = self._execute_tool(tool_name, tool_args)
await self._respond(send, 200, {
"content": [{"type": "text", "text": json.dumps(result, indent=2)}],
"isError": "error" in result,
})
return
if path == "/health" and method == "GET":
await self._respond(send, 200, {
"status": "ok", "tools": len(TOOLS), "has_api_key": bool(self.api_key),
})
return
if path == "/" and method == "GET":
await self._respond(send, 200, {
"name": "telnyx-mcp",
"description": "Telnyx MCP Server, send SMS, search numbers, run inference",
"tools": [t["name"] for t in TOOLS],
"endpoints": {"list_tools": "/mcp/tools/list", "call_tool": "/mcp/tools/call", "health": "/health"},
})
return
await self._respond(send, 404, {"error": "not found"})
The handler accepts both params and a flat shape for the tool name and arguments, so it works with MCP clients that send either convention.
Understanding MCP
MCP is an HTTP contract. There are two methods any server must support:
tools/list, return a JSON array of tool definitions. Each tool has aname,description, and JSON Schema describing its arguments. The agent reads this to know what it can call.tools/call, accept a JSON body withnameandarguments, execute the tool, and return the result as acontentarray with atypeoftextand atextfield containing the JSON-stringified result.
That's it. No streaming, no sessions, no authentication at the MCP layer, the transport is plain HTTPS with JSON bodies. Any HTTP client can be an MCP server. Any HTTP server exposing those two endpoints is an MCP server.
The Telnyx implementation accepts both {"name": "send_sms", "arguments": {...}} (flat) and {"params": {"name": "send_sms", "arguments": {...}}} (nested) shapes, because different MCP clients use different conventions.
Deploy It
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/edge-mcp-server-deploy-python
telnyx-edge auth login
telnyx-edge secrets add TELNYX_API_KEY <your-api-key>
telnyx-edge ship
telnyx-edge ship packages func.toml plus the function/ directory and pushes them to Telnyx Edge Compute. The deploy returns a URL like https://mcp-telnyx-tools-<id>.telnyxcompute.com. The TELNYX_API_KEY is stored as an Edge Compute secret and injected into the function as an environment variable on each invocation, the agent never sees the key.
Verify the deploy:
curl https://mcp-telnyx-tools-<id>.telnyxcompute.com/health
# {"status":"ok","tools":4,"has_api_key":true}
curl https://mcp-telnyx-tools-<id>.telnyxcompute.com/mcp/tools/list
# {"tools":[{"name":"send_sms","description":"...","inputSchema":{...}}, ...]}
curl -X POST https://mcp-telnyx-tools-<id>.telnyxcompute.com/mcp/tools/call \
-H "Content-Type: application/json" \
-d '{"name": "list_phone_numbers", "arguments": {"page_size": 5}}'
# {"content":[{"type":"text","text":"{...phone numbers...}"}],"isError":false}
Point your MCP-compatible agent at the deployed URL and it will discover the four tools and call them as part of its reasoning loop.
Connecting AI Agents
MCP support is built into Claude Desktop, Cursor, and a growing number of agent frameworks. Each one has its own way of registering a remote MCP server, the connection details are always the same: the deployed URL plus, if your agent requires authentication, a Bearer token.
For Claude Desktop, the server URL is enough. For Claude Code, Cursor, or other MCP clients, add the URL to your agent's MCP configuration. The first request from the agent hits /mcp/tools/list, learns the four tools, and uses them whenever the user's request maps to send SMS, search numbers, run inference, or list numbers.
Going to Production
This example ships with sensible defaults for a working demo. For production:
- Authentication at the MCP layer, the example has none. Add Bearer token validation on
/mcp/tools/listand/mcp/tools/callso only authorized agents can discover and call your tools. The Telnyx API key is already protected (it's a secret, not a request parameter), but the MCP endpoint itself is open. - Rate limiting, protect against runaway agents. Wrap
_execute_toolwith a per-IP or per-token rate limiter. - Tool-level access control, agents that should only send SMS, not run inference, should not see
run_inferencein the tool list. FilterTOOLSper-agent based on the authenticated token. - Logging and tracing, add structured logs for each
tools/callinvocation so you can audit what agents did. The function already has a logger; pipe it somewhere searchable. - Timeout tuning,
run_inferencedefaults to 256 tokens, which returns in under a second. For longer completions, raisemax_tokensand theurlopentimeout accordingly. - Local development, the
func.pyis plain ASGI, so it runs under any ASGI server (uvicorn function.func:appwith a tinyapp = MCPServer()wrapper). Iterate locally before shipping to the edge.