Skip to content
Open
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ build/
dist/
debug_payload.json
brainstorm_outputs/
reference/
reference/
.env
.llmwiki.yaml
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,7 @@ Detailed guides have been moved to [`docs/guides/`](docs/guides/) to keep this R
| [**Advanced**](docs/guides/advanced.md) | Brainstorm, SSJ Developer Mode, Tmux, Proactive monitoring, Checkpoints, Plan mode, Session management, Cloud sync |
| [**Recipes**](docs/guides/recipes.md) | 12 step-by-step examples: code review, Telegram remote control, autonomous research, bug fix, brainstorm, session search, browse web pages, email, PDF/Excel analysis, and more |
| [**Plugin Authoring**](docs/guides/plugin-authoring.md) | Build your own plugin: tools, commands, skills, MCP servers, publishing checklist |
| [**llmwiki Memory Plugin**](docs/guides/llmwiki.md) | Persistent memory via llmwiki-py: install, configure, update, and use WikiRead/Write/Search/Append |
| [**Example Plugin**](examples/example-plugin/) | Copy-and-edit starter template with working tools, commands, and skills |
| [**Contributing**](CONTRIBUTING.md) | Project structure, architecture guide, PR checklist |

Expand Down
12 changes: 11 additions & 1 deletion agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ def run(

# Compact context if approaching window limit
try:
maybe_compact(state, config)
from ui.render import _start_tool_spinner, _stop_tool_spinner, set_spinner_phrase
set_spinner_phrase("compacting context…")
_start_tool_spinner()
try:
maybe_compact(state, config)
finally:
_stop_tool_spinner()
except Exception as _compact_err:
_log.warn("compact_failed", error=str(_compact_err))

Expand Down Expand Up @@ -136,6 +142,8 @@ def run(
tool_schemas=get_tool_schemas(),
config=config,
):
if cancel_check and cancel_check():
return
if isinstance(event, (TextChunk, ThinkingChunk)):
yield event
elif isinstance(event, AssistantTurn):
Expand Down Expand Up @@ -242,6 +250,8 @@ def _exec_one(tc):
"""Execute a single tool call, return (tc, result, permitted)."""
tid = tc["id"]
permitted = permissions[tid]
if cancel_check and cancel_check():
return tc, "[Interrupted by user]", False
if not permitted:
if config.get("permission_mode") == "plan":
plan_file = runtime.get_ctx(config).plan_file or ""
Expand Down
7 changes: 6 additions & 1 deletion bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ def bootstrap(config: dict) -> None:
import tools as _tools # noqa: F401
_log.debug("bootstrap_tools_ready")

# ── 3. Health-check HTTP server ────────────────────────────────────────
# ── 3. MCP servers ─────────────────────────────────────────────────────
# Importing mcp.tools triggers background connection + tool registration.
import cc_mcp.tools as _mcp_tools # noqa: F401
_log.debug("bootstrap_mcp_connecting")

# ── 5. Health-check HTTP server ────────────────────────────────────────
port = config.get("health_check_port")
if port:
try:
Expand Down
94 changes: 78 additions & 16 deletions cc_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import time
from typing import Any, Dict, List, Optional

from .oauth import acquire_token, get_cached_token
from .types import (
MCPServerConfig, MCPServerState, MCPTool, MCPTransport,
INIT_PARAMS, make_notification, make_request,
Expand Down Expand Up @@ -148,21 +149,48 @@ def __init__(self, config: MCPServerConfig):
self._sse_pending: Dict[int, dict] = {}
self._running = False

def _get_client(self):
if self._client is None:
try:
import httpx
self._client = httpx.Client(
headers=self._config.headers,
timeout=self._config.timeout,
follow_redirects=True,
)
except ImportError:
raise RuntimeError("httpx is required for HTTP/SSE MCP transport: pip install httpx")
def _get_client(self, oauth_token: Optional[str] = None):
try:
import httpx
except ImportError:
raise RuntimeError("httpx is required for HTTP/SSE MCP transport: pip install httpx")
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
**self._config.headers,
}
# Inject cached OAuth token if no Authorization header already configured
if oauth_token and "Authorization" not in self._config.headers:
headers["Authorization"] = f"Bearer {oauth_token}"
if self._client is None or oauth_token:
if self._client:
try:
self._client.close()
except Exception:
pass
self._client = httpx.Client(
headers=headers,
timeout=self._config.timeout,
follow_redirects=True,
)
return self._client

def _needs_oauth(self) -> bool:
"""True if this server has no static auth header configured."""
return "Authorization" not in self._config.headers

def _try_oauth(self, www_auth_header: str) -> None:
"""Run OAuth flow and rebuild the HTTP client with the new token."""
token = acquire_token(self._config.url, www_auth_header)
self._get_client(oauth_token=token)

def start(self) -> None:
"""For SSE transport: connect to the /sse endpoint and get session URL."""
# Inject a cached OAuth token before first connection if available
if self._needs_oauth():
cached = get_cached_token(self._config.url)
if cached:
self._get_client(oauth_token=cached)
if self._config.transport == MCPTransport.SSE:
self._start_sse()
else:
Expand Down Expand Up @@ -241,8 +269,24 @@ def request(self, method: str, params: Optional[dict] = None, timeout: Optional[
else:
# For HTTP: POST and get response directly
resp = client.post(self._session_url or self._config.url, json=msg, timeout=wait_secs)

# OAuth: on 401 with no static auth, run browser flow and retry once
if resp.status_code == 401 and self._needs_oauth():
www_auth = resp.headers.get("www-authenticate", "")
self._try_oauth(www_auth)
resp = self._client.post(self._session_url or self._config.url, json=msg, timeout=wait_secs)

resp.raise_for_status()
result = resp.json()
ct = resp.headers.get("content-type", "")
if "text/event-stream" in ct:
# Server returned SSE envelope — extract the data: line
result = None
for line in resp.text.splitlines():
if line.startswith("data:"):
result = json.loads(line[5:].strip())
break
else:
result = resp.json()

if result is None:
raise TimeoutError(f"MCP server '{self._config.name}' timed out on '{method}'")
Expand Down Expand Up @@ -307,6 +351,21 @@ def connect(self) -> None:
self._transport.start()
self._handshake()
self.state = MCPServerState.CONNECTED
except RuntimeError as e:
# If handshake failed due to OAuth being triggered mid-connect, retry once
if "401" in str(e) or "Unauthorized" in str(e):
try:
self._transport.stop()
self._transport = self._make_transport()
self._transport.start()
self._handshake()
self.state = MCPServerState.CONNECTED
return
except Exception:
pass
self.state = MCPServerState.ERROR
self._error = str(e)
raise
except Exception as e:
self.state = MCPServerState.ERROR
self._error = str(e)
Expand Down Expand Up @@ -492,16 +551,19 @@ def all_tools(self) -> List[MCPTool]:

def call_tool(self, qualified_name: str, arguments: dict) -> str:
"""Dispatch a tool call by qualified name (mcp__server__tool)."""
# Parse server and tool name from qualified name
parts = qualified_name.split("__", 2)
if len(parts) != 3 or parts[0] != "mcp":
raise ValueError(f"Invalid MCP tool name: {qualified_name}")
server_name = parts[1]
server_name_sanitized = parts[1]
tool_name = parts[2]

client = self._clients.get(server_name)
client = next(
(c for c in self._clients.values()
if "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in c.config.name) == server_name_sanitized),
None,
)
if client is None:
raise RuntimeError(f"MCP server '{server_name}' not configured")
raise RuntimeError(f"MCP server '{server_name_sanitized}' not configured")

# Auto-reconnect if dropped
if not client.alive:
Expand Down
Loading
Loading