Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1327,6 +1327,18 @@ mcp-cli --log-file ~/.mcp-cli/logs/debug.log --server sqlite
- **XSS Prevention**: Tool names and user-supplied content are HTML-escaped before template injection
- **URL Scheme Validation**: `ui/open-link` only allows `http://` and `https://` schemes
- **Tool Name Validation**: Bridge rejects tool names not matching the MCP spec character set
- **WebSocket Origin Validation** (v0.20.1+): The local app-host server rejects any WebSocket handshake whose `Origin` header doesn't match its own `http://localhost:<port>` host page, so an unrelated webpage can't attach to a running app's bridge
- **Tool Permission Enforcement** (v0.20.1+): When a resource declares an allow-list of tools it may call, the bridge enforces it on every `tools/call` — not just a tool-name syntax check
- **SSRF-Safe Resource Fetch** (v0.20.1+): Direct HTTP(S) resource fetches are validated against private/loopback/link-local address ranges before connecting, and re-validated at every redirect hop

### Dashboard Security (v0.20.1+)
- **WebSocket Origin Validation**: The dashboard's WebSocket server applies the same Origin check as MCP Apps
- **Sanitized Markdown Rendering**: Assistant chat messages are rendered through DOMPurify rather than a hand-rolled sanitizer
- **Escaped View Metadata**: Server-declared view names and icons are HTML-escaped before being inserted into panel headers
- **Sanitized Agent/Session Paths**: Agent and session identifiers are sanitized before use as filesystem path components

### Plan Execution Security (v0.20.1+)
- **Tool Confirmation Enforced**: Plan execution (`/plan`, `plan_create_and_execute`) honors the same confirm-tools preference and trusted-domain policy as the interactive chat path; if confirmation is required and no prompt is available, the call is declined rather than executed unconfirmed

## 🚀 Performance Features

Expand Down
12 changes: 12 additions & 0 deletions docs/MCP_APPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ The bridge rejects tool names not matching `^[a-zA-Z0-9_\-./]+$` per the MCP spe

`_safe_json_dumps()` falls back to `_to_serializable()` on `TypeError`/`ValueError`, with circular reference protection via a visited-object set.

### WebSocket Origin Validation (v0.20.1+)

The local app-host server validates the `Origin` header on every WebSocket upgrade request against the `http://localhost:<port>` (or `127.0.0.1`) host page origin, via `mcp_cli.utils.loopback_origin.is_allowed_origin()`. Browsers attach an `Origin` header to WebSocket handshakes automatically but don't enforce same-origin policy on the connection itself — enforcement has to happen server-side. Handshakes with a mismatched or missing Origin are rejected with HTTP 403 before the upgrade completes.

### Tool Permission Enforcement (v0.20.1+)

`AppInfo.permissions` (from the resource's `_meta.ui.permissions`) is enforced in `AppBridge._handle_tool_call()`: if a resource declares a `tools` allow-list, only tools on that list can be invoked via the bridge, in addition to the existing tool-name syntax check. A resource that declares no permissions keeps the previous unrestricted behavior.

### SSRF-Safe Resource Fetch (v0.20.1+)

Direct HTTP(S) fetches (for `resource_uri` or a tool result's `viewUrl`) are validated with `mcp_cli.utils.url_safety.is_safe_fetch_url()`, which resolves the hostname and rejects private, loopback, link-local, and other non-public address ranges. Redirects are followed manually so each hop is re-validated rather than trusted after the first check.

## Session Reliability

### Deferred Tool Result Delivery
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "mcp-cli"
version = "0.20.0"
version = "0.20.1"
description = "A cli for the Model Context Provider"
requires-python = ">=3.11"
readme = "README.md"
Expand Down
8 changes: 6 additions & 2 deletions src/mcp_cli/agents/group_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mcp_cli.chat.session_store import _sanitize_path_component

if TYPE_CHECKING:
from mcp_cli.agents.manager import AgentManager

Expand Down Expand Up @@ -69,9 +71,11 @@ async def save_group(
}
)

# Save session if available
# Save session if available. agent_id can originate from an
# LLM-controllable agent_spawn tool call, so sanitize it before
# using it as a path component.
ctx = snapshot["context"]
agent_dir = base / agent_id
agent_dir = base / _sanitize_path_component(agent_id)
agent_dir.mkdir(parents=True, exist_ok=True)
try:
history = getattr(ctx, "conversation_history", [])
Expand Down
37 changes: 37 additions & 0 deletions src/mcp_cli/apps/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,24 @@ async def _handle_tool_call(self, msg_id: Any, params: dict[str, Any]) -> str:
}
)

if not self._is_tool_permitted(tool_name):
logger.warning(
"App %s attempted to call tool %r outside its declared "
"permission scope",
self.app_info.tool_name,
tool_name,
)
return json.dumps(
{
"jsonrpc": "2.0",
"id": msg_id,
"error": {
"code": -32603,
"message": f"Tool not permitted: {tool_name!r}",
},
}
)

logger.debug(
"App %s calling tool %s with %s",
self.app_info.tool_name,
Expand Down Expand Up @@ -210,6 +228,25 @@ async def _handle_tool_call(self, msg_id: Any, params: dict[str, Any]) -> str:
}
)

def _is_tool_permitted(self, tool_name: str) -> bool:
"""Check *tool_name* against the resource's declared permission scope.

``self.app_info.permissions`` comes from the resource's own
``_meta.ui.permissions`` — a scope the *server* declared, not
something mcp-cli invents. When a resource declares a ``tools``
allow-list, only those tools may be invoked via this bridge. A
resource that declares no permissions (or a permissions dict with
no ``tools`` key) hasn't opted into scoping, so nothing here
restricts it beyond the existing tool-name syntax check.
"""
permissions = self.app_info.permissions
if not permissions:
return True
allowed_tools = permissions.get("tools")
if not isinstance(allowed_tools, list):
return True
return tool_name in allowed_tools

# ------------------------------------------------------------------ #
# Handler: resources/read #
# ------------------------------------------------------------------ #
Expand Down
78 changes: 62 additions & 16 deletions src/mcp_cli/apps/host.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
DEFAULT_APP_MAX_CONCURRENT,
DEFAULT_HTTP_REQUEST_TIMEOUT,
)
from mcp_cli.utils.loopback_origin import is_allowed_origin as _is_allowed_origin
from mcp_cli.utils.url_safety import is_safe_fetch_url

if TYPE_CHECKING:
from mcp_cli.tools.manager import ToolManager
Expand Down Expand Up @@ -355,6 +357,27 @@ def process_request(
websockets.Headers({"Content-Length": str(len(body))}),
body,
)

# Reject cross-origin WebSocket upgrades. Browsers attach an
# Origin header to WS handshakes but do not enforce same-origin
# policy on them the way they do for fetch()/XHR — enforcement
# is the server's responsibility, so any page that knows (or
# scans for) this port could otherwise attach to the bridge.
origin = request.headers.get("Origin")
if not _is_allowed_origin(origin, app_info.port):
logger.warning(
"Rejected WebSocket connection for app %s: disallowed Origin %r",
app_info.tool_name,
origin,
)
body = b"Forbidden"
return Response(
http.HTTPStatus.FORBIDDEN,
"Forbidden",
websockets.Headers({"Content-Length": str(len(body))}),
body,
)

# Return None to proceed with WebSocket upgrade for /ws
return None

Expand Down Expand Up @@ -419,28 +442,51 @@ async def _find_available_port(self) -> int:
f"Could not find available port after {max_attempts} attempts"
)

_MAX_REDIRECTS = 5

@staticmethod
async def _fetch_http_resource(url: str) -> tuple[str, dict[str, Any]]:
"""Fetch HTML content directly from an HTTP/HTTPS URL."""
"""Fetch HTML content directly from an HTTP/HTTPS URL.

*url* comes from the connected MCP server (resource_uri or a tool
result's viewUrl), so it's validated against is_safe_fetch_url()
before every request — including each redirect hop, since a
same-origin-looking URL could redirect to an internal address.
"""
import httpx

current_url = url
async with httpx.AsyncClient(
follow_redirects=True, timeout=DEFAULT_HTTP_REQUEST_TIMEOUT
follow_redirects=False, timeout=DEFAULT_HTTP_REQUEST_TIMEOUT
) as client:
resp = await client.get(url)
resp.raise_for_status()
html = resp.text
# Wrap in a resource-like structure for CSP/permissions extraction
resource = {
"contents": [
{
"uri": url,
"mimeType": resp.headers.get("content-type", "text/html"),
"text": html,
}
]
}
return html, resource
for _ in range(AppHostServer._MAX_REDIRECTS + 1):
if not is_safe_fetch_url(current_url):
raise RuntimeError(
f"Refusing to fetch disallowed URL: {current_url}"
)
resp = await client.get(current_url)
if resp.is_redirect:
location = resp.headers.get("location")
if not location:
resp.raise_for_status()
break
current_url = str(resp.url.join(location))
continue
resp.raise_for_status()
html = resp.text
# Wrap in a resource-like structure for CSP/permissions extraction
resource = {
"contents": [
{
"uri": current_url,
"mimeType": resp.headers.get("content-type", "text/html"),
"text": html,
}
]
}
return html, resource

raise RuntimeError(f"Too many redirects fetching {url}")

@staticmethod
def _extract_html(resource: dict[str, Any]) -> str:
Expand Down
10 changes: 10 additions & 0 deletions src/mcp_cli/chat/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,16 @@ def process_browser_file(
ValueError
If the file is too large or has an unsupported extension.
"""
# Reject grossly oversized payloads from the encoded length alone,
# before decoding the whole thing into memory (base64 inflates size by
# ~4/3, so this is a cheap upper-bound check ahead of the exact one
# below).
if len(data_b64) * 3 // 4 > DEFAULT_MAX_ATTACHMENT_SIZE_BYTES:
raise ValueError(
f"File too large: ~{len(data_b64) * 3 // 4:,} bytes "
f"(max {DEFAULT_MAX_ATTACHMENT_SIZE_BYTES:,})"
)

raw = base64.b64decode(data_b64)
size = len(raw)
if size > DEFAULT_MAX_ATTACHMENT_SIZE_BYTES:
Expand Down
3 changes: 2 additions & 1 deletion src/mcp_cli/chat/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ async def process_conversation(self, max_turns: int = 100):
max_turns: Maximum number of conversation turns before forcing exit (default: 100)
"""
turn_count = 0
last_tool_signature = None # Track last tool call to detect true duplicates
# Track last tool call to detect true duplicates
last_tool_signature: str | None = None
tools_for_completion = None # Will be set based on context
after_tool_calls = False # True when resuming after tool execution

Expand Down
22 changes: 17 additions & 5 deletions src/mcp_cli/chat/session_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@
logger = logging.getLogger(__name__)


def _sanitize_path_component(value: str) -> str:
"""Strip path separators and '..' so *value* can't escape its parent dir.

Used for any identifier (session_id, agent_id) that ends up as a path
component but may originate from LLM-controllable input (e.g. an
agent_spawn tool call), not just direct user input.
"""
return value.replace("/", "_").replace("\\", "_").replace("..", "_")


class SessionMetadata(BaseModel):
"""Metadata for a saved session."""

Expand Down Expand Up @@ -57,16 +67,18 @@ def __init__(
if sessions_dir is None:
sessions_dir = Path(DEFAULT_SESSIONS_DIR).expanduser()
self.agent_id = agent_id
# Agent-namespaced subdirectory
self.sessions_dir = sessions_dir / agent_id
# Agent-namespaced subdirectory — sanitized since agent_id can
# originate from an LLM-controllable agent_spawn tool call, not
# just direct user input.
safe_agent_id = _sanitize_path_component(agent_id)
self.sessions_dir = sessions_dir / safe_agent_id
self.sessions_dir.mkdir(parents=True, exist_ok=True)
# Keep reference to root for backward-compat migration
self._root_dir = sessions_dir

def _session_path(self, session_id: str) -> Path:
"""Get the file path for a session."""
# Sanitize session_id to prevent path traversal
safe_id = session_id.replace("/", "_").replace("\\", "_").replace("..", "_")
safe_id = _sanitize_path_component(session_id)
return self.sessions_dir / f"{safe_id}.json"

def save(self, data: SessionData) -> Path:
Expand Down Expand Up @@ -120,7 +132,7 @@ def _migrate_from_root(self, session_id: str) -> Path | None:

Returns the new path if migration succeeded, None otherwise.
"""
safe_id = session_id.replace("/", "_").replace("\\", "_").replace("..", "_")
safe_id = _sanitize_path_component(session_id)
legacy_path = self._root_dir / f"{safe_id}.json"
if not legacy_path.exists() or not legacy_path.is_file():
return None
Expand Down
2 changes: 1 addition & 1 deletion src/mcp_cli/commands/cmd/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ async def _handle_tool_calls(
messages=messages,
max_tokens=4096,
)
return final_response.get("response", response_text)
return str(final_response.get("response", response_text))
except Exception:
return response_text

Expand Down
35 changes: 32 additions & 3 deletions src/mcp_cli/commands/plan/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
CommandResult,
)
from mcp_cli.config.enums import PlanAction
from mcp_cli.planning.backends import ConfirmPromptCallback

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -139,7 +140,7 @@ async def execute(self, **kwargs) -> CommandResult:
success=False,
error="Plan ID required. Usage: /plan resume <id>",
)
return await self._resume_plan(planning_context, remainder.strip())
return await self._resume_plan(planning_context, remainder.strip(), kwargs)

else:
return CommandResult(
Expand Down Expand Up @@ -301,6 +302,18 @@ async def on_tool_complete(tool_name, result_text, success, elapsed):
if display:
await display.stop_tool_execution(result_text, success)

confirm_prompt: ConfirmPromptCallback | None = None
if ui_manager is not None and hasattr(ui_manager, "do_confirm_tool_execution"):

async def _confirm_prompt(tool_name: str, arguments: dict) -> bool:
return bool(
await ui_manager.do_confirm_tool_execution(
tool_name=tool_name, arguments=arguments
)
)

confirm_prompt = _confirm_prompt

# Get model_manager for LLM-driven execution
model_manager = (kwargs or {}).get("model_manager")

Expand All @@ -311,6 +324,7 @@ async def on_tool_complete(tool_name, result_text, success, elapsed):
on_step_complete=on_step_complete,
on_tool_start=on_tool_start,
on_tool_complete=on_tool_complete,
confirm_prompt=confirm_prompt,
)

result = await runner.execute_plan(plan_data, dry_run=dry_run)
Expand Down Expand Up @@ -347,7 +361,9 @@ async def _delete_plan(self, context, plan_id: str) -> CommandResult:
error=f"Plan not found: {plan_id}",
)

async def _resume_plan(self, context, plan_id: str) -> CommandResult:
async def _resume_plan(
self, context, plan_id: str, kwargs: dict | None = None
) -> CommandResult:
"""Resume an interrupted plan."""
from chuk_term.ui import output
from mcp_cli.planning.executor import PlanRunner
Expand All @@ -359,7 +375,20 @@ async def _resume_plan(self, context, plan_id: str) -> CommandResult:
error=f"Plan not found: {plan_id}",
)

runner = PlanRunner(context)
ui_manager = (kwargs or {}).get("ui_manager")
confirm_prompt: ConfirmPromptCallback | None = None
if ui_manager is not None and hasattr(ui_manager, "do_confirm_tool_execution"):

async def _confirm_prompt(tool_name: str, arguments: dict) -> bool:
return bool(
await ui_manager.do_confirm_tool_execution(
tool_name=tool_name, arguments=arguments
)
)

confirm_prompt = _confirm_prompt

runner = PlanRunner(context, confirm_prompt=confirm_prompt)
checkpoint = runner.load_checkpoint(plan_id)

if not checkpoint:
Expand Down
4 changes: 4 additions & 0 deletions src/mcp_cli/config/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,10 @@
DEFAULT_MEMORY_MAX_PROMPT_CHARS = 2000
"""Maximum characters for memory section in system prompt."""

DEFAULT_MEMORY_MAX_ENTRY_CHARS = 10_000
"""Maximum characters for a single memory entry's content, to bound
on-disk growth from repeated remember() calls with large payloads."""


# ================================================================
# Planning Defaults (Tier 6)
Expand Down
Loading