diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test-pr.yml index 45bf587..b903960 100644 --- a/.github/workflows/test-pr.yml +++ b/.github/workflows/test-pr.yml @@ -33,6 +33,5 @@ jobs: - name: Install Packages and Test run: | - pip install -r requirements.txt - pip install -r dev-requirements.txt + pip install ".[dev]" python -m pytest -v diff --git a/.gitignore b/.gitignore index f8de3fe..82bdead 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,14 @@ venv.bak/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# OS / editor +.DS_Store +.obsidian/ +.mcp.json + +# Serena +.serena/ + # Visual Studio Code # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore diff --git a/README.md b/README.md index 6c7ef0b..6914d7e 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,10 @@ cd mcp-server ### Prerequisites In order to use the Bandwidth MCP Server, you'll need the following things, set as environment variables. -- Valid Bandwidth API Credentials - - This will be the username and password of your Bandwidth API user +- Valid Bandwidth OAuth2 Client Credentials + - You will need a client ID and client secret for your Bandwidth API application - For more info on creating API credentials, see our [Credentials](https://dev.bandwidth.com/docs/credentials) page -- Bandwidth Account ID - - The ID of the account you'd like to make API calls on behalf of +- Your account ID is auto-discovered from JWT claims after authentication — you do not need to provide it ### Configuration @@ -33,19 +32,24 @@ The server will respect both system environment variables and those configured v The following variables will be required to use the server: ```sh -BW_USERNAME # Your Bandwidth API User Username -BW_PASSWORD # Your Bandwidth API User Password +BW_CLIENT_ID # Your Bandwidth OAuth2 client ID +BW_CLIENT_SECRET # Your Bandwidth OAuth2 client secret ``` The following variables are optional or conditionally required: ```sh -BW_ACCOUNT_ID # Your Bandwidth Account ID. Required for most API operations. -BW_NUMBER # A valid phone number on your Bandwidth account. Used with our Messaging and MFA APIs. Must be in E164 format. -BW_MESSAGING_APPLICATION_ID # A Bandwidth Messaging Application ID. Used with our Messaging and MFA APIs. -BW_VOICE_APPLICATION_ID # A Bandwidth Voice Application ID. Used with our MFA API. -BW_MCP_TOOLS # The list of MCP tools you'd like to enable. If not set, all tools are enabled. -BW_MCP_EXCLUDE_TOOLS # The list of MCP tools you'd like to exclude. Takes priority over BW_MCP_TOOLS. +BW_ACCOUNT_ID # Your Bandwidth Account ID. Optional — auto-discovered from JWT claims after authentication. +BW_NUMBER # A valid phone number on your Bandwidth account. Used with our Messaging API. Must be in E164 format. +BW_MESSAGING_APPLICATION_ID # A Bandwidth Messaging Application ID. Used with our Messaging API. +BW_VOICE_APPLICATION_ID # A Bandwidth Voice Application ID. Used to auto-configure voice callbacks in hosted mode. +BW_MCP_PROFILE # Named tool preset (voice, messaging, lookup, onboarding, recordings, full). Comma-separated to combine. +BW_MCP_TOOLS # Explicit tool allowlist (comma-separated operationIds). Overrides BW_MCP_PROFILE. +BW_MCP_EXCLUDE_TOOLS # Explicit tool denylist (comma-separated). Takes priority over BW_MCP_TOOLS and profiles. +BW_ENVIRONMENT # `test` or `uat` to target Bandwidth's test environment. Defaults to prod. +BW_API_URL # API gateway override. Also serves the Dashboard XML API under /api/v2. +BW_VOICE_URL # Voice API base override. +BW_MESSAGING_URL # Messaging API base override. ``` #### Including or Excluding Tools @@ -85,6 +89,14 @@ BW_MCP_EXCLUDE_TOOLS=createLookup,getLookupStatus --exclude-tools createLookup,getLookupStatus ``` +**Account Creation Flow (Build Registration)** + +Bandwidth Build is the free voice-first trial. Use this profile when the user has no credentials yet. The MCP only exposes `createRegistration` to kick things off — SMS verification, password set, and API credential generation all happen in pages Bandwidth links the user to. The agent shouldn't try to consume the SMS code over the API; it belongs to the user's browser flow. + +```sh +BW_MCP_TOOLS=createRegistration +``` + ## Using the Server Below you'll find instructions for using our MCP server with different common AI agents, as well as instructions for running the server locally. For usage with AI agents, it is recommended to use a combination of [uv](https://github.com/astral-sh/uv?tab=readme-ov-file#uv) and environment variables to start and configure the server respectively. @@ -131,8 +143,8 @@ Then follow the prompts like the example below. "command":"uvx", "args": ["--from", "/path/to/mcp-server", "start"], "env": { - "BW_USERNAME": "", - "BW_PASSWORD": "", + "BW_CLIENT_ID": "", + "BW_CLIENT_SECRET": "", "BW_MCP_TOOLS": "tools,to,enable", "BW_MCP_EXCLUDE_TOOLS": "tools,to,exclude", } @@ -161,8 +173,8 @@ uvx --from /path/to/mcp-server start "command": "uvx", "args": ["--from", "/path/to/mcp-server", "start"], "env": { - "BW_USERNAME": "", - "BW_PASSWORD": "", + "BW_CLIENT_ID": "", + "BW_CLIENT_SECRET": "", "BW_MCP_TOOLS": "tools,to,enable", "BW_MCP_EXCLUDE_TOOLS": "tools,to,exclude", } @@ -186,8 +198,8 @@ uvx --from /path/to/mcp-server start "command": "uvx", "args": ["--from", "/path/to/mcp-server", "start"], "env": { - "BW_USERNAME": "", - "BW_PASSWORD": "", + "BW_CLIENT_ID": "", + "BW_CLIENT_SECRET": "", "BW_MCP_TOOLS": "tools,to,enable", "BW_MCP_EXCLUDE_TOOLS": "tools,to,exclude", } @@ -212,11 +224,11 @@ Once these are installed, create a virtual environment using: python -m venv .venv ``` -Then activate and install the required packaged from the `requirements.txt` file. +Then activate and install the project with its dependencies. ```sh source .venv/bin/activate -pip install -r requirements.txt +pip install . ``` After all packages are installed in the virtual environment, you can run the server locally using: @@ -234,68 +246,104 @@ then you can start the server by running the following command from the root dir uvx --from ./ start ``` +## Hosted Mode + +Run the server over HTTP to enable remote access and webhook callbacks: + +```bash +BW_MCP_TRANSPORT=streamable-http \ +BW_MCP_PORT=8080 \ +BW_MCP_BASE_URL=https://your-server.example.com \ +BW_CLIENT_ID=your_client_id \ +BW_CLIENT_SECRET=your_client_secret \ +python src/app.py +``` + +### Local Callback Tunnel (dev only) + +Voice and messaging callbacks need Bandwidth to reach your server over a public +URL. In production you set `BW_MCP_BASE_URL`. For local development, set +`BW_MCP_DEV_TUNNEL=1` and the server will open an ephemeral +[`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) +tunnel, use the resulting `*.trycloudflare.com` URL as `BW_MCP_BASE_URL`, and +auto-wire your voice app's callbacks to it — no ngrok needed. + +```bash +BW_MCP_TRANSPORT=streamable-http \ +BW_MCP_DEV_TUNNEL=1 \ +BW_CLIENT_ID=your_client_id \ +BW_CLIENT_SECRET=your_client_secret \ +python src/app.py +``` + +Requires `cloudflared` on your PATH (`brew install cloudflared` on macOS). If +it's missing, the server logs a warning and starts without a tunnel. This is +for **development and testing only** — production deployments should use a real +host via `BW_MCP_BASE_URL`. The tunnel never starts unless `BW_MCP_DEV_TUNNEL` +is set. + +### Tool Profiles + +Reduce context window pressure with named presets: + +```bash +BW_MCP_PROFILE=messaging # SMS/MMS tools only +BW_MCP_PROFILE=voice # Voice + BXML tools +BW_MCP_PROFILE=onboarding # Account creation +BW_MCP_PROFILE=lookup # Number intelligence +BW_MCP_PROFILE=messaging,voice # Combine profiles +``` + +Profiles set via `BW_MCP_PROFILE` env var or `--profile` CLI flag. Use `BW_MCP_TOOLS` to override with specific tool names. + ## Tools List -## **Multi-Factor Authentication (MFA)** -- `generateMessagingCode` - Send MFA code via SMS -- `generateVoiceCode` - Send MFA code via voice call -- `verifyCode` - Verify a previously sent MFA code - -## **Phone Number Lookup** -- `createLookup` - Create a phone number lookup request -- `getLookupStatus` - Get status of an existing lookup request - -## **Voice & Call Management** -- `listCalls` - Returns a list of call events with filtering options -- `listCall` - Returns details for a single call event - -## **Reporting & Analytics** -- `getReports` - Get history of created reports -- `createReport` - Create a new report instance -- `getReportStatus` - Get status of a report -- `getReportFile` - Download report file -- `getReportDefinitions` - Get available report definitions - -## **Media Management** -- `listMedia` - List your media files -- `getMedia` - Download a specific media file -- `uploadMedia` - Upload a media file -- `deleteMedia` - Delete a media file - -## **Messaging** -- `listMessages` - List messages with filtering options -- `createMessage` - Send SMS/MMS messages -- `createMultiChannelMessage` - Send multi-channel messages (RBM, SMS, MMS) - -## **Address Management** -- `getAddressFields` - Get supported address fields by country -- `validateAddress` - Validate an address and get excluded features -- `listAddresses` - List all addresses -- `createAddress` - Create an address -- `getAddress` - Get an address by ID -- `updateAddress` - Update an address -- `listCityInfo` - List city info search results - -## **Compliance** -- `listDocumentTypes` - List all accepted document types and metadata requirements -- `listEndUserTypes` - List all End user types and accepted metadata -- `listEndUserActivationRequirements` - List requirements for End user activation -- `getComplianceDocumentMetadata` - Get metadata of uploaded documents -- `updateComplianceDocument` - Modify document data and file -- `downloadComplianceDocuments` - Download document using document ID -- `createComplianceDocument` - Upload a document with metadata -- `listComplianceEndUsers` - List all End users of an account -- `createComplianceEndUser` - Create an End user -- `getComplianceEndUser` - Retrieve an End User by ID -- `updateComplianceEndUser` - Update End user details - -## **Requirements Packages** -- `listRequirementsPackages` - List all requirements packages -- `createRequirementsPackage` - Create a requirements package -- `getRequirementsPackage` - Retrieve a requirements package -- `patchRequirementsPackage` - Update Requirements package -- `getRequirementsPackageAssets` - Get assets attached to requirements package -- `attachRequirementsPackageAsset` - Attach an asset to requirements package -- `detachRequirementsPackageAsset` - Detach an asset from requirements package -- `validateNumberActivation` - Validate number activation requirements -- `getRequirementsPackageHistory` - Get history of a requirements package +Tools are grouped into profiles that mirror the workflows you'd use the server for. +Loading a single profile keeps your agent's context small. The full agent reference +— including auth model, error codes, and the "trust nothing" guidance for async +calls — lives in [`src/specs/AGENTS.md`](src/specs/AGENTS.md). + +The default tool set is `voice` + `messaging` + `lookup` (plus +`setCredentials` / `clearCredentials`, always loaded). Override with +`BW_MCP_PROFILE`, `BW_MCP_TOOLS`, or `BW_MCP_EXCLUDE_TOOLS`. + +### Session management (always loaded) +- `setCredentials` — authenticate the session (stdio transport only) +- `clearCredentials` — log out of the session + +### Profile: `onboarding` (no auth required) +Kicks off a new Bandwidth Build account. Only one tool is exposed — SMS verification, password set, and API credential generation all happen in the user's browser. The agent hands off after the kickoff. +- `createRegistration` — submit contact details; Bandwidth then SMSes an OTP and emails a password-set link + +### Profile: `voice` +- `listApplications` / `createApplication` — find or create a voice app +- `listPhoneNumbers` — find numbers on the account +- `createCall` — place an outbound call +- `getCallState` — read current call state (always poll after `createCall`) +- `listCalls` — list call events with filtering +- `updateCall` / `updateCallBxml` — redirect, hang up, or replace BXML +- `generateBXML` — build valid BXML from a verb list +- `respondToCallback` — queue a BXML response for an active callback (first-write-wins) +- `getCallbackEvents` — read recent voice / messaging callback events +- `configureCallbacks` — point an application's webhook URLs at this server + +### Profile: `messaging` +- `createMessage` — send SMS / MMS +- `listMessages` — query message history +- `getInboundMessages` — read inbound messages captured by this server +- `listMedia` / `getMedia` / `uploadMedia` / `deleteMedia` — manage MMS media +- `configureCallbacks` — point an application's callbacks at this server + +### Profile: `lookup` +- `createSyncLookup` — one-shot lookup for a small input set +- `createAsyncBulkLookup` — kick off a bulk lookup +- `getAsyncBulkLookup` — poll a bulk lookup + +### Profile: `recordings` +- `listCallRecordings` / `getCallRecording` — list / inspect recordings +- `downloadCallRecording` — download the media +- `deleteRecording` — remove a recording +- `transcribeCallRecording` / `getRecordingTranscription` — request and read transcription + +See [`src/specs/AGENTS.md`](src/specs/AGENTS.md) for argument-level guidance, polling +patterns, and the structured error shape the server returns on failure. diff --git a/common_use_cases.md b/common_use_cases.md index 5ee6d06..a18074f 100644 --- a/common_use_cases.md +++ b/common_use_cases.md @@ -41,25 +41,6 @@ BW_MCP_TOOLS=createLookup,getLookupStatus --tools createLookup,getLookupStatus ``` -## Utilizing our MFA Service - -To create and verify multi-factor authentication codes, you'll need our three MFA tools. -- `generateMessagingCode` - Used to generate and send an MFA code via SMS -- `generateVoiceCode` - Use to generate and send an MFA code via a phone call -- `verifyCode` - Verify an MFA code sent with one of the previous tools - -Generating Messaging and Voice codes requires the `BW_MESSAGING_APPLICATION_ID` and `BW_VOICE_APPLICATION_ID` environment variables respectively. -Both of these tools also require `BW_ACCOUNT_ID` and `BW_NUMBER`. - -**Enabling these tools** -```sh -# Environment Variable -BW_MCP_TOOLS=generateMessagingCode,generateVoiceCode,verifyCode - -# CLI Flag ---tools generateMessagingCode,generateVoiceCode,verifyCode -``` - ## Adding a Business End User To add an end user, you'll need three specific Compliance endpoints. diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index 7dac1cb..0000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -pytest>=8.4.1 -pytest-asyncio>=1.1.0 -pytest-httpx>=0.35.0 -black>=25.1.0 diff --git a/pyproject.toml b/pyproject.toml index f68b66b..8b5b803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bw-mcp-server" -version = "0.1.0" +version = "0.3.0" description = "Bandwidth MCP Server" authors = [ {name = "Bandwidth", email = "dx@bandwidth.com"} @@ -9,8 +9,8 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "httpx~=0.28.0", - "fastmcp~=2.13.0", - "mcp~=1.23.0", + "fastmcp~=3.2", + "mcp~=1.24", "pyyaml~=6.0.0", "werkzeug>=3.1.4" ] @@ -18,7 +18,23 @@ dependencies = [ [project.scripts] start = "app:main" -[dependency-groups] +[tool.setuptools] +py-modules = [ + "app", "config", "servers", "server_utils", "resources", + "instructions", "profiles", "oauth", "event_store", "callbacks", "tunnel", +] +packages = ["specs", "tools"] + +[tool.setuptools.package-dir] +"" = "src" + +[tool.setuptools.package-data] +specs = ["*.yml", "*.md"] + +[tool.pytest.ini_options] +pythonpath = ["."] + +[project.optional-dependencies] dev = [ "black>=25.1.0", "pytest>=8.4.1", diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 5cdecbe..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -httpx~=0.28.0 -fastmcp~=2.13.0 -mcp~=1.23.0 -pyyaml~=6.0.0 -werkzeug>=3.1.4 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/src/app.py b/src/app.py index 6d8aae8..cabbd7f 100644 --- a/src/app.py +++ b/src/app.py @@ -1,29 +1,119 @@ -import asyncio import os - -os.environ["FASTMCP_EXPERIMENTAL_ENABLE_NEW_OPENAPI_PARSER"] = "true" +import sys +from contextlib import asynccontextmanager from fastmcp import FastMCP from servers import create_bandwidth_mcp -from config import load_config, get_enabled_tools, get_excluded_tools +from config import ( + load_config, + authenticate_config, + get_enabled_tools, + get_excluded_tools, + get_transport_config, +) +from tools.credentials import register_credentials_tools +from tools.callbacks import register_callback_tools +from tools.voice import register_voice_tools +from tools.discovery import register_discovery_tools +from instructions import build_instructions +from event_store import EventStore +from callbacks import register_callback_routes +from tunnel import start_tunnel, stop_tunnel -mcp = FastMCP(name="Bandwidth MCP") +_config = {} +_event_store = EventStore() -async def setup(mcp: FastMCP = mcp): - """Setup the Bandwidth MCP server with tools and resources.""" +@asynccontextmanager +async def lifespan(mcp_instance: FastMCP): + """Server lifespan — runs setup inside FastMCP's event loop.""" enabled_tools = get_enabled_tools() excluded_tools = get_excluded_tools() - config = load_config() + _config.update(load_config()) + await authenticate_config(_config) + + # Auto-tunnel: opt-in only. Requires BW_MCP_DEV_TUNNEL truthy in addition + # to a non-stdio transport and no explicit BW_MCP_BASE_URL. + transport_config = get_transport_config() + dev_tunnel_flag = os.environ.get("BW_MCP_DEV_TUNNEL", "").strip() + dev_tunnel_enabled = bool(dev_tunnel_flag) and dev_tunnel_flag != "0" + if ( + dev_tunnel_enabled + and not _config.get("BW_MCP_BASE_URL") + and transport_config["transport"] != "stdio" + ): + print( + "WARNING: BW_MCP_DEV_TUNNEL is set — starting an ephemeral public " + "tunnel via cloudflared. Do not use this for production traffic.", + file=sys.stderr, + flush=True, + ) + tunnel_url = start_tunnel(transport_config["port"]) + if tunnel_url: + _config["BW_MCP_BASE_URL"] = tunnel_url print("Setting up Bandwidth MCP server...") - await create_bandwidth_mcp(mcp, enabled_tools, excluded_tools, config) + await create_bandwidth_mcp(mcp_instance, enabled_tools, excluded_tools, _config) + + register_credentials_tools(mcp_instance, _config) + register_callback_tools(mcp_instance, _event_store, _config) + register_voice_tools(mcp_instance, _event_store, _config) + register_discovery_tools(mcp_instance, _config) + + # Auto-configure voice app callbacks to current tunnel/base URL + base_url = _config.get("BW_MCP_BASE_URL") + voice_app = _config.get("BW_VOICE_APPLICATION_ID") + if base_url and voice_app and _config.get("BW_ACCESS_TOKEN"): + from tools.callbacks import configure_callbacks_flow + + try: + result = await configure_callbacks_flow(_config, voice_app, base_url, ["voice"]) + print(f"Auto-configured voice callbacks → {base_url}") + except Exception as e: + print(f"Warning: Failed to auto-configure callbacks: {e}") + + all_tools = await mcp_instance.list_tools() + mcp_instance.instructions = build_instructions( + _config, [tool.name for tool in all_tools] + ) + + yield + + stop_tunnel() + + +mcp = FastMCP(name="Bandwidth MCP", lifespan=lifespan) + +# Register callback HTTP routes at module level — must happen before mcp.run() +# so Starlette includes them in the app's route table. +register_callback_routes(mcp, _event_store) + + +# For tests that call setup() directly +async def setup(mcp_instance: FastMCP = None): + """Setup for testing — wraps the lifespan context.""" + if mcp_instance is None: + mcp_instance = mcp + async with lifespan(mcp_instance): + yield mcp_instance def main(): """Main function to run the Bandwidth MCP server.""" - asyncio.run(setup()) - mcp.run() + transport_config = get_transport_config() + transport = transport_config["transport"] + + try: + if transport == "stdio": + mcp.run() + else: + mcp.run( + transport=transport, + host=transport_config["host"], + port=transport_config["port"], + ) + except KeyboardInterrupt: + pass if __name__ == "__main__": diff --git a/src/callbacks.py b/src/callbacks.py new file mode 100644 index 0000000..941ac5a --- /dev/null +++ b/src/callbacks.py @@ -0,0 +1,97 @@ +"""Callback routes for Bandwidth webhooks. + +Messaging callbacks are fire-and-forget (store event, return 200). +Voice callbacks are stateful (store event, return BXML or redirect). + +Routes are registered directly on the FastMCP instance via custom_route +so they're served on the same HTTP transport as MCP tools. +""" + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from event_store import EventStore + + +def _bxml_response(bxml: str) -> Response: + return Response(content=bxml, media_type="application/xml") + + +def _redirect_bxml(call_id: str) -> str: + return f'' + + +def register_callback_routes(mcp, event_store: EventStore) -> None: + """Register callback HTTP routes on the FastMCP server.""" + + @mcp.custom_route("/callbacks/messaging/inbound", methods=["POST"]) + async def messaging_inbound(request: Request) -> JSONResponse: + payload = await request.json() + for event in payload: + key = event.get("message", {}).get("from", "unknown") + event_store.push("messaging.inbound", key, event) + return JSONResponse({"status": "ok"}) + + @mcp.custom_route("/callbacks/messaging/status", methods=["POST"]) + async def messaging_status(request: Request) -> JSONResponse: + payload = await request.json() + for event in payload: + key = event.get("message", {}).get("id", "unknown") + event_store.push("messaging.status", key, event) + return JSONResponse({"status": "ok"}) + + @mcp.custom_route("/callbacks/voice/answer", methods=["POST"]) + async def voice_answer(request: Request) -> Response: + payload = await request.json() + call_id = payload.get("callId", "unknown") + event_store.push("voice.answer", call_id, payload) + + # Check if BXML was pre-queued (agent called respondToCallback before the call was answered) + existing_call = event_store.get_call(call_id) + if existing_call and existing_call.pending_bxml: + existing_call.from_number = payload.get("from", "") + existing_call.to_number = payload.get("to", "") + existing_call.application_id = payload.get("applicationId", "") + bxml = existing_call.consume_pending_bxml() + return _bxml_response(bxml) + + # No pre-queued BXML — create call state and redirect to wait for agent + event_store.create_call( + call_id=call_id, + from_number=payload.get("from", ""), + to_number=payload.get("to", ""), + application_id=payload.get("applicationId", ""), + ) + return _bxml_response(_redirect_bxml(call_id)) + + @mcp.custom_route("/callbacks/voice/gather", methods=["POST"]) + async def voice_gather(request: Request) -> Response: + payload = await request.json() + call_id = payload.get("callId", "unknown") + event_store.push("voice.gather", call_id, payload) + call = event_store.get_call(call_id) + if call: + speech = payload.get("speech", {}) + transcript = speech.get("transcript", "") + digits = payload.get("digits", "") + text = transcript or digits or "(no input)" + call.add_turn("caller", text) + return _bxml_response(_redirect_bxml(call_id)) + + @mcp.custom_route("/callbacks/voice/disconnect", methods=["POST"]) + async def voice_disconnect(request: Request) -> JSONResponse: + payload = await request.json() + call_id = payload.get("callId", "unknown") + event_store.push("voice.disconnect", call_id, payload) + event_store.remove_call(call_id) + return JSONResponse({"status": "ok"}) + + @mcp.custom_route("/callbacks/voice/continue/{call_id}", methods=["POST"]) + async def voice_continue(request: Request) -> Response: + call_id = request.path_params["call_id"] + call = event_store.get_call(call_id) + if call: + bxml = call.consume_pending_bxml() + if bxml: + return _bxml_response(bxml) + return _bxml_response(_redirect_bxml(call_id)) diff --git a/src/config.py b/src/config.py index ed01d7c..8c6d6a9 100644 --- a/src/config.py +++ b/src/config.py @@ -1,38 +1,78 @@ import os -from typing import Dict, List, Optional +import warnings +from typing import Any, Dict, List, Optional from argparse import ArgumentParser, Namespace +from profiles import resolve_profile, DEFAULT_TOOLS -def load_config() -> Dict[str, str]: + +def load_config() -> Dict[str, Any]: """Load Bandwidth configuration from environment variables.""" - config = {} - required_vars = ["BW_USERNAME", "BW_PASSWORD"] - optional_vars = [ + config: Dict[str, Any] = {} + + all_vars = [ + "BW_CLIENT_ID", + "BW_CLIENT_SECRET", "BW_ACCOUNT_ID", "BW_NUMBER", "BW_MESSAGING_APPLICATION_ID", "BW_VOICE_APPLICATION_ID", ] - # Add all variables that exist - for var in required_vars + optional_vars: - value = os.getenv(var) + for var in all_vars: + value = os.environ.get(var) if value: config[var] = value - # Required variables - for var in required_vars: - if var not in config.keys(): - raise ValueError(f"Missing required environment variable: {var}") + if "BW_CLIENT_ID" not in config or "BW_CLIENT_SECRET" not in config: + warnings.warn( + "BW_CLIENT_ID/BW_CLIENT_SECRET not set. Only Build Registration tools will be available. " + "Use the setCredentials tool to authenticate and enable all API tools.", + UserWarning, + ) + + # Transport config + transport_vars = [ + "BW_MCP_TRANSPORT", + "BW_MCP_HOST", + "BW_MCP_PORT", + "BW_MCP_BASE_URL", + "BW_VOICE_FALLBACK_NUMBER", + ] + for var in transport_vars: + value = os.environ.get(var) + if value: + config[var] = value return config +async def authenticate_config(config: Dict[str, Any]) -> None: + """If client credentials are present, do OAuth2 token exchange. + + Mutates config in place — adds BW_ACCESS_TOKEN and BW_ACCOUNT_ID. + """ + if "BW_CLIENT_ID" not in config or "BW_CLIENT_SECRET" not in config: + return + + from oauth import get_oauth_token + + try: + token_data = await get_oauth_token( + config["BW_CLIENT_ID"], config["BW_CLIENT_SECRET"] + ) + config["BW_ACCESS_TOKEN"] = token_data["access_token"] + accounts = token_data["accounts"] + if accounts and "BW_ACCOUNT_ID" not in config: + config["BW_ACCOUNT_ID"] = accounts[0] + except Exception as e: + warnings.warn(f"OAuth2 token exchange failed at startup: {e}", UserWarning) + + def _parse_cli_args(args: Optional[List[str]] = None) -> Namespace: """Parse command line arguments with proper type hints.""" parser = ArgumentParser(description="Bandwidth MCP Server") - # Tools parser.add_argument( "--tools", help="Comma-separated list of tool names to enable. If not specified, all tools are enabled.", @@ -43,6 +83,22 @@ def _parse_cli_args(args: Optional[List[str]] = None) -> Namespace: help="Comma-separated list of tool names to disable.", type=str, ) + parser.add_argument( + "--profile", + help="Named tool profile (or comma-separated profiles) to enable. Use 'full' for all tools.", + type=str, + ) + parser.add_argument( + "--transport", + help="Transport type: stdio (default), sse, or streamable-http.", + type=str, + choices=["stdio", "sse", "streamable-http"], + ) + parser.add_argument( + "--port", + help="Port for HTTP transport (default: 8080).", + type=int, + ) return parser.parse_known_args(args)[0] @@ -54,25 +110,59 @@ def _parse_arg_list(arg_string: str) -> List[str]: def _parse_flags(cli_arg: Optional[str], env_var: str) -> Optional[List[str]]: """Get flag values from CLI argument or environment variable.""" - # Try CLI argument first if cli_arg: return _parse_arg_list(cli_arg) - - # Fall back to environment variable env_value = os.getenv(env_var) if env_value: return _parse_arg_list(env_value) - return None +def get_profile_tools() -> Optional[List[str]]: + """Get tool list from profile, if specified.""" + args = _parse_cli_args() + profile_str = args.profile or os.getenv("BW_MCP_PROFILE") + return resolve_profile(profile_str) + + def get_enabled_tools() -> Optional[List[str]]: - """Get the list of enabled tools from CLI args or environment variable.""" + """Get the list of enabled tools from CLI args, env var, or profile. + + Priority: --tools > BW_MCP_TOOLS > --profile > BW_MCP_PROFILE > default profile. + Use BW_MCP_PROFILE=full to load everything. + + Returns None to mean "no filter — load every tool the specs expose." + """ args = _parse_cli_args() - return _parse_flags(args.tools, "BW_MCP_TOOLS") + explicit = _parse_flags(args.tools, "BW_MCP_TOOLS") + if explicit: + return explicit + # "full" must be checked on the raw string. resolve_profile() returns None + # for both "no profile set" and "profile=full"; without this guard, full + # would fall through to DEFAULT_TOOLS instead of loading everything. + profile_str = args.profile or os.getenv("BW_MCP_PROFILE") + if profile_str: + names = [p.strip() for p in profile_str.split(",") if p.strip()] + if "full" in names: + return None + profile_tools = resolve_profile(profile_str) + if profile_tools is not None: + return profile_tools + # No explicit config — use the default curated set + return DEFAULT_TOOLS def get_excluded_tools() -> Optional[List[str]]: """Get the list of excluded tools from CLI args or environment variable.""" args = _parse_cli_args() return _parse_flags(args.exclude_tools, "BW_MCP_EXCLUDE_TOOLS") + + +def get_transport_config() -> Dict[str, Any]: + """Get transport configuration from CLI args and env vars.""" + args = _parse_cli_args() + return { + "transport": args.transport or os.getenv("BW_MCP_TRANSPORT", "stdio"), + "host": os.getenv("BW_MCP_HOST", "127.0.0.1"), + "port": args.port or int(os.getenv("BW_MCP_PORT", "8080")), + } diff --git a/src/event_store.py b/src/event_store.py new file mode 100644 index 0000000..65d84a5 --- /dev/null +++ b/src/event_store.py @@ -0,0 +1,94 @@ +"""In-memory event store for callback events and call state. + +Ring buffer per event type+key, with per-session read cursors for multi-session +safety and first-write-wins on voice BXML responses. +""" + +import time +from collections import defaultdict, deque +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class CallState: + call_id: str + from_number: str + to_number: str + application_id: str + started_at: float = field(default_factory=time.time) + turns: list[dict] = field(default_factory=list) + pending_bxml: Optional[str] = None + metadata: dict = field(default_factory=dict) + + def add_turn(self, role: str, text: str) -> None: + self.turns.append({"role": role, "text": text, "timestamp": time.time()}) + + def try_set_bxml(self, bxml: str) -> bool: + if self.pending_bxml is not None: + return False + self.pending_bxml = bxml + return True + + def consume_pending_bxml(self) -> Optional[str]: + bxml = self.pending_bxml + self.pending_bxml = None + return bxml + + +class EventStore: + def __init__(self, max_events: int = 1000, ttl_seconds: int = 3600): + self._max_events = max_events + self._ttl = ttl_seconds + self._events: dict[str, deque[dict]] = defaultdict( + lambda: deque(maxlen=max_events) + ) + self._global_counter: int = 0 + self._session_cursors: dict[str, int] = {} + self._calls: dict[str, CallState] = {} + + def push(self, event_type: str, key: str, event: dict) -> None: + self._global_counter += 1 + # Use nanosecond precision to avoid timestamp collisions on fast hardware + received_at = time.time_ns() / 1e9 + event = {**event, "_received_at": received_at, "_seq": self._global_counter} + self._events[f"{event_type}:{key}"].append(event) + self._events[event_type].append(event) + + def get_events( + self, event_type: str, key: Optional[str] = None, since: Optional[float] = None + ) -> list[dict]: + bucket = f"{event_type}:{key}" if key else event_type + events = list(self._events.get(bucket, [])) + now = time.time() + events = [e for e in events if now - e["_received_at"] < self._ttl] + if since is not None: + events = [e for e in events if e["_received_at"] > since] + return events + + def get_unread(self, event_type: str, session_id: str) -> list[dict]: + cursor_key = f"{session_id}:{event_type}" + last_seq = self._session_cursors.get(cursor_key, 0) + events = list(self._events.get(event_type, [])) + unread = [e for e in events if e["_seq"] > last_seq] + if unread: + self._session_cursors[cursor_key] = unread[-1]["_seq"] + return unread + + def create_call( + self, call_id: str, from_number: str, to_number: str, application_id: str + ) -> CallState: + call = CallState( + call_id=call_id, + from_number=from_number, + to_number=to_number, + application_id=application_id, + ) + self._calls[call_id] = call + return call + + def get_call(self, call_id: str) -> Optional[CallState]: + return self._calls.get(call_id) + + def remove_call(self, call_id: str) -> None: + self._calls.pop(call_id, None) diff --git a/src/instructions.py b/src/instructions.py new file mode 100644 index 0000000..69abf40 --- /dev/null +++ b/src/instructions.py @@ -0,0 +1,173 @@ +"""Dynamic MCP instructions builder. + +Generates the instructions string sent to LLM clients during MCP initialization. +Adapts content based on which tools are actually loaded and what config is present. +""" + +from typing import Any + +HEADER = """# Bandwidth MCP Server + +You have access to Bandwidth's communication APIs as MCP tools. Everything you need is available as tools on this server — do NOT read source code, make raw curl calls, or explore the codebase. Just use the tools. + +## Quick Start +- Read resource://config to see what's configured (credentials, account ID, application IDs, phone numbers). +- Phone numbers must be in E.164 format (e.g. +19195551234). +- Application IDs are UUIDs. +- All tools are listed in your tool list. If you need to discover account resources (applications, phone numbers), use the API tools directly — they're already registered.""" + +NO_CREDENTIALS_SECTION = """ +## Not Authenticated +No API credentials were provided at startup. All tools are available but API calls will return 401. + +**To authenticate:** The user needs to add credentials to their MCP server configuration and restart: +```json +{ + "env": { + "BW_CLIENT_ID": "CLI-xxxxxxxx-xxxx-...", + "BW_CLIENT_SECRET": "your-secret-here" + } +} +``` +Tell the user to add these to their MCP config and restart the server. + +Alternatively, for new accounts: use Build Registration (`createRegistration` kicks it off; the user finishes SMS, password, and credential generation in their browser). See the Build Registration section if that tool is loaded.""" + +MESSAGING_SECTION = """ +## Sending a Message (step by step) + +1. You need: a `from` number (your Bandwidth number, E.164), an `applicationId` (messaging application UUID), and the `to` number. +2. Check resource://config for BW_NUMBER and BW_MESSAGING_APPLICATION_ID. If not set, use the Numbers API tools to find numbers and applications on the account. +3. Call `createMessage` with `to`, `from`, `applicationId`, and `text`. +4. To check delivery: call `listMessages` to search message history. +5. To receive replies: call `getInboundMessages` to read incoming messages (requires hosted mode with callbacks configured).""" + +LOOKUP_SECTION = """ +## Phone Number Lookup +Requires: BW_ACCOUNT_ID +- **createLookup** then **getLookupStatus**: Async operation. Create the lookup, take the requestId from the response, poll getLookupStatus until status is complete. Don't treat "pending" as failure.""" + +VOICE_SECTION = """ +## Making a Voice Call (step by step) + +Follow these steps exactly: + +1. **Read resource://config** to get `BW_ACCOUNT_ID` and `BW_MCP_BASE_URL`. +2. **Find a phone number**: Call `listPhoneNumbers` and pick one (E.164 format). +3. **Find a voice application**: Call `listApplications` and look for a `Voice-V2` app. + - If none exists, call `createApplication(name="Voice App")` — it auto-configures callback URLs. + - If the app's callback URLs don't point at this server, call `configureCallbacks(application_id, BW_MCP_BASE_URL)`. +4. **Generate BXML**: Call `generateBXML` with what to say. + - One-shot: `generateBXML(verbs=[{"type": "SpeakSentence", "text": "Hello!", "voice": "julie"}, {"type": "Hangup"}], auto_gather=False)` + - Conversation: `generateBXML(verbs=[{"type": "SpeakSentence", "text": "How can I help?", "voice": "julie"}])` (auto_gather=True is default, enables barge-in) +5. **Create the call**: `createCall(accountId, from, to, applicationId, answerUrl)` where answerUrl = BW_MCP_BASE_URL + `/callbacks/voice/answer`. +6. **Queue BXML immediately**: `respondToCallback(call_id, bxml)` — do this right after createCall. The BXML is delivered when the callee picks up. +7. **For conversations**: Poll `getCallbackEvents(event_type="voice.gather")` for caller speech, generate new BXML, deliver with `respondToCallback`. + +### BXML tips +- `auto_gather=True` (default) wraps SpeakSentence in Gather for barge-in (caller can interrupt). +- `auto_gather=False` for fire-and-forget messages (say something, then Hangup). +- Use `voice="julie"` for natural-sounding TTS. +- For structured input, add `input_type: "speech dtmf"` so callers can speak or press keys.""" + +CALLBACK_SECTION = """ +## Inbound Events & Callbacks +- **getInboundMessages**: Get recent inbound SMS/MMS received by your number. +- **getCallbackEvents**: Get all callback events (voice + messaging), filterable by type, call ID, or phone number. +- **configureCallbacks**: Point a Bandwidth application's webhook URLs at this server. Self-configuring — one call and webhooks are wired.""" + +REPORTING_SECTION = """ +## Reporting & Analytics +Requires: BW_ACCOUNT_ID +- **createReport** → **getReportStatus** → **getReportFile**: Async report generation. Create, poll status, then download.""" + +CREDENTIALS_SECTION = """ +## Session Management +- **clearCredentials**: Log out — clears stored credentials and access token. Authenticated tools will return 401 until you call **setCredentials** again.""" + +REGISTRATION_SECTION = """ +## Build Registration (No Auth Required) + +Use this to register a new Bandwidth **Build** account — Bandwidth's free voice-first trial. Proactively suggest it when the user: +- asks how to make / create / sign up for a Bandwidth account, +- says they don't have an account or credentials yet, or +- wants to try the server out, test it, kick the tires, or "see what it can do." + +Don't wait for them to say "Build Registration" by name — most users won't know the term. + +**The agent only calls one tool. Everything else happens in the user's browser.** This mirrors the CLI's `band account register` — the API kicks off registration; SMS and email verification finish in the Bandwidth signup pages. + +**Flow:** +1. Call **createRegistration** with phoneNumber, email, firstName, lastName. Bandwidth then sends: + - an SMS OTP to the phone number (the user enters this on the signup page), and + - an email with a password-set link (clicking it asks for an email OTP too). +2. Stop calling tools. Tell the user (verbatim or close): + + > I've started your Build registration. To finish: + > 1. Enter the 6-digit code you got by SMS into the Bandwidth signup page. + > 2. Open the registration email from Bandwidth, click the link, set a password, and enter the OTP from that email. + > 3. In the Bandwidth App, go to **Account > API Credentials** and generate OAuth2 credentials. Paste them back here when you have them. + +3. Offer to open the user's default mail app for them (`open -a Mail` on macOS, `xdg-open mailto:` on Linux, equivalent on Windows). Only run that with their consent. +4. When the user pastes credentials, call **setCredentials(client_id, client_secret)** to unlock authenticated tools. + +**Do NOT call any tool to "verify" the SMS code or email OTP** — those codes belong to the user's browser flow and the agent intercepting them breaks signup. Do not poll waiting for credentials; the API has no way to deliver them.""" + +ERROR_SECTION = """ +## Error Patterns +- **401 Unauthorized**: Wrong credentials. Check BW_CLIENT_ID/BW_CLIENT_SECRET. +- **422 Validation Error**: Missing or malformed fields. Phone numbers must be E.164 (+19195551234). Application IDs are UUIDs. +- **"Tool not found"**: Check BW_MCP_TOOLS / BW_MCP_EXCLUDE_TOOLS filters. +- **"Pending" responses**: Lookup and reporting are async — poll the status tool, don't treat pending as failure. +- **No authenticated tools**: Credentials weren't set. Use the Build Registration flow or set env vars.""" + + +# Mapping: if ANY of these tools are loaded, include the section +_SECTION_TRIGGERS: list[tuple[list[str], str]] = [ + ( + ["createRegistration"], + REGISTRATION_SECTION, + ), + (["createMessage", "listMessages", "createMultiChannelMessage"], MESSAGING_SECTION), + ( + [ + "createLookup", + "getLookupStatus", + "createSyncLookup", + "createAsyncBulkLookup", + ], + LOOKUP_SECTION, + ), + (["createCall", "generateBXML", "respondToCallback"], VOICE_SECTION), + ( + ["getInboundMessages", "getCallbackEvents", "configureCallbacks"], + CALLBACK_SECTION, + ), + (["createReport", "getReportStatus", "getReportFile"], REPORTING_SECTION), + (["clearCredentials"], CREDENTIALS_SECTION), +] + + +def build_instructions(config: dict[str, Any], loaded_tools: list[str]) -> str: + """Build the MCP instructions string based on loaded tools and config. + + Args: + config: Server configuration dict (credentials, app IDs, etc.) + loaded_tools: List of tool names currently registered on the server. + + Returns: + Instructions string for the MCP initialization handshake. + """ + sections = [HEADER] + + if not config.get("BW_ACCESS_TOKEN"): + sections.append(NO_CREDENTIALS_SECTION) + + tool_set = set(loaded_tools) + for trigger_tools, section in _SECTION_TRIGGERS: + if tool_set & set(trigger_tools): + sections.append(section) + + sections.append(ERROR_SECTION) + + return "\n".join(sections) diff --git a/src/oauth.py b/src/oauth.py new file mode 100644 index 0000000..72d06b1 --- /dev/null +++ b/src/oauth.py @@ -0,0 +1,78 @@ +"""OAuth2 client credentials flow for Bandwidth API. + +Exchanges client ID + secret for a Bearer token, and extracts account IDs +from the JWT claims — same flow as the Bandwidth CLI. +""" + +import base64 +import json +from typing import Any + +import httpx + +from urls import oauth_token_url + + +def _decode_jwt_payload(token: str) -> dict[str, Any]: + """Decode JWT payload without verification (we trust Bandwidth's token endpoint).""" + parts = token.split(".") + if len(parts) != 3: + raise ValueError("Invalid JWT format") + # JWT base64url → standard base64 + payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4) + payload_bytes = base64.urlsafe_b64decode(payload_b64) + return json.loads(payload_bytes) + + +async def get_oauth_token( + client_id: str, + client_secret: str, + token_url: str | None = None, +) -> dict[str, Any]: + """Exchange client credentials for a Bearer token. + + Args: + client_id: Bandwidth API client ID. + client_secret: Bandwidth API client secret. + token_url: OAuth2 token endpoint. + + Returns: + Dict with keys: access_token, accounts (list), token_type. + + Raises: + RuntimeError: If token exchange fails. + """ + auth_bytes = f"{client_id}:{client_secret}".encode("utf-8") + auth_b64 = base64.b64encode(auth_bytes).decode("utf-8") + + resolved_token_url = token_url or oauth_token_url() + + async with httpx.AsyncClient() as client: + response = await client.post( + resolved_token_url, + data={"grant_type": "client_credentials"}, + headers={ + "Authorization": f"Basic {auth_b64}", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + + if response.status_code != 200: + raise RuntimeError( + f"OAuth2 token exchange failed ({response.status_code}): {response.text}" + ) + + token_data = response.json() + access_token = token_data.get("access_token") + if not access_token: + raise RuntimeError("No access_token in OAuth2 response") + + # Extract account IDs from JWT claims + claims = _decode_jwt_payload(access_token) + accounts = claims.get("accounts", []) + + return { + "access_token": access_token, + "accounts": accounts, + "token_type": token_data.get("token_type", "bearer"), + } diff --git a/src/profiles.py b/src/profiles.py new file mode 100644 index 0000000..a53801c --- /dev/null +++ b/src/profiles.py @@ -0,0 +1,111 @@ +"""Tool profiles — curated tool sets mirroring the CLI's command surface. + +Instead of loading entire OpenAPI specs (430+ tools), we cherrypick the +operationIds that map to real CLI commands. This keeps context small and +matches the agent experience to the CLI experience. +""" + +from typing import Optional + +# Cherrypicked operationIds mapped from the CLI command structure. +# Format: CLI command → operationId(s) from the OpenAPI specs. + +PROFILES: dict[str, list[str]] = { + # bw call create/list/get/update/hangup + "voice": [ + "createCall", + "listCalls", + "getCallState", + "updateCall", + "updateCallBxml", + # Custom tools + "generateBXML", + "respondToCallback", + "getCallbackEvents", + "configureCallbacks", + # Discovery — find your number and app + "listPhoneNumbers", + "listApplications", + "createApplication", + ], + # bw call recording list/get/delete/download + transcription + "recordings": [ + "listCallRecordings", + "getCallRecording", + "deleteRecording", + "downloadCallRecording", + "transcribeCallRecording", + "getRecordingTranscription", + ], + # Numbers API tools are disabled — the API is XML-based and from_openapi + # sends JSON. These will be re-enabled when we have a proper adapter. + # "applications": [...], + # "numbers": [...], + # "sites": [...], + # "locations": [...], + # bw account register (Build registration). Only the kickoff is exposed — + # SMS and email verification happen in the user's browser, not via API. + "onboarding": [ + "createRegistration", + ], + # createMessage/listMessages + media + "messaging": [ + "createMessage", + "listMessages", + "listMedia", + "getMedia", + "uploadMedia", + "deleteMedia", + "getInboundMessages", + "configureCallbacks", + ], + # Phone number lookup + "lookup": [ + "createSyncLookup", + "createAsyncBulkLookup", + "getAsyncBulkLookup", + ], +} + +# Always included regardless of profile +_ALWAYS_TOOLS = ["setCredentials", "clearCredentials"] + +# Default: voice + messaging + lookup +DEFAULT_TOOLS = list(dict.fromkeys( + PROFILES["voice"] + + PROFILES["messaging"] + + PROFILES["lookup"] + + _ALWAYS_TOOLS +)) + + +def resolve_profile(profile_str: Optional[str]) -> Optional[list[str]]: + """Resolve a profile string into a list of tool names. + + Returns: + List of tool names, or None for 'full' (all tools). + """ + if not profile_str: + return None + + names = [p.strip() for p in profile_str.split(",") if p.strip()] + if not names: + return None + + if "full" in names: + return None + + tools: list[str] = [] + for name in names: + if name == "default": + tools.extend(DEFAULT_TOOLS) + elif name not in PROFILES: + available = ", ".join(sorted(PROFILES.keys())) + raise ValueError( + f"Unknown profile: '{name}'. Available: {available}, default, full" + ) + else: + tools.extend(PROFILES[name]) + + tools.extend(_ALWAYS_TOOLS) + return list(dict.fromkeys(tools)) diff --git a/src/resources.py b/src/resources.py index 80be9ec..d0f4cb8 100644 --- a/src/resources.py +++ b/src/resources.py @@ -1,6 +1,6 @@ +from pathlib import Path from typing import List -from fastmcp.resources import HttpResource, Resource - +from fastmcp.resources import FunctionResource, HttpResource, Resource number_order_guide_resource = HttpResource( name="Bandwidth Number Order Guide", @@ -11,7 +11,20 @@ url="https://dev.bandwidth.com/docs/numbers/guides/searchingForNumbers.md", ) +import specs + +_agents_md_path = Path(specs.__file__).parent / "AGENTS.md" + +mcp_agent_reference_resource = FunctionResource( + name="Bandwidth MCP Agent Reference", + description="Structured reference for AI agents using the Bandwidth MCP Server. Covers available tools, required credentials, common workflows, error patterns, and limitations.", + tags={"bandwidth", "agent", "reference", "docs"}, + uri="resource://mcp_agent_reference", + mime_type="text/markdown", + fn=lambda: _agents_md_path.read_text(encoding="utf-8"), +) + def get_bandwidth_resources() -> List[Resource]: """Get all Bandwidth resources.""" - return [number_order_guide_resource] + return [number_order_guide_resource, mcp_agent_reference_resource] diff --git a/src/server_utils.py b/src/server_utils.py index 2aa5fb5..a3f4ef3 100644 --- a/src/server_utils.py +++ b/src/server_utils.py @@ -1,24 +1,49 @@ import copy +import hashlib +import warnings import yaml import httpx -import base64 +from pathlib import Path from fastmcp import FastMCP from fastmcp.resources import FunctionResource -from fastmcp.server.openapi import MCPType, HTTPRoute +from fastmcp.server.providers.openapi import MCPType +from fastmcp.utilities.openapi import HTTPRoute from typing import Dict, List, Optional, Any, Callable from resources import get_bandwidth_resources +CACHE_DIR = Path.home() / ".bw-mcp" / "spec-cache" + + +def _cache_key(url: str) -> str: + return hashlib.sha256(url.encode()).hexdigest()[:16] + ".yml" + + +def _save_spec_cache(url: str, spec: dict) -> None: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + cache_path = CACHE_DIR / _cache_key(url) + cache_path.write_text(yaml.dump(spec), encoding="utf-8") + + +def _load_spec_cache(url: str) -> dict | None: + cache_path = CACHE_DIR / _cache_key(url) + if not cache_path.exists(): + return None + try: + return yaml.safe_load(cache_path.read_text(encoding="utf-8")) + except Exception: + return None + async def print_server_info(mcp: FastMCP) -> None: """Print concise server information.""" - all_tools = await mcp.get_tools() - all_resources = await mcp.get_resources() + all_tools = await mcp.list_tools() + all_resources = await mcp.list_resources() - tool_names = list(all_tools.keys()) - resource_names = [resource.name for resource in all_resources.values()] + tool_names = [tool.name for tool in all_tools] + resource_names = [resource.name for resource in all_resources] print("Bandwidth MCP Server Started") print( @@ -58,12 +83,92 @@ def route_map_fn(route: HTTPRoute, mcp_type: MCPType) -> MCPType: return route_map_fn +def _fix_malformed_refs(obj: Any) -> Any: + """Fix malformed $ref strings in OpenAPI specs. + + Bandwidth's numbers spec has $refs as bare strings like: + "$ref:'#/components/schemas/Foo'" + instead of proper dicts like: + {"$ref": "#/components/schemas/Foo"} + """ + if isinstance(obj, str) and obj.startswith("$ref:"): + # Extract the ref path from malformed string + ref_path = obj.split(":", 1)[1].strip("'\"") + return {"$ref": ref_path} + if isinstance(obj, dict): + for k, v in list(obj.items()): + obj[k] = _fix_malformed_refs(v) + elif isinstance(obj, list): + for i, item in enumerate(obj): + obj[i] = _fix_malformed_refs(item) + return obj + + +def _fix_response_descriptions(obj: Any) -> Any: + """Fix responses missing 'description' field (required by OpenAPI 3.x). + + Bandwidth's numbers spec has responses like: + 204: {content: {description: "No Content"}} + instead of: + 204: {description: "No Content"} + """ + if isinstance(obj, dict) and "responses" in obj: + for code, response in obj["responses"].items(): + if isinstance(response, dict) and "description" not in response: + # Try to extract description from content + if "content" in response and isinstance(response["content"], dict): + desc = response["content"].get("description", str(code)) + if isinstance(desc, str): + response["description"] = desc + del response["content"] + continue + response["description"] = str(code) + if isinstance(obj, dict): + for v in obj.values(): + _fix_response_descriptions(v) + elif isinstance(obj, list): + for item in obj: + _fix_response_descriptions(item) + return obj + + +def _fix_misplaced_allof(obj: Any) -> Any: + """Fix allOf misplaced inside 'properties' instead of at schema level. + + Bandwidth's numbers spec has schemas like: + {properties: {allOf: [...], type: 'object'}} + where allOf should be a sibling of properties, not a child: + {allOf: [...], type: 'object'} + """ + if isinstance(obj, dict): + if "properties" in obj and isinstance(obj["properties"], dict): + props = obj["properties"] + if "allOf" in props and isinstance(props["allOf"], list): + # Hoist allOf from properties to the schema level + obj["allOf"] = props.pop("allOf") + # Move type up too if it's in properties + if "type" in props and not any( + k not in ("allOf", "type", "xml") for k in props + ): + obj.setdefault("type", props.pop("type", "object")) + if "xml" in props: + obj.setdefault("xml", props.pop("xml")) + if not props: + del obj["properties"] + for v in obj.values(): + _fix_misplaced_allof(v) + elif isinstance(obj, list): + for item in obj: + _fix_misplaced_allof(item) + return obj + + def _clean_openapi_spec(spec: Dict[str, Any]) -> Dict[str, Any]: - """Recursively clean OpenAPI spec: - - Remove all callbacks - - Remove all 4xx/5xx responses - - Remove any field starting with 'x-' - - Remove all path resources that start with 'x-' + """Clean and patch OpenAPI spec for compatibility. + + - Remove callbacks, 4xx/5xx responses, x- fields + - Fix malformed $ref strings (bare strings → proper dicts) + - Fix responses missing required 'description' field """ cleaned_spec = copy.deepcopy(spec) @@ -95,43 +200,108 @@ def _clean(obj: Any) -> Any: _clean(item) return obj - return _clean(cleaned_spec) + _clean(cleaned_spec) + _fix_malformed_refs(cleaned_spec) + _fix_response_descriptions(cleaned_spec) + _fix_misplaced_allof(cleaned_spec) + _patch_phone_number_lookup(cleaned_spec) + + return cleaned_spec + + +def _patch_phone_number_lookup(spec: Dict[str, Any]) -> None: + """The upstream phone-number-lookup-v2 spec ships with empty request body + schemas for both createSyncLookup and createAsyncBulkLookup. Without a + schema, FastMCP from_openapi can't surface the body field to the agent, + so the lookup tools become unusable. + + Inject the real shape (confirmed against the live API on stage). The body + field is `phoneNumbers`, not `tns` as some older docs imply. + """ + paths = spec.get("paths", {}) + targets = ( + "/accounts/{accountId}/phoneNumberLookup", + "/accounts/{accountId}/phoneNumberLookup/bulk", + ) + body_schema = { + "type": "object", + "required": ["phoneNumbers"], + "properties": { + "phoneNumbers": { + "type": "array", + "items": {"type": "string"}, + "description": "Phone numbers in E.164 format to look up (e.g. \"+19195551234\").", + } + }, + } + for path in targets: + op = paths.get(path, {}).get("post") + if not isinstance(op, dict): + continue + rb = op.setdefault("requestBody", {"required": True, "content": {}}) + content = rb.setdefault("content", {}) + media = content.setdefault("application/json", {}) + # Only inject if the upstream schema is missing or empty. + if not media.get("schema"): + media["schema"] = body_schema async def fetch_openapi_spec(url: str) -> Dict[str, Any]: - """Fetch and parse OpenAPI spec from URL.""" + """Fetch and parse OpenAPI spec from URL or local file, with cache fallback.""" + # Local file path — read directly, no caching needed + local_path = Path(url) + if local_path.exists(): + try: + spec_object = yaml.safe_load(local_path.read_text(encoding="utf-8")) + if not spec_object: + raise ValueError(f"Empty or invalid YAML spec from {url}") + return _clean_openapi_spec(spec_object) + except yaml.YAMLError as e: + raise RuntimeError(f"Failed to parse local spec {url}: {e}") from e + + # Remote URL — fetch, cache, fallback try: async with httpx.AsyncClient() as client: response = await client.get(url) response.raise_for_status() spec_text = response.text - spec_object = yaml.safe_load(spec_text) if not spec_object: raise ValueError(f"Empty or invalid YAML spec from {url}") - - return _clean_openapi_spec(spec_object) - except httpx.HTTPError as e: + cleaned = _clean_openapi_spec(spec_object) + _save_spec_cache(url, cleaned) + return cleaned + except (ValueError, yaml.YAMLError): + raise + except Exception as e: + cached = _load_spec_cache(url) + if cached: + warnings.warn(f"Using cached spec for {url}: {e}") + return cached raise RuntimeError(f"Failed to fetch OpenAPI spec from {url}: {e}") from e - except yaml.YAMLError as e: - raise RuntimeError(f"Failed to parse YAML spec from {url}: {e}") from e -def create_auth_header(username: str, password: str) -> str: - """Create a basic authentication header.""" - auth_bytes = f"{username}:{password}".encode("utf-8") - return base64.b64encode(auth_bytes).decode("utf-8") +_SENSITIVE_KEYS = { + "BW_CLIENT_SECRET", + "BW_ACCESS_TOKEN", + "_authenticated_servers_loaded", +} + + +def _safe_config(config: Dict[str, Any]) -> Dict[str, Any]: + """Return config with sensitive values redacted.""" + return {k: ("***" if k in _SENSITIVE_KEYS else v) for k, v in config.items()} def add_resources(mcp: FastMCP, config: Dict[str, Any]) -> FastMCP: """Add configuration and other resources to the MCP server.""" config_resource = FunctionResource( name="Bandwidth API Configuration", - description="Object containing API credentials, application IDs, and account ID.", - tags={"bandwidth", "config", "credentials"}, + description="Shows which credentials, application IDs, and account ID are configured. Sensitive values are redacted.", + tags={"bandwidth", "config"}, uri="resource://config", mime_type="application/json", - fn=lambda: config, + fn=lambda: _safe_config(config), ) mcp.add_resource(config_resource) diff --git a/src/servers.py b/src/servers.py index f2de360..2dca3ae 100644 --- a/src/servers.py +++ b/src/servers.py @@ -1,3 +1,7 @@ +from pathlib import Path + +import specs + from fastmcp import FastMCP from httpx import AsyncClient from typing import Dict, List, Optional, Callable, Any @@ -5,46 +9,94 @@ from server_utils import ( add_resources, create_route_map_fn, - create_auth_header, fetch_openapi_spec, print_server_info, ) +from urls import swap_host + +_SPECS_DIR = Path(specs.__file__).parent +# All API specs. Tools are cherrypicked by profiles, so loading all specs +# is fine — only the operationIds in the active profile get registered. api_server_info: Dict[str, Dict[str, Any]] = { "messaging": {"url": "https://dev.bandwidth.com/spec/messaging.yml"}, - "multi-factor-auth": { - "url": "https://dev.bandwidth.com/spec/multi-factor-auth.yml" - }, "phone-number-lookup": { "url": "https://dev.bandwidth.com/spec/phone-number-lookup-v2.yml" }, - "insights": {"url": "https://dev.bandwidth.com/spec/insights.yml"}, + "voice": {"url": "https://dev.bandwidth.com/spec/voice.yml"}, + "insights": { + "url": "https://dev.bandwidth.com/spec/insights.yml", + # listCalls/listCall collide with voice spec — exclude from insights + "exclude_tools": ["listCalls", "listCall"], + }, "end-user-management": { "url": "https://dev.bandwidth.com/spec/end-user-management.yml" }, + # Numbers API is XML-based — from_openapi sends JSON which the API rejects. + # Disabled until we have a proper XML adapter or hand-written tools. + # "numbers": {"url": "https://dev.bandwidth.com/spec/numbers.yml"}, + "toll-free-verification": { + "url": "https://dev.bandwidth.com/spec/toll-free-verification.yml" + }, + "build-registration": { + "url": str(_SPECS_DIR / "build.yml"), + }, } async def _create_server( - url: str, route_map_fn: Optional[Callable] = None, config: Dict[str, Any] = {} + url: str, + route_map_fn: Optional[Callable] = None, + config: Optional[Dict[str, Any]] = None, ) -> FastMCP: - """Create an MCP server from the provided spec URL and credentials.""" - # Fetch and clean the OpenAPI spec + """Create an MCP server from the provided spec URL and credentials. + + Always registers tools regardless of auth state. Auth is checked at + call time — unauthenticated calls get a 401 from the API, not a + startup failure. + """ + if config is None: + config = {} spec_object = await fetch_openapi_spec(url) - # Validate spec structure if "servers" not in spec_object or not spec_object["servers"]: raise ValueError(f"OpenAPI spec from {url} has no servers defined") + # Spec server URLs hardcode prod hosts (api/voice/messaging/mfa/insights + # .bandwidth.com). Rewrite so BW_ENVIRONMENT and per-host overrides take + # effect for OpenAPI-derived tools too. swap_host preserves the path. + spec_object["servers"][0]["url"] = swap_host(spec_object["servers"][0]["url"]) base_url = spec_object["servers"][0]["url"] - auth_b64 = create_auth_header(config["BW_USERNAME"], config["BW_PASSWORD"]) + + headers = {"User-Agent": "Bandwidth MCP Server"} + token = config.get("BW_ACCESS_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + + async def _ensure_content_type(request): + """Workaround for FastMCP from_openapi: when the OpenAPI spec declares + a non-JSON request body (e.g. application/xml for updateCallBxml), + FastMCP sends the body via httpx `content=` without setting Content-Type. + Bandwidth's APIs reject those with 415. Sniff the body and inject the + right header before the request goes out. + """ + if not request.content: + return + if request.headers.get("content-type"): + return + body_start = request.content[:64].lstrip() if isinstance(request.content, bytes) else request.content[:64].lstrip().encode() + if body_start.startswith(b"<"): + request.headers["content-type"] = "application/xml" + elif body_start.startswith(b"{") or body_start.startswith(b"["): + request.headers["content-type"] = "application/json" + else: + request.headers["content-type"] = "application/octet-stream" client = AsyncClient( base_url=base_url, - headers={ - "Authorization": f"Basic {auth_b64}", - "User-Agent": "Bandwidth MCP Server", - }, + headers=headers, + follow_redirects=True, + event_hooks={"request": [_ensure_content_type]}, ) mcp = FastMCP.from_openapi( @@ -61,7 +113,7 @@ async def create_bandwidth_mcp( mcp: FastMCP, enabled_tools: Optional[List[str]], excluded_tools: Optional[List[str]], - config: Dict[str, Any] = {}, + config: Optional[Dict[str, Any]] = None, ) -> FastMCP: """Create the Bandwidth MCP server from all supplied APIs, taking into account enabled and excluded APIs. @@ -77,14 +129,25 @@ async def create_bandwidth_mcp( Raises: RuntimeError: If any API server fails to create or import """ + if config is None: + config = {} route_map_fn = create_route_map_fn(enabled_tools, excluded_tools) for api_name, api_info in api_server_info.items(): try: + # Merge per-spec exclusions (only when not using explicit enabled_tools) + spec_excludes = api_info.get("exclude_tools", []) + if spec_excludes and not enabled_tools: + combined_excluded = list(set((excluded_tools or []) + spec_excludes)) + spec_route_map_fn = create_route_map_fn(None, combined_excluded) + else: + spec_route_map_fn = route_map_fn server = await _create_server( - api_info["url"], route_map_fn=route_map_fn, config=config + api_info["url"], + route_map_fn=spec_route_map_fn, + config=config, ) - await mcp.import_server(server) + mcp.mount(server) except Exception as e: print(f"Warning: Failed to create server for {api_name}: {e}") diff --git a/src/specs/AGENTS.md b/src/specs/AGENTS.md new file mode 100644 index 0000000..3421a48 --- /dev/null +++ b/src/specs/AGENTS.md @@ -0,0 +1,397 @@ +# Bandwidth MCP Server — Agent Reference + +Structured reference for AI agents using the Bandwidth MCP Server. Covers the +tool inventory, the credentials each tool needs, the order to call things, and +how the server reports failure. Self-contained — an agent should not need to +cross-reference anything else to operate. + +## Overview + +The MCP server exposes a curated subset of Bandwidth's APIs as MCP tools. +Tools are grouped into workflow-oriented profiles (voice, messaging, lookup, +onboarding, recordings). Selecting a profile at startup limits the tools +loaded so the agent's context stays small. The surface complements the `band` +CLI — see [Limitations](#limitations) for what's not exposed here. + +What the server does: +- One-shot API calls (create a message, place a call, run a lookup). +- State queries (get call state, list messages, fetch callback events). +- BXML generation and first-write-wins callback responses for live calls. +- Build Registration (kick off account creation without prior credentials — + the API only handles phone-OTP; signup finishes in the user's email and the + Bandwidth App). + +What the server does not do: +- Mid-call streaming or media manipulation. Voice is callback-driven through + `respondToCallback`; the server is not a media server. +- Batch operations. Each tool acts on one resource. +- Message-body retrieval. Bandwidth does not store message contents. + +## Auth + +The server uses OAuth2 client credentials. Two ways to authenticate: + +1. Set env vars before starting the server: + - `BW_CLIENT_ID` — OAuth2 client ID + - `BW_CLIENT_SECRET` — OAuth2 client secret +2. Call `setCredentials(client_id, client_secret)` mid-session. This is the + path stdio sessions use when bootstrapping through Build Registration — + `createRegistration` kicks off signup; the user finishes SMS and email + verification in their browser, then generates API credentials in the + Bandwidth App (`Account > API Credentials`). Once the user pastes those + credentials back, the agent loads them via `setCredentials` to unlock + authenticated tools. See [Build Registration](#build-registration) for + the full flow. + +The account ID is discovered from the JWT `sub`/`accounts` claim after the +client_credentials grant. Agents never need to provide an account ID +manually. + +`clearCredentials` logs the session out and forces re-auth on the next +authenticated call. + +### Host URLs + +Production is the default. `BW_ENVIRONMENT=test` (or `uat`) flips the API and +Voice hosts to the test environment in one shot, matching the CLI. Individual +hosts can also be overridden with their own env var; per-host overrides win +over `BW_ENVIRONMENT`. + +| Env var | Purpose | +|---|---| +| `BW_ENVIRONMENT` | `test` / `uat` to target the test environment; unset for prod | +| `BW_API_URL` | API gateway base — also serves the Dashboard XML API under `/api/v2` | +| `BW_VOICE_URL` | Voice API base | +| `BW_MESSAGING_URL` | Messaging API base | + +Leave them unset for normal use. + +### Local callback tunnel (dev only) + +Voice and messaging callbacks (`respondToCallback`, `getCallbackEvents`, +`getInboundMessages`) need Bandwidth to reach this server over a public URL. +In hosted mode that's `BW_MCP_BASE_URL`. For local development the server can +open an ephemeral public tunnel for you instead of requiring ngrok: + +- Set `BW_MCP_DEV_TUNNEL=1` (plus a non-stdio transport like + `BW_MCP_TRANSPORT=streamable-http`, and leave `BW_MCP_BASE_URL` unset). +- On startup the server runs `cloudflared` to get a `*.trycloudflare.com` + URL, sets it as `BW_MCP_BASE_URL`, and auto-points the voice app's + callbacks at it. + +Requires `cloudflared` on the PATH (`brew install cloudflared`); if it's +missing the server logs a warning and starts without a tunnel. **Dev/testing +only — never for production.** If a user is wiring up callbacks locally and +hitting "my webhooks never arrive," suggest `BW_MCP_DEV_TUNNEL=1` rather than +telling them to set up their own tunnel. + +## Account types and capabilities + +Two account shapes matter: + +- **Bandwidth Build account.** Voice-only, credit-based. Messaging, number + ordering/lookup-by-account, toll-free verification, and 10DLC are not + available. A Build account ships with one pre-provisioned voice + application and one phone number — the agent does not create either. +- **Full account.** Messaging, voice, lookup, and numbers all available + subject to the credential's roles. + +When a tool is invoked against an account that doesn't have the required +feature, the server returns the standard error shape (see [Output shape](#output-shape)) +with `code: "feature_limit"` and a `recovery` hint pointing at the upgrade +path. The agent should treat `feature_limit` as non-retryable and surface the +hint to the user. + +Two early calls let an agent branch correctly before doing real work: + +1. `listApplications` — returns the apps already on the account. On a Build + account this is the pre-provisioned voice app. +2. `listPhoneNumbers` — returns the numbers already on the account. On a Build + account this is the pre-provisioned number. + +If both return data, the agent can place a call without provisioning anything. + +## Tool inventory + +Tools are grouped by workflow. The grouping mirrors `src/profiles.py`. Loading +a single profile keeps unused tools out of the agent's context. + +Always loaded: + +| Tool | Purpose | Auth | +|---|---|---| +| `setCredentials` | Authenticate the session (client_id/secret) | none | +| `clearCredentials` | Log out the session | session token | + +### Profile: `onboarding` + +No credentials needed — use this to start a Build account from zero. Only +one tool is exposed because SMS and email verification happen in the user's +browser; an API call that consumed the OTP would break the signup page. + +| Tool | Purpose | Check after | +|---|---|---| +| `createRegistration` | Submit contact details; Bandwidth then emails a password-set link and SMS's an OTP to the user | response carries a `registrationId` and status `USER_CREATION_PENDING` — then stop calling tools and walk the user through the [Build Registration](#build-registration) handoff | + +### Profile: `voice` + +Auth: client_credentials. Voice application ID is required for `createCall` +(discover via `listApplications`). + +| Tool | Purpose | Check after | +|---|---|---| +| `listApplications` | Find or confirm the voice app on the account | non-empty list, app type is voice | +| `createApplication` | Create a voice application with callback URLs | record the new `applicationId` | +| `listPhoneNumbers` | Find numbers usable as the `from` of a call | non-empty list | +| `createCall` | Initiate an outbound call | **always** poll `getCallState` — see [Trust nothing](#trust-nothing) | +| `getCallState` | Read the current state of a call | inspect `state` and `disconnectCause` | +| `listCalls` | List call events with filtering | — | +| `updateCall` | Redirect, hang up, or pause an active call | poll `getCallState` | +| `updateCallBxml` | Replace the BXML on an active call | poll `getCallState` | +| `generateBXML` | Build valid BXML from a verb list | inspect returned XML before sending | +| `respondToCallback` | Queue a BXML response for an active callback | first-write-wins; second writer gets `code: "conflict"` | +| `getCallbackEvents` | Read recent voice/messaging callback events | check `event_type` and `timestamp` | +| `configureCallbacks` | Point an application's callback URLs at this server | confirm via `listApplications` | + +### Profile: `recordings` + +Auth: client_credentials. + +| Tool | Purpose | Check after | +|---|---|---| +| `listCallRecordings` | List recordings for a call | non-empty list | +| `getCallRecording` | Fetch metadata for one recording | `status` is `complete` | +| `deleteRecording` | Remove a recording | absent on next list | +| `downloadCallRecording` | Download the media | binary payload | +| `transcribeCallRecording` | Request transcription | poll `getRecordingTranscription` | +| `getRecordingTranscription` | Read transcription state | `status` is `complete` | + +### Profile: `messaging` + +Auth: client_credentials. Full account only — Build returns `feature_limit`. + +| Tool | Purpose | Check after | +|---|---|---| +| `createMessage` | Send SMS or MMS | 202 means **accepted, not delivered**; watch `getCallbackEvents` for `message-delivered` / `message-failed` | +| `listMessages` | Query message history | requires at least one filter; timestamps must be millisecond precision (`2024-01-01T00:00:00.000Z`) | +| `getInboundMessages` | Read inbound SMS/MMS captured by this server | filter by number and time | +| `listMedia` / `getMedia` / `uploadMedia` / `deleteMedia` | Manage MMS media | URL from `uploadMedia` feeds `createMessage` | +| `configureCallbacks` | Point an application's callbacks at this server | confirm via `listApplications` | + +### Profile: `lookup` + +Auth: client_credentials. + +| Tool | Purpose | Check after | +|---|---|---| +| `createSyncLookup` | One-shot lookup (small input) | response is the result | +| `createAsyncBulkLookup` | Lookup for many numbers | poll `getAsyncBulkLookup` | +| `getAsyncBulkLookup` | Poll a bulk lookup | `status` is `complete` | + +## Output shape + +All tools return JSON dicts. Success responses are the tool's natural payload +— not wrapped in `{data: ...}`. The agent reads fields directly off the +response. + +Failure responses use a single structured shape: + +```json +{ + "error": "human-readable message", + "code": "feature_limit | auth | not_found | rate_limited | conflict | timeout", + "recovery": "what to try next" +} +``` + +Code semantics: + +| Code | Meaning | Retryable? | +|---|---|---| +| `auth` | Credentials missing, expired, or invalid (401) | Re-auth via `setCredentials`, then retry | +| `feature_limit` | Account/credential cannot use this feature (402, 403 role/plan, Build limits) | No — surface `recovery` and stop | +| `not_found` | Resource ID does not exist (404) | No — verify the ID | +| `conflict` | Duplicate or first-write-wins loss (409, also `respondToCallback`) | Sometimes — query state first | +| `rate_limited` | Throttled or quota exceeded (429) | Yes, with backoff | +| `timeout` | Polling deadline exceeded with no terminal state | Query state and decide | + +Agents should branch on `code`, not on `error` text. The text is for humans. + +## Trust nothing + +The most important rule for agents using this server: **`createCall` returns +immediately with a `callId` even when the call never actually goes out.** A +mis-provisioned `from` number, a routing failure, or a downstream carrier +reject all produce a happy 200/201 response with a valid-looking `callId`. + +Always poll `getCallState` before reporting success to the user. + +What to look at: + +| Field | Healthy value | Bad value | +|---|---|---| +| `state` | `active`, then `completed` | stuck on `initiated` for more than a few seconds | +| `disconnectCause` | `hangup`, `busy`, `timeout` | `error` | +| `errorMessage` | absent | anything — especially `Service unavailable` | + +If `disconnectCause` is `error`, the call never connected. Try a different +`from` number, or re-check provisioning via `listApplications` / +`listPhoneNumbers`. + +The same rule applies to `createMessage`: a 202 means "accepted for +processing," not "delivered." Delivery confirmation arrives later through +`getCallbackEvents` as a `message-delivered` or `message-failed` event. Never +tell the user a message was delivered based solely on the `createMessage` +return value. + +## Async operations + +Several tools are async by design. The server does not block — the agent +polls. + +| Tool | Poll with | Recommended interval | Notes | +|---|---|---|---| +| `createCall` | `getCallState` | 500ms–1s for the first few polls; 2–5s after | Call can fail silently; see [Trust nothing](#trust-nothing) | +| `createMessage` | `getCallbackEvents` filtered by `messageId` | 1–2s | Delivery only confirms via webhook | +| `transcribeCallRecording` | `getRecordingTranscription` | 5s | Transcription can take longer than the recording | +| `createAsyncBulkLookup` | `getAsyncBulkLookup` | 2–5s | Result includes per-number status | + +`respondToCallback` has first-write-wins semantics: if two BXML responses race +for the same callback, the second returns `code: "conflict"` and is dropped. +This is intentional — it lets multiple agent sessions safely observe the same +call without stepping on each other. The agent that wants to drive the call +should be the first to write, and should treat `conflict` as "another writer +already responded; re-read `getCallbackEvents` for the next prompt." + +The EventStore (the in-memory queue feeding `getCallbackEvents` and +`getInboundMessages`) holds events for a bounded TTL — assume on the order of +an hour. Don't rely on it as durable storage; pull events as soon as you need +them and persist anything you care about long-term. + +## Provisioning workflows + +### Build Registration + +Bandwidth **Build** is the free voice-first trial. Proactively offer this +flow — without waiting for the user to name it — whenever the user: + +- asks how to make / create / sign up for a Bandwidth account, +- says they don't have an account or credentials yet, or +- wants to try things out, test, or experiment with the server. + +It mirrors the `band account register` flow in the CLI — the API kicks off +registration, and that's it. SMS phone verification, password set, and API +credential generation all happen in pages Bandwidth links the user to. +**The agent cannot finish this flow autonomously, and must not try to +consume the SMS/email OTP via API — doing so breaks the user's browser +flow.** + +``` +createRegistration(phoneNumber, email, firstName, lastName) + # → registrationId, status: USER_CREATION_PENDING + # → Bandwidth sends an SMS OTP to the phone + # → Bandwidth emails a password-set link +# stop calling tools; hand off to the user +``` + +**After `createRegistration`, tell the user:** + +1. Enter the 6-digit SMS code on the Bandwidth signup page (not in chat). +2. Open the registration email, click the link, set a password, and enter + the OTP delivered to the same email. +3. In the Bandwidth App, go to **Account > API Credentials** and generate + OAuth2 credentials. +4. Paste the credentials back into chat. + +It's helpful to offer to open the user's mail app for them — `open -a Mail` +(macOS), `xdg-open mailto:` (Linux), or the equivalent on Windows. Run it +with the user's consent only. + +Once the user pastes credentials, call `setCredentials(client_id, +client_secret)` to unlock authenticated tools. **Never poll a tool waiting +for credentials to appear** — the API has no surface for delivering them, +and there is no API for "verify the SMS code" — that intentionally only +exists in the browser. + +### Place an outbound call (Build account) + +A Build account ships with everything needed. The agent does not provision. + +``` +listApplications # find the pre-provisioned voice app → applicationId +listPhoneNumbers # find the pre-provisioned number → from +createCall(from, to, # initiate + applicationId, + answerUrl) # → callId +getCallState(callId) # poll until state=completed + # verify disconnectCause != "error" +``` + +If `listPhoneNumbers` returns empty, the account is in a state the agent +cannot recover — escalate to the user. + +### Send a message (full account) + +``` +listApplications # find the messaging application → applicationId +listPhoneNumbers # find the from number +createMessage(from, to, # send + applicationId, + text) # → 202 with messageId +getCallbackEvents( # poll for delivery + event_type="message-delivered" or + event_type="message-failed", + message_id=messageId) +``` + +If `createMessage` returns `code: "feature_limit"`, the account is Build — +surface the `recovery` hint and stop. + +## Limitations + +- **No batch operations.** Each tool acts on a single resource. Bulk lookup is + the only exception, and it's still one tool call returning one request ID. +- **No message-content retrieval.** Bandwidth does not store message bodies. + After send, the text is gone. `listMessages` returns metadata only — + timestamps, direction, segment counts. +- **No 10DLC tools.** The server does not expose campaign creation, brand + registration, or number-to-campaign assignment. Use the `band` CLI + (`band tendlc`) or the Bandwidth App for these flows. +- **No toll-free verification tools.** TFV status checks and submission are + available via the `band` CLI (`band tfv`), not here. +- **No number ordering / provisioning.** Search, order, activation, and + release of new numbers live in the `band` CLI (`band number`) and the + Bandwidth App. The MCP server can list numbers already on the account. +- **No sub-accounts, sites, locations, or peer assignments.** Account + topology management is CLI-only today. +- **Build accounts are voice-only.** Anything outside voice / app discovery + returns `code: "feature_limit"`. +- **No real-time media.** Voice is callback/BXML driven. The server cannot + stream audio, inject media mid-stream, or act as a media relay. +- **EventStore is in-memory.** Callback events are not durable across server + restarts. Persistent capture requires an external store. +- **`setCredentials` is session-scoped.** Credentials set via the tool do not + survive a server restart. For persistence, set `BW_CLIENT_ID` / + `BW_CLIENT_SECRET` before starting the server. + +## Error patterns + +Common API failures and the structured response the agent will see: + +| Trigger | Code | Recovery | +|---|---|---| +| `setCredentials` never called and env vars unset | `auth` | Call `setCredentials` or restart with env vars | +| Bearer token expired mid-session | `auth` | Server attempts silent refresh; on failure surfaces `auth` — agent re-calls `setCredentials` | +| Build account calls `createMessage` | `feature_limit` | Stop; surface upgrade path from `recovery` | +| Credential lacks a role (Campaign Mgmt, TFV) on full account | `feature_limit` | Escalate to the user's account manager | +| Tool referenced an ID that doesn't exist | `not_found` | Verify ID; re-list parents | +| Duplicate `createApplication` with same name | `conflict` | Re-list and reuse the existing one | +| Second writer to `respondToCallback` | `conflict` | Re-read `getCallbackEvents`; another session is driving | +| 429 from upstream | `rate_limited` | Exponential backoff and retry | +| Async poll exceeded deadline | `timeout` | Query the resource directly before retrying the originating call | +| `listMessages` called with zero filters | `error` (validation, surfaced verbatim) | Add at least one of `to`, `from`, `messageId`, or a date range | +| `listMessages` called with second-precision date | `error` | Use millisecond precision: `2024-01-01T00:00:00.000Z` | + +The agent should branch on `code`. Treat `feature_limit`, `not_found`, and +validation errors as non-retryable. Treat `auth`, `rate_limited`, and +`timeout` as retryable after the appropriate corrective step. diff --git a/src/specs/__init__.py b/src/specs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/specs/build.yml b/src/specs/build.yml new file mode 100644 index 0000000..6d7c224 --- /dev/null +++ b/src/specs/build.yml @@ -0,0 +1,100 @@ +openapi: 3.0.3 +info: + title: Bandwidth Build Registration + version: 1.0.0 + description: | + Registration API for new Bandwidth Build accounts. + + This API only kicks off registration — every subsequent step (SMS phone + verification, password set, API credential generation) happens in the + user's browser via links Bandwidth emails them. The agent must not try + to consume the SMS code or email OTP itself, or the user's browser flow + will break. + + After createRegistration returns, the user must: + + 1. Enter the SMS OTP that arrives at the registered phone — in the + Bandwidth signup page, not back into chat. + 2. Open the registration email and set a password (the page asks for + a second OTP delivered to the same email). + 3. In the Bandwidth App, go to Account > API Credentials and generate + OAuth2 credentials. + 4. Paste those credentials back into chat — the agent then calls + setCredentials to unlock authenticated tools. +servers: + - url: https://api.bandwidth.com/v1/express + description: Production +security: [] +paths: + /registration: + post: + operationId: createRegistration + summary: Initialize a new Build account registration + description: | + Submits a Build account registration. Bandwidth then sends the user + an SMS OTP (entered in the signup page) and an email with a + password-set link. The agent's job ends here — do not attempt to + complete verification via the API. + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/registerRequest' + responses: + '200': + description: Registration created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/registerResponse' +components: + schemas: + registerRequest: + type: object + required: + - phoneNumber + - email + - firstName + - lastName + additionalProperties: false + properties: + phoneNumber: + type: string + description: US phone number in E.164 format. Receives the SMS verification code the user enters in the signup page. + example: "+19195551234" + email: + type: string + description: Customer email address. Receives the password-set link that completes signup. + example: "jane@example.com" + firstName: + type: string + description: Customer's first name + example: "Jane" + lastName: + type: string + description: Customer's last name + example: "Doe" + registerResponse: + type: object + properties: + links: + type: array + items: {} + data: + type: object + properties: + message: + type: string + example: "Onboarding request received successfully" + status: + type: string + example: "USER_CREATION_PENDING" + registrationId: + type: string + format: uuid + example: "5fce253e-fdbc-433f-829b-6839d1edbebe" + errors: + type: array + items: {} diff --git a/src/tools/__init__.py b/src/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/callbacks.py b/src/tools/callbacks.py new file mode 100644 index 0000000..e649b6a --- /dev/null +++ b/src/tools/callbacks.py @@ -0,0 +1,212 @@ +"""MCP tools for reading callback events and configuring webhooks.""" + +from typing import Optional + +import httpx + +from event_store import EventStore +from urls import dashboard_api_base + + +async def get_inbound_messages_flow( + event_store: EventStore, + phone_number: Optional[str] = None, + since: Optional[float] = None, +) -> dict: + if phone_number: + events = event_store.get_events( + "messaging.inbound", key=phone_number, since=since + ) + else: + events = event_store.get_events("messaging.inbound", since=since) + cleaned = [{k: v for k, v in e.items() if not k.startswith("_")} for e in events] + return {"events": cleaned, "count": len(cleaned)} + + +async def get_callback_events_flow( + event_store: EventStore, + event_type: Optional[str] = None, + call_id: Optional[str] = None, + phone_number: Optional[str] = None, + since: Optional[float] = None, +) -> dict: + if call_id and event_type: + events = event_store.get_events(event_type, key=call_id, since=since) + elif phone_number and event_type: + events = event_store.get_events(event_type, key=phone_number, since=since) + elif event_type: + events = event_store.get_events(event_type, since=since) + else: + all_events = [] + for et in [ + "messaging.inbound", + "messaging.status", + "voice.answer", + "voice.gather", + "voice.disconnect", + ]: + all_events.extend(event_store.get_events(et, since=since)) + all_events.sort(key=lambda e: e.get("_received_at", 0)) + events = all_events + cleaned = [{k: v for k, v in e.items() if not k.startswith("_")} for e in events] + return {"events": cleaned, "count": len(cleaned)} + + +async def configure_callbacks_flow( + config: dict, + application_id: str, + base_url: str, + types: Optional[list[str]] = None, +) -> dict: + """Update a Bandwidth application's callback URLs via the Dashboard XML API. + + The `types` argument is advisory only. A Bandwidth application has exactly + one ServiceType, and the Dashboard API rejects callback fields that don't + match it (error 12962: "CallbackUrl is set but it is only allowed with + ServiceType Messaging-V2", and the converse for voice). So the fields to + set are derived from the app's actual ServiceType, fetched below — not + from the caller's hint. + """ + token = config.get("BW_ACCESS_TOKEN") + account_id = config.get("BW_ACCOUNT_ID") + if not token or not account_id: + return {"error": "Not authenticated. Call setCredentials first."} + + # GET the app first — we need its ServiceType to choose the right callback + # fields, plus AppName to preserve on the PUT. + api_url = f"{dashboard_api_base()}/accounts/{account_id}/applications/{application_id}" + + async with httpx.AsyncClient(follow_redirects=True) as client: + get_resp = await client.get( + api_url, + headers={"Authorization": f"Bearer {token}", "Accept": "application/xml"}, + ) + + # Extract current name and service type from the XML + from xml.etree.ElementTree import fromstring + + app_xml = fromstring(get_resp.text) + app_el = app_xml.find(".//Application") + if app_el is None: + app_el = app_xml + app_name = "" + service_type = "" + for child in app_el: + if child.tag == "AppName": + app_name = child.text or "" + if child.tag == "ServiceType": + service_type = child.text or "" + + # Choose callbacks by the app's actual ServiceType. Sending the wrong + # family's fields is what the API rejects with 400/12962. + callbacks: dict = {} + if service_type == "Voice-V2": + callbacks["callInitiatedCallbackUrl"] = f"{base_url}/callbacks/voice/answer" + callbacks["callStatusCallbackUrl"] = f"{base_url}/callbacks/voice/disconnect" + elif service_type == "Messaging-V2": + callbacks["callbackUrl"] = f"{base_url}/callbacks/messaging/inbound" + callbacks["statusCallbackUrl"] = f"{base_url}/callbacks/messaging/status" + else: + return { + "error": f"Cannot configure callbacks for service type '{service_type or 'unknown'}'", + "application_id": application_id, + } + + # Build the full XML with required fields + updated callbacks + xml_parts = [ + f"{service_type}", + f"{app_name}", + ] + # XML tags match the Bandwidth Dashboard API naming (PascalCase) + tag_map = { + "callInitiatedCallbackUrl": "CallInitiatedCallbackUrl", + "callStatusCallbackUrl": "CallStatusCallbackUrl", + "callbackUrl": "CallbackUrl", + "statusCallbackUrl": "StatusCallbackUrl", + } + for k, v in callbacks.items(): + tag = tag_map.get(k, k) + xml_parts.append(f"<{tag}>{v}") + + xml_body = f"{''.join(xml_parts)}" + + async with httpx.AsyncClient(follow_redirects=True) as client: + response = await client.put( + api_url, + content=xml_body, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/xml", + }, + ) + + if response.status_code >= 400: + return { + "error": f"Failed to update application ({response.status_code})", + "details": response.text, + } + + return { + "status": "configured", + "application_id": application_id, + "base_url": base_url, + "serviceType": service_type, + "callbacks": callbacks, + } + + +def register_callback_tools(mcp, event_store: EventStore, config: dict = None) -> None: + @mcp.tool(name="getInboundMessages") + async def get_inbound_messages( + phone_number: Optional[str] = None, + since: Optional[float] = None, + ) -> dict: + """Get recent inbound SMS/MMS messages received by your Bandwidth number. + + Args: + phone_number: Filter by sender phone number (E.164 format). + since: Only return events after this Unix timestamp. + """ + return await get_inbound_messages_flow(event_store, phone_number, since) + + @mcp.tool(name="getCallbackEvents") + async def get_callback_events( + event_type: Optional[str] = None, + call_id: Optional[str] = None, + phone_number: Optional[str] = None, + since: Optional[float] = None, + ) -> dict: + """Get callback events from Bandwidth webhooks. + + Filterable by event type (messaging.inbound, voice.gather, etc.), + call ID, phone number, and timestamp. + + Args: + event_type: Filter by event type (e.g. "messaging.inbound", "voice.gather"). + call_id: Filter voice events by call ID. + phone_number: Filter by phone number. + since: Only return events after this Unix timestamp. + """ + return await get_callback_events_flow( + event_store, event_type, call_id, phone_number, since + ) + + if config is not None: + + @mcp.tool(name="configureCallbacks") + async def configure_callbacks( + application_id: str, + base_url: str, + types: Optional[list[str]] = None, + ) -> dict: + """Configure a Bandwidth application's callback URLs to point at this server. + + Sets the application's webhook URLs so Bandwidth sends inbound messages + and voice events to this MCP server. One call and webhooks are wired. + + Args: + application_id: The Bandwidth application ID to configure. + base_url: The public URL of this server (e.g. https://your-server.ngrok.io). + types: Which callback types to register. Default: ["messaging", "voice"]. + """ + return await configure_callbacks_flow(config, application_id, base_url, types) diff --git a/src/tools/credentials.py b/src/tools/credentials.py new file mode 100644 index 0000000..1aa43a3 --- /dev/null +++ b/src/tools/credentials.py @@ -0,0 +1,113 @@ +"""Credential management tools — login (OAuth2) and logout. + +setCredentials: Takes a client ID and secret, exchanges them for a Bearer +token, extracts account IDs from JWT claims, and reloads authenticated servers. + +clearCredentials: Removes stored credentials and access token so that +authenticated API tools return 401 until the user logs in again. +""" + +import os + +from oauth import get_oauth_token + +_AUTH_KEYS = [ + "BW_CLIENT_ID", + "BW_CLIENT_SECRET", + "BW_ACCESS_TOKEN", + "BW_ACCOUNT_ID", +] + + +async def set_credentials_flow( + config: dict, + client_id: str, + client_secret: str, +) -> dict: + """Authenticate via OAuth2 and update the shared config. + + Note: This updates the token in config, but tools created at startup + already have their httpx client headers set. For full effect, the user + should add credentials to their MCP config and restart the server. + This tool is primarily useful for Build registration flows. + """ + token_data = await get_oauth_token(client_id, client_secret) + + config["BW_CLIENT_ID"] = client_id + config["BW_CLIENT_SECRET"] = client_secret + config["BW_ACCESS_TOKEN"] = token_data["access_token"] + + accounts = token_data["accounts"] + if accounts: + config["BW_ACCOUNT_ID"] = accounts[0] + + return { + "status": "credentials_set", + "client_id": client_id, + "accounts": accounts, + "active_account": accounts[0] if accounts else None, + "message": "Authenticated. For best results, add BW_CLIENT_ID and BW_CLIENT_SECRET to your MCP server config and restart.", + } + + +def clear_credentials_flow(config: dict) -> dict: + """Remove stored credentials and access token from the shared config.""" + removed = [key for key in _AUTH_KEYS if key in config] + for key in _AUTH_KEYS: + config.pop(key, None) + config.pop("_authenticated_servers_loaded", None) + + return { + "status": "logged_out", + "cleared": removed, + "message": "Credentials cleared. Authenticated API tools will return 401 until you call setCredentials again.", + } + + +def register_credentials_tools( + mcp, + config: dict, +): + """Register the setCredentials and clearCredentials tools on the MCP server. + + setCredentials accepts secret material as tool arguments and is only + registered for stdio transport. Under remote transports it is omitted + entirely; clearCredentials remains available since it only mutates + in-memory state. + """ + + transport = os.environ.get("BW_MCP_TRANSPORT", "stdio") + + if transport == "stdio": + @mcp.tool(name="setCredentials") + async def set_credentials( + client_id: str, + client_secret: str, + ) -> dict: + """Authenticate with Bandwidth using OAuth2 client credentials. + + Exchanges your client ID and secret for a Bearer token and discovers + your account ID automatically. Primarily for Build registration flows. + + For normal usage, add BW_CLIENT_ID and BW_CLIENT_SECRET to your MCP + server configuration so authentication happens at startup. + + Args: + client_id: Bandwidth API client ID (e.g. CLI-xxxxxxxx-xxxx-...) + client_secret: Bandwidth API client secret + """ + return await set_credentials_flow( + config=config, + client_id=client_id, + client_secret=client_secret, + ) + + @mcp.tool(name="clearCredentials") + async def clear_credentials() -> dict: + """Log out of Bandwidth by clearing stored credentials. + + Removes the client ID, client secret, access token, and account ID + from the current session. Authenticated API tools will return 401 + until you call setCredentials again. + """ + return clear_credentials_flow(config) diff --git a/src/tools/discovery.py b/src/tools/discovery.py new file mode 100644 index 0000000..b1ca587 --- /dev/null +++ b/src/tools/discovery.py @@ -0,0 +1,228 @@ +"""Account discovery tools — list applications, phone numbers, sites. + +The Numbers/Dashboard API is XML-based, so we can't use from_openapi. +These are hand-written tools that hit the XML endpoints directly and +return clean JSON for the agent. +""" + +from xml.etree.ElementTree import fromstring + +import httpx + +from urls import dashboard_api_base + + +async def _dashboard_get(config: dict, path: str) -> str: + """Make an authenticated GET to the Bandwidth Dashboard API.""" + token = config.get("BW_ACCESS_TOKEN") + if not token: + raise RuntimeError("Not authenticated. Set BW_CLIENT_ID and BW_CLIENT_SECRET.") + account_id = config.get("BW_ACCOUNT_ID") + if not account_id: + raise RuntimeError("No account ID. Authentication may have failed.") + + url = f"{dashboard_api_base()}/accounts/{account_id}/{path}" + async with httpx.AsyncClient(follow_redirects=True) as client: + resp = await client.get( + url, + headers={"Authorization": f"Bearer {token}", "Accept": "application/xml"}, + ) + resp.raise_for_status() + return resp.text + + +def _xml_text(el, tag, default=""): + """Extract text from an XML child element.""" + child = el.find(tag) + return child.text if child is not None and child.text else default + + +async def list_applications_flow(config: dict) -> dict: + """List voice and messaging applications on the account.""" + xml = await _dashboard_get(config, "applications") + root = fromstring(xml) + + apps = [] + for app_el in root.iter("Application"): + apps.append({ + "applicationId": _xml_text(app_el, "ApplicationId"), + "name": _xml_text(app_el, "AppName"), + "serviceType": _xml_text(app_el, "ServiceType"), + "callInitiatedCallbackUrl": _xml_text(app_el, "CallInitiatedCallbackUrl"), + "callStatusCallbackUrl": _xml_text(app_el, "CallStatusCallbackUrl"), + }) + + return {"applications": apps, "count": len(apps)} + + +async def list_phone_numbers_flow( + config: dict, + size: int = 100, + status: str = "Inservice", +) -> dict: + """List phone numbers on the account. + + The Dashboard API has two endpoints that return overlapping data, gated by + different credential roles: + - `/tns?accountId=…&status=…` requires the **Numbers** role + (CLI's primary choice — see cli/cmd/number/list.go) + - `/accounts/{id}/inserviceNumbers` requires the **inservice** role + (older endpoint; status-implicit "Inservice") + + Different creds get one, both, or neither. Try `/tns` first to match the + CLI; if it 403s, fall back to `inserviceNumbers` before reporting failure. + """ + account_id = config.get("BW_ACCOUNT_ID") + if not account_id: + raise RuntimeError("No account ID. Authentication may have failed.") + token = config.get("BW_ACCESS_TOKEN") + if not token: + raise RuntimeError("Not authenticated. Set BW_CLIENT_ID and BW_CLIENT_SECRET.") + + import httpx + + tns_url = ( + f"{dashboard_api_base()}/tns" + f"?accountId={account_id}&status={status}&size={size}&page=1" + ) + inservice_url = ( + f"{dashboard_api_base()}/accounts/{account_id}/inserviceNumbers?size={size}" + ) + + async def _fetch(url: str) -> httpx.Response: + async with httpx.AsyncClient(follow_redirects=True) as client: + return await client.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/xml", + }, + ) + + resp = await _fetch(tns_url) + if resp.status_code == 403: + # Cred lacks Numbers role — try the inservice path. + resp = await _fetch(inservice_url) + resp.raise_for_status() + + root = fromstring(resp.text) + + # Both endpoints expose either (tns) or + # (inserviceNumbers). Try both tag shapes. + numbers = [] + for fn_el in root.iter("FullNumber"): + if fn_el.text: + n = fn_el.text + if not n.startswith("+"): + n = f"+1{n}" if len(n) == 10 else f"+{n}" + numbers.append(n) + if not numbers: + for tn_el in root.iter("TelephoneNumber"): + if tn_el.text: + n = tn_el.text + if not n.startswith("+"): + n = f"+1{n}" if len(n) == 10 else f"+{n}" + numbers.append(n) + + total = _xml_text(root, "TotalCount", str(len(numbers))) + return {"numbers": numbers, "totalCount": int(total), "returned": len(numbers)} + + +async def create_application_flow( + config: dict, + name: str, + service_type: str = "Voice-V2", + callback_url: str = "", +) -> dict: + """Create a new Bandwidth application.""" + token = config.get("BW_ACCESS_TOKEN") + account_id = config.get("BW_ACCOUNT_ID") + if not token or not account_id: + raise RuntimeError("Not authenticated.") + + # Use the server's base URL for callbacks if available + base_url = config.get("BW_MCP_BASE_URL", callback_url) + if not callback_url and base_url: + callback_url = base_url + + # Build callback URLs based on service type + if service_type == "Voice-V2" and callback_url: + answer_url = f"{callback_url}/callbacks/voice/answer" + status_url = f"{callback_url}/callbacks/voice/disconnect" + else: + answer_url = callback_url or "https://example.com" + status_url = callback_url or "https://example.com" + + xml_body = f""" + {name} + {service_type} + {answer_url} + {status_url} + """ + + url = f"{dashboard_api_base()}/accounts/{account_id}/applications" + async with httpx.AsyncClient(follow_redirects=True) as client: + resp = await client.post( + url, + content=xml_body, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/xml", + }, + ) + resp.raise_for_status() + + root = fromstring(resp.text) + app_el = root.find(".//Application") + if app_el is None: + return {"error": "Failed to parse application response", "raw": resp.text} + + return { + "applicationId": _xml_text(app_el, "ApplicationId"), + "name": _xml_text(app_el, "AppName"), + "serviceType": _xml_text(app_el, "ServiceType"), + "callInitiatedCallbackUrl": _xml_text(app_el, "CallInitiatedCallbackUrl"), + "callStatusCallbackUrl": _xml_text(app_el, "CallStatusCallbackUrl"), + } + + +def register_discovery_tools(mcp, config: dict) -> None: + """Register account discovery tools on the MCP server.""" + + @mcp.tool(name="listApplications") + async def list_applications() -> dict: + """List all voice and messaging applications on your Bandwidth account. + + Returns application IDs, names, service types, and callback URLs. + Use this to find your voice application ID for createCall. + """ + return await list_applications_flow(config) + + @mcp.tool(name="listPhoneNumbers") + async def list_phone_numbers(size: int = 100, status: str = "Inservice") -> dict: + """List phone numbers on your Bandwidth account. + + Returns phone numbers in E.164 format. Use this to find a 'from' + number for createCall or createMessage. + + Args: + size: Maximum numbers to return (default 100). + status: Comma-separated statuses (Inservice, InAccount, Aging). + """ + return await list_phone_numbers_flow(config, size, status) + + @mcp.tool(name="createApplication") + async def create_application( + name: str, + service_type: str = "Voice-V2", + ) -> dict: + """Create a new Bandwidth application (voice or messaging). + + Creates the application with callback URLs automatically pointed + at this server. Use this if listApplications returns no Voice-V2 apps. + + Args: + name: A name for the application. + service_type: "Voice-V2" (default) or "Messaging-V2". + """ + return await create_application_flow(config, name, service_type) diff --git a/src/tools/voice.py b/src/tools/voice.py new file mode 100644 index 0000000..3b377d5 --- /dev/null +++ b/src/tools/voice.py @@ -0,0 +1,180 @@ +"""MCP tools for programmable voice: BXML generation and call response.""" + +from typing import Any +from xml.etree.ElementTree import Element, SubElement, tostring + +from event_store import EventStore + + +def _snake_to_camel(name: str) -> str: + parts = name.split("_") + return parts[0] + "".join(p.capitalize() for p in parts[1:]) + + +def _build_verb(verb: dict[str, Any], parent: Element) -> None: + verb_type = verb.get("type") + if not verb_type: + raise ValueError("Each verb must have a 'type' field") + + if verb_type == "SpeakSentence": + el = SubElement(parent, "SpeakSentence") + el.text = verb.get("text", "") + if "voice" in verb: + el.set("voice", verb["voice"]) + if "locale" in verb: + el.set("locale", verb["locale"]) + elif verb_type == "Gather": + el = SubElement(parent, "Gather") + for attr in [ + "input_type", + "max_wait_time", + "speech_timeout", + "max_digits", + "inter_digit_timeout", + "terminating_digits", + "first_digit_timeout", + "repeat_count", + "gather_url", + "gather_method", + ]: + if attr in verb: + el.set(_snake_to_camel(attr), str(verb[attr])) + for child_verb in verb.get("verbs", []): + _build_verb(child_verb, el) + elif verb_type == "Transfer": + el = SubElement(parent, "Transfer") + if "transfer_caller_id" in verb: + el.set("transferCallerId", verb["transfer_caller_id"]) + phone = SubElement(el, "PhoneNumber") + phone.text = verb.get("transfer_to", "") + elif verb_type == "Hangup": + SubElement(parent, "Hangup") + elif verb_type == "Pause": + el = SubElement(parent, "Pause") + if "duration" in verb: + el.set("duration", str(verb["duration"])) + elif verb_type == "Redirect": + el = SubElement(parent, "Redirect") + if "redirect_url" in verb: + el.set("redirectUrl", verb["redirect_url"]) + elif verb_type == "Record": + el = SubElement(parent, "Record") + for attr in [ + "max_duration", + "silence_timeout", + "callback_url", + "file_format", + "transcribe", + ]: + if attr in verb: + el.set(_snake_to_camel(attr), str(verb[attr])) + elif verb_type == "PlayAudio": + el = SubElement(parent, "PlayAudio") + el.text = verb.get("url", "") + elif verb_type == "Ring": + el = SubElement(parent, "Ring") + if "duration" in verb: + el.set("duration", str(verb["duration"])) + elif verb_type == "SendDtmf": + el = SubElement(parent, "SendDtmf") + el.text = verb.get("digits", "") + elif verb_type == "Bridge": + el = SubElement(parent, "Bridge") + el.set("targetCall", verb.get("target_call", "")) + elif verb_type == "StartRecording": + el = SubElement(parent, "StartRecording") + if "callback_url" in verb: + el.set("recordingAvailableUrl", verb["callback_url"]) + elif verb_type == "StopRecording": + SubElement(parent, "StopRecording") + elif verb_type == "StartTranscription": + el = SubElement(parent, "StartTranscription") + if "callback_url" in verb: + el.set("transcriptionAvailableUrl", verb["callback_url"]) + if "tracks" in verb: + el.set("tracks", verb["tracks"]) + elif verb_type == "StopTranscription": + SubElement(parent, "StopTranscription") + else: + raise ValueError(f"Unknown BXML verb: '{verb_type}'") + + +async def generate_bxml_flow( + verbs: list[dict[str, Any]], + auto_gather: bool = False, + gather_url: str = "", +) -> str: + root = Element("Response") + for verb in verbs: + if auto_gather and verb.get("type") == "SpeakSentence": + gather_verb = { + "type": "Gather", + "max_wait_time": 8, + "speech_timeout": 2, + "input_type": "speech dtmf", + "verbs": [verb], + } + if gather_url: + gather_verb["gather_url"] = gather_url + _build_verb(gather_verb, root) + else: + _build_verb(verb, root) + return tostring(root, encoding="unicode", xml_declaration=False) + + +async def respond_to_callback_flow( + event_store: EventStore, + call_id: str, + bxml: str, +) -> dict: + """Queue BXML for a call. Creates the call state if it doesn't exist yet + (allows pre-queuing BXML before the answer callback arrives).""" + call = event_store.get_call(call_id) + if not call: + # Pre-create call state so BXML is ready when the callback arrives + call = event_store.create_call(call_id, "", "", "") + if not call.try_set_bxml(bxml): + return {"error": "already_handled", "call_id": call_id} + call.add_turn("agent", "(BXML response queued)") + return {"status": "queued", "call_id": call_id} + + +def register_voice_tools(mcp, event_store: EventStore, config: dict = None) -> None: + @mcp.tool(name="generateBXML") + async def generate_bxml( + verbs: list[dict[str, Any]], + auto_gather: bool = True, + ) -> str: + """Generate valid Bandwidth XML (BXML) from verb descriptions. + + Each verb is a dict with 'type' and type-specific fields. Supported types: + SpeakSentence, Gather, Transfer, PlayAudio, Record, Pause, Hangup, + Redirect, Bridge, Ring, SendDtmf, StartRecording, StopRecording, + StartTranscription, StopTranscription. + + When auto_gather is True (default), top-level SpeakSentence verbs are + wrapped in Gather for barge-in support (caller can interrupt). + + Args: + verbs: List of verb descriptions. + auto_gather: Wrap SpeakSentence in Gather for barge-in. Default True. + """ + base_url = (config or {}).get("BW_MCP_BASE_URL", "") + gather_url = f"{base_url}/callbacks/voice/gather" if base_url else "" + return await generate_bxml_flow(verbs, auto_gather, gather_url) + + @mcp.tool(name="respondToCallback") + async def respond_to_callback(call_id: str, bxml: str) -> dict: + """Queue a BXML response for an active voice call. + + Use after reading a gather result from getCallbackEvents and generating + BXML via generateBXML. The next redirect for this call will deliver the BXML. + + First-write-wins: if another session already queued BXML for this call, + this call returns an error instead of overwriting. + + Args: + call_id: The call ID to respond to. + bxml: Valid BXML string (use generateBXML to produce this). + """ + return await respond_to_callback_flow(event_store, call_id, bxml) diff --git a/src/tunnel.py b/src/tunnel.py new file mode 100644 index 0000000..59da0d1 --- /dev/null +++ b/src/tunnel.py @@ -0,0 +1,82 @@ +"""Auto-tunnel for dev mode using cloudflared. + +Starts a Cloudflare quick tunnel so the MCP server's callback routes +are reachable from the internet without manual setup. For development +and testing only — production deployments should use a real host and +set BW_MCP_BASE_URL. +""" + +import subprocess +import time +import re +import atexit +import warnings +from typing import Optional + +_tunnel_process: Optional[subprocess.Popen] = None + + +def start_tunnel(port: int) -> Optional[str]: + """Start a cloudflared tunnel and return the public URL. + + Returns None if cloudflared isn't installed or tunnel fails to start. + """ + global _tunnel_process + + try: + # Check if cloudflared is available + subprocess.run( + ["cloudflared", "--version"], + capture_output=True, + check=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + warnings.warn( + "cloudflared not installed. Callback URLs won't work without a public URL. " + "Install with: brew install cloudflared (macOS) or see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/", + UserWarning, + ) + return None + + # Start the tunnel + _tunnel_process = subprocess.Popen( + ["cloudflared", "tunnel", "--url", f"http://localhost:{port}"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # Register cleanup + atexit.register(stop_tunnel) + + # Wait for the URL to appear in stderr + url = _wait_for_url(_tunnel_process, timeout=15) + if url: + print(f"Tunnel active: {url}") + else: + warnings.warn("Failed to start cloudflared tunnel — timed out waiting for URL.", UserWarning) + stop_tunnel() + + return url + + +def _wait_for_url(process: subprocess.Popen, timeout: int = 15) -> Optional[str]: + """Read cloudflared stderr until we find the tunnel URL.""" + start = time.time() + while time.time() - start < timeout: + if process.poll() is not None: + return None + line = process.stderr.readline().decode("utf-8", errors="replace") + # cloudflared prints the URL like: https://xxx-xxx.trycloudflare.com + match = re.search(r"(https://[a-zA-Z0-9-]+\.trycloudflare\.com)", line) + if match: + return match.group(1) + return None + + +def stop_tunnel() -> None: + """Stop the cloudflared tunnel if running.""" + global _tunnel_process + if _tunnel_process and _tunnel_process.poll() is None: + _tunnel_process.terminate() + _tunnel_process.wait(timeout=5) + _tunnel_process = None diff --git a/src/urls.py b/src/urls.py new file mode 100644 index 0000000..7f5f66f --- /dev/null +++ b/src/urls.py @@ -0,0 +1,114 @@ +"""Centralized API host resolution. + +Production hosts are the defaults. `BW_ENVIRONMENT=test` (or `uat`) flips the +API and Voice hosts to the test environment in one shot, matching the `band` +CLI. Each host can also be overridden individually via its env var +(`BW_API_URL`, `BW_VOICE_URL`, `BW_MESSAGING_URL`, `BW_MFA_URL`, +`BW_INSIGHTS_URL`); a per-host override wins over `BW_ENVIRONMENT`. + +The Dashboard XML API is served from the API gateway at `{api_base}/api/v2` +— same shape the CLI uses — so there is no separate `BW_DASHBOARD_URL`. + +`swap_host(url)` rewrites OpenAPI spec server URLs so the from_openapi tools +respect the same env vars. Without it, the bundled specs would pin every +generated tool to its hardcoded prod host. +""" + +from __future__ import annotations + +import os +from urllib.parse import urlparse, urlunparse + +_PROD = { + "api": "https://api.bandwidth.com", + "voice": "https://voice.bandwidth.com", + "messaging": "https://messaging.bandwidth.com", + "mfa": "https://mfa.bandwidth.com", + "insights": "https://insights.bandwidth.com", +} + +_TEST = { + "api": "https://test.api.bandwidth.com", + "voice": "https://test.voice.bandwidth.com", + "messaging": "https://messaging.bandwidth.com", + "mfa": "https://mfa.bandwidth.com", + "insights": "https://insights.bandwidth.com", +} + +_OVERRIDE_ENV = { + "api": "BW_API_URL", + "voice": "BW_VOICE_URL", + "messaging": "BW_MESSAGING_URL", + "mfa": "BW_MFA_URL", + "insights": "BW_INSIGHTS_URL", +} + +# Reverse map for swap_host: known prod hostnames → host key. Includes both +# the "split" hosts (voice.bandwidth.com) and api.bandwidth.com itself, since +# several specs (lookup, voice, TFV, end-user-management) declare api.bandwidth.com +# as their server. +_HOST_TO_KEY = { + "api.bandwidth.com": "api", + "voice.bandwidth.com": "voice", + "messaging.bandwidth.com": "messaging", + "mfa.bandwidth.com": "mfa", + "insights.bandwidth.com": "insights", +} + + +def _resolve(host: str) -> str: + override = os.environ.get(_OVERRIDE_ENV[host]) + if override: + return override.rstrip("/") + env = os.environ.get("BW_ENVIRONMENT", "").lower() + if env in ("test", "uat"): + return _TEST[host] + return _PROD[host] + + +def api_base() -> str: + return _resolve("api") + + +def voice_base() -> str: + return _resolve("voice") + + +def messaging_base() -> str: + return _resolve("messaging") + + +def mfa_base() -> str: + return _resolve("mfa") + + +def insights_base() -> str: + return _resolve("insights") + + +def oauth_token_url() -> str: + return f"{api_base()}/api/v1/oauth2/token" + + +def dashboard_api_base() -> str: + """Dashboard XML API base — served from the API gateway under /api/v2.""" + return f"{api_base()}/api/v2" + + +def swap_host(url: str) -> str: + """Rewrite a spec server URL so it honors BW_ENVIRONMENT / BW_*_URL. + + Takes a full URL (e.g. `https://api.bandwidth.com/v2`), looks up the + host in `_HOST_TO_KEY`, and rewrites it to whatever `_resolve` returns + for that host key. Path/query are preserved. Unknown hosts pass through + untouched. + """ + parsed = urlparse(url) + key = _HOST_TO_KEY.get(parsed.netloc) + if key is None: + return url + new_base = _resolve(key) + new_parsed = urlparse(new_base) + return urlunparse( + parsed._replace(scheme=new_parsed.scheme, netloc=new_parsed.netloc) + ) diff --git a/test/fixtures/build.yml b/test/fixtures/build.yml new file mode 100644 index 0000000..6d7c224 --- /dev/null +++ b/test/fixtures/build.yml @@ -0,0 +1,100 @@ +openapi: 3.0.3 +info: + title: Bandwidth Build Registration + version: 1.0.0 + description: | + Registration API for new Bandwidth Build accounts. + + This API only kicks off registration — every subsequent step (SMS phone + verification, password set, API credential generation) happens in the + user's browser via links Bandwidth emails them. The agent must not try + to consume the SMS code or email OTP itself, or the user's browser flow + will break. + + After createRegistration returns, the user must: + + 1. Enter the SMS OTP that arrives at the registered phone — in the + Bandwidth signup page, not back into chat. + 2. Open the registration email and set a password (the page asks for + a second OTP delivered to the same email). + 3. In the Bandwidth App, go to Account > API Credentials and generate + OAuth2 credentials. + 4. Paste those credentials back into chat — the agent then calls + setCredentials to unlock authenticated tools. +servers: + - url: https://api.bandwidth.com/v1/express + description: Production +security: [] +paths: + /registration: + post: + operationId: createRegistration + summary: Initialize a new Build account registration + description: | + Submits a Build account registration. Bandwidth then sends the user + an SMS OTP (entered in the signup page) and an email with a + password-set link. The agent's job ends here — do not attempt to + complete verification via the API. + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/registerRequest' + responses: + '200': + description: Registration created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/registerResponse' +components: + schemas: + registerRequest: + type: object + required: + - phoneNumber + - email + - firstName + - lastName + additionalProperties: false + properties: + phoneNumber: + type: string + description: US phone number in E.164 format. Receives the SMS verification code the user enters in the signup page. + example: "+19195551234" + email: + type: string + description: Customer email address. Receives the password-set link that completes signup. + example: "jane@example.com" + firstName: + type: string + description: Customer's first name + example: "Jane" + lastName: + type: string + description: Customer's last name + example: "Doe" + registerResponse: + type: object + properties: + links: + type: array + items: {} + data: + type: object + properties: + message: + type: string + example: "Onboarding request received successfully" + status: + type: string + example: "USER_CREATION_PENDING" + registrationId: + type: string + format: uuid + example: "5fce253e-fdbc-433f-829b-6839d1edbebe" + errors: + type: array + items: {} diff --git a/test/fixtures/multi-factor-auth.yml b/test/fixtures/multi-factor-auth.yml deleted file mode 100644 index acc50f2..0000000 --- a/test/fixtures/multi-factor-auth.yml +++ /dev/null @@ -1,311 +0,0 @@ -openapi: 3.0.3 -info: - title: Multi-Factor Authentication - description: |- - Bandwidth's Two-Factor Authentication service - - ## Base Path - - https://mfa.bandwidth.com/api/v1 - contact: - name: Bandwidth Support - email: support@bandwidth.com - url: https://support.bandwidth.com - termsOfService: https://www.bandwidth.com/legal/terms-of-use-bandwidthcom-web-sites/ - version: 3.1.0 -servers: - - url: https://mfa.bandwidth.com/api/v1 - description: Production -paths: - /accounts/{accountId}/code/voice: - post: - tags: - - MFA - summary: Voice Authentication Code - description: Send an MFA Code via a phone call. - operationId: generateVoiceCode - parameters: - - $ref: '#/components/parameters/accountId' - requestBody: - $ref: '#/components/requestBodies/codeRequest' - responses: - '200': - $ref: '#/components/responses/voiceCodeResponse' - '400': - $ref: '#/components/responses/mfaBadRequestError' - '401': - $ref: '#/components/responses/mfaUnauthorizedError' - '403': - $ref: '#/components/responses/mfaForbiddenError' - '500': - $ref: '#/components/responses/mfaInternalServerError' - /accounts/{accountId}/code/messaging: - post: - tags: - - MFA - summary: Messaging Authentication Code - description: Send an MFA code via text message (SMS). - operationId: generateMessagingCode - parameters: - - $ref: '#/components/parameters/accountId' - requestBody: - $ref: '#/components/requestBodies/codeRequest' - responses: - '200': - $ref: '#/components/responses/messagingCodeResponse' - '400': - $ref: '#/components/responses/mfaBadRequestError' - '401': - $ref: '#/components/responses/mfaUnauthorizedError' - '403': - $ref: '#/components/responses/mfaForbiddenError' - '500': - $ref: '#/components/responses/mfaInternalServerError' - /accounts/{accountId}/code/verify: - post: - tags: - - MFA - summary: Verify Authentication Code - description: Verify a previously sent MFA code. - operationId: verifyCode - parameters: - - $ref: '#/components/parameters/accountId' - requestBody: - $ref: '#/components/requestBodies/codeVerify' - responses: - '200': - $ref: '#/components/responses/verifyCodeResponse' - '400': - $ref: '#/components/responses/mfaBadRequestError' - '401': - $ref: '#/components/responses/mfaUnauthorizedError' - '403': - $ref: '#/components/responses/mfaForbiddenError' - '429': - $ref: '#/components/responses/mfaTooManyRequestsError' - '500': - $ref: '#/components/responses/mfaInternalServerError' -components: - schemas: - codeRequest: - type: object - properties: - to: - type: string - description: The phone number to send the mfa code to. - pattern: '^\+[1-9]\d{1,14}$' - example: '+19195551234' - from: - type: string - description: The application phone number, the sender of the mfa code. - pattern: '^\+[1-9]\d{1,14}$' - maxLength: 32 - example: '+19195554321' - applicationId: - type: string - description: The application unique ID, obtained from Bandwidth. - maxLength: 50 - example: 66fd98ae-ac8d-a00f-7fcd-ba3280aeb9b1 - scope: - type: string - description: >- - An optional field to denote what scope or action the mfa code is - addressing. If not supplied, defaults to "2FA". - maxLength: 25 - example: 2FA - message: - type: string - description: >- - The message format of the mfa code. There are three values that the - system will replace "{CODE}", "{NAME}", "{SCOPE}". The "{SCOPE}" - and "{NAME} value template are optional, while "{CODE}" must be - supplied. As the name would suggest, code will be replace with the - actual mfa code. Name is replaced with the application name, - configured during provisioning of mfa. The scope value is the same - value sent during the call and partitioned by the server. - maxLength: 2048 - example: 'Your temporary {NAME} {SCOPE} code is {CODE}' - digits: - type: integer - description: >- - The number of digits for your mfa code. The valid number ranges - from 2 to 8, inclusively. - minimum: 4 - maximum: 8 - example: 6 - required: - - to - - from - - applicationId - - message - - digits - voiceCodeResponse: - type: object - properties: - callId: - type: string - description: Programmable Voice API Call ID. - example: c-15ac29a2-1331029c-2cb0-4a07-b215-b22865662d85 - messagingCodeResponse: - type: object - properties: - messageId: - type: string - description: Messaging API Message ID. - example: 1589228074636lm4k2je7j7jklbn2 - verifyCodeRequest: - type: object - properties: - to: - type: string - description: The phone number to send the mfa code to. - pattern: '^\+[1-9]\d{1,14}$' - example: '+19195551234' - scope: - type: string - description: >- - An optional field to denote what scope or action the mfa code is - addressing. If not supplied, defaults to "2FA". - example: 2FA - expirationTimeInMinutes: - type: number - description: >- - The time period, in minutes, to validate the mfa code. By setting - this to 3 minutes, it will mean any code generated within the last 3 - minutes are still valid. The valid range for expiration time is - between 0 and 15 minutes, exclusively and inclusively, respectively. - minimum: 1 - maximum: 15 - example: 3 - code: - type: string - description: The generated mfa code to check if valid. - minLength: 4 - maxLength: 8 - example: '123456' - required: - - to - - expirationTimeInMinutes - - code - verifyCodeResponse: - type: object - properties: - valid: - type: boolean - description: Whether or not the supplied code is valid. - example: true - mfaRequestError: - type: object - properties: - error: - type: string - description: A message describing the error with your request. - example: 400 Request is malformed or invalid - requestId: - type: string - description: The associated requestId from AWS. - example: 354cc8a3-6701-461e-8fa7-8671703dd898 - mfaUnauthorizedRequestError: - type: object - properties: - message: - type: string - description: Unauthorized - example: Unauthorized - mfaForbiddenRequestError: - type: object - properties: - message: - type: string - description: The message containing the reason behind the request being forbidden. - example: Missing Authentication Token - responses: - voiceCodeResponse: - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/voiceCodeResponse' - messagingCodeResponse: - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/messagingCodeResponse' - verifyCodeResponse: - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/verifyCodeResponse' - mfaBadRequestError: - description: Bad Request - headers: {} - content: - application/json: - schema: - $ref: '#/components/schemas/mfaRequestError' - mfaUnauthorizedError: - description: Unauthorized - headers: {} - content: - application/json: - schema: - $ref: '#/components/schemas/mfaUnauthorizedRequestError' - mfaForbiddenError: - description: Forbidden - headers: {} - content: - application/json: - schema: - $ref: '#/components/schemas/mfaForbiddenRequestError' - mfaTooManyRequestsError: - description: Too Many Requests - headers: {} - content: - application/json: - schema: - $ref: '#/components/schemas/mfaRequestError' - mfaInternalServerError: - description: Internal Server Error - headers: {} - content: - application/json: - schema: - $ref: '#/components/schemas/mfaRequestError' - parameters: - accountId: - in: path - name: accountId - required: true - schema: - type: string - description: Your Bandwidth Account ID. - example: "9900000" - requestBodies: - codeRequest: - description: MFA code request body. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/codeRequest' - codeVerify: - description: MFA code verify request body. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/verifyCodeRequest' - securitySchemes: - Basic: - type: http - scheme: basic - description: >- - Basic authentication is a simple authentication scheme built into the HTTP protocol. To use it, send your HTTP requests with an Authorization header that contains the word Basic followed by a space and a base64-encoded string `username:password`. - - Example: `Authorization: Basic ZGVtbZpwQDU1dzByZA==` -security: - - Basic: [] -tags: - - name: MFA diff --git a/test/fixtures/numbers.yml b/test/fixtures/numbers.yml new file mode 100644 index 0000000..8d28ee1 --- /dev/null +++ b/test/fixtures/numbers.yml @@ -0,0 +1,7 @@ +openapi: 3.0.3 +info: + title: Bandwidth Test Fixture + version: 1.0.0 +servers: + - url: https://api.bandwidth.com/v2 +paths: {} diff --git a/test/fixtures/toll-free-verification.yml b/test/fixtures/toll-free-verification.yml new file mode 100644 index 0000000..8d28ee1 --- /dev/null +++ b/test/fixtures/toll-free-verification.yml @@ -0,0 +1,7 @@ +openapi: 3.0.3 +info: + title: Bandwidth Test Fixture + version: 1.0.0 +servers: + - url: https://api.bandwidth.com/v2 +paths: {} diff --git a/test/fixtures/voice.yml b/test/fixtures/voice.yml new file mode 100644 index 0000000..8d28ee1 --- /dev/null +++ b/test/fixtures/voice.yml @@ -0,0 +1,7 @@ +openapi: 3.0.3 +info: + title: Bandwidth Test Fixture + version: 1.0.0 +servers: + - url: https://api.bandwidth.com/v2 +paths: {} diff --git a/test/test_build.py b/test/test_build.py new file mode 100644 index 0000000..a42b188 --- /dev/null +++ b/test/test_build.py @@ -0,0 +1,78 @@ +import pytest +from utils import create_mock, tool_map, server_client +from src.servers import _create_server + + +@pytest.mark.asyncio +async def test_build_server_has_one_tool(httpx_mock): + """Build Registration API should expose exactly 1 tool — createRegistration.""" + create_mock(httpx_mock, "build") + + server = await _create_server( + url="https://dev.bandwidth.com/spec/build.yml", + config={}, + + ) + tools = await tool_map(server) + assert len(tools) == 1 + + +@pytest.mark.asyncio +async def test_build_server_tool_name(httpx_mock): + """Build registration should expose only createRegistration — SMS/email verification happens in the browser.""" + create_mock(httpx_mock, "build") + + server = await _create_server( + url="https://dev.bandwidth.com/spec/build.yml", + config={}, + + ) + tools = await tool_map(server) + tool_names = sorted(tools.keys()) + assert tool_names == ["createRegistration"] + + +@pytest.mark.asyncio +async def test_build_server_no_auth_header(httpx_mock): + """Build Registration API requires no auth — server should work without credentials.""" + create_mock(httpx_mock, "build") + + server = await _create_server( + url="https://dev.bandwidth.com/spec/build.yml", + config={}, + + ) + tools = await tool_map(server) + assert len(tools) == 1 + client = await server_client(server) + assert "authorization" not in { + k.lower() for k in client.headers.keys() + }, "Build registration server should not have an Authorization header" + + +@pytest.mark.asyncio +async def test_create_registration_tool_parameters(httpx_mock): + """createRegistration tool should require phoneNumber, email, firstName, lastName.""" + create_mock(httpx_mock, "build") + + server = await _create_server( + url="https://dev.bandwidth.com/spec/build.yml", + config={}, + + ) + tools = await tool_map(server) + create_reg = tools["createRegistration"] + + params = create_reg.parameters + assert params is not None + assert "properties" in params + assert "phoneNumber" in params["properties"] + assert "email" in params["properties"] + assert "firstName" in params["properties"] + assert "lastName" in params["properties"] + assert set(params.get("required", [])) == { + "phoneNumber", + "email", + "firstName", + "lastName", + } diff --git a/test/test_bxml.py b/test/test_bxml.py new file mode 100644 index 0000000..93830da --- /dev/null +++ b/test/test_bxml.py @@ -0,0 +1,155 @@ +import pytest +from xml.etree.ElementTree import fromstring +from src.tools.voice import generate_bxml_flow + + +@pytest.mark.asyncio +async def test_speak_sentence(): + result = await generate_bxml_flow( + [{"type": "SpeakSentence", "text": "Hello world"}] + ) + assert " & "quotes"'}] + ) + fromstring(result) diff --git a/test/test_callback_tools.py b/test/test_callback_tools.py new file mode 100644 index 0000000..1b31cb2 --- /dev/null +++ b/test/test_callback_tools.py @@ -0,0 +1,128 @@ +import pytest +from fastmcp import FastMCP +from src.event_store import EventStore +from src.tools.callbacks import register_callback_tools +from utils import tool_map + + +@pytest.fixture +def event_store(): + return EventStore(max_events=100, ttl_seconds=3600) + + +@pytest.fixture +def mcp_with_callbacks(event_store): + mcp = FastMCP(name="Test") + register_callback_tools(mcp, event_store) + return mcp + + +@pytest.mark.asyncio +async def test_callback_tools_registered(mcp_with_callbacks): + tools = await tool_map(mcp_with_callbacks) + assert "getInboundMessages" in tools + assert "getCallbackEvents" in tools + + +@pytest.mark.asyncio +async def test_get_inbound_messages(event_store): + from src.tools.callbacks import get_inbound_messages_flow + + event_store.push( + "messaging.inbound", + "+19195551234", + {"message": {"text": "hi", "from": "+19195551234"}}, + ) + event_store.push( + "messaging.inbound", + "+19195559999", + {"message": {"text": "other", "from": "+19195559999"}}, + ) + result = await get_inbound_messages_flow(event_store) + assert len(result["events"]) == 2 + result = await get_inbound_messages_flow(event_store, phone_number="+19195551234") + assert len(result["events"]) == 1 + assert result["events"][0]["message"]["text"] == "hi" + + +@pytest.mark.asyncio +async def test_get_callback_events(event_store): + from src.tools.callbacks import get_callback_events_flow + + event_store.push("messaging.inbound", "+19195551234", {"type": "message-received"}) + event_store.push("voice.gather", "call-1", {"type": "gather"}) + result = await get_callback_events_flow(event_store, event_type="voice.gather") + assert len(result["events"]) == 1 + assert result["events"][0]["type"] == "gather" + + +def _app_xml(service_type: str) -> str: + return ( + "" + "app-1" + f"{service_type}" + "X" + "" + ) + + +@pytest.mark.asyncio +async def test_configure_callbacks_voice_app_sets_only_voice_fields(httpx_mock): + """Regression: configureCallbacks with the both-types default must not send + messaging fields to a Voice-V2 app — the API rejects that with 400/12962.""" + from src.tools.callbacks import configure_callbacks_flow + + base = "https://api.bandwidth.com/api/v2" + url = f"{base}/accounts/123/applications/app-1" + httpx_mock.add_response(method="GET", url=url, text=_app_xml("Voice-V2")) + httpx_mock.add_response(method="PUT", url=url, text="", status_code=200) + + cfg = {"BW_ACCESS_TOKEN": "t", "BW_ACCOUNT_ID": "123"} + res = await configure_callbacks_flow( + cfg, "app-1", "https://srv.example.com", types=["voice", "messaging"] + ) + + assert res["status"] == "configured" + assert set(res["callbacks"]) == {"callInitiatedCallbackUrl", "callStatusCallbackUrl"} + put_body = [r for r in httpx_mock.get_requests() if r.method == "PUT"][0].content.decode() + # Messaging tags must be absent (angle-bracketed to avoid matching the + # voice tags, which contain "CallbackUrl" as a substring). + assert "" not in put_body + assert "" not in put_body + assert "" in put_body + + +@pytest.mark.asyncio +async def test_configure_callbacks_messaging_app_sets_only_messaging_fields(httpx_mock): + from src.tools.callbacks import configure_callbacks_flow + + base = "https://api.bandwidth.com/api/v2" + url = f"{base}/accounts/123/applications/app-1" + httpx_mock.add_response(method="GET", url=url, text=_app_xml("Messaging-V2")) + httpx_mock.add_response(method="PUT", url=url, text="", status_code=200) + + cfg = {"BW_ACCESS_TOKEN": "t", "BW_ACCOUNT_ID": "123"} + res = await configure_callbacks_flow( + cfg, "app-1", "https://srv.example.com", types=["voice", "messaging"] + ) + + assert res["status"] == "configured" + assert set(res["callbacks"]) == {"callbackUrl", "statusCallbackUrl"} + put_body = [r for r in httpx_mock.get_requests() if r.method == "PUT"][0].content.decode() + assert "" not in put_body + assert "" not in put_body + assert "" in put_body + + +@pytest.mark.asyncio +async def test_configure_callbacks_unknown_service_type_errors(httpx_mock): + from src.tools.callbacks import configure_callbacks_flow + + base = "https://api.bandwidth.com/api/v2" + url = f"{base}/accounts/123/applications/app-1" + httpx_mock.add_response(method="GET", url=url, text=_app_xml("Mystery-V9")) + + cfg = {"BW_ACCESS_TOKEN": "t", "BW_ACCOUNT_ID": "123"} + res = await configure_callbacks_flow(cfg, "app-1", "https://srv.example.com") + assert "error" in res + assert "Mystery-V9" in res["error"] diff --git a/test/test_callbacks.py b/test/test_callbacks.py new file mode 100644 index 0000000..0cc3760 --- /dev/null +++ b/test/test_callbacks.py @@ -0,0 +1,108 @@ +import pytest +from fastmcp import FastMCP +from starlette.testclient import TestClient +from src.callbacks import register_callback_routes +from src.event_store import EventStore + + +@pytest.fixture +def event_store(): + return EventStore(max_events=100, ttl_seconds=3600) + + +@pytest.fixture +def client(event_store): + mcp = FastMCP(name="Callback Test") + register_callback_routes(mcp, event_store) + app = mcp.http_app() + return TestClient(app) + + +class TestMessagingCallbacks: + def test_inbound_message(self, client, event_store): + payload = [ + { + "type": "message-received", + "message": { + "from": "+19195551234", + "to": ["+19195554321"], + "text": "Hello from tests", + "id": "msg-abc123", + }, + } + ] + response = client.post("/callbacks/messaging/inbound", json=payload) + assert response.status_code == 200 + events = event_store.get_events("messaging.inbound") + assert len(events) == 1 + assert events[0]["message"]["text"] == "Hello from tests" + + def test_message_status(self, client, event_store): + payload = [ + { + "type": "message-delivered", + "message": {"id": "msg-abc123"}, + } + ] + response = client.post("/callbacks/messaging/status", json=payload) + assert response.status_code == 200 + events = event_store.get_events("messaging.status") + assert len(events) == 1 + + +class TestVoiceCallbacks: + def test_answer_callback_returns_redirect(self, client, event_store): + payload = { + "eventType": "answer", + "callId": "call-123", + "from": "+19195551234", + "to": "+19195554321", + "applicationId": "app-1", + } + response = client.post("/callbacks/voice/answer", json=payload) + assert response.status_code == 200 + assert "application/xml" in response.headers["content-type"] + assert "" + bxml = call.consume_pending_bxml() + assert bxml == "" + assert call.pending_bxml is None + + def test_consume_pending_bxml_returns_none_when_empty(self): + self.store.create_call("call-123", "+11111111111", "+12222222222", "app-1") + call = self.store.get_call("call-123") + assert call.consume_pending_bxml() is None + + def test_first_write_wins_for_bxml(self): + self.store.create_call("call-123", "+11111111111", "+12222222222", "app-1") + call = self.store.get_call("call-123") + assert call.try_set_bxml("") is True + assert ( + call.try_set_bxml( + "Too late" + ) + is False + ) + assert "Hangup" in call.pending_bxml + + def test_remove_call(self): + self.store.create_call("call-123", "+11111111111", "+12222222222", "app-1") + self.store.remove_call("call-123") + assert self.store.get_call("call-123") is None diff --git a/test/test_hosted_safety.py b/test/test_hosted_safety.py new file mode 100644 index 0000000..11fbf6d --- /dev/null +++ b/test/test_hosted_safety.py @@ -0,0 +1,71 @@ +"""Tests for hosted-mode safety defaults.""" + +import pytest +from fastmcp import FastMCP +from utils import tool_map + + +@pytest.mark.asyncio +async def test_set_credentials_registered_when_transport_unset(monkeypatch): + monkeypatch.delenv("BW_MCP_TRANSPORT", raising=False) + from src.tools.credentials import register_credentials_tools + + mcp = FastMCP(name="Test") + register_credentials_tools(mcp, {}) + tools = await tool_map(mcp) + assert "setCredentials" in tools + assert "clearCredentials" in tools + + +@pytest.mark.asyncio +async def test_set_credentials_registered_when_transport_stdio(monkeypatch): + monkeypatch.setenv("BW_MCP_TRANSPORT", "stdio") + from src.tools.credentials import register_credentials_tools + + mcp = FastMCP(name="Test") + register_credentials_tools(mcp, {}) + tools = await tool_map(mcp) + assert "setCredentials" in tools + assert "clearCredentials" in tools + + +@pytest.mark.asyncio +async def test_set_credentials_not_registered_for_streamable_http(monkeypatch): + monkeypatch.setenv("BW_MCP_TRANSPORT", "streamable-http") + from src.tools.credentials import register_credentials_tools + + mcp = FastMCP(name="Test") + register_credentials_tools(mcp, {}) + tools = await tool_map(mcp) + assert "setCredentials" not in tools + assert "clearCredentials" in tools + + +@pytest.mark.asyncio +async def test_set_credentials_not_registered_for_sse(monkeypatch): + monkeypatch.setenv("BW_MCP_TRANSPORT", "sse") + from src.tools.credentials import register_credentials_tools + + mcp = FastMCP(name="Test") + register_credentials_tools(mcp, {}) + tools = await tool_map(mcp) + assert "setCredentials" not in tools + assert "clearCredentials" in tools + + +def test_transport_config_host_default(monkeypatch): + """Default host binds to loopback only.""" + monkeypatch.delenv("BW_MCP_HOST", raising=False) + from src.config import get_transport_config + + cfg = get_transport_config() + assert cfg["host"] == "127.0.0.1" + + +def test_transport_config_host_explicit(monkeypatch): + """An explicit BW_MCP_HOST is honored.""" + monkeypatch.setenv("BW_MCP_HOST", "0.0.0.0") + from src.config import get_transport_config + + cfg = get_transport_config() + assert cfg["host"] == "0.0.0.0" diff --git a/test/test_instructions.py b/test/test_instructions.py new file mode 100644 index 0000000..24759ef --- /dev/null +++ b/test/test_instructions.py @@ -0,0 +1,70 @@ +import pytest +from src.instructions import build_instructions + + +def test_build_instructions_includes_header(): + """Instructions always start with the server identity.""" + result = build_instructions(config={}, loaded_tools=[]) + assert "Bandwidth MCP Server" in result + + +def test_build_instructions_includes_messaging_when_tools_loaded(): + """Messaging section appears when messaging tools are loaded.""" + result = build_instructions( + config={}, loaded_tools=["createMessage", "listMessages"] + ) + assert "createMessage" in result + assert "SMS" in result or "send" in result.lower() + + +def test_build_instructions_excludes_messaging_when_no_tools(): + """Messaging section absent when no messaging tools loaded.""" + result = build_instructions(config={}, loaded_tools=["createLookup"]) + assert "createMessage" not in result + + +def test_build_instructions_includes_no_credentials_warning(): + """Warning appears when no username in config.""" + result = build_instructions(config={}, loaded_tools=[]) + assert "setCredentials" in result or "Build Registration" in result + + +def test_build_instructions_no_warning_when_credentials_present(): + """No credential warning when access token is set.""" + result = build_instructions( + config={"BW_ACCESS_TOKEN": "bearer-token-here"}, + loaded_tools=["createMessage"], + ) + assert "no credentials" not in result.lower() + + +def test_build_instructions_includes_lookup_section(): + """Lookup section appears when lookup tools loaded.""" + result = build_instructions( + config={}, loaded_tools=["createLookup", "getLookupStatus"] + ) + assert "createLookup" in result + assert "getLookupStatus" in result + + +def test_build_instructions_includes_voice_section(): + """Voice section appears when voice tools loaded.""" + result = build_instructions(config={}, loaded_tools=["createCall", "generateBXML"]) + assert "createCall" in result + assert "BXML" in result + + +def test_build_instructions_includes_callback_section(): + """Callback section appears when callback tools loaded.""" + result = build_instructions( + config={}, loaded_tools=["getCallbackEvents", "getInboundMessages"] + ) + assert "getCallbackEvents" in result + + +def test_build_instructions_includes_error_patterns(): + """Error patterns section is always included.""" + result = build_instructions(config={}, loaded_tools=[]) + assert "401" in result + assert "422" in result + assert "E.164" in result diff --git a/test/test_integration.py b/test/test_integration.py new file mode 100644 index 0000000..b7ebbc6 --- /dev/null +++ b/test/test_integration.py @@ -0,0 +1,72 @@ +"""Integration tests verifying the full MCP server setup.""" + +import pytest +from unittest.mock import AsyncMock, patch +from fastmcp import FastMCP +from pytest_httpx import HTTPXMock +from utils import create_mock, tool_map + + +@pytest.mark.asyncio +async def test_instructions_set_after_setup(httpx_mock: HTTPXMock, monkeypatch): + """After setup, mcp.instructions is set and contains relevant content.""" + monkeypatch.setenv("BW_CLIENT_ID", "CLI-test") + monkeypatch.setenv("BW_CLIENT_SECRET", "test-secret") + + mock_token = { + "access_token": "test-bearer-token", + "accounts": ["12345"], + "token_type": "bearer", + } + + for name in [ + "messaging", + "phone-number-lookup-v2", + "insights", + "end-user-management", + "voice", + "toll-free-verification", + ]: + create_mock(httpx_mock, name) + + with patch("oauth.get_oauth_token", new_callable=AsyncMock) as mock_oauth: + mock_oauth.return_value = mock_token + from src.app import lifespan + + test_mcp = FastMCP(name="Integration Test") + async with lifespan(test_mcp): + assert test_mcp.instructions is not None + assert "Bandwidth MCP Server" in test_mcp.instructions + assert "createMessage" in test_mcp.instructions + + +@pytest.mark.asyncio +async def test_callback_tools_available_after_setup(httpx_mock: HTTPXMock, monkeypatch): + """Callback and voice tools are registered after setup.""" + monkeypatch.delenv("BW_CLIENT_ID", raising=False) + monkeypatch.delenv("BW_CLIENT_SECRET", raising=False) + + # Reset module-level config from previous test + import src.app + src.app._config.clear() + + for name in [ + "messaging", + "phone-number-lookup-v2", + "insights", + "end-user-management", + "voice", + "toll-free-verification", + ]: + create_mock(httpx_mock, name) + + from src.app import lifespan + + test_mcp = FastMCP(name="Integration Test") + async with lifespan(test_mcp): + tools = await tool_map(test_mcp) + assert "getInboundMessages" in tools + assert "getCallbackEvents" in tools + assert "generateBXML" in tools + assert "respondToCallback" in tools + assert "setCredentials" in tools diff --git a/test/test_profiles.py b/test/test_profiles.py new file mode 100644 index 0000000..4fe78cd --- /dev/null +++ b/test/test_profiles.py @@ -0,0 +1,61 @@ +import pytest +from src.profiles import resolve_profile, DEFAULT_TOOLS + + +def test_resolve_single_profile(): + tools = resolve_profile("messaging") + assert "createMessage" in tools + assert "listMessages" in tools + + +def test_resolve_combined_profiles(): + tools = resolve_profile("messaging,lookup") + assert "createMessage" in tools + assert "createSyncLookup" in tools + + +def test_resolve_full_profile(): + tools = resolve_profile("full") + assert tools is None + + +def test_resolve_unknown_profile_raises(): + with pytest.raises(ValueError, match="Unknown profile"): + resolve_profile("nonexistent") + + +def test_resolve_none_returns_none(): + tools = resolve_profile(None) + assert tools is None + + +def test_resolve_empty_string_returns_none(): + tools = resolve_profile("") + assert tools is None + + +def test_voice_profile(): + tools = resolve_profile("voice") + assert "createCall" in tools + assert "generateBXML" in tools + assert "getCallbackEvents" in tools + + +def test_onboarding_profile(): + tools = resolve_profile("onboarding") + assert "createRegistration" in tools + assert "setCredentials" in tools + + +def test_always_tools_included(): + """setCredentials and clearCredentials are always included.""" + tools = resolve_profile("lookup") + assert "setCredentials" in tools + assert "clearCredentials" in tools + + +def test_default_tools_include_core(): + """Default tools include voice and messaging.""" + assert "createCall" in DEFAULT_TOOLS + assert "createMessage" in DEFAULT_TOOLS + assert "generateBXML" in DEFAULT_TOOLS diff --git a/test/test_servers.py b/test/test_servers.py index f7a6932..c20e4ef 100644 --- a/test/test_servers.py +++ b/test/test_servers.py @@ -1,14 +1,14 @@ import pytest from fastmcp import FastMCP from pytest_httpx import HTTPXMock -from utils import create_mock +from utils import create_mock, tool_map, server_client from src.servers import create_bandwidth_mcp, _create_server async def create_mcp_server(name=None, tools=None, excluded_tools=None): """Fixture to create and return a FastMCP instance.""" mcp = FastMCP(name=name or "Test MCP") - config = {"BW_USERNAME": "test_user", "BW_PASSWORD": "test_pass"} + config = {"BW_ACCESS_TOKEN": "test-bearer-token"} enabled_tools = tools if tools is not None else [] excluded_tools = excluded_tools if excluded_tools is not None else [] @@ -17,15 +17,6 @@ async def create_mcp_server(name=None, tools=None, excluded_tools=None): return mcp -def calculate_expected_tools(tools, excluded_tools, total_tools=47): - if tools and not excluded_tools: - return len(tools) - elif excluded_tools: - return total_tools - len(excluded_tools) - else: - return total_tools - - server_configuration_list = [ ([], []), ([], ["getReports", "createReport"]), @@ -40,55 +31,42 @@ def calculate_expected_tools(tools, excluded_tools, total_tools=47): async def test_full_mcp_server_creation(tools, excluded_tools, httpx_mock: HTTPXMock): """Test that the MCP server is created correctly with included and excluded tools.""" - expected_tools = calculate_expected_tools(tools, excluded_tools) - name = f"Test MCP with {expected_tools} Tools" - for name in [ "messaging", - "multi-factor-auth", "phone-number-lookup-v2", "insights", "end-user-management", + "voice", + "toll-free-verification", ]: create_mock(httpx_mock, name) - mcp = await create_mcp_server(name, tools, excluded_tools) - mcp_tools = await mcp.get_tools() + mcp = await create_mcp_server("Test MCP", tools, excluded_tools) + mcp_tools = await tool_map(mcp) mcp_tool_names = list(mcp_tools.keys()) - mcp_resources = await mcp.get_resources() + mcp_resources = await mcp.list_resources() assert isinstance(mcp, FastMCP) - assert mcp.name == name, f"Expected MCP name '{name}', got '{mcp.name}'" - assert ( - len(mcp_tools) == expected_tools - ), f"Expected {expected_tools} tools, got {len(mcp_tools)}" - assert len(mcp_resources) == 2, f"Expected 2 resources, got {len(mcp_resources)}" + assert len(mcp_tools) > 0, "Should have at least some tools loaded" + assert len(mcp_resources) == 3, f"Expected 3 resources, got {len(mcp_resources)}" if excluded_tools: for tool in excluded_tools: - assert ( - tool not in mcp_tool_names - ), f"Excluded tool {tool} should not be present" + assert tool not in mcp_tool_names, f"Excluded tool {tool} should not be present" if tools and not excluded_tools: + assert len(mcp_tools) == len(tools), f"Expected {len(tools)} tools, got {len(mcp_tools)}" for tool in tools: assert tool in mcp_tool_names, f"Enabled tool {tool} should be present" spec_list = [ - ( - "https://dev.bandwidth.com/spec/multi-factor-auth.yml", - {"BW_USERNAME": "test_user_mfa", "BW_PASSWORD": "test_pass_mfa"}, - "https://mfa.bandwidth.com/api/v1/", - {"generateMessagingCode", "generateVoiceCode", "verifyCode"}, - "Basic dGVzdF91c2VyX21mYTp0ZXN0X3Bhc3NfbWZh", - ), ( "https://dev.bandwidth.com/spec/phone-number-lookup-v2.yml", - {"BW_USERNAME": "test_user_tnlookup", "BW_PASSWORD": "test_pass_tnlookup"}, + {"BW_ACCESS_TOKEN": "test-token-lookup"}, "https://api.bandwidth.com/v2/", {"createSyncLookup", "createAsyncBulkLookup", "getAsyncBulkLookup"}, - "Basic dGVzdF91c2VyX3RubG9va3VwOnRlc3RfcGFzc190bmxvb2t1cA==", + "Bearer test-token-lookup", ), ] @@ -103,10 +81,10 @@ async def test_individual_mcp_server_creation( """Test that individual MCP servers are created correctly.""" server = await _create_server(url, None, config) - server_client = server._client - server_tools = await server.get_tools() + server_tools = await tool_map(server) server_tool_names = set(server_tools.keys()) + client = await server_client(server) assert isinstance(server, FastMCP) assert ( @@ -116,14 +94,14 @@ async def test_individual_mcp_server_creation( server_tool_names == expected_tools ), f"Expected tools {expected_tools}, got {server_tool_names}" assert ( - server_client.headers["User-Agent"] == "Bandwidth MCP Server" - ), f"Expected User-Agent 'Bandwidth MCP Server', got '{server_client.headers['User-Agent']}'" + client.headers["User-Agent"] == "Bandwidth MCP Server" + ), f"Expected User-Agent 'Bandwidth MCP Server', got '{client.headers['User-Agent']}'" assert ( - server_client.base_url == expected_base_url - ), f"Expected base URL '{expected_base_url}', got '{server_client.base_url}'" + client.base_url == expected_base_url + ), f"Expected base URL '{expected_base_url}', got '{client.base_url}'" assert ( - server_client.headers["Authorization"] == expected_auth_header - ), f"Expected auth header '{expected_auth_header}', got '{server_client.headers['Authorization']}'" + client.headers["Authorization"] == expected_auth_header + ), f"Expected auth header '{expected_auth_header}', got '{client.headers['Authorization']}'" @pytest.mark.asyncio diff --git a/test/test_spec_cache.py b/test/test_spec_cache.py new file mode 100644 index 0000000..c7d9bcf --- /dev/null +++ b/test/test_spec_cache.py @@ -0,0 +1,65 @@ +import pytest +import yaml +import httpx +from pathlib import Path +from src.server_utils import ( + fetch_openapi_spec, + _save_spec_cache, + _load_spec_cache, + CACHE_DIR, +) + + +@pytest.fixture +def tmp_cache(tmp_path, monkeypatch): + """Redirect spec cache to a temp directory.""" + monkeypatch.setattr("src.server_utils.CACHE_DIR", tmp_path) + return tmp_path + + +SAMPLE_SPEC = { + "openapi": "3.0.3", + "info": {"title": "Test", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": {}, +} + + +def test_save_and_load_cache(tmp_cache): + url = "https://dev.bandwidth.com/spec/test.yml" + _save_spec_cache(url, SAMPLE_SPEC) + loaded = _load_spec_cache(url) + assert loaded == SAMPLE_SPEC + + +def test_load_cache_returns_none_when_missing(tmp_cache): + loaded = _load_spec_cache("https://dev.bandwidth.com/spec/missing.yml") + assert loaded is None + + +@pytest.mark.asyncio +async def test_fetch_caches_on_success(tmp_cache, httpx_mock): + url = "https://dev.bandwidth.com/spec/cached-test.yml" + httpx_mock.add_response(url=url, text=yaml.dump(SAMPLE_SPEC)) + result = await fetch_openapi_spec(url) + assert result["info"]["title"] == "Test" + cached = _load_spec_cache(url) + assert cached is not None + assert cached["info"]["title"] == "Test" + + +@pytest.mark.asyncio +async def test_fetch_falls_back_to_cache(tmp_cache, httpx_mock): + url = "https://dev.bandwidth.com/spec/fallback-test.yml" + _save_spec_cache(url, SAMPLE_SPEC) + httpx_mock.add_exception(httpx.ConnectError("Network down"), url=url) + result = await fetch_openapi_spec(url) + assert result["info"]["title"] == "Test" + + +@pytest.mark.asyncio +async def test_fetch_raises_when_no_cache_no_network(tmp_cache, httpx_mock): + url = "https://dev.bandwidth.com/spec/nowhere.yml" + httpx_mock.add_exception(httpx.ConnectError("Network down"), url=url) + with pytest.raises(RuntimeError, match="Failed to fetch"): + await fetch_openapi_spec(url) diff --git a/test/test_urls.py b/test/test_urls.py new file mode 100644 index 0000000..6fba525 --- /dev/null +++ b/test/test_urls.py @@ -0,0 +1,187 @@ +"""Tests for src/urls.py — host resolution.""" + +import urls + + +def _clear_env(monkeypatch): + for k in [ + "BW_ENVIRONMENT", + "BW_API_URL", + "BW_VOICE_URL", + "BW_MESSAGING_URL", + "BW_MFA_URL", + "BW_INSIGHTS_URL", + ]: + monkeypatch.delenv(k, raising=False) + + +def test_production_defaults(monkeypatch): + _clear_env(monkeypatch) + assert urls.api_base() == "https://api.bandwidth.com" + assert urls.voice_base() == "https://voice.bandwidth.com" + assert urls.messaging_base() == "https://messaging.bandwidth.com" + + +def test_override_api(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_API_URL", "https://example.invalid") + assert urls.api_base() == "https://example.invalid" + # Other hosts unaffected. + assert urls.voice_base() == "https://voice.bandwidth.com" + + +def test_override_trailing_slash_trimmed(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_API_URL", "https://example.invalid/") + assert urls.api_base() == "https://example.invalid" + + +def test_each_host_has_independent_override(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_API_URL", "https://a.invalid") + monkeypatch.setenv("BW_VOICE_URL", "https://v.invalid") + monkeypatch.setenv("BW_MESSAGING_URL", "https://m.invalid") + assert urls.api_base() == "https://a.invalid" + assert urls.voice_base() == "https://v.invalid" + assert urls.messaging_base() == "https://m.invalid" + + +def test_empty_override_falls_through_to_default(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_API_URL", "") + assert urls.api_base() == "https://api.bandwidth.com" + + +def test_oauth_token_url_uses_api_base(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_API_URL", "https://override.invalid") + assert urls.oauth_token_url() == "https://override.invalid/api/v1/oauth2/token" + + +def test_dashboard_api_base_uses_api_base(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_API_URL", "https://override.invalid") + assert urls.dashboard_api_base() == "https://override.invalid/api/v2" + + +def test_environment_test_flips_api_and_voice(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_ENVIRONMENT", "test") + assert urls.api_base() == "https://test.api.bandwidth.com" + assert urls.voice_base() == "https://test.voice.bandwidth.com" + # Messaging is the same host in test, matching the CLI. + assert urls.messaging_base() == "https://messaging.bandwidth.com" + + +def test_environment_uat_alias(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_ENVIRONMENT", "uat") + assert urls.api_base() == "https://test.api.bandwidth.com" + + +def test_environment_is_case_insensitive(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_ENVIRONMENT", "TEST") + assert urls.api_base() == "https://test.api.bandwidth.com" + + +def test_per_host_override_wins_over_environment(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_ENVIRONMENT", "test") + monkeypatch.setenv("BW_API_URL", "https://custom.invalid") + assert urls.api_base() == "https://custom.invalid" + # Voice still flips to the test default. + assert urls.voice_base() == "https://test.voice.bandwidth.com" + + +def test_unknown_environment_falls_back_to_prod(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_ENVIRONMENT", "staging") + assert urls.api_base() == "https://api.bandwidth.com" + + +def test_no_inlined_hosts_in_src(monkeypatch): + """Source under src/ must not inline any production hosts as string literals + outside of urls.py and OpenAPI spec metadata.""" + import pathlib + + root = pathlib.Path(__file__).parent.parent / "src" + forbidden = [ + "https://api.bandwidth.com", + "https://voice.bandwidth.com", + "https://dashboard.bandwidth.com", + "https://messaging.bandwidth.com", + "https://test.api.bandwidth.com", + "https://test.voice.bandwidth.com", + ] + allowed_files = {"urls.py"} + offenders = [] + for py in root.rglob("*.py"): + if py.name in allowed_files: + continue + text = py.read_text() + for needle in forbidden: + if needle in text: + offenders.append(f"{py}: {needle}") + assert not offenders, "Inlined host strings found:\n" + "\n".join(offenders) + + +def test_mfa_and_insights_bases(monkeypatch): + _clear_env(monkeypatch) + assert urls.mfa_base() == "https://mfa.bandwidth.com" + assert urls.insights_base() == "https://insights.bandwidth.com" + + +def test_mfa_url_override(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_MFA_URL", "https://stage.mfa.invalid") + assert urls.mfa_base() == "https://stage.mfa.invalid" + + +def test_swap_host_unchanged_for_unknown_host(monkeypatch): + _clear_env(monkeypatch) + assert urls.swap_host("https://example.com/foo") == "https://example.com/foo" + + +def test_swap_host_applies_env_override(monkeypatch): + """swap_host rewrites the host portion of a spec server URL while preserving the path.""" + _clear_env(monkeypatch) + monkeypatch.setenv("BW_VOICE_URL", "https://stage.voice.invalid") + assert ( + urls.swap_host("https://voice.bandwidth.com/api/v2") + == "https://stage.voice.invalid/api/v2" + ) + + +def test_swap_host_uses_environment_mapping(monkeypatch): + """BW_ENVIRONMENT=test flips known hosts to their test equivalents.""" + _clear_env(monkeypatch) + monkeypatch.setenv("BW_ENVIRONMENT", "test") + assert ( + urls.swap_host("https://api.bandwidth.com/v2") + == "https://test.api.bandwidth.com/v2" + ) + assert ( + urls.swap_host("https://voice.bandwidth.com/api/v2") + == "https://test.voice.bandwidth.com/api/v2" + ) + + +def test_swap_host_per_host_override_wins(monkeypatch): + _clear_env(monkeypatch) + monkeypatch.setenv("BW_ENVIRONMENT", "test") + monkeypatch.setenv("BW_API_URL", "https://override.invalid") + assert ( + urls.swap_host("https://api.bandwidth.com/v2/foo") + == "https://override.invalid/v2/foo" + ) + + +def test_swap_host_messaging_stays_prod_in_test_env(monkeypatch): + """BW_ENVIRONMENT=test keeps messaging on prod (matches CLI behavior).""" + _clear_env(monkeypatch) + monkeypatch.setenv("BW_ENVIRONMENT", "test") + assert ( + urls.swap_host("https://messaging.bandwidth.com/api/v2") + == "https://messaging.bandwidth.com/api/v2" + ) diff --git a/test/utils.py b/test/utils.py index b2ec6a9..d0bb3a3 100644 --- a/test/utils.py +++ b/test/utils.py @@ -1,6 +1,27 @@ from pytest_httpx import HTTPXMock +async def tool_map(mcp): + """Return {tool_name: tool} for a FastMCP server. + + fastmcp 3.x replaced the dict-returning get_tools() with list_tools() + (a list of Tool objects). This restores the name→tool mapping the tests + rely on. + """ + return {tool.name: tool for tool in await mcp.list_tools()} + + +async def server_client(mcp): + """Return the shared httpx client backing a from_openapi server's tools. + + fastmcp 3.x stores the client per-tool at tool._client (was server._client + in 2.x). All tools from one _create_server share the same client, so the + first tool's is representative. + """ + tools = await mcp.list_tools() + return tools[0]._client + + def create_mock(httpx_mock: HTTPXMock, spec_name: str): """Helper function to create a mock response for HTTPX.""" with open(f"test/fixtures/{spec_name}.yml", "r", encoding="utf-8") as f: