Skip to content

Add LLM provider routing for speaking bots - #31

Closed
Lazare-42 wants to merge 6 commits into
mainfrom
feature/llm-provider-routing
Closed

Lazare-42 wants to merge 6 commits into
mainfrom
feature/llm-provider-routing

Conversation

@Lazare-42

Copy link
Copy Markdown
Contributor

Summary

  • add per-request llm_provider and llm_model fields to the speaking bot API
  • route Pipecat LLM setup across OpenAI, Anthropic, and Z.ai/OpenAI-compatible services
  • add Anthropic Pipecat extra, provider env examples, README docs, and regenerated OpenAPI snapshots

Defaults

  • OpenAI: gpt-5.5
  • Anthropic: claude-opus-4-8
  • Z.ai: glm-5.2 via ZAI_BASE_URL=https://api.z.ai/api/paas/v4/

Validation

  • nix develop -c poetry check --lock
  • nix develop -c poetry run python -m py_compile scripts/meetingbaas.py app/models.py app/routes.py
  • nix develop -c poetry run python -m unittest tests.test_models tests.test_prompt_context tests.test_runtime tests.test_mcp_client
  • nix develop -c ruff check scripts/meetingbaas.py app/models.py app/routes.py tests/test_models.py

@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Summary by CodeRabbit

  • New Features
    • Added support for external prompt context, configurable MCP tool connections, request-scoped LLM selection, and speech speed controls.
    • Improved bot startup to carry richer per-request configuration through to runtime.
  • Bug Fixes
    • Strengthened validation around remote URLs and private-network access to reduce unsafe fetches.
    • Updated bot persona handling for more reliable prompt-based selection and fallback behavior.
  • Documentation
    • Expanded API docs and added committed OpenAPI snapshots to keep the schema in sync with the app.

Walkthrough

Adds prompt data source and MCP server configuration models, a prompt-context loading service with SSRF protections, wires these into the /bots join flow with LLM provider/model and speech-speed overrides, persists persona data to file for subprocess handoff, implements MCP client transports and runner tool wiring, and adds OpenAPI snapshot export, docs, and tests.

Changes

Prompt Data and MCP Integration

Layer / File(s) Summary
Prompt data/MCP data models
app/models.py
Adds PromptDataSource, MCPServerConfig, MCPConfig, MCPTransport, LLMProvider, and extends BotRequest with prompt-data, MCP, LLM provider/model, and speech-speed fields.
Prompt context loading service
app/services/prompt_context.py
New service estimates/truncates text to a token budget, fetches URL sources with SSRF guards, and formats MCP metadata into prompt context.
Join flow wiring
app/routes.py
Refactors persona resolution, loads prompt context, merges results into resolved_persona_data, and passes through MCP/LLM/speech overrides.
Persona data file handoff
core/process.py
Writes persona data to a JSON file and passes --persona-data-file to the Pipecat subprocess; tightens exception handling.
Low-level MCP client
utils/mcp_client.py, tests/test_mcp_client.py
Implements stdio and HTTP MCP JSON-RPC clients with framing, SSRF validation, SSE parsing, result normalization, and tests.
Runner LLM/TTS provider selection
scripts/meetingbaas.py
Adds provider/model resolution for OpenAI/Anthropic/Z.ai and version-tolerant Cartesia TTS speed configuration.
Runner live MCP wiring
scripts/meetingbaas.py
Adds LiveMCPManager, tool schema translation, setup_mcp_tools, system prompt augmentation, shutdown cleanup, and --persona-data-file CLI support.
OpenAPI snapshots, docs, env, tests
scripts/export_openapi.py, speaking-bot-openapi.json, README.md, env.example, pyproject.toml, tests/test_models.py, tests/test_openapi_snapshot.py, tests/test_prompt_context.py
Adds an export script, committed OpenAPI snapshot, documentation and env variables for the new fields, dependency extras update, and validation tests.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

Suggested reviewers: cyril-l-m

Poem

A rabbit hops through prompts and streams,
Fetching context, guarding SSRF dreams,
MCP tools now hop into view,
Persona files saved fresh and new,
With snapshots stamped in JSON light,
This burrow's ready — code review, take flight! 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly highlights the main change: routing LLM providers for speaking bots.
Description check ✅ Passed The description directly matches the changeset by describing provider routing, defaults, and supporting docs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/llm-provider-routing

Comment @coderabbitai help to get the list of available commands.

@Lazare-42
Lazare-42 force-pushed the feature/llm-provider-routing branch from 5f105cf to 6ebdae1 Compare July 9, 2026 08:03
@Lazare-42

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 21

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/models.py`:
- Line 4: Update the new public models in app/models.py to use modern built-in
collection annotations instead of typing.Dict/typing.List, and replace Optional
with the PEP 604 form using | None. Apply this consistently across the affected
model definitions and any related type hints in the referenced model blocks,
while keeping Literal and Any where appropriate. Focus on the imports and the
model classes/functions that currently use Dict, List, or Optional so the
annotations match the project’s style and Ruff expectations.
- Around line 92-93: The URL validation in the prompt/MCP model helpers
currently allows both HTTP and HTTPS, which should be tightened to reject
cleartext URLs. Update the validation logic in the prompt data source and MCP
URL checks (the helper that normalizes and validates URLs in app/models.py) so
user-configured endpoints require https:// by default, and only allow http://
behind an explicit local-development exception flag or equivalent gate. Ensure
the existing ValueError message and any related validation paths consistently
reflect the new HTTPS-only requirement.
- Around line 67-70: The inline prompt field in the model schema is currently
unbounded, so very large requests can be accepted before
`prompt_data_token_limit` is checked. Add a `max_length` constraint to the
`text` field in `app.models` (the `Field` definition for `text`) or enforce an
upstream request-body size limit so oversized inline payloads are rejected
earlier.

In `@app/services/prompt_context.py`:
- Around line 53-57: The prompt context is leaking full source URLs into
LLM-visible text and persona metadata. Update _section_header() to display only
a redacted/omitted URL, and ensure source_record uses the same sanitized value
before handing data to subprocesses. Apply the same redaction flow anywhere else
the URL is propagated in the prompt context build path so presigned or tokenized
URLs are never exposed downstream.
- Around line 81-89: The SSRF guard in prompt_context.py is only validating the
URL before aiohttp opens the connection, but the actual outbound request in the
fetch flow still re-resolves the hostname and can bypass
`_validate_fetch_url()`. Update the request path around `_validate_fetch_url`,
`aiohttp.ClientSession`, and `session.get` so the validated address is bound to
the real connection, either by using a connector/resolver pinned to the resolved
IP or by connecting to the resolved IP while preserving the original Host
header. Ensure the fix is applied in the same fetch routine that reads `url`,
`headers`, and `timeout`.

In `@core/process.py`:
- Around line 48-57: The persona payload file written in the parent needs
cleanup if the subprocess never successfully takes ownership of it. Update the
spawn flow around the payload creation in process.py so the parent tracks the
`persona_data_path` and unlinks it when `Popen` raises or startup fails before
the child parses `--persona-data-file`, and ensure the early-startup path also
removes it. Use the existing payload-writing block and the subprocess
launch/hand-off logic as the anchor points so the cleanup is tied to the same
`persona_data_path`/`client_id` lifecycle.
- Around line 160-161: The final process cleanup in the kill path is swallowing
exceptions, so the last process.kill() failure is lost. Update the exception
handling in the cleanup logic around the process.kill() attempts in process.py
to catch the final failure explicitly and log it with the relevant
process/context instead of using a bare except that passes. Keep the existing
retry flow, but ensure the last failure is surfaced through the same process
handling method used in this cleanup block.

In `@env.example`:
- Around line 89-91: The MCP connection comment is inaccurate because it points
operators to a nonexistent mcp.servers[].env field and suggests stdio support
that the API rejects. Update the note in env.example to reflect the actual
MCPServerConfig options and supported transports, using the relevant MCP server
config terminology and transport names from the README/tests (for example,
mcp.servers[] and the public HTTP-based transports) so it no longer implies
stdio or per-server env support.

In `@README.md`:
- Around line 402-405: The snapshot field list in the README is missing the
`llm_provider` and `llm_model` `BotRequest` fields, so update the documentation
where the current `BotRequest` fields are described to include both of those
schema fields alongside `prompt_data_sources`, `prompt_data_token_limit`, `mcp`,
and `speech_speed`. Keep the existing service snapshot list unchanged and ensure
the wording around `BotRequest` matches the generated schema used by the
snapshot docs.

In `@scripts/meetingbaas.py`:
- Around line 458-467: setup_mcp_tools currently leaves partially opened MCP
clients dangling if manager.connect() throws before main() reaches its shutdown
path. Update setup_mcp_tools to catch failures around LiveMCPManager.connect and
ensure the manager is closed/disconnected on error before re-raising, so any
subprocess/HTTP resources are cleaned up. Use the existing setup_mcp_tools and
LiveMCPManager symbols to locate the setup and teardown logic, and make the
cleanup happen for both the connect failure path and the no-discovery path if
needed.
- Around line 4-6: The CLI parser is still using json.load/json.loads while the
module only imports json as jsonlib, so persona-data parsing will fail with
NameError. Update the relevant parsing logic in meetingbaas.py so the import and
all JSON call sites are consistent, either by restoring the json name or by
switching every existing json.* usage (including the persona handoff path) to
jsonlib.*.
- Around line 1157-1170: The persona data file cleanup in the
args.persona_data_file block should happen regardless of whether json.load
succeeds. Move the os.remove(args.persona_data_file) attempt into a
finally-style path around the open/json.load logic in meetingbaas.py so the file
is deleted after every read attempt, and keep the persona_name extraction from
persona_data["path"] in the existing success path.
- Around line 403-411: `build_mcp_tool_name()` can generate duplicate function
names, and `self._tools[function_name] = tool_ref` currently overwrites earlier
entries in `meetingbaas.py`. Update the tool-registration path around
`build_mcp_tool_name`, `LiveMCPTool`, and `self._tools` to detect collisions
before inserting; if a name already exists, either reject the tool with a clear
error or generate a unique disambiguated name and keep the schema/dispatch
mapping consistent. Make sure the registration logic and any exposed MCP schema
use the same final function_name for each tool.

In `@speaking-bot-openapi.json`:
- Around line 396-409: The speech_speed schema in speaking-bot-openapi.json is
wider than the runtime clamp in the speech speed resolution path, so requests
can validate and then be silently adjusted. Align the OpenAPI bounds with the
Cartesia clamp used in scripts/meetingbaas.py (the speech speed resolution
logic) by narrowing the schema, or alternatively update the clamp and docs
together so the API contract and runtime behavior match.

In `@tests/test_mcp_client.py`:
- Around line 108-123: The URL validation tests are relying on shared process
environment state, so they can behave incorrectly if MCP_ALLOW_PRIVATE_URLS is
already set. Update test_validate_mcp_http_url_blocks_localhost and
test_validate_mcp_http_url_allows_exact_private_url to explicitly isolate the
environment by clearing or patching MCP_ALLOW_PRIVATE_URLS around the
validate_mcp_http_url calls, while preserving the existing
MCP_ALLOWED_PRIVATE_URLS setup/restore logic.

In `@tests/test_models.py`:
- Around line 11-18: Duplicate the dynamic module loading setup used around
MCPServerConfig into a shared test helper so it is not repeated across test
files. Move the importlib.util.spec_from_file_location / module_from_spec /
exec_module logic into a common helper such as tests/_helpers.py or a conftest
fixture, and have tests/test_models.py and the matching code in
tests/test_prompt_context.py call that helper to load models.py once.

In `@utils/mcp_client.py`:
- Around line 126-147: Add defensive limits and timeouts to read_stdio_message
so a stalled or malicious MCP subprocess cannot block forever or allocate
unbounded memory. Wrap the readline()/readexactly() path in asyncio timeout
handling, validate the parsed Content-Length against a reasonable maximum before
calling readexactly(), and surface McpClientError with clear context from
read_stdio_message so callers of the stdio MCP client fail fast instead of
hanging.
- Around line 378-382: The subprocess shutdown in the process.wait() timeout
path can still hang if the MCP server ignores SIGTERM. Update the termination
flow in the wait_for block around process.terminate() so it waits for a short
grace period and then force-kills the process if it does not exit, using the
existing process handle in mcp_client.py. Make sure the cleanup logic in this
timeout branch cannot block indefinitely and that any forced kill still waits
for the process to fully exit.
- Around line 76-85: The SSRF guard in _is_private_ip currently only blocks
private/loopback/link-local/etc. but still allows other non-global ranges like
100.64.0.0/10. Update the predicate to reject any address that is not globally
routable, and keep the allowlist override path separate so outbound MCP URLs are
permitted only when the target IP is explicitly allowlisted. Ensure the fix is
applied in _is_private_ip and any caller that relies on it for URL validation.
- Around line 39-44: The MCP tool name generator can collapse different
server/tool pairs into the same truncated value, which then causes
LiveMCPManager to overwrite entries in _tools. Update build_mcp_tool_name to
preserve uniqueness when truncating, such as by adding a deterministic hash or
suffix derived from the original raw name, and ensure the returned name still
fits the 64-character limit. Keep the existing sanitization logic, but make the
final name collision-resistant so distinct MCP tools cannot resolve to the same
function_name.
- Around line 108-117: validate_mcp_http_url() currently checks the resolved
host once, but session.post(self.url) still lets aiohttp resolve the hostname
again, which can allow DNS rebinding after validation. Update the MCP HTTP
client path in McpClient to reuse the validated address at connect time by
adding a custom resolver/connector that pins the resolved IP, or tighten the URL
handling so only IP literals and an explicit allowlist are accepted. Keep the
fix centered around validate_mcp_http_url() and the code that builds the aiohttp
session/request.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2efd214c-447e-43eb-950e-264ceb5e089a

📥 Commits

Reviewing files that changed from the base of the PR and between 274ac1d and 5f105cf.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • README.md
  • app/models.py
  • app/routes.py
  • app/services/prompt_context.py
  • core/process.py
  • env.example
  • meeting-baas-openapi-v1.json
  • openapi.json
  • pyproject.toml
  • scripts/export_openapi.py
  • scripts/meetingbaas.py
  • speaking-bot-openapi.json
  • tests/test_mcp_client.py
  • tests/test_models.py
  • tests/test_openapi_snapshot.py
  • tests/test_prompt_context.py
  • utils/mcp_client.py

Comment thread app/models.py

from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Literal, Optional

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use built-in collection types and | None annotations.

Ruff flags typing.Dict/typing.List, and the project guideline requires modern built-in annotations. Apply this consistently across the new public models.

Proposed cleanup
-from typing import Any, Dict, List, Literal, Optional
+from typing import Any, Literal

-    text: Optional[str] = Field(
+    text: str | None = Field(
...
-    headers: Optional[Dict[str, str]] = Field(
+    headers: dict[str, str] | None = Field(
...
-    tools: Optional[List[str]] = Field(
+    tools: list[str] | None = Field(
...
-    prompt_data_sources: Optional[List[PromptDataSource]] = Field(
+    prompt_data_sources: list[PromptDataSource] | None = Field(

Also applies to: 67-79, 123-151, 187-192, 248-290

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 4-4: typing.Dict is deprecated, use dict instead

(UP035)


[warning] 4-4: typing.List is deprecated, use list instead

(UP035)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models.py` at line 4, Update the new public models in app/models.py to
use modern built-in collection annotations instead of typing.Dict/typing.List,
and replace Optional with the PEP 604 form using | None. Apply this consistently
across the affected model definitions and any related type hints in the
referenced model blocks, while keeping Literal and Any where appropriate. Focus
on the imports and the model classes/functions that currently use Dict, List, or
Optional so the annotations match the project’s style and Ruff expectations.

Sources: Coding guidelines, Linters/SAST tools

Comment thread app/models.py
Comment on lines +67 to +70
text: Optional[str] = Field(
None,
description="Inline context. Required when type is text.",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

git ls-files app/models.py && printf '\n---\n' && sed -n '1,140p' app/models.py && printf '\n---\n' && rg -n "prompt_data_token_limit|max_length|request-size|body size|content-length|token_limit|inline context|type is text" -S .

Repository: Meeting-BaaS/speaking-meeting-bot

Length of output: 9434


🏁 Script executed:

sed -n '240,320p' app/models.py && printf '\n---\n' && sed -n '1,260p' app/services/prompt_context.py && printf '\n---\n' && sed -n '200,260p' app/routes.py && printf '\n---\n' && rg -n "max_request|body_size|max_body|limit.*bytes|Content-Length|request size|request_size|client_max_body_size|limit_bytes" -S app README.md .

Repository: Meeting-BaaS/speaking-meeting-bot

Length of output: 14926


🏁 Script executed:

rg -n "FastAPI\\(|uvicorn|nginx|client_max_body_size|max_body|request size|body size|Content-Length|limit.*bytes|prompt_data_token_limit|PROMPT_DATA_SOURCE_MAX_BYTES" -S . && printf '\n---\n' && git ls-files | rg '(^|/)(main|app|server|run|docker|nginx|compose|proxy|deploy|README|docs).*'

Repository: Meeting-BaaS/speaking-meeting-bot

Length of output: 7548


Cap inline prompt text app/models.py:67-70 — text is unbounded here, and there’s no request-body limit elsewhere in this path, so a client can send a very large inline payload and pay the parse/validation cost before prompt_data_token_limit applies. Add a max_length here or enforce an upstream body-size cap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models.py` around lines 67 - 70, The inline prompt field in the model
schema is currently unbounded, so very large requests can be accepted before
`prompt_data_token_limit` is checked. Add a `max_length` constraint to the
`text` field in `app.models` (the `Field` definition for `text`) or enforce an
upstream request-body size limit so oversized inline payloads are rejected
earlier.

Comment thread app/models.py
Comment on lines +92 to +93
if not normalized.startswith(("http://", "https://")):
raise ValueError("prompt data source url must start with http:// or https://")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not allow cleartext HTTP for user-configured prompt and MCP URLs.

These URLs can be fetched with caller-supplied headers or MCP credentials, so accepting http:// risks transmitting secrets and prompt data in cleartext. Prefer requiring https://, with any local-dev exception gated explicitly.

Proposed hardening
-        if not normalized.startswith(("http://", "https://")):
-            raise ValueError("prompt data source url must start with http:// or https://")
+        if not normalized.startswith("https://"):
+            raise ValueError("prompt data source url must start with https://")
...
-        if not normalized.startswith(("http://", "https://")):
-            raise ValueError("mcp server url must start with http:// or https://")
+        if not normalized.startswith("https://"):
+            raise ValueError("mcp server url must start with https://")

Also applies to: 163-164

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 93-93: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models.py` around lines 92 - 93, The URL validation in the prompt/MCP
model helpers currently allows both HTTP and HTTPS, which should be tightened to
reject cleartext URLs. Update the validation logic in the prompt data source and
MCP URL checks (the helper that normalizes and validates URLs in app/models.py)
so user-configured endpoints require https:// by default, and only allow http://
behind an explicit local-development exception flag or equivalent gate. Ensure
the existing ValueError message and any related validation paths consistently
reflect the new HTTPS-only requirement.

Source: Linters/SAST tools

Comment on lines +53 to +57
def _section_header(index: int, name: str, source_type: str, url: str | None) -> str:
heading = f"Source {index}: {name} ({source_type})"
if url:
heading += f"\nURL: {url}"
return heading

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Redact source URLs before adding them to prompt context or persona metadata.

_section_header() puts the full URL into LLM-visible context, and source_record keeps it for subprocess handoff. Presigned URLs or query tokens would be exposed downstream. Store/display a redacted URL, or omit it from the prompt entirely.

Proposed redaction helper
+def _redact_url_for_prompt(url: str | None) -> str | None:
+    if not url:
+        return None
+    parsed = urlparse(url)
+    return parsed._replace(query="", fragment="").geturl()
+
+
 def _section_header(index: int, name: str, source_type: str, url: str | None) -> str:
     heading = f"Source {index}: {name} ({source_type})"
-    if url:
-        heading += f"\nURL: {url}"
+    display_url = _redact_url_for_prompt(url)
+    if display_url:
+        heading += f"\nURL: {display_url}"
     return heading
...
         source_record = _dump_source(source)
         source_record.pop("headers", None)
         source_record.pop("text", None)
+        if source_record.get("url"):
+            source_record["url"] = _redact_url_for_prompt(source_record["url"])

Also applies to: 212-215

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/prompt_context.py` around lines 53 - 57, The prompt context is
leaking full source URLs into LLM-visible text and persona metadata. Update
_section_header() to display only a redacted/omitted URL, and ensure
source_record uses the same sanitized value before handing data to subprocesses.
Apply the same redaction flow anywhere else the URL is propagated in the prompt
context build path so presigned or tokenized URLs are never exposed downstream.

Comment on lines +81 to +89
url = _get(source, "url")
_validate_fetch_url(url)
headers = _get(source, "headers") or {}
max_bytes = int(os.getenv("PROMPT_DATA_SOURCE_MAX_BYTES", DEFAULT_SOURCE_MAX_BYTES))

timeout = aiohttp.ClientTimeout(total=12)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers, allow_redirects=False) as resp:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether prompt URL fetching uses a custom aiohttp resolver/connector
# that binds DNS validation to the actual connection.
rg -n -C3 'ClientSession|TCPConnector|resolver|getaddrinfo|_validate_fetch_url' app/services/prompt_context.py

Repository: Meeting-BaaS/speaking-meeting-bot

Length of output: 1215


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== prompt_context outline =="
ast-grep outline app/services/prompt_context.py --view expanded || true

echo
echo "== relevant file slice =="
sed -n '1,240p' app/services/prompt_context.py | cat -n

Repository: Meeting-BaaS/speaking-meeting-bot

Length of output: 10567


Bind SSRF checks to the actual outbound connection. _validate_fetch_url() only checks DNS once, but aiohttp resolves the hostname again in session.get(), so a rebinding host can pass validation and still connect to a private address. Pin the validated address in the connector/resolver, or connect to the resolved IP while keeping the original Host header.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 88-89: Use a single with statement with multiple contexts instead of nested with statements

(SIM117)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/prompt_context.py` around lines 81 - 89, The SSRF guard in
prompt_context.py is only validating the URL before aiohttp opens the
connection, but the actual outbound request in the fetch flow still re-resolves
the hostname and can bypass `_validate_fetch_url()`. Update the request path
around `_validate_fetch_url`, `aiohttp.ClientSession`, and `session.get` so the
validated address is bound to the real connection, either by using a
connector/resolver pinned to the resolved IP or by connecting to the resolved IP
while preserving the original Host header. Ensure the fix is applied in the same
fetch routine that reads `url`, `headers`, and `timeout`.

Comment thread utils/mcp_client.py
Comment on lines +39 to +44
def build_mcp_tool_name(server_name: str, tool_name: str) -> str:
"""Return an OpenAI/Pipecat-safe function name for an MCP tool."""
raw = f"mcp_{server_name}_{tool_name}".lower()
safe = re.sub(r"[^a-z0-9_]", "_", raw)
safe = re.sub(r"_+", "_", safe).strip("_")
return safe[:64] or "mcp_tool"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent MCP function-name collisions.

Sanitizing plus truncating can map distinct server/tool pairs to the same 64-character name; LiveMCPManager then overwrites _tools[function_name], so calls can route to the wrong MCP tool.

Proposed fix
+import hashlib
+
 def build_mcp_tool_name(server_name: str, tool_name: str) -> str:
     """Return an OpenAI/Pipecat-safe function name for an MCP tool."""
     raw = f"mcp_{server_name}_{tool_name}".lower()
     safe = re.sub(r"[^a-z0-9_]", "_", raw)
     safe = re.sub(r"_+", "_", safe).strip("_")
-    return safe[:64] or "mcp_tool"
+    if not safe:
+        return "mcp_tool"
+    digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
+    prefix = safe[:55].rstrip("_")
+    return f"{prefix}_{digest}"[:64]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def build_mcp_tool_name(server_name: str, tool_name: str) -> str:
"""Return an OpenAI/Pipecat-safe function name for an MCP tool."""
raw = f"mcp_{server_name}_{tool_name}".lower()
safe = re.sub(r"[^a-z0-9_]", "_", raw)
safe = re.sub(r"_+", "_", safe).strip("_")
return safe[:64] or "mcp_tool"
import hashlib
def build_mcp_tool_name(server_name: str, tool_name: str) -> str:
"""Return an OpenAI/Pipecat-safe function name for an MCP tool."""
raw = f"mcp_{server_name}_{tool_name}".lower()
safe = re.sub(r"[^a-z0-9_]", "_", raw)
safe = re.sub(r"_+", "_", safe).strip("_")
if not safe:
return "mcp_tool"
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
prefix = safe[:55].rstrip("_")
return f"{prefix}_{digest}"[:64]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@utils/mcp_client.py` around lines 39 - 44, The MCP tool name generator can
collapse different server/tool pairs into the same truncated value, which then
causes LiveMCPManager to overwrite entries in _tools. Update build_mcp_tool_name
to preserve uniqueness when truncating, such as by adding a deterministic hash
or suffix derived from the original raw name, and ensure the returned name still
fits the 64-character limit. Keep the existing sanitization logic, but make the
final name collision-resistant so distinct MCP tools cannot resolve to the same
function_name.

Comment thread utils/mcp_client.py
Comment on lines +76 to +85
def _is_private_ip(value: str) -> bool:
parsed = ip_address(value)
return (
parsed.is_private
or parsed.is_loopback
or parsed.is_link_local
or parsed.is_multicast
or parsed.is_reserved
or parsed.is_unspecified
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject all non-global addresses in the SSRF guard.

100.64.0.0/10 and other non-global ranges are not caught by the current predicate. For outbound MCP URLs, allow only globally routable addresses unless explicitly allowlisted.

Proposed fix
 def _is_private_ip(value: str) -> bool:
     parsed = ip_address(value)
-    return (
-        parsed.is_private
-        or parsed.is_loopback
-        or parsed.is_link_local
-        or parsed.is_multicast
-        or parsed.is_reserved
-        or parsed.is_unspecified
-    )
+    return not parsed.is_global
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _is_private_ip(value: str) -> bool:
parsed = ip_address(value)
return (
parsed.is_private
or parsed.is_loopback
or parsed.is_link_local
or parsed.is_multicast
or parsed.is_reserved
or parsed.is_unspecified
)
def _is_private_ip(value: str) -> bool:
parsed = ip_address(value)
return not parsed.is_global
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@utils/mcp_client.py` around lines 76 - 85, The SSRF guard in _is_private_ip
currently only blocks private/loopback/link-local/etc. but still allows other
non-global ranges like 100.64.0.0/10. Update the predicate to reject any address
that is not globally routable, and keep the allowlist override path separate so
outbound MCP URLs are permitted only when the target IP is explicitly
allowlisted. Ensure the fix is applied in _is_private_ip and any caller that
relies on it for URL validation.

Comment thread utils/mcp_client.py
Comment on lines +108 to +117
try:
addresses = socket.getaddrinfo(host, None)
except socket.gaierror as e:
raise McpClientError(f"Could not resolve MCP HTTP host '{host}': {e}") from e

for address in addresses:
if _is_private_ip(address[4][0]):
raise McpClientError(
f"MCP HTTP URL resolves to private or local address: {host}"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

python3 - <<'PY'
from pathlib import Path

path = Path("utils/mcp_client.py")
text = path.read_text()
for i, line in enumerate(text.splitlines(), 1):
    if 1 <= i <= 260:
        if "validate_mcp_http_url" in line or "session.post" in line or "aiohttp" in line or "resolver" in line or "connector" in line:
            print(f"{i}: {line}")
PY

Repository: Meeting-BaaS/speaking-meeting-bot

Length of output: 251


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- utils/mcp_client.py (lines 80-170) ---'
sed -n '80,170p' utils/mcp_client.py | cat -n

echo
echo '--- utils/mcp_client.py (lines 170-260) ---'
sed -n '170,260p' utils/mcp_client.py | cat -n

echo
echo '--- search for aiohttp session/connector/resolver usage ---'
rg -n "ClientSession|TCPConnector|resolver=|session\.post|validate_mcp_http_url|MCP HTTP URL" utils . -g '!**/__pycache__/**'

Repository: Meeting-BaaS/speaking-meeting-bot

Length of output: 9917


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- utils/mcp_client.py (lines 450-500) ---'
sed -n '450,500p' utils/mcp_client.py | cat -n

echo
echo '--- tests/test_mcp_client.py (lines 90-190) ---'
sed -n '90,190p' tests/test_mcp_client.py | cat -n

Repository: Meeting-BaaS/speaking-meeting-bot

Length of output: 7533


🌐 Web query:

aiohttp TCPConnector default DNS resolution request hostname re-resolved DNS rebinding documentation

💡 Result:

In aiohttp, the TCPConnector manages DNS resolution and uses an internal cache by default to optimize performance [1][2]. Default DNS Behavior: - DNS Caching: The TCPConnector features an internal DNS cache enabled by default (use_dns_cache=True) [1][2]. - TTL: By default, cached DNS entries expire after 10 seconds (ttl_dns_cache=10) [1][2]. If this value is set to None, entries are cached indefinitely [1][2]. - Resolution Bypass: When the host is identified as a valid IP address literal (e.g., standard dotted-quad IPv4 or IPv6 literals), aiohttp bypasses the resolver and connects directly to the IP, avoiding DNS lookups [3]. Recent updates have tightened this logic to reject non-canonical numeric IPv4 forms (such as octal or hex-style representations) to prevent security bypasses [3]. DNS Rebinding Considerations: - aiohttp does not provide built-in, automated protection specifically designed to detect or prevent DNS rebinding attacks at the connector level. Because the connector relies on a local cache by default, if a domain's IP changes rapidly (faster than the 10-second TTL), the application may continue to connect to the stale, cached IP [4]. - To mitigate concerns related to DNS rebinding or to ensure real-time resolution for highly dynamic environments, you can disable the DNS cache by setting use_dns_cache=False when initializing the TCPConnector [4][1]. - Alternatively, for advanced scenarios, you can implement a custom resolver by subclassing aiohttp.abc.AbstractResolver to enforce stricter resolution policies or to bypass caching entirely [5][2].

Citations:


Pin MCP HTTP DNS resolution. validate_mcp_http_url() resolves the hostname, but session.post(self.url) still hands the hostname to aiohttp’s connector, which performs its own DNS lookup with only a short-lived cache. An attacker-controlled host can pass validation on a public IP and then rebind to a private IP at connect time. Pin the validated address through a custom resolver/connector, or restrict MCP HTTP URLs to IP literals plus an explicit allowlist.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 111-111: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 115-117: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@utils/mcp_client.py` around lines 108 - 117, validate_mcp_http_url()
currently checks the resolved host once, but session.post(self.url) still lets
aiohttp resolve the hostname again, which can allow DNS rebinding after
validation. Update the MCP HTTP client path in McpClient to reuse the validated
address at connect time by adding a custom resolver/connector that pins the
resolved IP, or tighten the URL handling so only IP literals and an explicit
allowlist are accepted. Keep the fix centered around validate_mcp_http_url() and
the code that builds the aiohttp session/request.

Comment thread utils/mcp_client.py
Comment on lines +126 to +147
async def read_stdio_message(reader: asyncio.StreamReader) -> dict[str, Any]:
"""Read one Content-Length framed JSON message from a subprocess stream."""
headers: dict[str, str] = {}

while True:
line = await reader.readline()
if line == b"":
raise McpClientError("MCP stdio server closed stdout")
if line in {b"\r\n", b"\n"}:
break
try:
name, value = line.decode("ascii").split(":", 1)
except ValueError as e:
raise McpClientError("Invalid MCP stdio header") from e
headers[name.strip().lower()] = value.strip()

try:
content_length = int(headers["content-length"])
except (KeyError, ValueError) as e:
raise McpClientError("Missing or invalid MCP stdio Content-Length") from e

body = await reader.readexactly(content_length)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add stdio request timeouts and frame-size limits.

A misbehaving MCP subprocess can hang readline()/readexactly() indefinitely or advertise an unbounded Content-Length, blocking bot startup/tool calls and risking memory exhaustion.

Proposed direction
+MAX_STDIO_FRAME_BYTES = 4 * 1024 * 1024
+STDIO_REQUEST_TIMEOUT_SECONDS = 12
+
 async def read_stdio_message(reader: asyncio.StreamReader) -> dict[str, Any]:
@@
     try:
         content_length = int(headers["content-length"])
     except (KeyError, ValueError) as e:
         raise McpClientError("Missing or invalid MCP stdio Content-Length") from e
+    if content_length < 0 or content_length > MAX_STDIO_FRAME_BYTES:
+        raise McpClientError("MCP stdio Content-Length is outside allowed bounds")
 
-    body = await reader.readexactly(content_length)
+    body = await asyncio.wait_for(
+        reader.readexactly(content_length),
+        timeout=STDIO_REQUEST_TIMEOUT_SECONDS,
+    )

Also applies to: 384-395

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 133-133: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 139-139: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 145-145: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@utils/mcp_client.py` around lines 126 - 147, Add defensive limits and
timeouts to read_stdio_message so a stalled or malicious MCP subprocess cannot
block forever or allocate unbounded memory. Wrap the readline()/readexactly()
path in asyncio timeout handling, validate the parsed Content-Length against a
reasonable maximum before calling readexactly(), and surface McpClientError with
clear context from read_stdio_message so callers of the stdio MCP client fail
fast instead of hanging.

Comment thread utils/mcp_client.py
Comment on lines +378 to +382
try:
await asyncio.wait_for(process.wait(), timeout=2)
except asyncio.TimeoutError:
process.terminate()
await process.wait()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Kill subprocesses that ignore termination.

After process.terminate(), await process.wait() can still hang forever if the MCP server ignores SIGTERM.

Proposed fix
         except asyncio.TimeoutError:
             process.terminate()
-            await process.wait()
+            try:
+                await asyncio.wait_for(process.wait(), timeout=2)
+            except asyncio.TimeoutError:
+                process.kill()
+                await process.wait()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
await asyncio.wait_for(process.wait(), timeout=2)
except asyncio.TimeoutError:
process.terminate()
await process.wait()
try:
await asyncio.wait_for(process.wait(), timeout=2)
except asyncio.TimeoutError:
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=2)
except asyncio.TimeoutError:
process.kill()
await process.wait()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@utils/mcp_client.py` around lines 378 - 382, The subprocess shutdown in the
process.wait() timeout path can still hang if the MCP server ignores SIGTERM.
Update the termination flow in the wait_for block around process.terminate() so
it waits for a short grace period and then force-kills the process if it does
not exit, using the existing process handle in mcp_client.py. Make sure the
cleanup logic in this timeout branch cannot block indefinitely and that any
forced kill still waits for the process to fully exit.

@Lazare-42

Copy link
Copy Markdown
Contributor Author

Superseded by the master feature PR #34 — fully contained (= #30 + LLM routing, both in #34). Consolidating review there.

@Lazare-42 Lazare-42 closed this Jul 19, 2026
Lazare-42 added a commit that referenced this pull request Jul 21, 2026
Master: external data + MCP + speech speed + LLM routing (consolidates #30/#31/#32)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant