diff --git a/pyproject.toml b/pyproject.toml index 33ccbb57..89fe861b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "keyring>=25.0", "tree-sitter>=0.25,<0.26", "tree-sitter-bash>=0.25,<0.26", + "mcp>=1.28.0", ] [project.optional-dependencies] diff --git a/src/iac_code/a2a/events.py b/src/iac_code/a2a/events.py index 5e666bc7..ffdd24f6 100644 --- a/src/iac_code/a2a/events.py +++ b/src/iac_code/a2a/events.py @@ -24,8 +24,10 @@ ) from iac_code.a2a.exposure import A2AExposureType, normalize_a2a_exposure_types from iac_code.a2a.metadata_redaction import A2AMetadataEchoRedactor +from iac_code.i18n import _ from iac_code.types.stream_events import ( ErrorEvent, + MCPProgressEvent, MessageEndEvent, PermissionRequestEvent, TextDeltaEvent, @@ -79,6 +81,45 @@ def make_text_part(text: str) -> Part: return Part(text=text) +async def publish_mcp_warnings( + event_queue: Any, + *, + task_id: str, + context_id: str, + runtime: Any, + state: int = TaskState.TASK_STATE_WORKING, + iac_code_session_id: str | None = None, +) -> None: + warnings = list(getattr(runtime, "mcp_config_warnings", None) or []) + pushed_count = getattr(runtime, "_a2a_mcp_warnings_pushed_count", 0) + if pushed_count >= len(warnings): + return + for warning in warnings[pushed_count:]: + message = str(getattr(warning, "message", None) or warning) + await _enqueue_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=state, + message=_agent_text_message( + task_id=task_id, + context_id=context_id, + text=_("MCP warning: {message}").format(message=message), + ), + metadata={ + "iac_code": { + "mcpWarning": { + "serverName": str(getattr(warning, "server_name", "")), + "code": str(getattr(warning, "code", "")), + "message": message, + } + } + }, + iac_code_session_id=iac_code_session_id, + ) + setattr(runtime, "_a2a_mcp_warnings_pushed_count", len(warnings)) + + def _extract_artifact_metadata(result: Any, artifact_store: Any | None) -> dict[str, Any] | None: if artifact_store is None or not isinstance(result, dict): return None @@ -303,6 +344,31 @@ async def publish_stream_event( ) return None + if isinstance(event, MCPProgressEvent): + if A2AExposureType.TOOL_TRACE not in enabled_exposure_types: + return None + progress_metadata = { + "status": "progress", + "toolUseId": event.tool_use_id or "", + "name": "mcp__{}__{}".format(event.server_name, event.tool_name), + "mcp": { + "serverName": event.server_name, + "toolName": event.tool_name, + "progress": event.progress, + "total": event.total, + "message": _truncate(event.message) if event.message else None, + }, + } + await _enqueue_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_WORKING, + metadata={"iac_code": {"tool": progress_metadata}}, + iac_code_session_id=iac_code_session_id, + ) + return None + if isinstance(event, PermissionRequestEvent): approved = auto_approve_permissions if permission_resolver is not None: diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index 21675fde..3ef32d89 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import contextlib import json import logging import os @@ -19,6 +20,7 @@ from iac_code.a2a.events import ( iac_code_session_metadata, make_text_part, + publish_mcp_warnings, publish_stream_event, with_iac_code_session_metadata, ) @@ -44,7 +46,7 @@ credentials_with_metadata_api_key, refresh_runtime_cloud_tools, ) -from iac_code.a2a.task_store import A2ATaskStore +from iac_code.a2a.task_store import A2ATaskStore, _close_runtime from iac_code.a2a.types import ( TASK_STATE_CANCELED, TASK_STATE_FAILED, @@ -934,6 +936,10 @@ async def publish_initial_task_if_missing() -> None: if route_pipeline_handoff_to_normal: await self._ensure_pipeline_handoff_context_in_session(context_id=context_id, cwd=cwd) + task.active_task = asyncio.current_task() + task.state = TASK_STATE_WORKING + self._task_store.mirror_task(task) + def runtime_factory(session_id: str) -> Any: session_storage = SessionStorage() resume_messages = None @@ -957,10 +963,28 @@ def runtime_factory(session_id: str) -> Any: runtime_factory=runtime_factory, ) if not hasattr(ctx.runtime, "agent_loop"): + old_runtime = ctx.runtime ctx.runtime = runtime_factory(ctx.session_id) self._task_store.mirror_context(ctx) + await _close_runtime(old_runtime) + except asyncio.CancelledError: + task.active_task = None + task.state = TASK_STATE_CANCELED + self._task_store.mirror_task(task) + with contextlib.suppress(Exception): + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_CANCELED, + text=_("Task canceled."), + ) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_task_canceled() + raise except Exception as exc: self._log_executor_exception("runtime setup", task_id=task_id, context_id=context_id) + task.active_task = None await self._publish_status( event_queue, task_id=task_id, @@ -978,6 +1002,7 @@ def runtime_factory(session_id: str) -> Any: if ctx.lock is None: ctx.lock = asyncio.Lock() if ctx.active_task_id is not None: + task.active_task = None task.state = TASK_STATE_FAILED await self._publish_status( event_queue, @@ -995,7 +1020,24 @@ def runtime_factory(session_id: str) -> Any: lock = ctx.lock try: await asyncio.wait_for(lock.acquire(), timeout=_CONTEXT_LOCK_ACQUIRE_TIMEOUT_SECONDS) + except asyncio.CancelledError: + task.active_task = None + task.state = TASK_STATE_CANCELED + self._task_store.mirror_task(task) + with contextlib.suppress(Exception): + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_CANCELED, + text=_("Task canceled."), + session_id=ctx.session_id, + ) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_task_canceled() + raise except TimeoutError: + task.active_task = None task.state = TASK_STATE_FAILED await self._publish_status( event_queue, @@ -1020,6 +1062,13 @@ def runtime_factory(session_id: str) -> Any: runtime = ctx.runtime if runtime is None: raise RuntimeError("A2A context runtime missing") + await publish_mcp_warnings( + event_queue, + task_id=task_id, + context_id=context_id, + runtime=runtime, + iac_code_session_id=ctx.session_id, + ) await self._publish_status( event_queue, task_id=task_id, @@ -1070,6 +1119,13 @@ def runtime_factory(session_id: str) -> Any: session_id=ctx.session_id, ) async for event in stream: + await publish_mcp_warnings( + event_queue, + task_id=task_id, + context_id=context_id, + runtime=runtime, + iac_code_session_id=ctx.session_id, + ) text_chunk = await publish_stream_event( event_queue, task_id=task_id, @@ -1083,6 +1139,13 @@ def runtime_factory(session_id: str) -> Any: ) if text_chunk: task.output_text.append(text_chunk) + await publish_mcp_warnings( + event_queue, + task_id=task_id, + context_id=context_id, + runtime=runtime, + iac_code_session_id=ctx.session_id, + ) task.state = TASK_STATE_INPUT_REQUIRED await self._publish_status( event_queue, diff --git a/src/iac_code/a2a/pipeline_events.py b/src/iac_code/a2a/pipeline_events.py index 024969ef..85f3fb99 100644 --- a/src/iac_code/a2a/pipeline_events.py +++ b/src/iac_code/a2a/pipeline_events.py @@ -15,6 +15,7 @@ AskUserQuestionEvent, CandidateDetailEvent, DiagramEvent, + MCPProgressEvent, PermissionRequestEvent, SubPipelineStreamEvent, TextDeltaEvent, @@ -148,6 +149,10 @@ def __init__(self, context: PipelineA2AContext): def last_sequence(self) -> int: return self._sequence + @property + def context(self) -> PipelineA2AContext: + return self._context + def hydrate_from_events(self, events: list[dict[str, Any]]) -> None: latest_parent_step_sequence = -1 for event in events: @@ -273,6 +278,8 @@ def translate(self, event: Any) -> list[dict[str, Any]]: return [self._translate_diagram_event(event)] if isinstance(event, ToolResultEvent): return self._translate_tool_result_event(event) + if isinstance(event, MCPProgressEvent): + return [self._translate_parent_scoped_display_event("tool_progress", _mcp_progress_data(event))] if isinstance(event, SubPipelineStreamEvent): return self._translate_sub_pipeline_stream_event(event) return [] @@ -539,6 +546,11 @@ def _translate_sub_pipeline_stream_event(self, event: SubPipelineStreamEvent) -> data = _tool_result_data(inner) input_data = None permission = None + elif isinstance(inner, MCPProgressEvent): + event_type = "tool_progress" + data = _mcp_progress_data(inner) + input_data = None + permission = None else: return [] @@ -1241,6 +1253,22 @@ def _tool_result_data(event: ToolResultEvent) -> dict[str, Any]: } +def _mcp_progress_data(event: MCPProgressEvent) -> dict[str, Any]: + data: dict[str, Any] = { + "toolName": "mcp__{}__{}".format(event.server_name, event.tool_name), + "toolUseId": event.tool_use_id or "", + "serverName": event.server_name, + "mcpToolName": event.tool_name, + } + if event.progress is not None: + data["progress"] = event.progress + if event.total is not None: + data["total"] = event.total + if event.message: + data["message"] = _truncate(event.message) + return data + + def _completion_step_id(envelope: dict[str, Any]) -> str | None: candidate_step = envelope.get("candidateStep") if isinstance(candidate_step, dict): diff --git a/src/iac_code/a2a/pipeline_executor.py b/src/iac_code/a2a/pipeline_executor.py index bdb8a38a..2c0151b6 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -13,7 +13,7 @@ from a2a.types import Message, Role, TaskState, TaskStatus, TaskStatusUpdateEvent from a2a.utils.errors import InvalidParamsError -from iac_code.a2a.events import make_text_part +from iac_code.a2a.events import make_text_part, publish_mcp_warnings from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator from iac_code.a2a.pipeline_journal import A2APipelineJournal from iac_code.a2a.pipeline_paths import ( @@ -275,6 +275,13 @@ def runtime_factory(session_id: str) -> Any: cwd=cwd, ) agent_runtime = pipeline_runtime.agent_runtime + await publish_mcp_warnings( + event_queue, + task_id=task_id, + context_id=context_id, + runtime=agent_runtime, + iac_code_session_id=ctx.session_id, + ) configure_runtime_model( agent_runtime, self._model, @@ -790,11 +797,25 @@ async def _consume_stream_until_restart( event = await next_task except StopAsyncIteration: next_task = None + await publish_mcp_warnings( + publisher.event_queue, + task_id=publisher.translator.context.task_id, + context_id=publisher.translator.context.context_id, + runtime=runtime.agent_runtime, + iac_code_session_id=publisher.translator.context.iac_code_session_id, + ) return _StreamConsumeResult(had_events=had_events, restart_requested=False) finally: next_task = None had_events = True + await publish_mcp_warnings( + publisher.event_queue, + task_id=publisher.translator.context.task_id, + context_id=publisher.translator.context.context_id, + runtime=runtime.agent_runtime, + iac_code_session_id=publisher.translator.context.iac_code_session_id, + ) text = await publisher.publish( event, permission_resolver=self._permission_resolver, diff --git a/src/iac_code/a2a/task_store.py b/src/iac_code/a2a/task_store.py index 43dfad3d..5cbb1bce 100644 --- a/src/iac_code/a2a/task_store.py +++ b/src/iac_code/a2a/task_store.py @@ -57,6 +57,10 @@ def __init__( self._cleanup_interval_seconds = cleanup_interval_seconds self._cleanup_task: asyncio.Task[None] | None = None self._mutation_lock = asyncio.Lock() + self._context_runtime_tasks: dict[str, asyncio.Task[Any]] = {} + self._context_runtime_waiters: dict[str, int] = {} + self._discarded_context_runtime_tasks: set[asyncio.Task[Any]] = set() + self._discarded_context_runtime_task_waiters: dict[asyncio.Task[Any], int] = {} self._owner_resolver = owner_resolver async def get(self, task_id: str, context: ServerCallContext | None = None) -> Task | None: @@ -228,6 +232,7 @@ async def get_or_create_context( runtime_factory: Callable[[str], Any], ) -> A2AContextRecord: context_id = validate_protocol_id(context_id) + create_task: asyncio.Task[Any] | None = None async with self._mutation_lock: if context_id in self._contexts: record = self._contexts[context_id] @@ -235,28 +240,100 @@ async def get_or_create_context( raise ValueError(_("A2A context expired")) if record.cwd != cwd: raise ValueError(_("A2A context belongs to a different workspace")) + if record.runtime is None: + create_task = self._context_runtime_tasks.get(context_id) + else: + record.touch() + self._mirror_context(record) + return record + else: + session_id: str | None = None + if self._persistence is not None: + snapshot = self._persistence.load_context(context_id) + if snapshot is not None: + if snapshot.cwd != cwd: + raise ValueError(_("A2A context belongs to a different workspace")) + session_id = snapshot.session_id + + if session_id is None: + session_id = str(uuid.uuid4()) + record = A2AContextRecord( + context_id=context_id, + session_id=session_id, + cwd=cwd, + runtime=None, + lock=asyncio.Lock(), + ) + self._contexts[context_id] = record + create_task = self._context_runtime_tasks.get(context_id) + if create_task is None: + create_task = asyncio.create_task(asyncio.to_thread(runtime_factory, session_id)) + self._context_runtime_tasks[context_id] = create_task + self._context_runtime_waiters[context_id] = self._context_runtime_waiters.get(context_id, 0) + 1 + + if create_task is None: # pragma: no cover - defensive guard for inconsistent state. + raise ValueError(_("A2A context not found")) + try: + runtime = await asyncio.shield(create_task) + except asyncio.CancelledError: + discard_task: asyncio.Task[Any] | None = None + async with self._mutation_lock: + remaining = self._decrement_context_runtime_waiter_locked(context_id) + if remaining == 0: + record = self._contexts.get(context_id) + if record is not None and record.runtime is None: + self._contexts.pop(context_id, None) + if self._context_runtime_tasks.get(context_id) is create_task: + discard_task = self._context_runtime_tasks.pop(context_id, None) + if discard_task is not None: + self._mark_discarded_context_runtime_task_locked(discard_task, waiters=0) + if discard_task is not None: + _close_runtime_task_when_done( + discard_task, + self._discarded_context_runtime_tasks, + self._discarded_context_runtime_task_waiters, + ) + raise + except Exception: + async with self._mutation_lock: + self._decrement_context_runtime_waiter_locked(context_id) + record = self._contexts.get(context_id) + if record is not None and record.runtime is None: + self._contexts.pop(context_id, None) + if self._context_runtime_tasks.get(context_id) is create_task: + self._context_runtime_tasks.pop(context_id, None) + raise + + async with self._mutation_lock: + self._decrement_context_runtime_waiter_locked(context_id) + record = self._contexts.get(context_id) + if record is None: + if self._context_runtime_tasks.get(context_id) is create_task: + self._context_runtime_tasks.pop(context_id, None) + if not self._release_discarded_context_runtime_task_locked(create_task): + await _close_runtime(runtime) + raise ValueError(_("A2A context not found")) + if record.expired: + if self._context_runtime_tasks.get(context_id) is create_task: + self._context_runtime_tasks.pop(context_id, None) + if not self._release_discarded_context_runtime_task_locked(create_task): + await _close_runtime(runtime) + raise ValueError(_("A2A context expired")) + if record.cwd != cwd: + if self._context_runtime_tasks.get(context_id) is create_task: + self._context_runtime_tasks.pop(context_id, None) + if not self._release_discarded_context_runtime_task_locked(create_task): + await _close_runtime(runtime) + raise ValueError(_("A2A context belongs to a different workspace")) + if record.runtime is None: + record.runtime = runtime + if self._context_runtime_tasks.get(context_id) is create_task: + self._context_runtime_tasks.pop(context_id, None) record.touch() self._mirror_context(record) - return record - - session_id: str | None = None - if self._persistence is not None: - snapshot = self._persistence.load_context(context_id) - if snapshot is not None: - if snapshot.cwd != cwd: - raise ValueError(_("A2A context belongs to a different workspace")) - session_id = snapshot.session_id - - if session_id is None: - session_id = str(uuid.uuid4()) - record = A2AContextRecord( - context_id=context_id, - session_id=session_id, - cwd=cwd, - runtime=runtime_factory(session_id), - lock=asyncio.Lock(), - ) - self._contexts[context_id] = record + elif runtime is not record.runtime: + await _close_runtime(runtime) + record.touch() self._mirror_context(record) return record @@ -311,6 +388,29 @@ async def get_task_record(self, task_id: str) -> A2ATaskRecord: raise ValueError(_("A2A task not found")) + def _decrement_context_runtime_waiter_locked(self, context_id: str) -> int: + count = self._context_runtime_waiters.get(context_id, 0) + if count <= 1: + self._context_runtime_waiters.pop(context_id, None) + return 0 + self._context_runtime_waiters[context_id] = count - 1 + return count - 1 + + def _mark_discarded_context_runtime_task_locked(self, task: asyncio.Task[Any], *, waiters: int) -> None: + self._discarded_context_runtime_tasks.add(task) + self._discarded_context_runtime_task_waiters[task] = max(waiters, 0) + + def _release_discarded_context_runtime_task_locked(self, task: asyncio.Task[Any]) -> bool: + if task not in self._discarded_context_runtime_tasks: + return False + remaining = self._discarded_context_runtime_task_waiters.get(task, 0) + if remaining <= 1: + self._discarded_context_runtime_task_waiters.pop(task, None) + self._discarded_context_runtime_tasks.discard(task) + else: + self._discarded_context_runtime_task_waiters[task] = remaining - 1 + return True + async def ensure_task_not_expired(self, task_id: str) -> None: async with self._mutation_lock: if validate_protocol_id(task_id) in self._expired_task_tombstones: @@ -342,10 +442,14 @@ async def cleanup_once(self, *, now_offset_seconds: float = 0) -> None: expired_context_ids = [ context_id for context_id, context in self._contexts.items() - if context.active_task_id is None and now - context.last_active > self._idle_timeout_seconds + if context.active_task_id is None + and now - context.last_active > self._idle_timeout_seconds + and context_id not in self._context_runtime_tasks ] for context_id in expired_context_ids: - self._contexts.pop(context_id, None) + record = self._contexts.pop(context_id, None) + if record is not None: + await _close_runtime(record.runtime) for task_id, task in list(self._tasks.items()): if task.context_id == context_id: task.expired = True @@ -369,14 +473,32 @@ async def start_cleanup_loop(self) -> None: self._cleanup_task = asyncio.create_task(self._cleanup_loop()) async def stop_cleanup_loop(self) -> None: - if self._cleanup_task is None: - return - self._cleanup_task.cancel() - try: - await self._cleanup_task - except asyncio.CancelledError: - pass - self._cleanup_task = None + if self._cleanup_task is not None: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + self._cleanup_task = None + async with self._mutation_lock: + records = list(self._contexts.values()) + runtime_tasks = [ + (task, self._context_runtime_waiters.get(context_id, 0)) + for context_id, task in self._context_runtime_tasks.items() + ] + self._contexts.clear() + self._context_runtime_tasks.clear() + self._context_runtime_waiters.clear() + for task, waiters in runtime_tasks: + self._mark_discarded_context_runtime_task_locked(task, waiters=waiters) + for record in records: + await _close_runtime(record.runtime) + for task, _waiters in runtime_tasks: + _close_runtime_task_when_done( + task, + self._discarded_context_runtime_tasks, + self._discarded_context_runtime_task_waiters, + ) async def _cleanup_loop(self) -> None: while True: @@ -485,6 +607,65 @@ def _remove_sdk_task_from_index(self, owner: str, task_id: str, context_id: str) self._sdk_tasks_by_context.pop(owner, None) +async def _close_runtime(runtime: Any | None) -> None: + if runtime is None: + return + close = getattr(runtime, "aclose", None) + if callable(close): + try: + result = close() + if asyncio.iscoroutine(result): + await result + return + except Exception: + logger.exception("Failed to close A2A runtime") + return + manager = getattr(runtime, "mcp_manager", None) + if manager is not None: + try: + await manager.disconnect_all() + except Exception: + logger.exception("Failed to disconnect A2A MCP manager") + agent_runtime = getattr(runtime, "agent_runtime", None) + if agent_runtime is not None and agent_runtime is not runtime: + await _close_runtime(agent_runtime) + + +def _close_runtime_task_when_done( + task: asyncio.Task[Any], + discarded_tasks: set[asyncio.Task[Any]] | None = None, + discarded_task_waiters: dict[asyncio.Task[Any], int] | None = None, +) -> None: + def discard_marker(done: asyncio.Task[Any]) -> None: + if discarded_task_waiters is not None: + discarded_task_waiters.pop(done, None) + if discarded_tasks is not None: + discarded_tasks.discard(done) + + def close_result(done: asyncio.Task[Any]) -> None: + try: + runtime = done.result() + except asyncio.CancelledError: + discard_marker(done) + return + except Exception: + discard_marker(done) + logger.debug("Discarded A2A runtime creation task failed", exc_info=True) + return + loop = done.get_loop() + if loop.is_closed(): + discard_marker(done) + return + loop.create_task(_close_runtime(runtime)) + if discarded_task_waiters is None or discarded_task_waiters.get(done, 0) <= 0: + discard_marker(done) + + if task.done(): + close_result(task) + else: + task.add_done_callback(close_result) + + def _copy_task(task: Task) -> Task: copied = Task() copied.CopyFrom(task) diff --git a/src/iac_code/acp/convert.py b/src/iac_code/acp/convert.py index 760c3491..c717b57c 100644 --- a/src/iac_code/acp/convert.py +++ b/src/iac_code/acp/convert.py @@ -9,9 +9,11 @@ from iac_code.a2a.artifacts import sanitize_public_tool_output_data from iac_code.acp.state import TurnState, display_tool_title from iac_code.acp.types import ACPContentBlock +from iac_code.i18n import _ from iac_code.types.stream_events import ( CompactionEvent, ErrorEvent, + MCPProgressEvent, MessageEndEvent, PermissionRequestEvent, PlanEvent, @@ -93,6 +95,10 @@ def _tool_kind(tool_name: str) -> ToolKind: return "execute" if tool_name.endswith("_doc_search"): return "fetch" + if tool_name.startswith("mcp__"): + if tool_name.endswith("__search") or tool_name.endswith("_search"): + return "fetch" + return "execute" return "other" @@ -226,6 +232,33 @@ def event_to_updates(self, event: StreamEvent) -> list[SessionUpdate]: content=[_text_tool_content(str(input))], ) ] + case MCPProgressEvent( + server_name=server_name, + tool_name=tool_name, + progress=progress_value, + total=total, + message=message, + tool_use_id=tool_use_id, + ): + return [ + acp.schema.ToolCallProgress( + session_update="tool_call_update", + tool_call_id=self.acp_tool_call_id(tool_use_id or f"mcp-{server_name}-{tool_name}"), + title=_("MCP {server}:{tool}").format(server=server_name, tool=tool_name), + status="in_progress", + content=[ + _text_tool_content( + _format_mcp_progress_text( + server_name=server_name, + tool_name=tool_name, + progress=progress_value, + total=total, + message=message, + ) + ) + ], + ) + ] case ToolResultEvent(tool_use_id=tool_use_id, tool_name=tool_name, result=result, is_error=is_error): visible_result = _public_tool_result_text(result) content: list[ @@ -326,6 +359,24 @@ def _text_tool_content(text: str) -> acp.schema.ContentToolCallContent: ) +def _format_mcp_progress_text( + *, + server_name: str, + tool_name: str, + progress: float | None, + total: float | None, + message: str | None, +) -> str: + parts = [_("MCP {server}:{tool}").format(server=server_name, tool=tool_name)] + if progress is not None and total is not None: + parts.append("{:g}/{:g}".format(progress, total)) + elif progress is not None: + parts.append("{:g}".format(progress)) + if message: + parts.append(sanitize_public_text(message)) + return ": ".join(parts) + + def _public_tool_result_text(value: Any) -> str: sanitized = sanitize_public_tool_output_data(value) if isinstance(sanitized, str): diff --git a/src/iac_code/acp/server.py b/src/iac_code/acp/server.py index 6ad881ab..69497ebb 100644 --- a/src/iac_code/acp/server.py +++ b/src/iac_code/acp/server.py @@ -21,6 +21,7 @@ from iac_code.acp.types import ACPContentBlock, MCPServer from iac_code.acp.version import negotiate_version from iac_code.commands import LocalCommand, create_default_registry +from iac_code.commands.registry import PromptCommand from iac_code.config import DEFAULT_MODEL, get_active_provider_key, load_saved_model from iac_code.i18n import _ from iac_code.pipeline.config import RunMode, get_run_mode @@ -170,7 +171,7 @@ async def initialize( image=False, audio=False, ), - mcp_capabilities=acp.schema.McpCapabilities(http=False, sse=False), + mcp_capabilities=acp.schema.McpCapabilities(http=True, sse=True), session_capabilities=acp.schema.SessionCapabilities( close=acp.schema.SessionCloseCapabilities(), list=acp.schema.SessionListCapabilities(), @@ -196,7 +197,7 @@ async def new_session( mcp_configs = _convert_mcp_servers(mcp_servers) model = load_saved_model() or DEFAULT_MODEL - runtime = self._create_runtime_with_auth_check(model=model, cwd=cwd) + runtime = await self._create_runtime_with_auth_check_async(model=model, cwd=cwd, mcp_configs=mcp_configs) replace_bash_with_acp_terminal( runtime.tool_registry, self.client_capabilities, @@ -220,6 +221,7 @@ async def new_session( ) # Push available commands to the client + await self._push_mcp_warnings(session.id) await self._push_available_commands(session.id) return response @@ -332,7 +334,12 @@ async def load_session( # 3. Rebuild agent runtime with restored history model = load_saved_model() or DEFAULT_MODEL - runtime = self._create_runtime_with_auth_check(model=model, session_id=session_id, cwd=cwd) + runtime = await self._create_runtime_with_auth_check_async( + model=model, + session_id=session_id, + cwd=cwd, + mcp_configs=mcp_configs, + ) replace_bash_with_acp_terminal( runtime.tool_registry, self.client_capabilities, @@ -357,6 +364,7 @@ async def load_session( session._replay_task = asyncio.create_task(self._replay_session_history(session, history)) # 6. Push available commands + await self._push_mcp_warnings(session_id) await self._push_available_commands(session_id) return acp.LoadSessionResponse(models=self._build_model_state(model)) @@ -399,7 +407,12 @@ async def fork_session( # 2. Create a new runtime for the fork new_session_id = str(uuid.uuid4()) model = load_saved_model() or DEFAULT_MODEL - runtime = self._create_runtime_with_auth_check(model=model, session_id=new_session_id, cwd=cwd) + runtime = await self._create_runtime_with_auth_check_async( + model=model, + session_id=new_session_id, + cwd=cwd, + mcp_configs=mcp_configs, + ) replace_bash_with_acp_terminal( runtime.tool_registry, self.client_capabilities, @@ -424,6 +437,7 @@ async def fork_session( if history: session._replay_task = asyncio.create_task(self._replay_session_history(session, history)) + await self._push_mcp_warnings(new_session_id) await self._push_available_commands(new_session_id) return acp.schema.ForkSessionResponse( @@ -447,6 +461,7 @@ async def resume_session( error = _active_session_project_error(cwd, session_id, session_id, active_session) if error is not None: raise error + await self._push_mcp_warnings(session_id) await self._push_available_commands(session_id) return acp.schema.ResumeSessionResponse() @@ -492,6 +507,7 @@ async def resume_session( error = _active_session_project_error(cwd, session_id, resolved_session_id, active_session) if error is not None: raise error + await self._push_mcp_warnings(resolved_session_id) await self._push_available_commands(resolved_session_id) return acp.schema.ResumeSessionResponse() @@ -508,7 +524,12 @@ async def resume_session( # 3. Rebuild agent runtime with restored history model = load_saved_model() or DEFAULT_MODEL - runtime = self._create_runtime_with_auth_check(model=model, session_id=resolved_session_id, cwd=cwd) + runtime = await self._create_runtime_with_auth_check_async( + model=model, + session_id=resolved_session_id, + cwd=cwd, + mcp_configs=mcp_configs, + ) replace_bash_with_acp_terminal( runtime.tool_registry, self.client_capabilities, @@ -527,6 +548,7 @@ async def resume_session( ) self.sessions[resolved_session_id] = session self.metrics.record_session_created() + await self._push_mcp_warnings(resolved_session_id) await self._push_available_commands(resolved_session_id) return acp.schema.ResumeSessionResponse() @@ -544,13 +566,60 @@ def _create_acp_session_from_runtime( if self.conn is None: raise acp.RequestError.internal_error({"error": "ACP client not connected"}) - return ACPSession( + session = ACPSession( runtime.session_id, runtime.agent_loop, self.conn, mcp_configs=mcp_configs, + mcp_manager=getattr(runtime, "mcp_manager", None), + command_registry=getattr(runtime, "command_registry", None), metrics=self.metrics, memory_manager=_runtime_command_memory_manager(runtime), + runtime=runtime, + mcp_config_warnings=getattr(runtime, "mcp_config_warnings", None), + ) + add_mcp_change_listener = getattr(runtime, "add_mcp_change_listener", None) + if add_mcp_change_listener is not None: + server_loop = asyncio.get_running_loop() + + async def on_mcp_commands_changed(server_name: str, capability: str) -> None: + _ = server_name + if session.id in self.sessions: + + async def push_updates() -> None: + if session.id not in self.sessions: + return + await self._push_mcp_warnings(session.id) + if capability in {"prompts", "resources"}: + await self._push_available_commands(session.id) + + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: # pragma: no cover - async listener normally has a loop. + running_loop = None + if running_loop is server_loop: + await push_updates() + else: + future = asyncio.run_coroutine_threadsafe(push_updates(), server_loop) + await asyncio.wrap_future(future) + + add_mcp_change_listener(on_mcp_commands_changed) + return session + + @staticmethod + async def _create_runtime_with_auth_check_async( + *, + model: str, + cwd: str, + session_id: str | None = None, + mcp_configs: list[dict[str, Any]] | None = None, + ): + return await asyncio.to_thread( + ACPServer._create_runtime_with_auth_check, + model=model, + session_id=session_id, + cwd=cwd, + mcp_configs=mcp_configs, ) @staticmethod @@ -559,10 +628,13 @@ def _create_runtime_with_auth_check( model: str, cwd: str, session_id: str | None = None, + mcp_configs: list[dict[str, Any]] | None = None, ): """Create an agent runtime, converting auth errors to ACP RequestError.""" try: - return create_agent_runtime(AgentFactoryOptions(model=model, session_id=session_id, cwd=cwd)) + return create_agent_runtime( + AgentFactoryOptions(model=model, session_id=session_id, cwd=cwd, mcp_configs=mcp_configs) + ) except Exception as exc: if _is_auth_error(exc): logger.warning("Authentication error during runtime creation: %s", exc) @@ -619,10 +691,13 @@ async def _push_available_commands(self, session_id: str) -> None: """Push the list of available slash commands to the client via session_update.""" if self.conn is None: return - registry = create_default_registry() + session = self.sessions.get(session_id) + if session is None: + return + registry = getattr(session, "command_registry", None) or create_default_registry() commands = [] for cmd in registry.get_all(): - if cmd.name not in ACP_SUPPORTED_COMMANDS: + if cmd.name not in ACP_SUPPORTED_COMMANDS and not isinstance(cmd, PromptCommand): continue # Build input hint: prefer arg_hint, fall back to arg_names hint = None @@ -631,6 +706,9 @@ async def _push_available_commands(self, session_id: str) -> None: hint = cmd.arg_hint elif cmd.arg_names: hint = " ".join(f"[{name}]" for name in cmd.arg_names) + elif isinstance(cmd, PromptCommand): + frontmatter = getattr(cmd.skill, "frontmatter", None) + hint = getattr(frontmatter, "argument_hint", None) input_spec = ( acp.schema.AvailableCommandInput(root=acp.schema.UnstructuredCommandInput(hint=hint)) if hint else None @@ -652,6 +730,31 @@ async def _push_available_commands(self, session_id: str) -> None: ), ) + async def _push_mcp_warnings(self, session_id: str) -> None: + """Surface MCP startup/config warnings to ACP clients once per session.""" + if self.conn is None: + return + session = self.sessions.get(session_id) + if session is None: + return + warnings = list(getattr(session, "mcp_config_warnings", None) or []) + pushed_count = getattr(session, "_mcp_warnings_pushed_count", 0) + if pushed_count >= len(warnings): + return + for warning in warnings[pushed_count:]: + message = getattr(warning, "message", None) or str(warning) + await self.conn.session_update( + session_id=session_id, + update=acp.schema.AgentMessageChunk( + session_update="agent_message_chunk", + content=acp.schema.TextContentBlock( + type="text", + text=_("MCP warning: {message}").format(message=message), + ), + ), + ) + setattr(session, "_mcp_warnings_pushed_count", len(warnings)) + # ------------------------------------------------------------------ # Cleanup loop # ------------------------------------------------------------------ diff --git a/src/iac_code/acp/session.py b/src/iac_code/acp/session.py index 880410d2..8b3c3892 100644 --- a/src/iac_code/acp/session.py +++ b/src/iac_code/acp/session.py @@ -27,6 +27,7 @@ ToolUseBlock, is_recalled_memory_message, ) +from iac_code.commands.registry import PromptCommand from iac_code.services.telemetry import use_session_id from iac_code.state.app_state import lookup_permission, record_permission from iac_code.types.permissions import PermissionDecision @@ -220,11 +221,16 @@ def __init__( agent_loop, conn: acp.Client, mcp_configs: list[dict] | None = None, + mcp_manager=None, + command_registry=None, metrics: ACPMetrics | None = None, memory_manager=None, + runtime=None, + mcp_config_warnings: list[Any] | None = None, ) -> None: self.id = session_id self.agent_loop = agent_loop + self.runtime = runtime self.memory_manager = memory_manager self._conn = conn self._current_task: asyncio.Task | None = None @@ -237,9 +243,11 @@ def __init__( self._permission_cache: OrderedDict[str, PermissionDecision] = OrderedDict() # Auto-detect tool names whose output is already displayed via ACP terminal. self._terminal_tool_names: set[str] = self._detect_terminal_tools() - # MCP server configs passed from the client (internal dict format) - # TODO: Wire into agent tool registry when MCP tool integration is implemented self.mcp_configs: list[dict] = mcp_configs or [] + self.mcp_manager = mcp_manager + self.mcp_config_warnings = mcp_config_warnings if mcp_config_warnings is not None else [] + self._mcp_warnings_pushed_count = 0 + self.command_registry = command_registry # Dynamic session configuration (temperature, max_tokens, etc.) self._config: dict[str, Any] = {} # Whether this session has been closed @@ -335,6 +343,14 @@ async def close(self) -> None: # Clean up turn state self._current_turn = None + runtime_close = getattr(self.runtime, "aclose", None) + if callable(runtime_close): + with contextlib.suppress(Exception): + await runtime_close() + elif self.mcp_manager is not None: + with contextlib.suppress(Exception): + await self.mcp_manager.disconnect_all() + # Clear permission cache and config self._permission_cache.clear() self._config.clear() @@ -350,20 +366,30 @@ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse: # Intercept slash commands before sending to agent loop prompt_text = acp_blocks_to_prompt_text(prompt) slash_registry = ACPSlashRegistry() + stream_factory = None if slash_registry.is_slash_command(prompt_text): - result = await slash_registry.execute( - prompt_text, - self.agent_loop, - memory_manager=self.memory_manager, - ) - await self._conn.session_update( - session_id=self.id, - update=acp.schema.AgentMessageChunk( - session_update="agent_message_chunk", - content=acp.schema.TextContentBlock(type="text", text=result), - ), - ) - return acp.PromptResponse(stop_reason="end_turn") + prompt_command = self._lookup_prompt_command(prompt_text) + if prompt_command is not None: + prompt_text, stream_factory = await self._prepare_prompt_command(prompt_text, prompt_command) + else: + result = await slash_registry.execute( + prompt_text, + self.agent_loop, + memory_manager=self.memory_manager, + ) + await self._conn.session_update( + session_id=self.id, + update=acp.schema.AgentMessageChunk( + session_update="agent_message_chunk", + content=acp.schema.TextContentBlock(type="text", text=result), + ), + ) + return acp.PromptResponse(stop_reason="end_turn") + + if stream_factory is None: + + def stream_factory(): + return self.agent_loop.run_streaming(prompt_text) converter: ACPEventConverter | None = None @@ -380,7 +406,7 @@ async def _run() -> None: ) logger.debug("Prompt started, session_id=%s, turn_id=%s", self.id, turn_id) with use_session_id(self.id): - async for event in self.agent_loop.run_streaming(prompt_text): + async for event in stream_factory(): if isinstance(event, PermissionRequestEvent): allowed = await self._request_permission(event) if event.response_future is not None and not event.response_future.done(): @@ -452,6 +478,42 @@ async def _run() -> None: response.field_meta = meta return response + def _lookup_prompt_command(self, prompt_text: str) -> PromptCommand | None: + command_registry = self.command_registry + if command_registry is None: + return None + stripped = prompt_text.strip() + parts = stripped[1:].split(None, 1) + if not parts: + return None + name = parts[0] + command = command_registry.get(name) or command_registry.get(name.lower()) + return command if isinstance(command, PromptCommand) else None + + async def _prepare_prompt_command(self, prompt_text: str, command: PromptCommand): + from iac_code.skills.processor import process_prompt_command + + stripped = prompt_text.strip() + parts = stripped[1:].split(None, 1) + args = parts[1] if len(parts) > 1 else "" + result = await process_prompt_command(command, args, session_id=self.id) + if result.is_fork: + return result.prompt_content, lambda: self.agent_loop.run_streaming(result.prompt_content) + context_manager = getattr(self.agent_loop, "context_manager", None) + if context_manager is not None: + for message in result.new_messages: + add_raw_message = getattr(context_manager, "add_raw_message", None) + if add_raw_message is not None: + add_raw_message(message) + if result.context_modifier: + apply_context_modifier = getattr(self.agent_loop, "_apply_context_modifier", None) + if apply_context_modifier is not None: + apply_context_modifier(result.context_modifier) + continue_streaming = getattr(self.agent_loop, "continue_streaming", None) + if callable(continue_streaming): + return result.prompt_content, continue_streaming + return result.prompt_content, lambda: self.agent_loop.run_streaming(result.prompt_content) + async def cancel(self) -> None: if self._current_task is not None and not self._current_task.done(): logger.info("Session %s cancel requested", self.id) diff --git a/src/iac_code/cli/headless.py b/src/iac_code/cli/headless.py index 5a6b6730..42e1a258 100644 --- a/src/iac_code/cli/headless.py +++ b/src/iac_code/cli/headless.py @@ -22,6 +22,7 @@ from iac_code.providers.manager import ProviderNotConfiguredError from iac_code.types.stream_events import ( ErrorEvent, + MCPProgressEvent, MessageEndEvent, PermissionRequestEvent, StackInstancesProgressEvent, @@ -74,12 +75,25 @@ def handle(self, event: Any) -> None: event.status, event.progress_percentage, ) + elif isinstance(event, MCPProgressEvent): + line = _format_mcp_progress(event) if line is not None: self._stream.write(line + "\n") self._stream.flush() +def _format_mcp_progress(event: MCPProgressEvent) -> str: + parts = [_("MCP {server}:{tool}").format(server=event.server_name, tool=event.tool_name)] + if event.progress is not None and event.total is not None: + parts.append("{:g}/{:g}".format(event.progress, event.total)) + elif event.progress is not None: + parts.append("{:g}".format(event.progress)) + if event.message: + parts.append(event.message) + return ": ".join(parts) + + class HeadlessRunner: """Run a single prompt headlessly, auto-approving all permission requests.""" @@ -104,6 +118,9 @@ def __init__( self._cli_permission_mode = cli_permission_mode self._verbose = verbose self._progress_stream = progress_stream or sys.stderr + self._mcp_config_warnings: list[Any] = [] + self._mcp_warnings_printed_count = 0 + self._runtime: Any | None = None def _print_provider_not_configured(self, exc: Exception) -> None: logger.error("Provider not configured: {}", exc) @@ -139,8 +156,19 @@ def _create_agent_loop(self) -> Any: cli_permission_mode=self._cli_permission_mode, ) ) + self._runtime = runtime + self._mcp_config_warnings = runtime.mcp_config_warnings if runtime.mcp_config_warnings is not None else [] return runtime.agent_loop + def _print_mcp_config_warnings(self) -> None: + warnings = list(self._mcp_config_warnings or []) + for warning in warnings[self._mcp_warnings_printed_count :]: + self._progress_stream.write( + _("MCP warning: {message}\n").format(message=getattr(warning, "message", warning)) + ) + self._progress_stream.flush() + self._mcp_warnings_printed_count = len(warnings) + async def run(self, prompt: str) -> int: """Execute a single prompt to completion and return an exit code.""" from iac_code.services.telemetry import graceful_shutdown, log_event @@ -157,7 +185,9 @@ async def run(self, prompt: str) -> int: try: agent_loop = self._create_agent_loop() + self._print_mcp_config_warnings() async for event in agent_loop.run_streaming(prompt): + self._print_mcp_config_warnings() if isinstance(event, PermissionRequestEvent): if event.response_future is not None: from iac_code.services.telemetry import log_event @@ -191,6 +221,15 @@ async def run(self, prompt: str) -> int: self._print_unexpected_error(exc) self._record_structured_error(writer, exc) has_error = True + finally: + runtime = self._runtime + self._runtime = None + close = getattr(runtime, "aclose", None) + if close is not None: + try: + await close() + except Exception: + logger.debug("Headless runtime close failed", exc_info=True) writer.finalize() diff --git a/src/iac_code/cli/main.py b/src/iac_code/cli/main.py index cb9d34c7..d93e92bb 100644 --- a/src/iac_code/cli/main.py +++ b/src/iac_code/cli/main.py @@ -71,6 +71,10 @@ def _a2a_server_missing_dependencies_message() -> str: app.command(name="update", help=_("Update iac-code to the latest version."))(_update_command) +from iac_code.mcp.cli import app as _mcp_app # noqa: E402 + +app.add_typer(_mcp_app, name="mcp") + @a2a_client_app.callback() def a2a_client( diff --git a/src/iac_code/commands/registry.py b/src/iac_code/commands/registry.py index 1d15115d..b116dc70 100644 --- a/src/iac_code/commands/registry.py +++ b/src/iac_code/commands/registry.py @@ -139,6 +139,15 @@ def register(self, command: Command) -> None: for alias in command.aliases: self._commands[alias] = command + def unregister(self, name: str) -> None: + """Unregister a command and aliases that point at it.""" + command = self._commands.pop(name, None) + if command is None: + return + for alias in command.aliases: + if self._commands.get(alias) is command: + del self._commands[alias] + def clear_prompt_commands(self) -> None: """Remove all skill-backed commands while preserving local commands.""" for name, command in list(self._commands.items()): diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 4ee656f4..41e5fc6a 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -48,6 +48,11 @@ msgstr "Unsicherer Artefaktdateiname" msgid "Unknown error" msgstr "Unbekannter Fehler" +#: src/iac_code/a2a/events.py src/iac_code/acp/server.py +#, python-brace-format +msgid "MCP warning: {message}" +msgstr "MCP-Warnung: {message}" + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -80,15 +85,15 @@ msgstr "" "Die Rollback-Bereinigung läuft noch. Bitte fahren Sie fort, nachdem die " "Bereinigung abgeschlossen ist." -#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py -msgid "Task is already working." -msgstr "Die Aufgabe läuft bereits." - #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py #: src/iac_code/a2a/transports/dispatcher.py msgid "Task canceled." msgstr "Aufgabe abgebrochen." +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "Task is already working." +msgstr "Die Aufgabe läuft bereits." + #: src/iac_code/a2a/executor.py #, python-brace-format msgid "Current model {model} does not support image input." @@ -170,6 +175,13 @@ msgstr "Aufgabe abgeschlossen." msgid "Task failed." msgstr "Aufgabe fehlgeschlagen." +#: src/iac_code/acp/convert.py src/iac_code/cli/headless.py +#: src/iac_code/mcp/tools.py src/iac_code/ui/renderer.py +#: src/iac_code/ui/stream_accumulator.py +#, python-brace-format +msgid "MCP {server}:{tool}" +msgstr "MCP {server}:{tool}" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "Sitzung nicht gefunden" @@ -500,6 +512,11 @@ msgstr "" msgid "Error: {error}" msgstr "Fehler: {error}" +#: src/iac_code/cli/headless.py +#, python-brace-format +msgid "MCP warning: {message}\n" +msgstr "MCP-Warnung: {message}\n" + #: src/iac_code/cli/install_git_bash.py #, python-brace-format msgid "Git Bash is already installed at {}" @@ -2122,6 +2139,575 @@ msgstr "vor {n} Minute{s}" msgid "{n} more line{s}" msgstr "{n} weitere Zeile{s}" +#: src/iac_code/mcp/cli.py +msgid "Manage MCP servers." +msgstr "MCP-Server verwalten." + +#: src/iac_code/mcp/cli.py +msgid "Command for stdio MCP server." +msgstr "Befehl für stdio-MCP-Server." + +#: src/iac_code/mcp/cli.py +msgid "Command argument. Can be repeated." +msgstr "Befehlsargument. Kann wiederholt werden." + +#: src/iac_code/mcp/cli.py +msgid "Environment variable KEY=VALUE. Can be repeated." +msgstr "Umgebungsvariable KEY=VALUE. Kann wiederholt werden." + +#: src/iac_code/mcp/cli.py +msgid "Transport type: stdio, http, sse." +msgstr "Transporttyp: stdio, http, sse." + +#: src/iac_code/mcp/cli.py +msgid "Remote MCP URL for http/sse." +msgstr "Remote-MCP-URL für http/sse." + +#: src/iac_code/mcp/cli.py +msgid "HTTP header KEY=VALUE. Can be repeated." +msgstr "HTTP-Header KEY=VALUE. Kann wiederholt werden." + +#: src/iac_code/mcp/cli.py +msgid "Config scope: user, local, project." +msgstr "Konfigurationsbereich: user, local, project." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client id." +msgstr "OAuth-Client-ID." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret. Pass the option without a value to enter it securely." +msgstr "" +"OAuth-Client-Secret. Übergeben Sie die Option ohne Wert, um es sicher " +"einzugeben." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret env var name." +msgstr "Name der Umgebungsvariable für das OAuth-Client-Secret." + +#: src/iac_code/mcp/cli.py +msgid "OAuth loopback callback port." +msgstr "OAuth-Loopback-Callback-Port." + +#: src/iac_code/mcp/cli.py +msgid "OAuth authorization server metadata URL." +msgstr "Metadaten-URL des OAuth-Autorisierungsservers." + +#: src/iac_code/mcp/cli.py +msgid "--command is required for stdio MCP servers." +msgstr "--command ist für stdio-MCP-Server erforderlich." + +#: src/iac_code/mcp/cli.py +msgid "Warning: on Windows, bare npx may need to be configured as: cmd /c npx" +msgstr "" +"Warnung: Unter Windows muss ein bloßes npx eventuell so konfiguriert " +"werden: cmd /c npx" + +#: src/iac_code/mcp/cli.py +msgid "--url is required for remote MCP servers." +msgstr "--url ist für Remote-MCP-Server erforderlich." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Added MCP server {name!r} to {scope} config." +msgstr "MCP-Server {name!r} zur Konfiguration {scope} hinzugefügt." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid JSON: {error}" +msgstr "Ungültiges JSON: {error}" + +#: src/iac_code/mcp/cli.py +msgid "MCP server JSON must be an object." +msgstr "Das JSON des MCP-Servers muss ein Objekt sein." + +#: src/iac_code/mcp/cli.py +msgid "No MCP servers configured." +msgstr "Keine MCP-Server konfiguriert." + +#: src/iac_code/mcp/cli.py +msgid "approved" +msgstr "genehmigt" + +#: src/iac_code/mcp/cli.py src/iac_code/ui/repl.py +msgid "pending" +msgstr "ausstehend" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP server {name!r} not found in {scope} config." +msgstr "MCP-Server {name!r} wurde in der Konfiguration {scope} nicht gefunden." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Removed MCP server {name!r} from {path}." +msgstr "MCP-Server {name!r} aus {path} entfernt." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Approved MCP server {name!r}." +msgstr "MCP-Server {name!r} genehmigt." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Rejected MCP server {name!r}." +msgstr "MCP-Server {name!r} abgelehnt." + +#: src/iac_code/mcp/cli.py +msgid "Reset MCP project approval choices." +msgstr "MCP-Projektgenehmigungen wurden zurückgesetzt." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP auth failed for {name!r}: {error}" +msgstr "MCP-Authentifizierung für {name!r} fehlgeschlagen: {error}" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Authenticated MCP server {name!r}." +msgstr "MCP-Server {name!r} authentifiziert." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Reset stored MCP auth state for {name!r}." +msgstr "Gespeicherter MCP-Authentifizierungsstatus für {name!r} zurückgesetzt." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid MCP scope {scope!r}. Valid values: user, local, project." +msgstr "Ungültiger MCP-Bereich {scope!r}. Gültige Werte: user, local, project." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Scope {scope!r} cannot be used for persisted MCP config." +msgstr "" +"Der Bereich {scope!r} kann nicht für persistierte MCP-Konfiguration " +"verwendet werden." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "" +"MCP {section} {key!r} may contain a secret; use an environment variable " +"reference like ${{VAR}} instead of storing plaintext." +msgstr "" +"MCP {section} {key!r} kann ein Secret enthalten; verwenden Sie eine " +"Umgebungsvariablenreferenz wie ${{VAR}}, statt Klartext zu speichern." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects KEY=VALUE, got {value!r}." +msgstr "{option} erwartet KEY=VALUE, erhalten wurde {value!r}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects a non-empty key." +msgstr "{option} erwartet einen nicht leeren Schlüssel." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Project MCP server {name!r} not found." +msgstr "Projekt-MCP-Server {name!r} nicht gefunden." + +#: src/iac_code/mcp/client.py src/iac_code/mcp/manager.py +#, python-brace-format +msgid "MCP server {server!r} is not connected." +msgstr "Der MCP-Server {server!r} ist nicht verbunden." + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "Unsupported MCP transport: {transport}" +msgstr "Nicht unterstützter MCP-Transport: {transport}" + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication." +msgstr "Der MCP-Server {server!r} erfordert Authentifizierung." + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "" +"{message}\n" +"MCP server stderr:\n" +"{stderr}" +msgstr "" +"{message}\n" +"stderr des MCP-Servers:\n" +"{stderr}" + +#: src/iac_code/mcp/config.py +msgid "MCP server names must be non-empty strings." +msgstr "MCP-Servernamen müssen nicht leere Zeichenketten sein." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Project MCP server {name!r} is pending approval." +msgstr "Der Projekt-MCP-Server {name!r} wartet auf Genehmigung." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Cannot persist MCP server config to {scope!r} scope." +msgstr "" +"MCP-Serverkonfiguration kann nicht im Bereich {scope!r} persistiert " +"werden." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP scope {scope!r} is not a persisted config scope." +msgstr "Der MCP-Bereich {scope!r} ist kein persistierter Konfigurationsbereich." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server {existing!r} has the same content signature as {current!r}; " +"keeping higher-precedence server {current!r}." +msgstr "" +"Der MCP-Server {existing!r} hat dieselbe Inhaltssignatur wie {current!r};" +" der höher priorisierte Server {current!r} wird beibehalten." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP server name {name!r} is reserved." +msgstr "Der MCP-Servername {name!r} ist reserviert." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server name {name!r} may only contain letters, numbers, dot, " +"underscore, and hyphen." +msgstr "" +"Der MCP-Servername {name!r} darf nur Buchstaben, Zahlen, Punkt, " +"Unterstrich und Bindestrich enthalten." + +#: src/iac_code/mcp/env_expansion.py +#, python-brace-format +msgid "Environment variable {name!r} is not set for MCP config." +msgstr "" +"Die Umgebungsvariable {name!r} ist für die MCP-Konfiguration nicht " +"gesetzt." + +#: src/iac_code/mcp/manager.py +#, python-brace-format +msgid "" +"MCP server {server!r} requested elicitation, but iac-code does not " +"support elicitation yet." +msgstr "" +"Der MCP-Server {server!r} hat elicitation angefordert, aber iac-code " +"unterstützt elicitation noch nicht." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "Could not discover OAuth metadata for MCP server {server!r}." +msgstr "OAuth-Metadaten für MCP-Server {server!r} konnten nicht ermittelt werden." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth token response for MCP server {server!r} did not include an access " +"token." +msgstr "" +"Die OAuth-Token-Antwort des MCP-Servers {server!r} enthielt kein Access-" +"Token." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "No refresh token is available for MCP server {server!r}." +msgstr "Für MCP-Server {server!r} ist kein Refresh-Token verfügbar." + +#: src/iac_code/mcp/oauth.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication: {error}" +msgstr "Der MCP-Server {server!r} erfordert Authentifizierung: {error}" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth refresh response for MCP server {server!r} did not include an " +"access token." +msgstr "" +"Die OAuth-Refresh-Antwort des MCP-Servers {server!r} enthielt kein " +"Access-Token." + +#: src/iac_code/mcp/oauth.py +msgid "Timed out waiting for MCP OAuth callback." +msgstr "Zeitüberschreitung beim Warten auf den MCP-OAuth-Callback." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback did not include a code." +msgstr "Der OAuth-Callback enthielt keinen Code." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth flow closed." +msgstr "OAuth-Ablauf wurde geschlossen." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback state did not match." +msgstr "Der Status des OAuth-Callbacks stimmte nicht überein." + +#: src/iac_code/mcp/oauth.py +msgid "MCP authentication complete. You can close this window." +msgstr "MCP-Authentifizierung abgeschlossen. Sie können dieses Fenster schließen." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth metadata endpoint did not return an object." +msgstr "Der OAuth-Metadaten-Endpunkt hat kein Objekt zurückgegeben." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth token endpoint did not return an object." +msgstr "Der OAuth-Token-Endpunkt hat kein Objekt zurückgegeben." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Structured content:\n" +"{content}" +msgstr "" +"Strukturierter Inhalt:\n" +"{content}" + +#: src/iac_code/mcp/output.py +msgid "MCP tool returned no content." +msgstr "Das MCP-Tool hat keinen Inhalt zurückgegeben." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Resource from MCP server {server!r}\n" +"URI: {uri}" +msgstr "" +"Ressource vom MCP-Server {server!r}\n" +"URI: {uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"{header}\n" +"MIME: {mime_type}" +msgstr "" +"{header}\n" +"MIME: {mime_type}" + +#: src/iac_code/mcp/output.py +msgid "(unnamed)" +msgstr "(unbenannt)" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Resource link: {name}" +msgstr "Ressourcenlink: {name}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "URI: {uri}" +msgstr "URI: {uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MIME: {mime_type}" +msgstr "MIME: {mime_type}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Unsupported MCP content block:\n" +"{content}" +msgstr "" +"Nicht unterstützter MCP-Inhaltsblock:\n" +"{content}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MCP {kind} content must contain base64 string data." +msgstr "MCP-Inhalt {kind} muss base64-Zeichenkettendaten enthalten." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Saved {mime_type} artifact as {artifact_id} ({bytes} bytes)." +msgstr "{mime_type}-Artefakt als {artifact_id} gespeichert ({bytes} Bytes)." + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt command {command!r} conflicts with an existing command." +msgstr "" +"Der MCP-Prompt-Befehl {command!r} steht in Konflikt mit einem vorhandenen" +" Befehl." + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt {prompt}" +msgstr "MCP-Prompt {prompt}" + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "Missing required MCP prompt argument: {name}" +msgstr "Erforderliches MCP-Prompt-Argument fehlt: {name}" + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments JSON must be an object." +msgstr "MCP-Prompt-Argumente im JSON-Format müssen ein Objekt sein." + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments must use key=value syntax." +msgstr "MCP-Prompt-Argumente müssen die Syntax key=value verwenden." + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments contain an unterminated quoted value." +msgstr "" +"MCP-Prompt-Argumente enthalten einen nicht abgeschlossenen Wert in " +"Anführungszeichen." + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} conflicts with an existing command." +msgstr "" +"Der MCP-Skill-Befehl {command!r} steht in Konflikt mit einem vorhandenen " +"Befehl." + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} could not be loaded: {error}" +msgstr "Der MCP-Skill-Befehl {command!r} konnte nicht geladen werden: {error}" + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill {command!r} was truncated to fit safety limits." +msgstr "" +"Der MCP-Skill {command!r} wurde gekürzt, um Sicherheitsgrenzen " +"einzuhalten." + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "MCP tool {tool!r} from server {server!r}." +msgstr "MCP-Tool {tool!r} vom Server {server!r}." + +#: src/iac_code/mcp/tools.py +msgid "List resources exposed by connected MCP servers." +msgstr "" +"Listet Ressourcen auf, die von verbundenen MCP-Servern bereitgestellt " +"werden." + +#: src/iac_code/mcp/tools.py +msgid "Optional MCP server name filter." +msgstr "Optionaler Filter nach MCP-Servername." + +#: src/iac_code/mcp/tools.py +msgid "No MCP resources are currently available." +msgstr "Derzeit sind keine MCP-Ressourcen verfügbar." + +#: src/iac_code/mcp/tools.py +msgid "Read a resource exposed by a connected MCP server." +msgstr "Eine von einem verbundenen MCP-Server bereitgestellte Ressource lesen." + +#: src/iac_code/mcp/tools.py +msgid "Start authentication for an MCP server." +msgstr "Authentifizierung für einen MCP-Server starten." + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "No MCP authentication flow is configured for {server!r}." +msgstr "Für {server!r} ist kein MCP-Authentifizierungsablauf konfiguriert." + +#: src/iac_code/mcp/tools.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Open this URL to authenticate MCP server {server!r}:\n" +"{url}" +msgstr "" +"Öffnen Sie diese URL, um MCP-Server {server!r} zu authentifizieren:\n" +"{url}" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"Unsupported MCP transport {transport!r} for server {server!r}. Supported " +"transports: {supported}." +msgstr "" +"Nicht unterstützter MCP-Transport {transport!r} für Server {server!r}. " +"Unterstützte Transporte: {supported}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth config must be an object." +msgstr "Die oauth-Konfiguration des MCP-Servers {server!r} muss ein Objekt sein." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses oauth.clientSecret, but plaintext client " +"secrets are not supported. Use oauth.clientSecretEnv instead." +msgstr "" +"Der MCP-Server {server!r} verwendet oauth.clientSecret, aber Klartext-" +"Client-Secrets werden nicht unterstützt. Verwenden Sie stattdessen " +"oauth.clientSecretEnv." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported oauth fields: {fields}." +msgstr "Der MCP-Server {server!r} hat nicht unterstützte oauth-Felder: {fields}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth.callbackPort must be an integer." +msgstr "oauth.callbackPort des MCP-Servers {server!r} muss eine Ganzzahl sein." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} config must be an object." +msgstr "Die Konfiguration des MCP-Servers {server!r} muss ein Objekt sein." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a type unless a stdio command is provided." +msgstr "" +"Der MCP-Server {server!r} benötigt einen type, sofern kein stdio-Befehl " +"angegeben ist." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} type must be a string." +msgstr "Der type des MCP-Servers {server!r} muss eine Zeichenkette sein." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses unsupported field 'headersHelper'. Static " +"headers are supported; dynamic headers need a later trusted-execution " +"design." +msgstr "" +"Der MCP-Server {server!r} verwendet das nicht unterstützte Feld " +"'headersHelper'. Statische Header werden unterstützt; dynamische Header " +"benötigen später ein vertrauenswürdiges Ausführungsdesign." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported config fields: {fields}." +msgstr "" +"Der MCP-Server {server!r} hat nicht unterstützte Konfigurationsfelder: " +"{fields}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a {field} string." +msgstr "Der MCP-Server {server!r} benötigt eine {field}-Zeichenkette." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a string." +msgstr "Das Feld {field} des MCP-Servers {server!r} muss eine Zeichenkette sein." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a list of strings." +msgstr "" +"Das Feld {field} des MCP-Servers {server!r} muss eine Liste von " +"Zeichenketten sein." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be an object of string values." +msgstr "" +"Das Feld {field} des MCP-Servers {server!r} muss ein Objekt mit " +"Zeichenkettenwerten sein." + #: src/iac_code/memory/memory_tools.py msgid "" "Read persistent memories. Omit name to list all, or provide name to read " @@ -2863,6 +3449,27 @@ msgstr "Volcengine CodingPlan" msgid "Anthropic Compatible" msgstr "Anthropic-kompatibel" +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} connection failed: {error}" +msgstr "Verbindung zu MCP-Server {server!r} fehlgeschlagen: {error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} {capability} discovery failed: {error}" +msgstr "" +"Ermittlung von {capability} für MCP-Server {server!r} fehlgeschlagen: " +"{error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Opened MCP auth URL for {server!r}:\n" +"{url}" +msgstr "" +"MCP-Authentifizierungs-URL für {server!r} geöffnet:\n" +"{url}" + #: src/iac_code/services/qwenpaw_source.py #, python-brace-format msgid "" @@ -3908,6 +4515,15 @@ msgstr "Nein, immer \"{rule}\" ablehnen (diese Sitzung)" msgid "No, always reject this tool" msgstr "Nein, dieses Tool immer ablehnen" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Approve project MCP server {server!r} from {source}? [y/N] " +msgstr "Projekt-MCP-Server {server!r} aus {source} genehmigen? [y/N] " + +#: src/iac_code/ui/repl.py +msgid "MCP warning:" +msgstr "MCP-Warnung:" + #: src/iac_code/ui/repl.py msgid "Press Ctrl+C again to exit." msgstr "Drücken Sie erneut Ctrl+C zum Beenden." @@ -3985,9 +4601,7 @@ msgstr "Unbekannter Skill: ${name}. Tippe /, um Befehle und Skills aufzulisten." #: src/iac_code/ui/repl.py #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." -msgstr "" -"Unknown command: /{name}. Type /help for available commands.Unbekannter " -"Befehl: /{name}. Geben Sie /help für verfügbare Befehle ein." +msgstr "Unbekannter Befehl: /{name}. Geben Sie /help für verfügbare Befehle ein." #: src/iac_code/ui/repl.py #, python-brace-format @@ -4088,10 +4702,6 @@ msgstr "" msgid "failed" msgstr "fehlgeschlagen" -#: src/iac_code/ui/repl.py -msgid "pending" -msgstr "ausstehend" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "in Bearbeitung" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 4afaa997..8abbf84d 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -49,6 +49,11 @@ msgstr "Nombre de archivo de artefacto no seguro" msgid "Unknown error" msgstr "Error desconocido" +#: src/iac_code/a2a/events.py src/iac_code/acp/server.py +#, python-brace-format +msgid "MCP warning: {message}" +msgstr "Advertencia MCP: {message}" + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -81,15 +86,15 @@ msgstr "" "La limpieza de rollback sigue en curso. Continúe cuando termine la " "limpieza." -#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py -msgid "Task is already working." -msgstr "La tarea ya está en ejecución." - #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py #: src/iac_code/a2a/transports/dispatcher.py msgid "Task canceled." msgstr "Tarea cancelada." +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "Task is already working." +msgstr "La tarea ya está en ejecución." + #: src/iac_code/a2a/executor.py #, python-brace-format msgid "Current model {model} does not support image input." @@ -171,6 +176,13 @@ msgstr "Tarea completada." msgid "Task failed." msgstr "La tarea falló." +#: src/iac_code/acp/convert.py src/iac_code/cli/headless.py +#: src/iac_code/mcp/tools.py src/iac_code/ui/renderer.py +#: src/iac_code/ui/stream_accumulator.py +#, python-brace-format +msgid "MCP {server}:{tool}" +msgstr "MCP {server}:{tool}" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "Sesión no encontrada" @@ -506,6 +518,11 @@ msgstr "" msgid "Error: {error}" msgstr "Error: {error}" +#: src/iac_code/cli/headless.py +#, python-brace-format +msgid "MCP warning: {message}\n" +msgstr "Advertencia MCP: {message}\n" + #: src/iac_code/cli/install_git_bash.py #, python-brace-format msgid "Git Bash is already installed at {}" @@ -2127,6 +2144,571 @@ msgstr "hace {n} minuto{s}" msgid "{n} more line{s}" msgstr "{n} línea{s} más" +#: src/iac_code/mcp/cli.py +msgid "Manage MCP servers." +msgstr "Administrar servidores MCP." + +#: src/iac_code/mcp/cli.py +msgid "Command for stdio MCP server." +msgstr "Comando para servidor MCP stdio." + +#: src/iac_code/mcp/cli.py +msgid "Command argument. Can be repeated." +msgstr "Argumento de comando. Se puede repetir." + +#: src/iac_code/mcp/cli.py +msgid "Environment variable KEY=VALUE. Can be repeated." +msgstr "Variable de entorno KEY=VALUE. Se puede repetir." + +#: src/iac_code/mcp/cli.py +msgid "Transport type: stdio, http, sse." +msgstr "Tipo de transporte: stdio, http, sse." + +#: src/iac_code/mcp/cli.py +msgid "Remote MCP URL for http/sse." +msgstr "URL MCP remota para http/sse." + +#: src/iac_code/mcp/cli.py +msgid "HTTP header KEY=VALUE. Can be repeated." +msgstr "Encabezado HTTP KEY=VALUE. Se puede repetir." + +#: src/iac_code/mcp/cli.py +msgid "Config scope: user, local, project." +msgstr "Ámbito de configuración: user, local, project." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client id." +msgstr "ID de cliente OAuth." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret. Pass the option without a value to enter it securely." +msgstr "" +"Secreto de cliente OAuth. Pasa la opción sin valor para introducirlo de " +"forma segura." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret env var name." +msgstr "Nombre de variable de entorno del secreto de cliente OAuth." + +#: src/iac_code/mcp/cli.py +msgid "OAuth loopback callback port." +msgstr "Puerto de callback loopback de OAuth." + +#: src/iac_code/mcp/cli.py +msgid "OAuth authorization server metadata URL." +msgstr "URL de metadatos del servidor de autorización OAuth." + +#: src/iac_code/mcp/cli.py +msgid "--command is required for stdio MCP servers." +msgstr "--command es obligatorio para servidores MCP stdio." + +#: src/iac_code/mcp/cli.py +msgid "Warning: on Windows, bare npx may need to be configured as: cmd /c npx" +msgstr "" +"Advertencia: en Windows, npx sin envoltorio puede tener que configurarse " +"como: cmd /c npx" + +#: src/iac_code/mcp/cli.py +msgid "--url is required for remote MCP servers." +msgstr "--url es obligatorio para servidores MCP remotos." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Added MCP server {name!r} to {scope} config." +msgstr "Servidor MCP {name!r} añadido a la configuración {scope}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid JSON: {error}" +msgstr "JSON no válido: {error}" + +#: src/iac_code/mcp/cli.py +msgid "MCP server JSON must be an object." +msgstr "El JSON del servidor MCP debe ser un objeto." + +#: src/iac_code/mcp/cli.py +msgid "No MCP servers configured." +msgstr "No hay servidores MCP configurados." + +#: src/iac_code/mcp/cli.py +msgid "approved" +msgstr "aprobado" + +#: src/iac_code/mcp/cli.py src/iac_code/ui/repl.py +msgid "pending" +msgstr "pendiente" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP server {name!r} not found in {scope} config." +msgstr "No se encontró el servidor MCP {name!r} en la configuración {scope}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Removed MCP server {name!r} from {path}." +msgstr "Servidor MCP {name!r} eliminado de {path}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Approved MCP server {name!r}." +msgstr "Servidor MCP {name!r} aprobado." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Rejected MCP server {name!r}." +msgstr "Servidor MCP {name!r} rechazado." + +#: src/iac_code/mcp/cli.py +msgid "Reset MCP project approval choices." +msgstr "Se restablecieron las decisiones de aprobación de MCP del proyecto." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP auth failed for {name!r}: {error}" +msgstr "Falló la autenticación MCP para {name!r}: {error}" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Authenticated MCP server {name!r}." +msgstr "Servidor MCP {name!r} autenticado." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Reset stored MCP auth state for {name!r}." +msgstr "" +"Se restableció el estado de autenticación guardado para el servidor MCP " +"{name!r}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid MCP scope {scope!r}. Valid values: user, local, project." +msgstr "Ámbito MCP no válido {scope!r}. Valores válidos: user, local, project." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Scope {scope!r} cannot be used for persisted MCP config." +msgstr "El ámbito {scope!r} no se puede usar para configuración MCP persistida." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "" +"MCP {section} {key!r} may contain a secret; use an environment variable " +"reference like ${{VAR}} instead of storing plaintext." +msgstr "" +"MCP {section} {key!r} puede contener un secreto; usa una referencia de " +"variable de entorno como ${{VAR}} en lugar de guardar texto plano." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects KEY=VALUE, got {value!r}." +msgstr "{option} espera KEY=VALUE, se recibió {value!r}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects a non-empty key." +msgstr "{option} espera una clave no vacía." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Project MCP server {name!r} not found." +msgstr "No se encontró el servidor MCP de proyecto {name!r}." + +#: src/iac_code/mcp/client.py src/iac_code/mcp/manager.py +#, python-brace-format +msgid "MCP server {server!r} is not connected." +msgstr "El servidor MCP {server!r} no está conectado." + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "Unsupported MCP transport: {transport}" +msgstr "Transporte MCP no compatible: {transport}" + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication." +msgstr "El servidor MCP {server!r} requiere autenticación." + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "" +"{message}\n" +"MCP server stderr:\n" +"{stderr}" +msgstr "" +"{message}\n" +"stderr del servidor MCP:\n" +"{stderr}" + +#: src/iac_code/mcp/config.py +msgid "MCP server names must be non-empty strings." +msgstr "Los nombres de servidores MCP deben ser cadenas no vacías." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Project MCP server {name!r} is pending approval." +msgstr "El servidor MCP de proyecto {name!r} está pendiente de aprobación." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Cannot persist MCP server config to {scope!r} scope." +msgstr "" +"No se puede persistir la configuración del servidor MCP en el ámbito " +"{scope!r}." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP scope {scope!r} is not a persisted config scope." +msgstr "El ámbito MCP {scope!r} no es un ámbito de configuración persistida." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server {existing!r} has the same content signature as {current!r}; " +"keeping higher-precedence server {current!r}." +msgstr "" +"El servidor MCP {existing!r} tiene la misma firma de contenido que " +"{current!r}; se conserva el servidor {current!r} de mayor precedencia." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP server name {name!r} is reserved." +msgstr "El nombre de servidor MCP {name!r} está reservado." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server name {name!r} may only contain letters, numbers, dot, " +"underscore, and hyphen." +msgstr "" +"El nombre de servidor MCP {name!r} solo puede contener letras, números, " +"punto, guion bajo y guion." + +#: src/iac_code/mcp/env_expansion.py +#, python-brace-format +msgid "Environment variable {name!r} is not set for MCP config." +msgstr "" +"La variable de entorno {name!r} no está definida para la configuración " +"MCP." + +#: src/iac_code/mcp/manager.py +#, python-brace-format +msgid "" +"MCP server {server!r} requested elicitation, but iac-code does not " +"support elicitation yet." +msgstr "" +"El servidor MCP {server!r} solicitó elicitation, pero iac-code todavía no" +" la admite." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "Could not discover OAuth metadata for MCP server {server!r}." +msgstr "No se pudieron descubrir los metadatos OAuth del servidor MCP {server!r}." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth token response for MCP server {server!r} did not include an access " +"token." +msgstr "" +"La respuesta de token OAuth del servidor MCP {server!r} no incluyó un " +"token de acceso." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "No refresh token is available for MCP server {server!r}." +msgstr "No hay token de actualización disponible para el servidor MCP {server!r}." + +#: src/iac_code/mcp/oauth.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication: {error}" +msgstr "El servidor MCP {server!r} requiere autenticación: {error}" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth refresh response for MCP server {server!r} did not include an " +"access token." +msgstr "" +"La respuesta de actualización OAuth del servidor MCP {server!r} no " +"incluyó un token de acceso." + +#: src/iac_code/mcp/oauth.py +msgid "Timed out waiting for MCP OAuth callback." +msgstr "Se agotó el tiempo de espera para el callback OAuth de MCP." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback did not include a code." +msgstr "El callback OAuth no incluyó un código." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth flow closed." +msgstr "El flujo OAuth se cerró." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback state did not match." +msgstr "El estado del callback OAuth no coincidió." + +#: src/iac_code/mcp/oauth.py +msgid "MCP authentication complete. You can close this window." +msgstr "Autenticación MCP completa. Puede cerrar esta ventana." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth metadata endpoint did not return an object." +msgstr "El endpoint de metadatos OAuth no devolvió un objeto." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth token endpoint did not return an object." +msgstr "El endpoint de token OAuth no devolvió un objeto." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Structured content:\n" +"{content}" +msgstr "" +"Contenido estructurado:\n" +"{content}" + +#: src/iac_code/mcp/output.py +msgid "MCP tool returned no content." +msgstr "La herramienta MCP no devolvió contenido." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Resource from MCP server {server!r}\n" +"URI: {uri}" +msgstr "" +"Recurso del servidor MCP {server!r}\n" +"URI: {uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"{header}\n" +"MIME: {mime_type}" +msgstr "" +"{header}\n" +"MIME: {mime_type}" + +#: src/iac_code/mcp/output.py +msgid "(unnamed)" +msgstr "(sin nombre)" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Resource link: {name}" +msgstr "Enlace de recurso: {name}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "URI: {uri}" +msgstr "URI: {uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MIME: {mime_type}" +msgstr "MIME: {mime_type}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Unsupported MCP content block:\n" +"{content}" +msgstr "" +"Bloque de contenido MCP no compatible:\n" +"{content}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MCP {kind} content must contain base64 string data." +msgstr "El contenido MCP {kind} debe contener datos de cadena base64." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Saved {mime_type} artifact as {artifact_id} ({bytes} bytes)." +msgstr "Artefacto {mime_type} guardado como {artifact_id} ({bytes} bytes)." + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt command {command!r} conflicts with an existing command." +msgstr "" +"El comando de prompt MCP {command!r} entra en conflicto con un comando " +"existente." + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt {prompt}" +msgstr "Prompt MCP {prompt}" + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "Missing required MCP prompt argument: {name}" +msgstr "Falta el argumento obligatorio de prompt MCP: {name}" + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments JSON must be an object." +msgstr "Los argumentos de prompt MCP en JSON deben ser un objeto." + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments must use key=value syntax." +msgstr "Los argumentos de prompt MCP deben usar sintaxis key=value." + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments contain an unterminated quoted value." +msgstr "Los argumentos de prompt MCP contienen un valor entre comillas sin cerrar." + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} conflicts with an existing command." +msgstr "" +"El comando de skill MCP {command!r} entra en conflicto con un comando " +"existente." + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} could not be loaded: {error}" +msgstr "No se pudo cargar el comando de skill MCP {command!r}: {error}" + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill {command!r} was truncated to fit safety limits." +msgstr "" +"La skill MCP {command!r} se truncó para ajustarse a los límites de " +"seguridad." + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "MCP tool {tool!r} from server {server!r}." +msgstr "Herramienta MCP {tool!r} del servidor {server!r}." + +#: src/iac_code/mcp/tools.py +msgid "List resources exposed by connected MCP servers." +msgstr "Lista los recursos expuestos por servidores MCP conectados." + +#: src/iac_code/mcp/tools.py +msgid "Optional MCP server name filter." +msgstr "Filtro opcional por nombre de servidor MCP." + +#: src/iac_code/mcp/tools.py +msgid "No MCP resources are currently available." +msgstr "Actualmente no hay recursos MCP disponibles." + +#: src/iac_code/mcp/tools.py +msgid "Read a resource exposed by a connected MCP server." +msgstr "Lee un recurso expuesto por un servidor MCP conectado." + +#: src/iac_code/mcp/tools.py +msgid "Start authentication for an MCP server." +msgstr "Iniciar autenticación para un servidor MCP." + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "No MCP authentication flow is configured for {server!r}." +msgstr "No hay flujo de autenticación MCP configurado para {server!r}." + +#: src/iac_code/mcp/tools.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Open this URL to authenticate MCP server {server!r}:\n" +"{url}" +msgstr "" +"Abre esta URL para autenticar el servidor MCP {server!r}:\n" +"{url}" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"Unsupported MCP transport {transport!r} for server {server!r}. Supported " +"transports: {supported}." +msgstr "" +"Transporte MCP {transport!r} no compatible para el servidor {server!r}. " +"Transportes compatibles: {supported}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth config must be an object." +msgstr "La configuración oauth del servidor MCP {server!r} debe ser un objeto." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses oauth.clientSecret, but plaintext client " +"secrets are not supported. Use oauth.clientSecretEnv instead." +msgstr "" +"El servidor MCP {server!r} usa oauth.clientSecret, pero no se admiten " +"secretos de cliente en texto plano. Usa oauth.clientSecretEnv en su " +"lugar." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported oauth fields: {fields}." +msgstr "El servidor MCP {server!r} tiene campos oauth no compatibles: {fields}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth.callbackPort must be an integer." +msgstr "oauth.callbackPort del servidor MCP {server!r} debe ser un entero." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} config must be an object." +msgstr "La configuración del servidor MCP {server!r} debe ser un objeto." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a type unless a stdio command is provided." +msgstr "" +"El servidor MCP {server!r} requiere un type salvo que se proporcione un " +"comando stdio." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} type must be a string." +msgstr "El type del servidor MCP {server!r} debe ser una cadena." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses unsupported field 'headersHelper'. Static " +"headers are supported; dynamic headers need a later trusted-execution " +"design." +msgstr "" +"El servidor MCP {server!r} usa el campo no compatible 'headersHelper'. Se" +" admiten encabezados estáticos; los encabezados dinámicos necesitan un " +"diseño posterior de ejecución confiable." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported config fields: {fields}." +msgstr "" +"El servidor MCP {server!r} tiene campos de configuración no compatibles: " +"{fields}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a {field} string." +msgstr "El servidor MCP {server!r} requiere una cadena {field}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a string." +msgstr "El campo {field} del servidor MCP {server!r} debe ser una cadena." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a list of strings." +msgstr "" +"El campo {field} del servidor MCP {server!r} debe ser una lista de " +"cadenas." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be an object of string values." +msgstr "" +"El campo {field} del servidor MCP {server!r} debe ser un objeto de " +"valores de cadena." + #: src/iac_code/memory/memory_tools.py msgid "" "Read persistent memories. Omit name to list all, or provide name to read " @@ -2857,6 +3439,27 @@ msgstr "Volcengine CodingPlan" msgid "Anthropic Compatible" msgstr "Compatible con Anthropic" +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} connection failed: {error}" +msgstr "Falló la conexión del servidor MCP {server!r}: {error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} {capability} discovery failed: {error}" +msgstr "" +"Falló el descubrimiento de {capability} del servidor MCP {server!r}: " +"{error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Opened MCP auth URL for {server!r}:\n" +"{url}" +msgstr "" +"URL de autenticación MCP abierta para {server!r}:\n" +"{url}" + #: src/iac_code/services/qwenpaw_source.py #, python-brace-format msgid "" @@ -3852,7 +4455,7 @@ msgstr "Completado ({child_count} usos de herramientas{token_info}{elapsed})" #: src/iac_code/ui/renderer.py #, python-brace-format msgid "+ {count} more tool uses (ctrl+o to expand)" -msgstr "+ {count} usos de herramientas más (ctrl+o para expandir)" +msgstr "+ {count} usos adicionales de herramienta (ctrl+o para expandir)" #: src/iac_code/ui/renderer.py #, python-brace-format @@ -3905,6 +4508,15 @@ msgstr "No, siempre denegar \"{rule}\" (esta sesión)" msgid "No, always reject this tool" msgstr "No, rechazar siempre esta herramienta" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Approve project MCP server {server!r} from {source}? [y/N] " +msgstr "¿Aprobar el servidor MCP de proyecto {server!r} desde {source}? [y/N] " + +#: src/iac_code/ui/repl.py +msgid "MCP warning:" +msgstr "Advertencia MCP:" + #: src/iac_code/ui/repl.py msgid "Press Ctrl+C again to exit." msgstr "Pulse Ctrl+C de nuevo para salir." @@ -3987,9 +4599,8 @@ msgstr "" #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "" -"Unknown command: /{name}. Type /help for available commands.Unknown " -"command: /{name}. Type /help for available commands.Comando desconocido: " -"/{name}. Escriba /help para ver los comandos disponibles." +"Comando desconocido: /{name}. Escriba /help para ver los comandos " +"disponibles." #: src/iac_code/ui/repl.py #, python-brace-format @@ -4089,10 +4700,6 @@ msgstr "" msgid "failed" msgstr "fallido" -#: src/iac_code/ui/repl.py -msgid "pending" -msgstr "pendiente" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "en curso" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 320370b5..78cd713a 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -49,6 +49,11 @@ msgstr "Nom de fichier d’artefact non sûr" msgid "Unknown error" msgstr "Erreur inconnue" +#: src/iac_code/a2a/events.py src/iac_code/acp/server.py +#, python-brace-format +msgid "MCP warning: {message}" +msgstr "Avertissement MCP : {message}" + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -81,15 +86,15 @@ msgstr "" "Le nettoyage du rollback est encore en cours. Continuez une fois le " "nettoyage terminé." -#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py -msgid "Task is already working." -msgstr "La tâche est déjà en cours." - #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py #: src/iac_code/a2a/transports/dispatcher.py msgid "Task canceled." msgstr "Tâche annulée." +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "Task is already working." +msgstr "La tâche est déjà en cours." + #: src/iac_code/a2a/executor.py #, python-brace-format msgid "Current model {model} does not support image input." @@ -169,6 +174,13 @@ msgstr "Tâche terminée." msgid "Task failed." msgstr "La tâche a échoué." +#: src/iac_code/acp/convert.py src/iac_code/cli/headless.py +#: src/iac_code/mcp/tools.py src/iac_code/ui/renderer.py +#: src/iac_code/ui/stream_accumulator.py +#, python-brace-format +msgid "MCP {server}:{tool}" +msgstr "MCP {server}:{tool}" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "Session introuvable" @@ -501,6 +513,11 @@ msgstr "" msgid "Error: {error}" msgstr "Erreur : {error}" +#: src/iac_code/cli/headless.py +#, python-brace-format +msgid "MCP warning: {message}\n" +msgstr "Avertissement MCP : {message}\n" + #: src/iac_code/cli/install_git_bash.py #, python-brace-format msgid "Git Bash is already installed at {}" @@ -2128,6 +2145,577 @@ msgstr "il y a {n} minute{s}" msgid "{n} more line{s}" msgstr "{n} ligne{s} supplémentaire{s}" +#: src/iac_code/mcp/cli.py +msgid "Manage MCP servers." +msgstr "Gérer les serveurs MCP." + +#: src/iac_code/mcp/cli.py +msgid "Command for stdio MCP server." +msgstr "Commande pour serveur MCP stdio." + +#: src/iac_code/mcp/cli.py +msgid "Command argument. Can be repeated." +msgstr "Argument de commande. Peut être répété." + +#: src/iac_code/mcp/cli.py +msgid "Environment variable KEY=VALUE. Can be repeated." +msgstr "Variable d’environnement KEY=VALUE. Peut être répétée." + +#: src/iac_code/mcp/cli.py +msgid "Transport type: stdio, http, sse." +msgstr "Type de transport : stdio, http, sse." + +#: src/iac_code/mcp/cli.py +msgid "Remote MCP URL for http/sse." +msgstr "URL MCP distante pour http/sse." + +#: src/iac_code/mcp/cli.py +msgid "HTTP header KEY=VALUE. Can be repeated." +msgstr "En-tête HTTP KEY=VALUE. Peut être répété." + +#: src/iac_code/mcp/cli.py +msgid "Config scope: user, local, project." +msgstr "Portée de configuration : user, local, project." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client id." +msgstr "ID client OAuth." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret. Pass the option without a value to enter it securely." +msgstr "" +"Secret client OAuth. Passez l’option sans valeur pour le saisir de façon " +"sécurisée." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret env var name." +msgstr "Nom de variable d’environnement du secret client OAuth." + +#: src/iac_code/mcp/cli.py +msgid "OAuth loopback callback port." +msgstr "Port de callback loopback OAuth." + +#: src/iac_code/mcp/cli.py +msgid "OAuth authorization server metadata URL." +msgstr "URL des métadonnées du serveur d’autorisation OAuth." + +#: src/iac_code/mcp/cli.py +msgid "--command is required for stdio MCP servers." +msgstr "--command est requis pour les serveurs MCP stdio." + +#: src/iac_code/mcp/cli.py +msgid "Warning: on Windows, bare npx may need to be configured as: cmd /c npx" +msgstr "" +"Avertissement : sous Windows, npx seul peut devoir être configuré ainsi :" +" cmd /c npx" + +#: src/iac_code/mcp/cli.py +msgid "--url is required for remote MCP servers." +msgstr "--url est requis pour les serveurs MCP distants." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Added MCP server {name!r} to {scope} config." +msgstr "Serveur MCP {name!r} ajouté à la configuration {scope}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid JSON: {error}" +msgstr "JSON invalide : {error}" + +#: src/iac_code/mcp/cli.py +msgid "MCP server JSON must be an object." +msgstr "Le JSON du serveur MCP doit être un objet." + +#: src/iac_code/mcp/cli.py +msgid "No MCP servers configured." +msgstr "Aucun serveur MCP configuré." + +#: src/iac_code/mcp/cli.py +msgid "approved" +msgstr "approuvé" + +#: src/iac_code/mcp/cli.py src/iac_code/ui/repl.py +msgid "pending" +msgstr "en attente" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP server {name!r} not found in {scope} config." +msgstr "Serveur MCP {name!r} introuvable dans la configuration {scope}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Removed MCP server {name!r} from {path}." +msgstr "Serveur MCP {name!r} supprimé de {path}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Approved MCP server {name!r}." +msgstr "Serveur MCP {name!r} approuvé." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Rejected MCP server {name!r}." +msgstr "Serveur MCP {name!r} rejeté." + +#: src/iac_code/mcp/cli.py +msgid "Reset MCP project approval choices." +msgstr "Choix d’approbation MCP du projet réinitialisés." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP auth failed for {name!r}: {error}" +msgstr "Échec de l’authentification MCP pour {name!r} : {error}" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Authenticated MCP server {name!r}." +msgstr "Serveur MCP {name!r} authentifié." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Reset stored MCP auth state for {name!r}." +msgstr "État d’authentification stocké réinitialisé pour le serveur MCP {name!r}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid MCP scope {scope!r}. Valid values: user, local, project." +msgstr "Portée MCP invalide {scope!r}. Valeurs valides : user, local, project." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Scope {scope!r} cannot be used for persisted MCP config." +msgstr "" +"La portée {scope!r} ne peut pas être utilisée pour une configuration MCP " +"persistée." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "" +"MCP {section} {key!r} may contain a secret; use an environment variable " +"reference like ${{VAR}} instead of storing plaintext." +msgstr "" +"MCP {section} {key!r} peut contenir un secret ; utilisez une référence de" +" variable d’environnement comme ${{VAR}} au lieu de stocker du texte en " +"clair." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects KEY=VALUE, got {value!r}." +msgstr "{option} attend KEY=VALUE, reçu {value!r}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects a non-empty key." +msgstr "{option} attend une clé non vide." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Project MCP server {name!r} not found." +msgstr "Serveur MCP de projet {name!r} introuvable." + +#: src/iac_code/mcp/client.py src/iac_code/mcp/manager.py +#, python-brace-format +msgid "MCP server {server!r} is not connected." +msgstr "Le serveur MCP {server!r} n’est pas connecté." + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "Unsupported MCP transport: {transport}" +msgstr "Transport MCP non pris en charge : {transport}" + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication." +msgstr "Le serveur MCP {server!r} nécessite une authentification." + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "" +"{message}\n" +"MCP server stderr:\n" +"{stderr}" +msgstr "" +"{message}\n" +"stderr du serveur MCP :\n" +"{stderr}" + +#: src/iac_code/mcp/config.py +msgid "MCP server names must be non-empty strings." +msgstr "Les noms de serveurs MCP doivent être des chaînes non vides." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Project MCP server {name!r} is pending approval." +msgstr "Le serveur MCP de projet {name!r} est en attente d’approbation." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Cannot persist MCP server config to {scope!r} scope." +msgstr "" +"Impossible de persister la configuration du serveur MCP dans la portée " +"{scope!r}." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP scope {scope!r} is not a persisted config scope." +msgstr "La portée MCP {scope!r} n’est pas une portée de configuration persistée." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server {existing!r} has the same content signature as {current!r}; " +"keeping higher-precedence server {current!r}." +msgstr "" +"Le serveur MCP {existing!r} a la même signature de contenu que " +"{current!r} ; conservation du serveur {current!r} de plus haute priorité." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP server name {name!r} is reserved." +msgstr "Le nom de serveur MCP {name!r} est réservé." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server name {name!r} may only contain letters, numbers, dot, " +"underscore, and hyphen." +msgstr "" +"Le nom de serveur MCP {name!r} ne peut contenir que des lettres, " +"chiffres, points, traits de soulignement et traits d’union." + +#: src/iac_code/mcp/env_expansion.py +#, python-brace-format +msgid "Environment variable {name!r} is not set for MCP config." +msgstr "" +"La variable d’environnement {name!r} n’est pas définie pour la " +"configuration MCP." + +#: src/iac_code/mcp/manager.py +#, python-brace-format +msgid "" +"MCP server {server!r} requested elicitation, but iac-code does not " +"support elicitation yet." +msgstr "" +"Le serveur MCP {server!r} a demandé une elicitation, mais iac-code ne la " +"prend pas encore en charge." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "Could not discover OAuth metadata for MCP server {server!r}." +msgstr "Impossible de découvrir les métadonnées OAuth du serveur MCP {server!r}." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth token response for MCP server {server!r} did not include an access " +"token." +msgstr "" +"La réponse de jeton OAuth du serveur MCP {server!r} ne contenait pas de " +"jeton d’accès." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "No refresh token is available for MCP server {server!r}." +msgstr "" +"Aucun jeton d’actualisation n’est disponible pour le serveur MCP " +"{server!r}." + +#: src/iac_code/mcp/oauth.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication: {error}" +msgstr "Le serveur MCP {server!r} nécessite une authentification : {error}" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth refresh response for MCP server {server!r} did not include an " +"access token." +msgstr "" +"La réponse d’actualisation OAuth du serveur MCP {server!r} ne contenait " +"pas de jeton d’accès." + +#: src/iac_code/mcp/oauth.py +msgid "Timed out waiting for MCP OAuth callback." +msgstr "Délai dépassé en attendant le callback OAuth MCP." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback did not include a code." +msgstr "Le callback OAuth ne contenait pas de code." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth flow closed." +msgstr "Le flux OAuth a été fermé." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback state did not match." +msgstr "L’état du callback OAuth ne correspondait pas." + +#: src/iac_code/mcp/oauth.py +msgid "MCP authentication complete. You can close this window." +msgstr "Authentification MCP terminée. Vous pouvez fermer cette fenêtre." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth metadata endpoint did not return an object." +msgstr "Le endpoint de métadonnées OAuth n’a pas renvoyé d’objet." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth token endpoint did not return an object." +msgstr "Le endpoint de jeton OAuth n’a pas renvoyé d’objet." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Structured content:\n" +"{content}" +msgstr "" +"Contenu structuré :\n" +"{content}" + +#: src/iac_code/mcp/output.py +msgid "MCP tool returned no content." +msgstr "L’outil MCP n’a renvoyé aucun contenu." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Resource from MCP server {server!r}\n" +"URI: {uri}" +msgstr "" +"Ressource du serveur MCP {server!r}\n" +"URI : {uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"{header}\n" +"MIME: {mime_type}" +msgstr "" +"{header}\n" +"MIME : {mime_type}" + +#: src/iac_code/mcp/output.py +msgid "(unnamed)" +msgstr "(sans nom)" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Resource link: {name}" +msgstr "Lien de ressource : {name}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "URI: {uri}" +msgstr "URI : {uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MIME: {mime_type}" +msgstr "MIME : {mime_type}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Unsupported MCP content block:\n" +"{content}" +msgstr "" +"Bloc de contenu MCP non pris en charge :\n" +"{content}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MCP {kind} content must contain base64 string data." +msgstr "Le contenu MCP {kind} doit contenir des données chaîne en base64." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Saved {mime_type} artifact as {artifact_id} ({bytes} bytes)." +msgstr "Artefact {mime_type} enregistré sous {artifact_id} ({bytes} octets)." + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt command {command!r} conflicts with an existing command." +msgstr "" +"La commande de prompt MCP {command!r} entre en conflit avec une commande " +"existante." + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt {prompt}" +msgstr "Prompt MCP {prompt}" + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "Missing required MCP prompt argument: {name}" +msgstr "Argument de prompt MCP obligatoire manquant : {name}" + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments JSON must be an object." +msgstr "Les arguments de prompt MCP en JSON doivent être un objet." + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments must use key=value syntax." +msgstr "Les arguments de prompt MCP doivent utiliser la syntaxe key=value." + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments contain an unterminated quoted value." +msgstr "" +"Les arguments de prompt MCP contiennent une valeur entre guillemets non " +"terminée." + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} conflicts with an existing command." +msgstr "" +"La commande de skill MCP {command!r} entre en conflit avec une commande " +"existante." + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} could not be loaded: {error}" +msgstr "Impossible de charger la commande de skill MCP {command!r} : {error}" + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill {command!r} was truncated to fit safety limits." +msgstr "" +"La skill MCP {command!r} a été tronquée pour respecter les limites de " +"sécurité." + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "MCP tool {tool!r} from server {server!r}." +msgstr "Outil MCP {tool!r} du serveur {server!r}." + +#: src/iac_code/mcp/tools.py +msgid "List resources exposed by connected MCP servers." +msgstr "Liste les ressources exposées par les serveurs MCP connectés." + +#: src/iac_code/mcp/tools.py +msgid "Optional MCP server name filter." +msgstr "Filtre facultatif par nom de serveur MCP." + +#: src/iac_code/mcp/tools.py +msgid "No MCP resources are currently available." +msgstr "Aucune ressource MCP n’est actuellement disponible." + +#: src/iac_code/mcp/tools.py +msgid "Read a resource exposed by a connected MCP server." +msgstr "Lire une ressource exposée par un serveur MCP connecté." + +#: src/iac_code/mcp/tools.py +msgid "Start authentication for an MCP server." +msgstr "Démarrer l’authentification pour un serveur MCP." + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "No MCP authentication flow is configured for {server!r}." +msgstr "Aucun flux d’authentification MCP n’est configuré pour {server!r}." + +#: src/iac_code/mcp/tools.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Open this URL to authenticate MCP server {server!r}:\n" +"{url}" +msgstr "" +"Ouvrez cette URL pour authentifier le serveur MCP {server!r} :\n" +"{url}" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"Unsupported MCP transport {transport!r} for server {server!r}. Supported " +"transports: {supported}." +msgstr "" +"Transport MCP {transport!r} non pris en charge pour le serveur " +"{server!r}. Transports pris en charge : {supported}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth config must be an object." +msgstr "La configuration oauth du serveur MCP {server!r} doit être un objet." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses oauth.clientSecret, but plaintext client " +"secrets are not supported. Use oauth.clientSecretEnv instead." +msgstr "" +"Le serveur MCP {server!r} utilise oauth.clientSecret, mais les secrets " +"client en clair ne sont pas pris en charge. Utilisez plutôt " +"oauth.clientSecretEnv." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported oauth fields: {fields}." +msgstr "" +"Le serveur MCP {server!r} contient des champs oauth non pris en charge : " +"{fields}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth.callbackPort must be an integer." +msgstr "oauth.callbackPort du serveur MCP {server!r} doit être un entier." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} config must be an object." +msgstr "La configuration du serveur MCP {server!r} doit être un objet." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a type unless a stdio command is provided." +msgstr "" +"Le serveur MCP {server!r} nécessite un type, sauf si une commande stdio " +"est fournie." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} type must be a string." +msgstr "Le type du serveur MCP {server!r} doit être une chaîne." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses unsupported field 'headersHelper'. Static " +"headers are supported; dynamic headers need a later trusted-execution " +"design." +msgstr "" +"Le serveur MCP {server!r} utilise le champ non pris en charge " +"'headersHelper'. Les en-têtes statiques sont pris en charge ; les en-" +"têtes dynamiques nécessitent une future conception d’exécution de " +"confiance." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported config fields: {fields}." +msgstr "" +"Le serveur MCP {server!r} contient des champs de configuration non pris " +"en charge : {fields}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a {field} string." +msgstr "Le serveur MCP {server!r} nécessite une chaîne {field}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a string." +msgstr "Le champ {field} du serveur MCP {server!r} doit être une chaîne." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a list of strings." +msgstr "Le champ {field} du serveur MCP {server!r} doit être une liste de chaînes." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be an object of string values." +msgstr "" +"Le champ {field} du serveur MCP {server!r} doit être un objet de valeurs " +"chaîne." + #: src/iac_code/memory/memory_tools.py msgid "" "Read persistent memories. Omit name to list all, or provide name to read " @@ -2856,6 +3444,25 @@ msgstr "Volcengine CodingPlan" msgid "Anthropic Compatible" msgstr "Compatible Anthropic" +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} connection failed: {error}" +msgstr "Échec de la connexion du serveur MCP {server!r} : {error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} {capability} discovery failed: {error}" +msgstr "La découverte de {capability} du serveur MCP {server!r} a échoué : {error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Opened MCP auth URL for {server!r}:\n" +"{url}" +msgstr "" +"URL d’authentification MCP ouverte pour {server!r} :\n" +"{url}" + #: src/iac_code/services/qwenpaw_source.py #, python-brace-format msgid "" @@ -3854,9 +4461,7 @@ msgstr "Terminé ({child_count} utilisations d’outil{token_info}{elapsed})" #: src/iac_code/ui/renderer.py #, python-brace-format msgid "+ {count} more tool uses (ctrl+o to expand)" -msgstr "" -"+ {count} more tool uses (ctrl+o to expand)+ {count} utilisations d’outil" -" supplémentaires (ctrl+o pour développer)" +msgstr "+ {count} utilisations d’outil supplémentaires (ctrl+o pour développer)" #: src/iac_code/ui/renderer.py #, python-brace-format @@ -3909,6 +4514,15 @@ msgstr "Non, toujours refuser \"{rule}\" (cette session)" msgid "No, always reject this tool" msgstr "Non, toujours refuser cet outil" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Approve project MCP server {server!r} from {source}? [y/N] " +msgstr "Approuver le serveur MCP de projet {server!r} depuis {source} ? [y/N] " + +#: src/iac_code/ui/repl.py +msgid "MCP warning:" +msgstr "Avertissement MCP :" + #: src/iac_code/ui/repl.py msgid "Press Ctrl+C again to exit." msgstr "Appuyez de nouveau sur Ctrl+C pour quitter." @@ -3989,8 +4603,8 @@ msgstr "" #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." msgstr "" -"Unknown command: /{name}. Type /help for available commands.Commande " -"inconnue : /{name}. Saisissez /help pour la liste des commandes." +"Commande inconnue : /{name}. Saisissez /help pour afficher les commandes " +"disponibles." #: src/iac_code/ui/repl.py #, python-brace-format @@ -4091,10 +4705,6 @@ msgstr "" msgid "failed" msgstr "échoué" -#: src/iac_code/ui/repl.py -msgid "pending" -msgstr "en attente" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "en cours" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 5bf7d6c9..858dac0a 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -44,6 +44,11 @@ msgstr "安全でないアーティファクトファイル名" msgid "Unknown error" msgstr "不明なエラー" +#: src/iac_code/a2a/events.py src/iac_code/acp/server.py +#, python-brace-format +msgid "MCP warning: {message}" +msgstr "MCP 警告: {message}" + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -68,15 +73,15 @@ msgid "" "completes." msgstr "ロールバッククリーンアップはまだ進行中です。完了後に続行してください。" -#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py -msgid "Task is already working." -msgstr "タスクはすでに実行中です。" - #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py #: src/iac_code/a2a/transports/dispatcher.py msgid "Task canceled." msgstr "タスクがキャンセルされました。" +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "Task is already working." +msgstr "タスクはすでに実行中です。" + #: src/iac_code/a2a/executor.py #, python-brace-format msgid "Current model {model} does not support image input." @@ -156,6 +161,13 @@ msgstr "タスクが完了しました。" msgid "Task failed." msgstr "タスクが失敗しました。" +#: src/iac_code/acp/convert.py src/iac_code/cli/headless.py +#: src/iac_code/mcp/tools.py src/iac_code/ui/renderer.py +#: src/iac_code/ui/stream_accumulator.py +#, python-brace-format +msgid "MCP {server}:{tool}" +msgstr "MCP {server}:{tool}" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "セッションが見つかりません" @@ -474,6 +486,11 @@ msgstr "" msgid "Error: {error}" msgstr "エラー:{error}" +#: src/iac_code/cli/headless.py +#, python-brace-format +msgid "MCP warning: {message}\n" +msgstr "MCP 警告: {message}\n" + #: src/iac_code/cli/install_git_bash.py #, python-brace-format msgid "Git Bash is already installed at {}" @@ -2058,6 +2075,539 @@ msgstr "{n} 分{s}前" msgid "{n} more line{s}" msgstr "あと {n} 行{s}" +#: src/iac_code/mcp/cli.py +msgid "Manage MCP servers." +msgstr "MCP server を管理します。" + +#: src/iac_code/mcp/cli.py +msgid "Command for stdio MCP server." +msgstr "stdio MCP server の command。" + +#: src/iac_code/mcp/cli.py +msgid "Command argument. Can be repeated." +msgstr "command 引数。繰り返し指定できます。" + +#: src/iac_code/mcp/cli.py +msgid "Environment variable KEY=VALUE. Can be repeated." +msgstr "環境変数 KEY=VALUE。繰り返し指定できます。" + +#: src/iac_code/mcp/cli.py +msgid "Transport type: stdio, http, sse." +msgstr "Transport type: stdio、http、sse。" + +#: src/iac_code/mcp/cli.py +msgid "Remote MCP URL for http/sse." +msgstr "http/sse 用のリモート MCP URL。" + +#: src/iac_code/mcp/cli.py +msgid "HTTP header KEY=VALUE. Can be repeated." +msgstr "HTTP header KEY=VALUE。繰り返し指定できます。" + +#: src/iac_code/mcp/cli.py +msgid "Config scope: user, local, project." +msgstr "設定スコープ: user、local、project。" + +#: src/iac_code/mcp/cli.py +msgid "OAuth client id." +msgstr "OAuth client ID。" + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret. Pass the option without a value to enter it securely." +msgstr "OAuth client secret。値なしでオプションを渡すと安全に入力できます。" + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret env var name." +msgstr "OAuth client secret の環境変数名。" + +#: src/iac_code/mcp/cli.py +msgid "OAuth loopback callback port." +msgstr "OAuth loopback callback port。" + +#: src/iac_code/mcp/cli.py +msgid "OAuth authorization server metadata URL." +msgstr "OAuth authorization server metadata URL。" + +#: src/iac_code/mcp/cli.py +msgid "--command is required for stdio MCP servers." +msgstr "stdio MCP server には --command が必要です。" + +#: src/iac_code/mcp/cli.py +msgid "Warning: on Windows, bare npx may need to be configured as: cmd /c npx" +msgstr "警告: Windows では、単独の npx は cmd /c npx として設定する必要がある場合があります" + +#: src/iac_code/mcp/cli.py +msgid "--url is required for remote MCP servers." +msgstr "リモート MCP server には --url が必要です。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Added MCP server {name!r} to {scope} config." +msgstr "MCP server {name!r} を {scope} 設定に追加しました。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid JSON: {error}" +msgstr "無効な JSON: {error}" + +#: src/iac_code/mcp/cli.py +msgid "MCP server JSON must be an object." +msgstr "MCP server JSON はオブジェクトでなければなりません。" + +#: src/iac_code/mcp/cli.py +msgid "No MCP servers configured." +msgstr "MCP server は設定されていません。" + +#: src/iac_code/mcp/cli.py +msgid "approved" +msgstr "承認済み" + +#: src/iac_code/mcp/cli.py src/iac_code/ui/repl.py +msgid "pending" +msgstr "保留中" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP server {name!r} not found in {scope} config." +msgstr "{scope} 設定に MCP server {name!r} が見つかりません。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Removed MCP server {name!r} from {path}." +msgstr "{path} から MCP server {name!r} を削除しました。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Approved MCP server {name!r}." +msgstr "MCP server {name!r} を承認しました。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Rejected MCP server {name!r}." +msgstr "MCP server {name!r} を拒否しました。" + +#: src/iac_code/mcp/cli.py +msgid "Reset MCP project approval choices." +msgstr "MCP project approval の選択をリセットしました。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP auth failed for {name!r}: {error}" +msgstr "{name!r} の MCP 認証に失敗しました: {error}" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Authenticated MCP server {name!r}." +msgstr "MCP server {name!r} を認証しました。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Reset stored MCP auth state for {name!r}." +msgstr "MCP server {name!r} の保存済み認証状態をリセットしました。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid MCP scope {scope!r}. Valid values: user, local, project." +msgstr "無効な MCP スコープ {scope!r}。有効な値: user、local、project。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Scope {scope!r} cannot be used for persisted MCP config." +msgstr "スコープ {scope!r} は永続化された MCP 設定には使用できません。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "" +"MCP {section} {key!r} may contain a secret; use an environment variable " +"reference like ${{VAR}} instead of storing plaintext." +msgstr "" +"MCP {section} {key!r} には secret が含まれる可能性があります。平文で保存せず、${{VAR}} " +"のような環境変数参照を使用してください。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects KEY=VALUE, got {value!r}." +msgstr "{option} は KEY=VALUE を期待していますが、{value!r} を受け取りました。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects a non-empty key." +msgstr "{option} には空でない key が必要です。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Project MCP server {name!r} not found." +msgstr "project MCP server {name!r} が見つかりません。" + +#: src/iac_code/mcp/client.py src/iac_code/mcp/manager.py +#, python-brace-format +msgid "MCP server {server!r} is not connected." +msgstr "MCP server {server!r} は接続されていません。" + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "Unsupported MCP transport: {transport}" +msgstr "未対応の MCP transport: {transport}" + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication." +msgstr "MCP server {server!r} には認証が必要です。" + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "" +"{message}\n" +"MCP server stderr:\n" +"{stderr}" +msgstr "" +"{message}\n" +"MCP server stderr:\n" +"{stderr}" + +#: src/iac_code/mcp/config.py +msgid "MCP server names must be non-empty strings." +msgstr "MCP server 名は空でない文字列でなければなりません。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Project MCP server {name!r} is pending approval." +msgstr "project MCP server {name!r} は承認待ちです。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Cannot persist MCP server config to {scope!r} scope." +msgstr "MCP server 設定を {scope!r} スコープに永続化できません。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP scope {scope!r} is not a persisted config scope." +msgstr "MCP スコープ {scope!r} は永続化設定スコープではありません。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server {existing!r} has the same content signature as {current!r}; " +"keeping higher-precedence server {current!r}." +msgstr "" +"MCP server {existing!r} は {current!r} と同じ content signature " +"を持っています。優先度の高い server {current!r} を保持します。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP server name {name!r} is reserved." +msgstr "MCP server 名 {name!r} は予約されています。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server name {name!r} may only contain letters, numbers, dot, " +"underscore, and hyphen." +msgstr "MCP server 名 {name!r} に使用できるのは、文字、数字、ドット、アンダースコア、ハイフンのみです。" + +#: src/iac_code/mcp/env_expansion.py +#, python-brace-format +msgid "Environment variable {name!r} is not set for MCP config." +msgstr "MCP 設定用の環境変数 {name!r} が設定されていません。" + +#: src/iac_code/mcp/manager.py +#, python-brace-format +msgid "" +"MCP server {server!r} requested elicitation, but iac-code does not " +"support elicitation yet." +msgstr "" +"MCP server {server!r} が elicitation を要求しましたが、iac-code はまだ elicitation " +"をサポートしていません。" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "Could not discover OAuth metadata for MCP server {server!r}." +msgstr "MCP server {server!r} の OAuth metadata を発見できませんでした。" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth token response for MCP server {server!r} did not include an access " +"token." +msgstr "MCP server {server!r} の OAuth token response に access token が含まれていません。" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "No refresh token is available for MCP server {server!r}." +msgstr "MCP server {server!r} で利用できる refresh token がありません。" + +#: src/iac_code/mcp/oauth.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication: {error}" +msgstr "MCP server {server!r} には認証が必要です: {error}" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth refresh response for MCP server {server!r} did not include an " +"access token." +msgstr "MCP server {server!r} の OAuth refresh response に access token が含まれていません。" + +#: src/iac_code/mcp/oauth.py +msgid "Timed out waiting for MCP OAuth callback." +msgstr "MCP OAuth callback の待機がタイムアウトしました。" + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback did not include a code." +msgstr "OAuth callback に code が含まれていません。" + +#: src/iac_code/mcp/oauth.py +msgid "OAuth flow closed." +msgstr "OAuth フローが閉じられました。" + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback state did not match." +msgstr "OAuth callback state が一致しません。" + +#: src/iac_code/mcp/oauth.py +msgid "MCP authentication complete. You can close this window." +msgstr "MCP 認証が完了しました。このウィンドウを閉じてかまいません。" + +#: src/iac_code/mcp/oauth.py +msgid "OAuth metadata endpoint did not return an object." +msgstr "OAuth metadata endpoint がオブジェクトを返しませんでした。" + +#: src/iac_code/mcp/oauth.py +msgid "OAuth token endpoint did not return an object." +msgstr "OAuth token endpoint がオブジェクトを返しませんでした。" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Structured content:\n" +"{content}" +msgstr "" +"構造化コンテンツ:\n" +"{content}" + +#: src/iac_code/mcp/output.py +msgid "MCP tool returned no content." +msgstr "MCP tool はコンテンツを返しませんでした。" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Resource from MCP server {server!r}\n" +"URI: {uri}" +msgstr "" +"MCP server {server!r} からのリソース\n" +"URI: {uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"{header}\n" +"MIME: {mime_type}" +msgstr "" +"{header}\n" +"MIME: {mime_type}" + +#: src/iac_code/mcp/output.py +msgid "(unnamed)" +msgstr "(名前なし)" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Resource link: {name}" +msgstr "リソースリンク: {name}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "URI: {uri}" +msgstr "URI: {uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MIME: {mime_type}" +msgstr "MIME: {mime_type}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Unsupported MCP content block:\n" +"{content}" +msgstr "" +"未対応の MCP content block:\n" +"{content}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MCP {kind} content must contain base64 string data." +msgstr "MCP {kind} コンテンツには base64 文字列データが必要です。" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Saved {mime_type} artifact as {artifact_id} ({bytes} bytes)." +msgstr "{mime_type} artifact を {artifact_id} として保存しました({bytes} bytes)。" + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt command {command!r} conflicts with an existing command." +msgstr "MCP prompt command {command!r} は既存の command と競合しています。" + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt {prompt}" +msgstr "MCP prompt {prompt}" + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "Missing required MCP prompt argument: {name}" +msgstr "必須の MCP prompt 引数がありません: {name}" + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments JSON must be an object." +msgstr "MCP prompt 引数 JSON はオブジェクトでなければなりません。" + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments must use key=value syntax." +msgstr "MCP prompt 引数は key=value 構文を使用する必要があります。" + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments contain an unterminated quoted value." +msgstr "MCP prompt 引数に終了していない引用値が含まれています。" + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} conflicts with an existing command." +msgstr "MCP skill command {command!r} は既存の command と競合しています。" + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} could not be loaded: {error}" +msgstr "MCP skill command {command!r} を読み込めませんでした: {error}" + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill {command!r} was truncated to fit safety limits." +msgstr "MCP skill {command!r} は安全上の制限に収まるよう切り詰められました。" + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "MCP tool {tool!r} from server {server!r}." +msgstr "server {server!r} の MCP tool {tool!r}。" + +#: src/iac_code/mcp/tools.py +msgid "List resources exposed by connected MCP servers." +msgstr "接続済み MCP server が公開するリソースを一覧表示します。" + +#: src/iac_code/mcp/tools.py +msgid "Optional MCP server name filter." +msgstr "任意の MCP server 名フィルター。" + +#: src/iac_code/mcp/tools.py +msgid "No MCP resources are currently available." +msgstr "現在利用可能な MCP リソースはありません。" + +#: src/iac_code/mcp/tools.py +msgid "Read a resource exposed by a connected MCP server." +msgstr "接続済み MCP server が公開するリソースを読み取ります。" + +#: src/iac_code/mcp/tools.py +msgid "Start authentication for an MCP server." +msgstr "MCP server の認証を開始します。" + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "No MCP authentication flow is configured for {server!r}." +msgstr "{server!r} には MCP 認証フローが設定されていません。" + +#: src/iac_code/mcp/tools.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Open this URL to authenticate MCP server {server!r}:\n" +"{url}" +msgstr "" +"この URL を開いて MCP server {server!r} を認証してください:\n" +"{url}" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"Unsupported MCP transport {transport!r} for server {server!r}. Supported " +"transports: {supported}." +msgstr "" +"server {server!r} の MCP transport {transport!r} は未対応です。対応 transport: " +"{supported}。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth config must be an object." +msgstr "MCP server {server!r} の oauth 設定はオブジェクトでなければなりません。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses oauth.clientSecret, but plaintext client " +"secrets are not supported. Use oauth.clientSecretEnv instead." +msgstr "" +"MCP server {server!r} は oauth.clientSecret を使用していますが、平文の client secret " +"はサポートされていません。代わりに oauth.clientSecretEnv を使用してください。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported oauth fields: {fields}." +msgstr "MCP server {server!r} には未対応の oauth fields があります: {fields}。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth.callbackPort must be an integer." +msgstr "MCP server {server!r} の oauth.callbackPort は整数でなければなりません。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} config must be an object." +msgstr "MCP server {server!r} の設定はオブジェクトでなければなりません。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a type unless a stdio command is provided." +msgstr "stdio command が指定されていない限り、MCP server {server!r} には type が必要です。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} type must be a string." +msgstr "MCP server {server!r} の type は文字列でなければなりません。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses unsupported field 'headersHelper'. Static " +"headers are supported; dynamic headers need a later trusted-execution " +"design." +msgstr "" +"MCP server {server!r} は未対応の field 'headersHelper' を使用しています。静的 headers " +"はサポートされていますが、動的 headers には今後の信頼された実行設計が必要です。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported config fields: {fields}." +msgstr "MCP server {server!r} には未対応の config fields があります: {fields}。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a {field} string." +msgstr "MCP server {server!r} には {field} 文字列が必要です。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a string." +msgstr "MCP server {server!r} の field {field} は文字列でなければなりません。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a list of strings." +msgstr "MCP server {server!r} の field {field} は文字列のリストでなければなりません。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be an object of string values." +msgstr "MCP server {server!r} の field {field} は文字列値のオブジェクトでなければなりません。" + #: src/iac_code/memory/memory_tools.py msgid "" "Read persistent memories. Omit name to list all, or provide name to read " @@ -2721,6 +3271,25 @@ msgstr "Volcengine CodingPlan" msgid "Anthropic Compatible" msgstr "Anthropic 互換" +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} connection failed: {error}" +msgstr "MCP server {server!r} の接続に失敗しました: {error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} {capability} discovery failed: {error}" +msgstr "MCP server {server!r} の {capability} discovery が失敗しました: {error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Opened MCP auth URL for {server!r}:\n" +"{url}" +msgstr "" +"{server!r} の MCP auth URL を開きました:\n" +"{url}" + #: src/iac_code/services/qwenpaw_source.py #, python-brace-format msgid "" @@ -3689,7 +4258,7 @@ msgstr "完了(ツール使用 {child_count} 回{token_info}{elapsed})" #: src/iac_code/ui/renderer.py #, python-brace-format msgid "+ {count} more tool uses (ctrl+o to expand)" -msgstr "+ ツール使用がさらに {count} 回(ctrl+o で展開)" +msgstr "+ さらに {count} 回の tool 使用(ctrl+o で展開)" #: src/iac_code/ui/renderer.py #, python-brace-format @@ -3742,6 +4311,15 @@ msgstr "いいえ、常に \"{rule}\" を拒否(このセッション)" msgid "No, always reject this tool" msgstr "いいえ、このツールは常に拒否" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Approve project MCP server {server!r} from {source}? [y/N] " +msgstr "{source} からの project MCP server {server!r} を承認しますか?[y/N] " + +#: src/iac_code/ui/repl.py +msgid "MCP warning:" +msgstr "MCP 警告:" + #: src/iac_code/ui/repl.py msgid "Press Ctrl+C again to exit." msgstr "終了するには Ctrl+C をもう一度押してください。" @@ -3815,9 +4393,7 @@ msgstr "不明なスキル: ${name}。/ を入力するとコマンドとスキ #: src/iac_code/ui/repl.py #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." -msgstr "" -"Unknown command: /{name}. Type /help for available " -"commands.不明なコマンドです:/{name}。利用可能なコマンドは /help を入力してください。" +msgstr "不明なコマンドです: /{name}。利用可能なコマンドは /help で確認してください。" #: src/iac_code/ui/repl.py #, python-brace-format @@ -3911,10 +4487,6 @@ msgstr "↺ ロールバッククリーンアップ再開: {count} 件のレコ msgid "failed" msgstr "失敗" -#: src/iac_code/ui/repl.py -msgid "pending" -msgstr "保留中" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "進行中" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 973842c1..7788970b 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -48,6 +48,11 @@ msgstr "Nome de arquivo de artefato inseguro" msgid "Unknown error" msgstr "Erro desconhecido" +#: src/iac_code/a2a/events.py src/iac_code/acp/server.py +#, python-brace-format +msgid "MCP warning: {message}" +msgstr "Aviso MCP: {message}" + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -80,15 +85,15 @@ msgstr "" "A limpeza de rollback ainda está em andamento. Continue após a limpeza " "terminar." -#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py -msgid "Task is already working." -msgstr "A tarefa já está em execução." - #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py #: src/iac_code/a2a/transports/dispatcher.py msgid "Task canceled." msgstr "Tarefa cancelada." +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "Task is already working." +msgstr "A tarefa já está em execução." + #: src/iac_code/a2a/executor.py #, python-brace-format msgid "Current model {model} does not support image input." @@ -168,6 +173,13 @@ msgstr "Tarefa concluída." msgid "Task failed." msgstr "A tarefa falhou." +#: src/iac_code/acp/convert.py src/iac_code/cli/headless.py +#: src/iac_code/mcp/tools.py src/iac_code/ui/renderer.py +#: src/iac_code/ui/stream_accumulator.py +#, python-brace-format +msgid "MCP {server}:{tool}" +msgstr "MCP {server}:{tool}" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "Sessão não encontrada" @@ -498,6 +510,11 @@ msgstr "" msgid "Error: {error}" msgstr "Erro: {error}" +#: src/iac_code/cli/headless.py +#, python-brace-format +msgid "MCP warning: {message}\n" +msgstr "Aviso MCP: {message}\n" + #: src/iac_code/cli/install_git_bash.py #, python-brace-format msgid "Git Bash is already installed at {}" @@ -2116,6 +2133,565 @@ msgstr "há {n} minuto{s}" msgid "{n} more line{s}" msgstr "mais {n} linha{s}" +#: src/iac_code/mcp/cli.py +msgid "Manage MCP servers." +msgstr "Gerenciar servidores MCP." + +#: src/iac_code/mcp/cli.py +msgid "Command for stdio MCP server." +msgstr "Comando para servidor MCP stdio." + +#: src/iac_code/mcp/cli.py +msgid "Command argument. Can be repeated." +msgstr "Argumento de comando. Pode ser repetido." + +#: src/iac_code/mcp/cli.py +msgid "Environment variable KEY=VALUE. Can be repeated." +msgstr "Variável de ambiente KEY=VALUE. Pode ser repetida." + +#: src/iac_code/mcp/cli.py +msgid "Transport type: stdio, http, sse." +msgstr "Tipo de transporte: stdio, http, sse." + +#: src/iac_code/mcp/cli.py +msgid "Remote MCP URL for http/sse." +msgstr "URL MCP remota para http/sse." + +#: src/iac_code/mcp/cli.py +msgid "HTTP header KEY=VALUE. Can be repeated." +msgstr "Cabeçalho HTTP KEY=VALUE. Pode ser repetido." + +#: src/iac_code/mcp/cli.py +msgid "Config scope: user, local, project." +msgstr "Escopo de configuração: user, local, project." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client id." +msgstr "ID do cliente OAuth." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret. Pass the option without a value to enter it securely." +msgstr "" +"Segredo de cliente OAuth. Passe a opção sem valor para inseri-lo com " +"segurança." + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret env var name." +msgstr "Nome da variável de ambiente do segredo de cliente OAuth." + +#: src/iac_code/mcp/cli.py +msgid "OAuth loopback callback port." +msgstr "Porta de callback loopback OAuth." + +#: src/iac_code/mcp/cli.py +msgid "OAuth authorization server metadata URL." +msgstr "URL de metadados do servidor de autorização OAuth." + +#: src/iac_code/mcp/cli.py +msgid "--command is required for stdio MCP servers." +msgstr "--command é obrigatório para servidores MCP stdio." + +#: src/iac_code/mcp/cli.py +msgid "Warning: on Windows, bare npx may need to be configured as: cmd /c npx" +msgstr "" +"Aviso: no Windows, npx sem wrapper pode precisar ser configurado como: " +"cmd /c npx" + +#: src/iac_code/mcp/cli.py +msgid "--url is required for remote MCP servers." +msgstr "--url é obrigatório para servidores MCP remotos." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Added MCP server {name!r} to {scope} config." +msgstr "Servidor MCP {name!r} adicionado à configuração {scope}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid JSON: {error}" +msgstr "JSON inválido: {error}" + +#: src/iac_code/mcp/cli.py +msgid "MCP server JSON must be an object." +msgstr "O JSON do servidor MCP deve ser um objeto." + +#: src/iac_code/mcp/cli.py +msgid "No MCP servers configured." +msgstr "Nenhum servidor MCP configurado." + +#: src/iac_code/mcp/cli.py +msgid "approved" +msgstr "aprovado" + +#: src/iac_code/mcp/cli.py src/iac_code/ui/repl.py +msgid "pending" +msgstr "pendente" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP server {name!r} not found in {scope} config." +msgstr "Servidor MCP {name!r} não encontrado na configuração {scope}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Removed MCP server {name!r} from {path}." +msgstr "Servidor MCP {name!r} removido de {path}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Approved MCP server {name!r}." +msgstr "Servidor MCP {name!r} aprovado." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Rejected MCP server {name!r}." +msgstr "Servidor MCP {name!r} rejeitado." + +#: src/iac_code/mcp/cli.py +msgid "Reset MCP project approval choices." +msgstr "As decisões de aprovação MCP do projeto foram redefinidas." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP auth failed for {name!r}: {error}" +msgstr "Falha na autenticação MCP para {name!r}: {error}" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Authenticated MCP server {name!r}." +msgstr "Servidor MCP {name!r} autenticado." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Reset stored MCP auth state for {name!r}." +msgstr "Estado de autenticação MCP armazenado redefinido para {name!r}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid MCP scope {scope!r}. Valid values: user, local, project." +msgstr "Escopo MCP inválido {scope!r}. Valores válidos: user, local, project." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Scope {scope!r} cannot be used for persisted MCP config." +msgstr "O escopo {scope!r} não pode ser usado para configuração MCP persistida." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "" +"MCP {section} {key!r} may contain a secret; use an environment variable " +"reference like ${{VAR}} instead of storing plaintext." +msgstr "" +"MCP {section} {key!r} pode conter um segredo; use uma referência de " +"variável de ambiente como ${{VAR}} em vez de armazenar texto simples." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects KEY=VALUE, got {value!r}." +msgstr "{option} espera KEY=VALUE, recebeu {value!r}." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects a non-empty key." +msgstr "{option} espera uma chave não vazia." + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Project MCP server {name!r} not found." +msgstr "Servidor MCP de projeto {name!r} não encontrado." + +#: src/iac_code/mcp/client.py src/iac_code/mcp/manager.py +#, python-brace-format +msgid "MCP server {server!r} is not connected." +msgstr "O servidor MCP {server!r} não está conectado." + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "Unsupported MCP transport: {transport}" +msgstr "Transporte MCP sem suporte: {transport}" + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication." +msgstr "O servidor MCP {server!r} exige autenticação." + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "" +"{message}\n" +"MCP server stderr:\n" +"{stderr}" +msgstr "" +"{message}\n" +"stderr do servidor MCP:\n" +"{stderr}" + +#: src/iac_code/mcp/config.py +msgid "MCP server names must be non-empty strings." +msgstr "Os nomes de servidores MCP devem ser strings não vazias." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Project MCP server {name!r} is pending approval." +msgstr "O servidor MCP de projeto {name!r} está aguardando aprovação." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Cannot persist MCP server config to {scope!r} scope." +msgstr "" +"Não é possível persistir a configuração do servidor MCP no escopo " +"{scope!r}." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP scope {scope!r} is not a persisted config scope." +msgstr "O escopo MCP {scope!r} não é um escopo de configuração persistida." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server {existing!r} has the same content signature as {current!r}; " +"keeping higher-precedence server {current!r}." +msgstr "" +"O servidor MCP {existing!r} tem a mesma assinatura de conteúdo que " +"{current!r}; mantendo o servidor {current!r} de maior precedência." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP server name {name!r} is reserved." +msgstr "O nome de servidor MCP {name!r} é reservado." + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server name {name!r} may only contain letters, numbers, dot, " +"underscore, and hyphen." +msgstr "" +"O nome de servidor MCP {name!r} só pode conter letras, números, ponto, " +"sublinhado e hífen." + +#: src/iac_code/mcp/env_expansion.py +#, python-brace-format +msgid "Environment variable {name!r} is not set for MCP config." +msgstr "A variável de ambiente {name!r} não está definida para a configuração MCP." + +#: src/iac_code/mcp/manager.py +#, python-brace-format +msgid "" +"MCP server {server!r} requested elicitation, but iac-code does not " +"support elicitation yet." +msgstr "" +"O servidor MCP {server!r} solicitou elicitation, mas o iac-code ainda não" +" oferece suporte a elicitation." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "Could not discover OAuth metadata for MCP server {server!r}." +msgstr "Não foi possível descobrir os metadados OAuth do servidor MCP {server!r}." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth token response for MCP server {server!r} did not include an access " +"token." +msgstr "" +"A resposta de token OAuth do servidor MCP {server!r} não incluiu um token" +" de acesso." + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "No refresh token is available for MCP server {server!r}." +msgstr "" +"Nenhum token de atualização está disponível para o servidor MCP " +"{server!r}." + +#: src/iac_code/mcp/oauth.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication: {error}" +msgstr "O servidor MCP {server!r} exige autenticação: {error}" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth refresh response for MCP server {server!r} did not include an " +"access token." +msgstr "" +"A resposta de atualização OAuth do servidor MCP {server!r} não incluiu um" +" token de acesso." + +#: src/iac_code/mcp/oauth.py +msgid "Timed out waiting for MCP OAuth callback." +msgstr "Tempo esgotado aguardando o callback OAuth do MCP." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback did not include a code." +msgstr "O callback OAuth não incluiu um código." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth flow closed." +msgstr "O fluxo OAuth foi fechado." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback state did not match." +msgstr "O estado do callback OAuth não correspondeu." + +#: src/iac_code/mcp/oauth.py +msgid "MCP authentication complete. You can close this window." +msgstr "Autenticação MCP concluída. Você pode fechar esta janela." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth metadata endpoint did not return an object." +msgstr "O endpoint de metadados OAuth não retornou um objeto." + +#: src/iac_code/mcp/oauth.py +msgid "OAuth token endpoint did not return an object." +msgstr "O endpoint de token OAuth não retornou um objeto." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Structured content:\n" +"{content}" +msgstr "" +"Conteúdo estruturado:\n" +"{content}" + +#: src/iac_code/mcp/output.py +msgid "MCP tool returned no content." +msgstr "A ferramenta MCP não retornou conteúdo." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Resource from MCP server {server!r}\n" +"URI: {uri}" +msgstr "" +"Recurso do servidor MCP {server!r}\n" +"URI: {uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"{header}\n" +"MIME: {mime_type}" +msgstr "" +"{header}\n" +"MIME: {mime_type}" + +#: src/iac_code/mcp/output.py +msgid "(unnamed)" +msgstr "(sem nome)" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Resource link: {name}" +msgstr "Link de recurso: {name}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "URI: {uri}" +msgstr "URI: {uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MIME: {mime_type}" +msgstr "MIME: {mime_type}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Unsupported MCP content block:\n" +"{content}" +msgstr "" +"Bloco de conteúdo MCP sem suporte:\n" +"{content}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MCP {kind} content must contain base64 string data." +msgstr "O conteúdo MCP {kind} deve conter dados de string base64." + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Saved {mime_type} artifact as {artifact_id} ({bytes} bytes)." +msgstr "Artefato {mime_type} salvo como {artifact_id} ({bytes} bytes)." + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt command {command!r} conflicts with an existing command." +msgstr "" +"O comando de prompt MCP {command!r} entra em conflito com um comando " +"existente." + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt {prompt}" +msgstr "Prompt MCP {prompt}" + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "Missing required MCP prompt argument: {name}" +msgstr "Argumento obrigatório de prompt MCP ausente: {name}" + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments JSON must be an object." +msgstr "Os argumentos de prompt MCP em JSON devem ser um objeto." + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments must use key=value syntax." +msgstr "Os argumentos de prompt MCP devem usar a sintaxe key=value." + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments contain an unterminated quoted value." +msgstr "Os argumentos de prompt MCP contêm um valor entre aspas sem fechamento." + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} conflicts with an existing command." +msgstr "" +"O comando de skill MCP {command!r} entra em conflito com um comando " +"existente." + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} could not be loaded: {error}" +msgstr "Não foi possível carregar o comando de skill MCP {command!r}: {error}" + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill {command!r} was truncated to fit safety limits." +msgstr "A skill MCP {command!r} foi truncada para caber nos limites de segurança." + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "MCP tool {tool!r} from server {server!r}." +msgstr "Ferramenta MCP {tool!r} do servidor {server!r}." + +#: src/iac_code/mcp/tools.py +msgid "List resources exposed by connected MCP servers." +msgstr "Lista recursos expostos por servidores MCP conectados." + +#: src/iac_code/mcp/tools.py +msgid "Optional MCP server name filter." +msgstr "Filtro opcional por nome de servidor MCP." + +#: src/iac_code/mcp/tools.py +msgid "No MCP resources are currently available." +msgstr "Nenhum recurso MCP está disponível no momento." + +#: src/iac_code/mcp/tools.py +msgid "Read a resource exposed by a connected MCP server." +msgstr "Ler um recurso exposto por um servidor MCP conectado." + +#: src/iac_code/mcp/tools.py +msgid "Start authentication for an MCP server." +msgstr "Iniciar autenticação para um servidor MCP." + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "No MCP authentication flow is configured for {server!r}." +msgstr "Nenhum fluxo de autenticação MCP está configurado para {server!r}." + +#: src/iac_code/mcp/tools.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Open this URL to authenticate MCP server {server!r}:\n" +"{url}" +msgstr "" +"Abra esta URL para autenticar o servidor MCP {server!r}:\n" +"{url}" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"Unsupported MCP transport {transport!r} for server {server!r}. Supported " +"transports: {supported}." +msgstr "" +"Transporte MCP {transport!r} sem suporte para o servidor {server!r}. " +"Transportes compatíveis: {supported}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth config must be an object." +msgstr "A configuração oauth do servidor MCP {server!r} deve ser um objeto." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses oauth.clientSecret, but plaintext client " +"secrets are not supported. Use oauth.clientSecretEnv instead." +msgstr "" +"O servidor MCP {server!r} usa oauth.clientSecret, mas segredos de cliente" +" em texto simples não são aceitos. Use oauth.clientSecretEnv em vez " +"disso." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported oauth fields: {fields}." +msgstr "O servidor MCP {server!r} tem campos oauth sem suporte: {fields}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth.callbackPort must be an integer." +msgstr "oauth.callbackPort do servidor MCP {server!r} deve ser um inteiro." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} config must be an object." +msgstr "A configuração do servidor MCP {server!r} deve ser um objeto." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a type unless a stdio command is provided." +msgstr "" +"O servidor MCP {server!r} requer um type, a menos que um comando stdio " +"seja fornecido." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} type must be a string." +msgstr "O type do servidor MCP {server!r} deve ser uma string." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses unsupported field 'headersHelper'. Static " +"headers are supported; dynamic headers need a later trusted-execution " +"design." +msgstr "" +"O servidor MCP {server!r} usa o campo sem suporte 'headersHelper'. " +"Cabeçalhos estáticos são aceitos; cabeçalhos dinâmicos precisam de um " +"desenho futuro de execução confiável." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported config fields: {fields}." +msgstr "" +"O servidor MCP {server!r} tem campos de configuração sem suporte: " +"{fields}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a {field} string." +msgstr "O servidor MCP {server!r} requer uma string {field}." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a string." +msgstr "O campo {field} do servidor MCP {server!r} deve ser uma string." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a list of strings." +msgstr "O campo {field} do servidor MCP {server!r} deve ser uma lista de strings." + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be an object of string values." +msgstr "" +"O campo {field} do servidor MCP {server!r} deve ser um objeto de valores " +"string." + #: src/iac_code/memory/memory_tools.py msgid "" "Read persistent memories. Omit name to list all, or provide name to read " @@ -2841,6 +3417,25 @@ msgstr "Volcengine CodingPlan" msgid "Anthropic Compatible" msgstr "Compatível com Anthropic" +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} connection failed: {error}" +msgstr "Falha na conexão do servidor MCP {server!r}: {error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} {capability} discovery failed: {error}" +msgstr "Falha na descoberta de {capability} do servidor MCP {server!r}: {error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Opened MCP auth URL for {server!r}:\n" +"{url}" +msgstr "" +"URL de autenticação MCP aberta para {server!r}:\n" +"{url}" + #: src/iac_code/services/qwenpaw_source.py #, python-brace-format msgid "" @@ -3879,6 +4474,15 @@ msgstr "Não, sempre negar \"{rule}\" (esta sessão)" msgid "No, always reject this tool" msgstr "Não, sempre rejeitar esta ferramenta" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Approve project MCP server {server!r} from {source}? [y/N] " +msgstr "Aprovar o servidor MCP de projeto {server!r} de {source}? [y/N] " + +#: src/iac_code/ui/repl.py +msgid "MCP warning:" +msgstr "Aviso MCP:" + #: src/iac_code/ui/repl.py msgid "Press Ctrl+C again to exit." msgstr "Pressione Ctrl+C novamente para sair." @@ -3956,7 +4560,9 @@ msgstr "" #: src/iac_code/ui/repl.py #, python-brace-format msgid "Unknown command: /{name}. Type /help for available commands." -msgstr "Comando desconhecido: /{name}. Digite /help para ver os comandos." +msgstr "" +"Comando desconhecido: /{name}. Digite /help para ver os comandos " +"disponíveis." #: src/iac_code/ui/repl.py #, python-brace-format @@ -4055,10 +4661,6 @@ msgstr "" msgid "failed" msgstr "falhou" -#: src/iac_code/ui/repl.py -msgid "pending" -msgstr "pendente" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "em andamento" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 7ed1c916..6fdc7df1 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -44,6 +44,11 @@ msgstr "不安全的工件文件名" msgid "Unknown error" msgstr "未知错误" +#: src/iac_code/a2a/events.py src/iac_code/acp/server.py +#, python-brace-format +msgid "MCP warning: {message}" +msgstr "MCP 警告:{message}" + #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py msgid "" "Cleanup state unavailable. Inspect the session file and cloud resources " @@ -68,15 +73,15 @@ msgid "" "completes." msgstr "回滚清理仍在进行中。请在清理完成后继续。" -#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py -msgid "Task is already working." -msgstr "任务已在运行。" - #: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py #: src/iac_code/a2a/transports/dispatcher.py msgid "Task canceled." msgstr "任务已取消。" +#: src/iac_code/a2a/executor.py src/iac_code/a2a/pipeline_executor.py +msgid "Task is already working." +msgstr "任务已在运行。" + #: src/iac_code/a2a/executor.py #, python-brace-format msgid "Current model {model} does not support image input." @@ -154,6 +159,13 @@ msgstr "任务已完成。" msgid "Task failed." msgstr "任务失败。" +#: src/iac_code/acp/convert.py src/iac_code/cli/headless.py +#: src/iac_code/mcp/tools.py src/iac_code/ui/renderer.py +#: src/iac_code/ui/stream_accumulator.py +#, python-brace-format +msgid "MCP {server}:{tool}" +msgstr "MCP {server}:{tool}" + #: src/iac_code/acp/server.py msgid "Session not found" msgstr "未找到会话" @@ -470,6 +482,11 @@ msgstr "" msgid "Error: {error}" msgstr "错误:{error}" +#: src/iac_code/cli/headless.py +#, python-brace-format +msgid "MCP warning: {message}\n" +msgstr "MCP 警告:{message}\n" + #: src/iac_code/cli/install_git_bash.py #, python-brace-format msgid "Git Bash is already installed at {}" @@ -2044,6 +2061,531 @@ msgstr "{n} 分钟前" msgid "{n} more line{s}" msgstr "还有 {n} 行" +#: src/iac_code/mcp/cli.py +msgid "Manage MCP servers." +msgstr "管理 MCP 服务器。" + +#: src/iac_code/mcp/cli.py +msgid "Command for stdio MCP server." +msgstr "stdio MCP 服务器的命令。" + +#: src/iac_code/mcp/cli.py +msgid "Command argument. Can be repeated." +msgstr "命令参数。可重复指定。" + +#: src/iac_code/mcp/cli.py +msgid "Environment variable KEY=VALUE. Can be repeated." +msgstr "环境变量 KEY=VALUE。可重复指定。" + +#: src/iac_code/mcp/cli.py +msgid "Transport type: stdio, http, sse." +msgstr "Transport 类型:stdio、http、sse。" + +#: src/iac_code/mcp/cli.py +msgid "Remote MCP URL for http/sse." +msgstr "http/sse 远程 MCP URL。" + +#: src/iac_code/mcp/cli.py +msgid "HTTP header KEY=VALUE. Can be repeated." +msgstr "HTTP header KEY=VALUE。可重复指定。" + +#: src/iac_code/mcp/cli.py +msgid "Config scope: user, local, project." +msgstr "配置作用域:user、local、project。" + +#: src/iac_code/mcp/cli.py +msgid "OAuth client id." +msgstr "OAuth 客户端 ID。" + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret. Pass the option without a value to enter it securely." +msgstr "OAuth 客户端密钥。不带值传入该选项可安全输入。" + +#: src/iac_code/mcp/cli.py +msgid "OAuth client secret env var name." +msgstr "OAuth 客户端密钥环境变量名。" + +#: src/iac_code/mcp/cli.py +msgid "OAuth loopback callback port." +msgstr "OAuth loopback 回调端口。" + +#: src/iac_code/mcp/cli.py +msgid "OAuth authorization server metadata URL." +msgstr "OAuth 授权服务器元数据 URL。" + +#: src/iac_code/mcp/cli.py +msgid "--command is required for stdio MCP servers." +msgstr "stdio MCP 服务器需要 --command。" + +#: src/iac_code/mcp/cli.py +msgid "Warning: on Windows, bare npx may need to be configured as: cmd /c npx" +msgstr "警告:在 Windows 上,裸 npx 可能需要配置为:cmd /c npx" + +#: src/iac_code/mcp/cli.py +msgid "--url is required for remote MCP servers." +msgstr "远程 MCP 服务器需要 --url。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Added MCP server {name!r} to {scope} config." +msgstr "已将 MCP 服务器 {name!r} 添加到 {scope} 配置。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid JSON: {error}" +msgstr "无效 JSON:{error}" + +#: src/iac_code/mcp/cli.py +msgid "MCP server JSON must be an object." +msgstr "MCP 服务器 JSON 必须是对象。" + +#: src/iac_code/mcp/cli.py +msgid "No MCP servers configured." +msgstr "未配置 MCP 服务器。" + +#: src/iac_code/mcp/cli.py +msgid "approved" +msgstr "已批准" + +#: src/iac_code/mcp/cli.py src/iac_code/ui/repl.py +msgid "pending" +msgstr "待处理" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP server {name!r} not found in {scope} config." +msgstr "在 {scope} 配置中找不到 MCP 服务器 {name!r}。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Removed MCP server {name!r} from {path}." +msgstr "已从 {path} 移除 MCP 服务器 {name!r}。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Approved MCP server {name!r}." +msgstr "已批准 MCP 服务器 {name!r}。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Rejected MCP server {name!r}." +msgstr "已拒绝 MCP 服务器 {name!r}。" + +#: src/iac_code/mcp/cli.py +msgid "Reset MCP project approval choices." +msgstr "已重置 MCP 项目审批选择。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "MCP auth failed for {name!r}: {error}" +msgstr "MCP 认证失败,服务器 {name!r}:{error}" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Authenticated MCP server {name!r}." +msgstr "已认证 MCP 服务器 {name!r}。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Reset stored MCP auth state for {name!r}." +msgstr "已重置 MCP 服务器 {name!r} 的已存储认证状态。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Invalid MCP scope {scope!r}. Valid values: user, local, project." +msgstr "无效的 MCP 作用域 {scope!r}。有效值:user、local、project。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Scope {scope!r} cannot be used for persisted MCP config." +msgstr "作用域 {scope!r} 不能用于持久化 MCP 配置。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "" +"MCP {section} {key!r} may contain a secret; use an environment variable " +"reference like ${{VAR}} instead of storing plaintext." +msgstr "MCP {section} {key!r} 可能包含 secret;请使用类似 ${{VAR}} 的环境变量引用,不要存储明文。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects KEY=VALUE, got {value!r}." +msgstr "{option} 需要 KEY=VALUE,收到 {value!r}。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "{option} expects a non-empty key." +msgstr "{option} 需要非空 key。" + +#: src/iac_code/mcp/cli.py +#, python-brace-format +msgid "Project MCP server {name!r} not found." +msgstr "未找到项目 MCP 服务器 {name!r}。" + +#: src/iac_code/mcp/client.py src/iac_code/mcp/manager.py +#, python-brace-format +msgid "MCP server {server!r} is not connected." +msgstr "MCP 服务器 {server!r} 未连接。" + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "Unsupported MCP transport: {transport}" +msgstr "不支持的 MCP transport:{transport}" + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication." +msgstr "MCP 服务器 {server!r} 需要认证。" + +#: src/iac_code/mcp/client.py +#, python-brace-format +msgid "" +"{message}\n" +"MCP server stderr:\n" +"{stderr}" +msgstr "" +"{message}\n" +"MCP 服务器 stderr:\n" +"{stderr}" + +#: src/iac_code/mcp/config.py +msgid "MCP server names must be non-empty strings." +msgstr "MCP 服务器名称必须是非空字符串。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Project MCP server {name!r} is pending approval." +msgstr "项目 MCP 服务器 {name!r} 正在等待批准。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "Cannot persist MCP server config to {scope!r} scope." +msgstr "不能将 MCP 服务器配置持久化到 {scope!r} 作用域。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP scope {scope!r} is not a persisted config scope." +msgstr "MCP 作用域 {scope!r} 不是持久化配置作用域。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server {existing!r} has the same content signature as {current!r}; " +"keeping higher-precedence server {current!r}." +msgstr "MCP 服务器 {existing!r} 与 {current!r} 具有相同内容签名;保留优先级更高的服务器 {current!r}。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "MCP server name {name!r} is reserved." +msgstr "MCP 服务器名称 {name!r} 是保留名称。" + +#: src/iac_code/mcp/config.py +#, python-brace-format +msgid "" +"MCP server name {name!r} may only contain letters, numbers, dot, " +"underscore, and hyphen." +msgstr "MCP 服务器名称 {name!r} 只能包含字母、数字、点、下划线和连字符。" + +#: src/iac_code/mcp/env_expansion.py +#, python-brace-format +msgid "Environment variable {name!r} is not set for MCP config." +msgstr "MCP 配置所需的环境变量 {name!r} 未设置。" + +#: src/iac_code/mcp/manager.py +#, python-brace-format +msgid "" +"MCP server {server!r} requested elicitation, but iac-code does not " +"support elicitation yet." +msgstr "MCP 服务器 {server!r} 请求 elicitation,但 iac-code 尚不支持 elicitation。" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "Could not discover OAuth metadata for MCP server {server!r}." +msgstr "无法发现 MCP 服务器 {server!r} 的 OAuth 元数据。" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth token response for MCP server {server!r} did not include an access " +"token." +msgstr "MCP 服务器 {server!r} 的 OAuth token 响应未包含 access token。" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "No refresh token is available for MCP server {server!r}." +msgstr "MCP 服务器 {server!r} 没有可用的 refresh token。" + +#: src/iac_code/mcp/oauth.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} requires authentication: {error}" +msgstr "MCP 服务器 {server!r} 需要认证:{error}" + +#: src/iac_code/mcp/oauth.py +#, python-brace-format +msgid "" +"OAuth refresh response for MCP server {server!r} did not include an " +"access token." +msgstr "MCP 服务器 {server!r} 的 OAuth refresh 响应未包含 access token。" + +#: src/iac_code/mcp/oauth.py +msgid "Timed out waiting for MCP OAuth callback." +msgstr "等待 MCP OAuth 回调超时。" + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback did not include a code." +msgstr "OAuth 回调未包含 code。" + +#: src/iac_code/mcp/oauth.py +msgid "OAuth flow closed." +msgstr "OAuth 流程已关闭。" + +#: src/iac_code/mcp/oauth.py +msgid "OAuth callback state did not match." +msgstr "OAuth 回调 state 不匹配。" + +#: src/iac_code/mcp/oauth.py +msgid "MCP authentication complete. You can close this window." +msgstr "MCP 认证已完成。你可以关闭此窗口。" + +#: src/iac_code/mcp/oauth.py +msgid "OAuth metadata endpoint did not return an object." +msgstr "OAuth 元数据端点未返回对象。" + +#: src/iac_code/mcp/oauth.py +msgid "OAuth token endpoint did not return an object." +msgstr "OAuth token 端点未返回对象。" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Structured content:\n" +"{content}" +msgstr "" +"结构化内容:\n" +"{content}" + +#: src/iac_code/mcp/output.py +msgid "MCP tool returned no content." +msgstr "MCP 工具未返回内容。" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Resource from MCP server {server!r}\n" +"URI: {uri}" +msgstr "" +"来自 MCP 服务器 {server!r} 的资源\n" +"URI:{uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"{header}\n" +"MIME: {mime_type}" +msgstr "" +"{header}\n" +"MIME:{mime_type}" + +#: src/iac_code/mcp/output.py +msgid "(unnamed)" +msgstr "(未命名)" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Resource link: {name}" +msgstr "资源链接:{name}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "URI: {uri}" +msgstr "URI:{uri}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MIME: {mime_type}" +msgstr "MIME:{mime_type}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "" +"Unsupported MCP content block:\n" +"{content}" +msgstr "" +"不支持的 MCP 内容块:\n" +"{content}" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "MCP {kind} content must contain base64 string data." +msgstr "MCP {kind} 内容必须包含 base64 字符串数据。" + +#: src/iac_code/mcp/output.py +#, python-brace-format +msgid "Saved {mime_type} artifact as {artifact_id} ({bytes} bytes)." +msgstr "已将 {mime_type} artifact 保存为 {artifact_id}({bytes} 字节)。" + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt command {command!r} conflicts with an existing command." +msgstr "MCP prompt 命令 {command!r} 与现有命令冲突。" + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "MCP prompt {prompt}" +msgstr "MCP prompt {prompt}" + +#: src/iac_code/mcp/prompts.py +#, python-brace-format +msgid "Missing required MCP prompt argument: {name}" +msgstr "缺少必需的 MCP prompt 参数:{name}" + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments JSON must be an object." +msgstr "MCP prompt 参数 JSON 必须是对象。" + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments must use key=value syntax." +msgstr "MCP prompt 参数必须使用 key=value 语法。" + +#: src/iac_code/mcp/prompts.py +msgid "MCP prompt arguments contain an unterminated quoted value." +msgstr "MCP prompt 参数包含未闭合的引号值。" + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} conflicts with an existing command." +msgstr "MCP skill 命令 {command!r} 与现有命令冲突。" + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill command {command!r} could not be loaded: {error}" +msgstr "无法加载 MCP skill 命令 {command!r}:{error}" + +#: src/iac_code/mcp/skills.py +#, python-brace-format +msgid "MCP skill {command!r} was truncated to fit safety limits." +msgstr "MCP skill {command!r} 已被截断以满足安全限制。" + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "MCP tool {tool!r} from server {server!r}." +msgstr "来自服务器 {server!r} 的 MCP 工具 {tool!r}。" + +#: src/iac_code/mcp/tools.py +msgid "List resources exposed by connected MCP servers." +msgstr "列出已连接 MCP 服务器暴露的资源。" + +#: src/iac_code/mcp/tools.py +msgid "Optional MCP server name filter." +msgstr "可选的 MCP 服务器名称过滤器。" + +#: src/iac_code/mcp/tools.py +msgid "No MCP resources are currently available." +msgstr "当前没有可用的 MCP 资源。" + +#: src/iac_code/mcp/tools.py +msgid "Read a resource exposed by a connected MCP server." +msgstr "读取已连接 MCP 服务器暴露的资源。" + +#: src/iac_code/mcp/tools.py +msgid "Start authentication for an MCP server." +msgstr "启动 MCP 服务器认证。" + +#: src/iac_code/mcp/tools.py +#, python-brace-format +msgid "No MCP authentication flow is configured for {server!r}." +msgstr "MCP 服务器 {server!r} 未配置认证流程。" + +#: src/iac_code/mcp/tools.py src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Open this URL to authenticate MCP server {server!r}:\n" +"{url}" +msgstr "" +"打开此 URL 以认证 MCP 服务器 {server!r}:\n" +"{url}" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"Unsupported MCP transport {transport!r} for server {server!r}. Supported " +"transports: {supported}." +msgstr "服务器 {server!r} 不支持 MCP transport {transport!r}。支持的 transport:{supported}。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth config must be an object." +msgstr "MCP 服务器 {server!r} 的 oauth 配置必须是对象。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses oauth.clientSecret, but plaintext client " +"secrets are not supported. Use oauth.clientSecretEnv instead." +msgstr "" +"MCP 服务器 {server!r} 使用 oauth.clientSecret,但不支持明文客户端密钥。请改用 " +"oauth.clientSecretEnv。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported oauth fields: {fields}." +msgstr "MCP 服务器 {server!r} 包含不支持的 oauth 字段:{fields}。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} oauth.callbackPort must be an integer." +msgstr "MCP 服务器 {server!r} 的 oauth.callbackPort 必须是整数。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} config must be an object." +msgstr "MCP 服务器 {server!r} 配置必须是对象。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a type unless a stdio command is provided." +msgstr "MCP 服务器 {server!r} 需要 type,除非已提供 stdio command。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} type must be a string." +msgstr "MCP 服务器 {server!r} 的 type 必须是字符串。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "" +"MCP server {server!r} uses unsupported field 'headersHelper'. Static " +"headers are supported; dynamic headers need a later trusted-execution " +"design." +msgstr "" +"MCP 服务器 {server!r} 使用不支持的字段 'headersHelper'。支持静态 headers;动态 headers " +"需要后续可信执行设计。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} has unsupported config fields: {fields}." +msgstr "MCP 服务器 {server!r} 包含不支持的配置字段:{fields}。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} requires a {field} string." +msgstr "MCP 服务器 {server!r} 需要 {field} 字符串。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a string." +msgstr "MCP 服务器 {server!r} 字段 {field} 必须是字符串。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be a list of strings." +msgstr "MCP 服务器 {server!r} 字段 {field} 必须是字符串列表。" + +#: src/iac_code/mcp/types.py +#, python-brace-format +msgid "MCP server {server!r} field {field} must be an object of string values." +msgstr "MCP 服务器 {server!r} 字段 {field} 必须是字符串值对象。" + #: src/iac_code/memory/memory_tools.py msgid "" "Read persistent memories. Omit name to list all, or provide name to read " @@ -2687,6 +3229,25 @@ msgstr "火山引擎编程计划" msgid "Anthropic Compatible" msgstr "Anthropic 兼容" +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} connection failed: {error}" +msgstr "MCP 服务器 {server!r} 连接失败:{error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "MCP server {server!r} {capability} discovery failed: {error}" +msgstr "MCP 服务器 {server!r} 的 {capability} 发现失败:{error}" + +#: src/iac_code/services/agent_factory.py +#, python-brace-format +msgid "" +"Opened MCP auth URL for {server!r}:\n" +"{url}" +msgstr "" +"已打开 MCP 认证 URL,服务器 {server!r}:\n" +"{url}" + #: src/iac_code/services/qwenpaw_source.py #, python-brace-format msgid "" @@ -3642,7 +4203,7 @@ msgstr "完成({child_count} 次工具调用{token_info}{elapsed})" #: src/iac_code/ui/renderer.py #, python-brace-format msgid "+ {count} more tool uses (ctrl+o to expand)" -msgstr "+ {count} 个工具调用 (ctrl+o 展开)" +msgstr "+ 还有 {count} 次工具调用(ctrl+o 展开)" #: src/iac_code/ui/renderer.py #, python-brace-format @@ -3695,6 +4256,15 @@ msgstr "否,始终拒绝 \"{rule}\"(本次会话)" msgid "No, always reject this tool" msgstr "否,始终拒绝此工具" +#: src/iac_code/ui/repl.py +#, python-brace-format +msgid "Approve project MCP server {server!r} from {source}? [y/N] " +msgstr "是否批准来自 {source} 的项目 MCP 服务器 {server!r}?[y/N] " + +#: src/iac_code/ui/repl.py +msgid "MCP warning:" +msgstr "MCP 警告:" + #: src/iac_code/ui/repl.py msgid "Press Ctrl+C again to exit." msgstr "再次按 Ctrl+C 退出。" @@ -3862,10 +4432,6 @@ msgstr "↺ 回滚清理恢复:所有 {count} 条记录都已完成。" msgid "failed" msgstr "已失败" -#: src/iac_code/ui/repl.py -msgid "pending" -msgstr "待处理" - #: src/iac_code/ui/repl.py msgid "in progress" msgstr "进行中" diff --git a/src/iac_code/mcp/__init__.py b/src/iac_code/mcp/__init__.py new file mode 100644 index 00000000..abef1f86 --- /dev/null +++ b/src/iac_code/mcp/__init__.py @@ -0,0 +1 @@ +"""Model Context Protocol integration for iac-code.""" diff --git a/src/iac_code/mcp/cli.py b/src/iac_code/mcp/cli.py new file mode 100644 index 00000000..905e7a9e --- /dev/null +++ b/src/iac_code/mcp/cli.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +import typer + +from iac_code.i18n import _ +from iac_code.mcp.config import ( + approve_project_mcp_server, + find_project_mcp_server_file, + load_mcp_configs, + read_mcp_server_config, + reject_project_mcp_server, + remove_mcp_server_config, + reset_project_mcp_server_choices, + resolve_mcp_workspace_root, + write_mcp_server_config, +) +from iac_code.mcp.oauth import clear_oauth_state, oauth_scope_identity, oauth_storage_key, run_oauth_loopback_flow +from iac_code.mcp.storage import MCPSecretStorage +from iac_code.mcp.types import MCPConfigError, MCPConfigScope + +app = typer.Typer(help=_("Manage MCP servers."), context_settings={"help_option_names": ["-h", "--help"]}) + + +@app.command("add") +def add_server( + name: str, + command: str | None = typer.Option(None, "--command", help=_("Command for stdio MCP server.")), + arg: list[str] | None = typer.Option(None, "--arg", help=_("Command argument. Can be repeated.")), + env: list[str] | None = typer.Option(None, "--env", help=_("Environment variable KEY=VALUE. Can be repeated.")), + transport_type: str = typer.Option("stdio", "--type", help=_("Transport type: stdio, http, sse.")), + url: str | None = typer.Option(None, "--url", help=_("Remote MCP URL for http/sse.")), + header: list[str] | None = typer.Option(None, "--header", help=_("HTTP header KEY=VALUE. Can be repeated.")), + scope: str | None = typer.Option(None, "--scope", help=_("Config scope: user, local, project.")), + client_id: str | None = typer.Option(None, "--client-id", help=_("OAuth client id.")), + client_secret: str | None = typer.Option( + None, + "--client-secret", + help=_("OAuth client secret. Pass the option without a value to enter it securely."), + prompt=True, + prompt_required=False, + hide_input=True, + ), + client_secret_env: str | None = typer.Option( + None, "--client-secret-env", help=_("OAuth client secret env var name.") + ), + callback_port: int | None = typer.Option(None, "--callback-port", help=_("OAuth loopback callback port.")), + auth_server_metadata_url: str | None = typer.Option( + None, + "--auth-server-metadata-url", + help=_("OAuth authorization server metadata URL."), + ), +) -> None: + config: dict[str, Any] = {} + if transport_type == "stdio": + if not command: + _fail(_("--command is required for stdio MCP servers.")) + config["command"] = command + if sys.platform == "win32" and command == "npx": + typer.echo( + _("Warning: on Windows, bare npx may need to be configured as: cmd /c npx"), + err=True, + ) + if arg: + config["args"] = arg + if env: + config["env"] = _parse_key_values(env, "--env") + else: + config["type"] = transport_type + if not url: + _fail(_("--url is required for remote MCP servers.")) + config["url"] = url + if header: + config["headers"] = _parse_key_values(header, "--header") + + oauth = _oauth_config( + client_id=client_id, + client_secret_env=client_secret_env, + callback_port=callback_port, + auth_server_metadata_url=auth_server_metadata_url, + ) + if oauth: + config["oauth"] = oauth + + resolved_scope = _resolve_scope_option(scope) + _write_config(name, config, scope=resolved_scope.value) + if client_secret: + stored = _read_config_or_fail(name, scope=resolved_scope) + normalized = _server_config_from_mapping(name, stored) + MCPSecretStorage().set_secret( + oauth_storage_key(normalized, "client_secret", scope=_oauth_scope_for_cli(name, resolved_scope)), + client_secret, + ) + typer.echo(_("Added MCP server {name!r} to {scope} config.").format(name=name, scope=resolved_scope.value)) + + +@app.command("add-json") +def add_json(name: str, config_json: str, scope: str | None = typer.Option(None, "--scope")) -> None: + try: + config = json.loads(config_json) + except json.JSONDecodeError as exc: + _fail(_("Invalid JSON: {error}").format(error=exc)) + if not isinstance(config, dict): + _fail(_("MCP server JSON must be an object.")) + resolved_scope = _resolve_scope_option(scope) + _write_config(name, config, scope=resolved_scope.value) + typer.echo(_("Added MCP server {name!r} to {scope} config.").format(name=name, scope=resolved_scope.value)) + + +@app.command("list") +def list_servers() -> None: + result = load_mcp_configs(cwd=Path.cwd(), include_pending_project=True) + if not result.servers: + typer.echo(_("No MCP servers configured.")) + return + for server in result.servers: + status = _("approved") if server.approved else _("pending") + typer.echo("{}\t{}\t{}\t{}".format(server.name, server.scope.value, server.transport.value, status)) + + +@app.command("get") +def get_server(name: str, scope: str = typer.Option("local", "--scope")) -> None: + config = read_mcp_server_config(name, scope=_parse_scope(scope), cwd=Path.cwd()) + if config is None: + _fail(_("MCP server {name!r} not found in {scope} config.").format(name=name, scope=scope)) + assert config is not None + typer.echo(json.dumps(_redact_config(config), ensure_ascii=False, indent=2, sort_keys=True)) + + +@app.command("remove") +def remove_server(name: str, scope: str = typer.Option("local", "--scope")) -> None: + path = remove_mcp_server_config(name, scope=_parse_scope(scope), cwd=Path.cwd()) + if path is None: + _fail(_("MCP server {name!r} not found in {scope} config.").format(name=name, scope=scope)) + typer.echo(_("Removed MCP server {name!r} from {path}.").format(name=name, path=path)) + + +@app.command("approve") +def approve_server(name: str) -> None: + project_file = _project_file_for(name) + try: + approve_project_mcp_server( + name, + project_file=project_file, + workspace_root=resolve_mcp_workspace_root(Path.cwd()), + ) + except MCPConfigError as exc: + _fail(str(exc)) + typer.echo(_("Approved MCP server {name!r}.").format(name=name)) + + +@app.command("reject") +def reject_server(name: str) -> None: + project_file = _project_file_for(name) + try: + reject_project_mcp_server( + name, + project_file=project_file, + workspace_root=resolve_mcp_workspace_root(Path.cwd()), + ) + except MCPConfigError as exc: + _fail(str(exc)) + typer.echo(_("Rejected MCP server {name!r}.").format(name=name)) + + +@app.command("reset-project-choices") +def reset_project_choices() -> None: + reset_project_mcp_server_choices() + typer.echo(_("Reset MCP project approval choices.")) + + +@app.command("auth") +def auth_server(name: str, scope: str = typer.Option("local", "--scope")) -> None: + parsed_scope = _parse_scope(scope) + config = _server_config_from_mapping(name, _read_config_or_fail(name, scope=parsed_scope)) + try: + run_oauth_loopback_flow(config, storage=MCPSecretStorage(), scope=_oauth_scope_for_cli(name, parsed_scope)) + except Exception as exc: + _fail(_("MCP auth failed for {name!r}: {error}").format(name=name, error=exc)) + typer.echo(_("Authenticated MCP server {name!r}.").format(name=name)) + + +@app.command("reset-auth") +def reset_auth(name: str, scope: str = typer.Option("local", "--scope")) -> None: + parsed_scope = _parse_scope(scope) + config = _server_config_from_mapping(name, _read_config_or_fail(name, scope=parsed_scope)) + clear_oauth_state(config, storage=MCPSecretStorage(), scope=_oauth_scope_for_cli(name, parsed_scope)) + typer.echo(_("Reset stored MCP auth state for {name!r}.").format(name=name)) + + +def _write_config(name: str, config: dict[str, Any], *, scope: str) -> Path: + try: + _validate_no_plaintext_secrets(config) + return write_mcp_server_config(name, config, scope=_parse_scope(scope), cwd=Path.cwd()) + except MCPConfigError as exc: + _fail(str(exc)) + raise AssertionError("unreachable") from exc + + +def _resolve_scope_option(scope: str | None) -> MCPConfigScope: + if scope is not None: + return _parse_scope(scope) + root = resolve_mcp_workspace_root(Path.cwd()) + if (root / ".git").exists() or (root / ".mcp.json").exists() or (root / ".iac-code").exists(): + return MCPConfigScope.LOCAL + return MCPConfigScope.USER + + +def _parse_scope(scope: str) -> MCPConfigScope: + try: + parsed = MCPConfigScope(scope) + except ValueError as exc: + _fail(_("Invalid MCP scope {scope!r}. Valid values: user, local, project.").format(scope=scope)) + raise AssertionError("unreachable") from exc + if parsed not in {MCPConfigScope.USER, MCPConfigScope.LOCAL, MCPConfigScope.PROJECT}: + _fail(_("Scope {scope!r} cannot be used for persisted MCP config.").format(scope=scope)) + return parsed + + +def _read_config_or_fail(name: str, *, scope: MCPConfigScope) -> dict[str, Any]: + config = read_mcp_server_config(name, scope=scope, cwd=Path.cwd()) + if config is None: + _fail(_("MCP server {name!r} not found in {scope} config.").format(name=name, scope=scope.value)) + assert config is not None + return config + + +def _oauth_scope_for_cli(name: str, scope: MCPConfigScope) -> MCPConfigScope | str | None: + source_path: Path | None = None + if scope is MCPConfigScope.LOCAL: + source_path = resolve_mcp_workspace_root(Path.cwd()) / ".iac-code" / "settings.local.yml" + elif scope is MCPConfigScope.PROJECT: + source_path = _project_file_for(name) + return oauth_scope_identity(scope, source_path=source_path) + + +def _server_config_from_mapping(name: str, config: dict[str, Any]): + from iac_code.mcp.types import MCPServerConfig + + try: + return MCPServerConfig.from_mapping(name, config) + except MCPConfigError as exc: + _fail(str(exc)) + raise AssertionError("unreachable") from exc + + +def _redact_config(config: dict[str, Any]) -> dict[str, Any]: + sensitive = {"authorization", "api-key", "apikey", "token", "secret", "password", "accesskeysecret"} + + def redact(value: Any, key: str = "") -> Any: + if isinstance(value, dict): + return {item_key: redact(item, str(item_key)) for item_key, item in value.items()} + if any(marker in key.lower() for marker in sensitive): + return "[redacted]" + return value + + return redact(config) + + +_ENV_REFERENCE_RE = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\}") +_SENSITIVE_NAME_MARKERS = ( + "authorization", + "api-key", + "api_key", + "apikey", + "accesskeysecret", + "access_key_secret", + "client_secret", + "password", + "secret", + "token", +) + + +def _validate_no_plaintext_secrets(config: dict[str, Any]) -> None: + for section in ("headers", "env"): + values = config.get(section) + if not isinstance(values, dict): + continue + for key, value in values.items(): + if not isinstance(key, str) or not isinstance(value, str): + continue + if _is_sensitive_key(key) and _ENV_REFERENCE_RE.search(value) is None: + section_label = "header" if section == "headers" else "env" + raise MCPConfigError( + _( + "MCP {section} {key!r} may contain a secret; use an environment variable reference " + "like ${{VAR}} instead of storing plaintext." + ).format(section=section_label, key=key) + ) + + +def _is_sensitive_key(key: str) -> bool: + normalized = key.lower().replace(" ", "").replace("_", "-") + alternate = key.lower().replace(" ", "") + return any(marker in normalized or marker in alternate for marker in _SENSITIVE_NAME_MARKERS) + + +def _parse_key_values(values: list[str], option_name: str) -> dict[str, str]: + parsed: dict[str, str] = {} + for value in values: + if "=" not in value: + _fail(_("{option} expects KEY=VALUE, got {value!r}.").format(option=option_name, value=value)) + key, item = value.split("=", 1) + if not key: + _fail(_("{option} expects a non-empty key.").format(option=option_name)) + parsed[key] = item + return parsed + + +def _oauth_config( + *, + client_id: str | None, + client_secret_env: str | None, + callback_port: int | None, + auth_server_metadata_url: str | None, +) -> dict[str, Any]: + oauth: dict[str, Any] = {} + if client_id: + oauth["clientId"] = client_id + if client_secret_env: + oauth["clientSecretEnv"] = client_secret_env + if callback_port is not None: + oauth["callbackPort"] = callback_port + if auth_server_metadata_url: + oauth["authServerMetadataUrl"] = auth_server_metadata_url + return oauth + + +def _project_file_for(name: str) -> Path: + project_file = find_project_mcp_server_file(name, cwd=Path.cwd()) + if project_file is None: + _fail(_("Project MCP server {name!r} not found.").format(name=name)) + assert project_file is not None + return project_file + + +def _fail(message: str) -> None: + typer.echo(message, err=True) + raise typer.Exit(1) diff --git a/src/iac_code/mcp/client.py b/src/iac_code/mcp/client.py new file mode 100644 index 00000000..5154328f --- /dev/null +++ b/src/iac_code/mcp/client.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import asyncio +import concurrent.futures +import inspect +import os +import threading +from contextlib import AsyncExitStack +from pathlib import Path +from tempfile import TemporaryFile +from typing import Any, Awaitable, Callable, Protocol, cast +from urllib.parse import urlparse + +from iac_code.i18n import _ +from iac_code.mcp.errors import MCPConnectionError, MCPNeedsAuthError +from iac_code.mcp.oauth import get_oauth_access_token_async +from iac_code.mcp.storage import MCPSecretStorage +from iac_code.mcp.types import MCPConfigScope, MCPServerConfig, MCPTransport + + +class MCPClientProtocol(Protocol): + async def connect(self) -> None: ... + + async def close(self) -> None: ... + + async def list_tools(self) -> Any: ... + + async def call_tool(self, name: str, arguments: dict[str, Any] | None = None, **kwargs: Any) -> Any: ... + + async def list_resources(self) -> Any: ... + + async def read_resource(self, uri: str) -> Any: ... + + async def list_prompts(self) -> Any: ... + + async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> Any: ... + + +ListChangedCallback = Callable[[str], Awaitable[None] | None] + +_MCP_CLIENT_MODULES_PRELOADED = False +_MCP_CLIENT_MODULES_PRELOAD_LOCK = threading.Lock() + + +class MCPClientAdapter: + """Thin MCP Python SDK adapter.""" + + def __init__( + self, + config: MCPServerConfig, + *, + roots: list[Path] | None = None, + scope: MCPConfigScope | str | None = None, + secret_storage: MCPSecretStorage | None = None, + list_changed_callback: ListChangedCallback | None = None, + ) -> None: + self.config = config + self.roots = [Path(root) for root in roots or []] + self.scope = scope + self._secret_storage = secret_storage or MCPSecretStorage() + self._list_changed_callback = list_changed_callback + self._stack: AsyncExitStack | None = None + self._session: Any = None + self.initialize_result: Any = None + self.stderr_tail: str | None = None + self._stderr_buffer: _BoundedTextBuffer | None = None + self._worker_thread: threading.Thread | None = None + self._worker_task: asyncio.Task[Any] | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._operations: asyncio.Queue[Any] | None = None + self._close_timeout_seconds = 5.0 + + async def connect(self) -> None: + if self._worker_thread is not None and self._worker_thread.is_alive(): + return + _ensure_mcp_client_modules_preloaded() + ready: concurrent.futures.Future[None] = concurrent.futures.Future() + thread = threading.Thread( + target=self._run_worker_thread, + args=(ready,), + name="iac-code-mcp-{}".format(self.config.name), + daemon=True, + ) + self._worker_thread = thread + thread.start() + try: + await asyncio.wrap_future(ready) + except BaseException: + if not ready.done(): + ready.cancel() + raise + + async def close(self) -> None: + thread = self._worker_thread + loop = self._loop + operations = self._operations + worker_task = self._worker_task + if thread is None: + return + force_cancel = self._session is None + if loop is not None and operations is not None and thread.is_alive(): + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + if running_loop is loop: + await operations.put(None) + if force_cancel and worker_task is not None: + worker_task.cancel() + return + try: + await asyncio.wrap_future(asyncio.run_coroutine_threadsafe(operations.put(None), loop)) + except RuntimeError: + pass + if force_cancel and worker_task is not None and not worker_task.done(): + try: + loop.call_soon_threadsafe(worker_task.cancel) + except RuntimeError: + pass + if thread is not threading.current_thread(): + await asyncio.to_thread(thread.join, self._close_timeout_seconds) + if not thread.is_alive() and self._worker_thread is thread: + self._worker_thread = None + + def _run_worker_thread(self, ready: concurrent.futures.Future[None]) -> None: + try: + asyncio.run(self._run_worker(ready)) + except BaseException as exc: # pragma: no cover - defensive for interpreter/runtime failures. + if not ready.done(): + ready.set_exception(exc) + + async def _run_worker(self, ready: concurrent.futures.Future[None]) -> None: + stack = AsyncExitStack() + operations: asyncio.Queue[Any] = asyncio.Queue() + self._loop = asyncio.get_running_loop() + self._operations = operations + self._worker_task = asyncio.current_task() + try: + session = await self._open_session(stack) + self._stack = stack + self._session = session + if not ready.done(): + ready.set_result(None) + while True: + item = await operations.get() + if item is None: + break + operation, future = item + if future.cancelled(): + continue + try: + result = await operation(session) + except Exception as exc: + if not future.cancelled(): + try: + future.set_exception(exc) + except concurrent.futures.InvalidStateError: + pass + else: + if not future.cancelled(): + try: + future.set_result(result) + except concurrent.futures.InvalidStateError: + pass + except Exception as exc: + if self._stderr_buffer is not None: + self.stderr_tail = self._stderr_buffer.getvalue() + error = _connection_error(self.config.name, exc, self.stderr_tail) + if not ready.done(): + ready.set_exception(error) + finally: + try: + await stack.aclose() + finally: + if self._stderr_buffer is not None: + self.stderr_tail = self._stderr_buffer.getvalue() + self._stderr_buffer.close() + self._stderr_buffer = None + self._stack = None + self._session = None + self.initialize_result = None + self._operations = None + self._loop = None + self._worker_task = None + self._worker_thread = None + + async def _put_operation(self, item: Any) -> None: + operations = self._operations + if operations is None: + raise MCPConnectionError(_("MCP server {server!r} is not connected.").format(server=self.config.name)) + await operations.put(item) + + async def _run_session_operation(self, operation: Callable[[Any], Awaitable[Any]]) -> Any: + loop = self._loop + operations = self._operations + if loop is None or operations is None: + raise MCPConnectionError(_("MCP server {server!r} is not connected.").format(server=self.config.name)) + + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + if running_loop is loop: + session = self._require_session() + return await operation(session) + + future: concurrent.futures.Future[Any] = concurrent.futures.Future() + try: + enqueue = asyncio.run_coroutine_threadsafe(operations.put((operation, future)), loop) + except RuntimeError as exc: + message = _("MCP server {server!r} is not connected.").format(server=self.config.name) + raise MCPConnectionError(message) from exc + await asyncio.wrap_future(enqueue) + return await asyncio.wrap_future(future) + + async def _list_roots_callback(self, context: Any) -> Any: + from mcp import types + + _ = context + roots = [types.Root(uri=cast(Any, root.resolve().as_uri()), name=root.name or str(root)) for root in self.roots] + return types.ListRootsResult(roots=roots) + + def _require_session(self) -> Any: + if self._session is None: + raise MCPConnectionError(_("MCP server {server!r} is not connected.").format(server=self.config.name)) + return self._session + + async def _remote_headers(self) -> dict[str, str] | None: + headers = dict(self.config.headers) + if self.config.oauth is not None: + token = await get_oauth_access_token_async(self.config, storage=self._secret_storage, scope=self.scope) + if token: + headers["Authorization"] = "Bearer {}".format(token) + return headers or None + + async def list_tools(self) -> Any: + return await self._run_session_operation(lambda session: session.list_tools()) + + async def call_tool(self, name: str, arguments: dict[str, Any] | None = None, **kwargs: Any) -> Any: + return await self._run_session_operation(lambda session: session.call_tool(name, arguments=arguments, **kwargs)) + + async def list_resources(self) -> Any: + return await self._run_session_operation(lambda session: session.list_resources()) + + async def read_resource(self, uri: str) -> Any: + return await self._run_session_operation(lambda session: session.read_resource(uri)) + + async def list_prompts(self) -> Any: + return await self._run_session_operation(lambda session: session.list_prompts()) + + async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> Any: + return await self._run_session_operation(lambda session: session.get_prompt(name, arguments=arguments)) + + async def _open_session(self, stack: AsyncExitStack) -> Any: + read_stream: Any + write_stream: Any + + if self.config.transport is MCPTransport.STDIO: + from mcp.client.stdio import StdioServerParameters, stdio_client + + errlog = _BoundedTextBuffer() + self._stderr_buffer = errlog + self.stderr_tail = None + params = StdioServerParameters( + command=self.config.command or "", + args=list(self.config.args), + env=_stdio_env(self.config.env), + ) + try: + read_stream, write_stream = await stack.enter_async_context( + stdio_client(params, errlog=cast(Any, errlog)) + ) + finally: + self.stderr_tail = errlog.getvalue() + elif self.config.transport is MCPTransport.HTTP: + from mcp.client.streamable_http import streamablehttp_client + + headers = await self._remote_headers() + read_stream, write_stream, _session_id = await stack.enter_async_context( + streamablehttp_client(self.config.url or "", headers=headers) + ) + elif self.config.transport is MCPTransport.SSE: + from mcp.client.sse import sse_client + + headers = await self._remote_headers() + read_stream, write_stream = await stack.enter_async_context( + sse_client(self.config.url or "", headers=headers) + ) + else: # pragma: no cover - MCPServerConfig validation prevents this. + raise MCPConnectionError( + _("Unsupported MCP transport: {transport}").format(transport=self.config.transport.value) + ) + + from mcp.client.session import ClientSession + + session = ClientSession(read_stream, write_stream, list_roots_callback=self._list_roots_callback) + if self._list_changed_callback is not None: + _install_list_changed_handler(session, self._list_changed_callback) + await stack.enter_async_context(session) + self.initialize_result = await session.initialize() + return session + + +def _ensure_mcp_client_modules_preloaded() -> None: + global _MCP_CLIENT_MODULES_PRELOADED + if _MCP_CLIENT_MODULES_PRELOADED: + return + with _MCP_CLIENT_MODULES_PRELOAD_LOCK: + if _MCP_CLIENT_MODULES_PRELOADED: + return + import mcp.client.session # noqa: F401 + import mcp.client.sse # noqa: F401 + import mcp.client.stdio # noqa: F401 + import mcp.client.streamable_http # noqa: F401 + + _MCP_CLIENT_MODULES_PRELOADED = True + + +_STDIO_ENV_ALLOWLIST = { + "APPDATA", + "ALL_PROXY", + "CURL_CA_BUNDLE", + "COMSPEC", + "HOME", + "HTTPS_PROXY", + "HTTP_PROXY", + "LANG", + "LC_ALL", + "LOCALAPPDATA", + "NODE_EXTRA_CA_CERTS", + "NO_PROXY", + "PATH", + "PATHEXT", + "REQUESTS_CA_BUNDLE", + "SHELL", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "SYSTEMROOT", + "TEMP", + "TMP", + "TMPDIR", + "UV_CACHE_DIR", + "UV_PYTHON_INSTALL_DIR", + "UV_TOOL_DIR", + "USER", + "USERPROFILE", + "USERNAME", + "WINDIR", + "XDG_CACHE_HOME", + "all_proxy", + "https_proxy", + "http_proxy", + "no_proxy", +} + + +def _stdio_env(explicit_env: dict[str, str]) -> dict[str, str]: + env = { + name: value + for name in _STDIO_ENV_ALLOWLIST + if (value := os.environ.get(name)) and _safe_stdio_inherited_env(name, value) + } + env.update(explicit_env) + return env + + +def _safe_stdio_inherited_env(name: str, value: str) -> bool: + if name.lower() in {"http_proxy", "https_proxy", "all_proxy"}: + parsed = urlparse(value) + return not parsed.username and not parsed.password + return True + + +def _looks_like_auth_required(exc: BaseException) -> bool: + response = getattr(exc, "response", None) + status_code = getattr(response, "status_code", None) + if status_code in {401, 403}: + return True + text = "{} {}".format(exc.__class__.__name__, str(exc)).lower() + return any(marker in text for marker in ("401", "unauthorized", "forbidden", "invalid_token", "oauth")) + + +def _connection_error(server_name: str, exc: BaseException, stderr_tail: str | None = None) -> Exception: + if isinstance(exc, MCPNeedsAuthError | MCPConnectionError): + return exc + if _looks_like_auth_required(exc): + return MCPNeedsAuthError(_("MCP server {server!r} requires authentication.").format(server=server_name)) + message = str(exc) + if stderr_tail: + message = _("{message}\nMCP server stderr:\n{stderr}").format(message=message, stderr=stderr_tail) + return MCPConnectionError(message) + + +def _install_list_changed_handler(session: Any, callback: ListChangedCallback) -> None: + original = session._received_notification + + async def received_notification(notification: Any) -> None: + await original(notification) + capability = _list_changed_capability(notification) + if capability is not None: + result = callback(capability) + if inspect.isawaitable(result): + asyncio.create_task(_await_callback(result)) + + session._received_notification = received_notification + + +async def _await_callback(awaitable: Awaitable[None]) -> None: + try: + await awaitable + except Exception as exc: + from loguru import logger + + logger.debug("MCP list_changed callback failed: {}", exc) + + +def _list_changed_capability(notification: Any) -> str | None: + root = getattr(notification, "root", notification) + method = getattr(root, "method", None) + if method == "notifications/tools/list_changed": + return "tools" + if method == "notifications/resources/list_changed": + return "resources" + if method == "notifications/prompts/list_changed": + return "prompts" + class_name = root.__class__.__name__ + if class_name == "ToolListChangedNotification": + return "tools" + if class_name == "ResourceListChangedNotification": + return "resources" + if class_name == "PromptListChangedNotification": + return "prompts" + return None + + +class _BoundedTextBuffer: + def __init__(self, *, max_chars: int = 8000) -> None: + self._max_chars = max_chars + self._file = TemporaryFile(mode="w+b") + + def write(self, value: str) -> int: + data = value.encode("utf-8", errors="replace") if isinstance(value, str) else bytes(value) + return self._file.write(data) + + def flush(self) -> None: + self._file.flush() + + def fileno(self) -> int: + return self._file.fileno() + + def getvalue(self) -> str: + self.flush() + self._file.seek(0, os.SEEK_END) + size = self._file.tell() + self._file.seek(max(0, size - self._max_chars)) + return self._file.read(self._max_chars).decode("utf-8", errors="replace") + + def close(self) -> None: + self._file.close() diff --git a/src/iac_code/mcp/config.py b/src/iac_code/mcp/config.py new file mode 100644 index 00000000..ebd940a7 --- /dev/null +++ b/src/iac_code/mcp/config.py @@ -0,0 +1,520 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +from iac_code.config import _load_yaml, _save_yaml, get_config_dir, get_settings_path +from iac_code.i18n import _ +from iac_code.mcp.env_expansion import expand_env +from iac_code.mcp.types import MCPConfigError, MCPConfigScope, MCPConfigWarning, MCPServerConfig, ScopedMCPServerConfig +from iac_code.utils.file_security import atomic_write_text, ensure_private_dir + +_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") +_RESERVED_SERVER_NAMES = {"list_mcp_resources", "read_mcp_resource"} + + +@dataclass(frozen=True) +class MCPConfigLoadResult: + servers: list[ScopedMCPServerConfig] + warnings: list[MCPConfigWarning] + pending: list[ScopedMCPServerConfig] + + def by_name(self) -> dict[str, ScopedMCPServerConfig]: + return {server.name: server for server in self.servers} + + +@dataclass(frozen=True) +class _ConfigSource: + scope: MCPConfigScope + source_path: Path | None + servers: Mapping[str, Any] + order: int + + @property + def label(self) -> str: + if self.source_path is None: + return self.scope.value + return str(self.source_path) + + +@dataclass(frozen=True) +class _Candidate: + server: ScopedMCPServerConfig + source_order: int + + +def load_mcp_configs( + *, + cwd: Path, + workspace_root: Path | None = None, + session_configs: Mapping[str, Any] | None = None, + env: Mapping[str, str] | None = None, + include_pending_project: bool = False, +) -> MCPConfigLoadResult: + root = _resolve_workspace_root(cwd, workspace_root) + sources = _collect_sources(cwd=Path(cwd), workspace_root=root, session_configs=session_configs) + warnings: list[MCPConfigWarning] = [] + pending: list[ScopedMCPServerConfig] = [] + candidates: list[_Candidate] = [] + + for source in sources: + for name, raw_config in source.servers.items(): + if not isinstance(name, str) or not name: + warnings.append( + MCPConfigWarning( + source=source.label, + code="invalid_name", + message=_("MCP server names must be non-empty strings."), + ) + ) + continue + name_error = _server_name_error(name) + if name_error is not None: + warnings.append( + MCPConfigWarning( + source=source.label, + server_name=name, + code="invalid_name", + message=name_error, + ) + ) + continue + + expanded, env_warnings = expand_env(raw_config, env=env, source=source.label, server_name=name) + warnings.extend(env_warnings) + if any(warning.code == "missing_env" for warning in env_warnings): + continue + try: + config = MCPServerConfig.from_mapping(name, expanded) + except MCPConfigError as exc: + warnings.append( + MCPConfigWarning( + source=source.label, + server_name=name, + code="invalid_config", + message=str(exc), + ) + ) + continue + + config_signature = config.content_signature() + approved = source.scope is not MCPConfigScope.PROJECT or _is_project_server_approved( + name, + project_file=source.source_path, + workspace_root=root, + config_signature=config_signature, + ) + scoped = ScopedMCPServerConfig( + config=config, + scope=source.scope, + source_path=str(source.source_path) if source.source_path is not None else None, + approved=approved, + ) + if source.scope is MCPConfigScope.PROJECT and not approved and not include_pending_project: + warning = MCPConfigWarning( + source=source.label, + server_name=name, + code="pending_approval", + message=_("Project MCP server {name!r} is pending approval.").format(name=name), + ) + warnings.append(warning) + pending.append( + ScopedMCPServerConfig( + config=config, + scope=source.scope, + source_path=scoped.source_path, + approved=False, + warning=warning, + ) + ) + continue + + candidates.append(_Candidate(server=scoped, source_order=source.order)) + + merged = _merge_candidates(candidates, warnings) + return MCPConfigLoadResult(servers=merged.servers, warnings=merged.warnings, pending=pending) + + +def approve_project_mcp_server( + server_name: str, + *, + project_file: Path, + workspace_root: Path, + config_signature: str | None = None, +) -> None: + state = _load_approval_state() + approvals = state.setdefault("approvals", {}) + signature = config_signature or _project_config_signature(server_name, project_file) + approvals[ + _project_approval_key( + server_name, + project_file=project_file, + workspace_root=workspace_root, + config_signature=signature, + ) + ] = True + _save_approval_state(state) + + +def reject_project_mcp_server( + server_name: str, + *, + project_file: Path, + workspace_root: Path, + config_signature: str | None = None, +) -> None: + state = _load_approval_state() + approvals = state.setdefault("approvals", {}) + signature = config_signature or _project_config_signature(server_name, project_file) + approvals[ + _project_approval_key( + server_name, + project_file=project_file, + workspace_root=workspace_root, + config_signature=signature, + ) + ] = False + _save_approval_state(state) + + +def reset_project_mcp_server_choices() -> None: + _save_approval_state({"approvals": {}}) + + +def find_project_mcp_server_file( + server_name: str, + *, + cwd: Path, + workspace_root: Path | None = None, +) -> Path | None: + root = _resolve_workspace_root(cwd, workspace_root) + files = _discover_project_files(cwd=Path(cwd), workspace_root=root) + for project_file in reversed(files): + servers = _mcp_servers_from_mapping(_load_json_object(project_file)) + if server_name in servers: + return project_file + return None + + +def read_mcp_server_config(name: str, *, scope: MCPConfigScope, cwd: Path) -> dict[str, Any] | None: + path = _scope_path(scope, cwd) + data = _load_scope_data(scope, path) + servers = data.get("mcpServers") + if not isinstance(servers, Mapping): + return None + value = servers.get(name) + return dict(value) if isinstance(value, Mapping) else None + + +def remove_mcp_server_config(name: str, *, scope: MCPConfigScope, cwd: Path) -> Path | None: + path = _scope_path(scope, cwd) + data = _load_scope_data(scope, path) + servers = data.get("mcpServers") + if not isinstance(servers, dict) or name not in servers: + return None + servers.pop(name) + if scope is MCPConfigScope.PROJECT: + _save_json(path, data) + else: + _save_yaml(path, data) + return path + + +def write_mcp_server_config( + name: str, + config: Mapping[str, Any], + *, + scope: MCPConfigScope, + cwd: Path, + workspace_root: Path | None = None, +) -> Path: + name_error = _server_name_error(name) + if name_error is not None: + raise MCPConfigError(name_error) + MCPServerConfig.from_mapping(name, config) + root = _resolve_workspace_root(cwd, workspace_root) + + if scope is MCPConfigScope.USER: + path = get_settings_path() + data = _load_yaml(path) + _set_mcp_server(data, name, config) + _save_yaml(path, data) + return path + + if scope is MCPConfigScope.LOCAL: + path = root / ".iac-code" / "settings.local.yml" + data = _load_yaml(path) + _set_mcp_server(data, name, config) + _save_yaml(path, data) + return path + + if scope is MCPConfigScope.PROJECT: + path = root / ".mcp.json" + data = _load_json_object(path) + _set_mcp_server(data, name, config) + _save_json(path, data) + return path + + raise MCPConfigError(_("Cannot persist MCP server config to {scope!r} scope.").format(scope=scope.value)) + + +def _scope_path(scope: MCPConfigScope, cwd: Path) -> Path: + root = _resolve_workspace_root(cwd, None) + if scope is MCPConfigScope.USER: + return get_settings_path() + if scope is MCPConfigScope.LOCAL: + return root / ".iac-code" / "settings.local.yml" + if scope is MCPConfigScope.PROJECT: + return root / ".mcp.json" + raise MCPConfigError(_("MCP scope {scope!r} is not a persisted config scope.").format(scope=scope.value)) + + +def _load_scope_data(scope: MCPConfigScope, path: Path) -> dict[str, Any]: + if scope is MCPConfigScope.PROJECT: + return _load_json_object(path) + return _load_yaml(path) + + +def _collect_sources( + *, + cwd: Path, + workspace_root: Path, + session_configs: Mapping[str, Any] | None, +) -> list[_ConfigSource]: + order = 0 + sources: list[_ConfigSource] = [] + + user_settings_path = get_settings_path() + sources.append( + _ConfigSource( + scope=MCPConfigScope.USER, + source_path=user_settings_path, + servers=_mcp_servers_from_mapping(_load_yaml(user_settings_path)), + order=order, + ) + ) + order += 1 + + for project_file in _discover_project_files(cwd=cwd, workspace_root=workspace_root): + sources.append( + _ConfigSource( + scope=MCPConfigScope.PROJECT, + source_path=project_file, + servers=_mcp_servers_from_mapping(_load_json_object(project_file)), + order=order, + ) + ) + order += 1 + + local_settings_path = workspace_root / ".iac-code" / "settings.local.yml" + sources.append( + _ConfigSource( + scope=MCPConfigScope.LOCAL, + source_path=local_settings_path, + servers=_mcp_servers_from_mapping(_load_yaml(local_settings_path)), + order=order, + ) + ) + order += 1 + + if session_configs: + sources.append( + _ConfigSource( + scope=MCPConfigScope.SESSION, + source_path=None, + servers=session_configs, + order=order, + ) + ) + + return sources + + +def _merge_candidates( + candidates: list[_Candidate], + warnings: list[MCPConfigWarning], +) -> MCPConfigLoadResult: + by_name: dict[str, _Candidate] = {} + for candidate in candidates: + existing = by_name.get(candidate.server.name) + if existing is None or _candidate_sort_key(candidate) >= _candidate_sort_key(existing): + by_name[candidate.server.name] = candidate + + by_signature: dict[str, _Candidate] = {} + for candidate in sorted(by_name.values(), key=_candidate_sort_key): + signature = candidate.server.config.content_signature() + existing = by_signature.get(signature) + if existing is None: + by_signature[signature] = candidate + continue + by_signature[signature] = candidate + warnings.append( + MCPConfigWarning( + source=candidate.server.source_path or candidate.server.scope.value, + server_name=candidate.server.name, + code="duplicate_config", + message=_( + "MCP server {existing!r} has the same content signature as {current!r}; " + "keeping higher-precedence server {current!r}." + ).format(existing=existing.server.name, current=candidate.server.name), + ) + ) + + servers = [candidate.server for candidate in sorted(by_signature.values(), key=_candidate_sort_key)] + return MCPConfigLoadResult(servers=servers, warnings=warnings, pending=[]) + + +def _candidate_sort_key(candidate: _Candidate) -> tuple[int, int]: + return (candidate.server.precedence, candidate.source_order) + + +def _resolve_workspace_root(cwd: Path, workspace_root: Path | None) -> Path: + if workspace_root is not None: + return Path(workspace_root).resolve() + + current = Path(cwd).resolve() + if current.is_file(): + current = current.parent + for path in (current, *current.parents): + if (path / ".git").exists(): + return path + return current + + +def resolve_mcp_workspace_root(cwd: Path, workspace_root: Path | None = None) -> Path: + return _resolve_workspace_root(cwd, workspace_root) + + +def _discover_project_files(*, cwd: Path, workspace_root: Path) -> list[Path]: + current = Path(cwd).resolve() + root = workspace_root.resolve() + if current.is_file(): + current = current.parent + if current != root and root not in current.parents: + current = root + + chain: list[Path] = [] + path = current + while True: + chain.append(path) + if path == root: + break + path = path.parent + + project_files: list[Path] = [] + for directory in reversed(chain): + project_file = directory / ".mcp.json" + if project_file.exists(): + project_files.append(project_file) + return project_files + + +def _mcp_servers_from_mapping(data: Mapping[str, Any]) -> Mapping[str, Any]: + servers = data.get("mcpServers") if isinstance(data, Mapping) else None + return servers if isinstance(servers, Mapping) else {} + + +def _set_mcp_server(data: dict[str, Any], name: str, config: Mapping[str, Any]) -> None: + servers = data.setdefault("mcpServers", {}) + if not isinstance(servers, dict): + servers = {} + data["mcpServers"] = servers + servers[name] = dict(config) + + +def _server_name_error(name: str) -> str | None: + if not name: + return _("MCP server names must be non-empty strings.") + if name in _RESERVED_SERVER_NAMES or name.startswith("mcp__"): + return _("MCP server name {name!r} is reserved.").format(name=name) + if _SERVER_NAME_RE.fullmatch(name) is None: + return _("MCP server name {name!r} may only contain letters, numbers, dot, underscore, and hyphen.").format( + name=name + ) + return None + + +def _load_json_object(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def _save_json(path: Path, data: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + content = json.dumps(data, indent=2, sort_keys=True) + "\n" + atomic_write_text(path, content) + + +def _approval_state_path() -> Path: + return get_config_dir() / "mcp" / "project-approvals.json" + + +def _load_approval_state() -> dict[str, Any]: + return _load_json_object(_approval_state_path()) + + +def _save_approval_state(state: Mapping[str, Any]) -> None: + path = _approval_state_path() + ensure_private_dir(path.parent) + _save_json(path, state) + + +def _is_project_server_approved( + server_name: str, + *, + project_file: Path | None, + workspace_root: Path, + config_signature: str, +) -> bool: + if project_file is None: + return False + state = _load_approval_state() + approvals = state.get("approvals") + if not isinstance(approvals, Mapping): + return False + key = _project_approval_key( + server_name, + project_file=project_file, + workspace_root=workspace_root, + config_signature=config_signature, + ) + return approvals.get(key) is True + + +def _project_approval_key( + server_name: str, + *, + project_file: Path, + workspace_root: Path, + config_signature: str, +) -> str: + material = "\0".join( + [ + str(Path(workspace_root).resolve()), + str(Path(project_file).resolve()), + server_name, + config_signature, + ] + ) + return hashlib.sha256(material.encode("utf-8")).hexdigest() + + +def _project_config_signature(server_name: str, project_file: Path) -> str: + raw_config = _mcp_servers_from_mapping(_load_json_object(project_file)).get(server_name) + expanded, _warnings = expand_env( + raw_config, + env=os.environ, + source=str(project_file), + server_name=server_name, + ) + return MCPServerConfig.from_mapping(server_name, expanded).content_signature() diff --git a/src/iac_code/mcp/env_expansion.py b/src/iac_code/mcp/env_expansion.py new file mode 100644 index 00000000..ac59f2fe --- /dev/null +++ b/src/iac_code/mcp/env_expansion.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import os +import re +from typing import Any, Mapping + +from iac_code.i18n import _ +from iac_code.mcp.types import MCPConfigWarning + +_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}") + + +def expand_env( + value: Any, + *, + env: Mapping[str, str] | None = None, + source: str, + server_name: str | None = None, +) -> tuple[Any, list[MCPConfigWarning]]: + """Recursively expand MCP config environment references.""" + + warnings: list[MCPConfigWarning] = [] + env_values = os.environ if env is None else env + + def expand_one(item: Any) -> Any: + if isinstance(item, str): + return _expand_string(item, env=env_values, source=source, server_name=server_name, warnings=warnings) + if isinstance(item, list): + return [expand_one(child) for child in item] + if isinstance(item, tuple): + return tuple(expand_one(child) for child in item) + if isinstance(item, dict): + return {key: expand_one(child) for key, child in item.items()} + return item + + return expand_one(value), warnings + + +def _expand_string( + value: str, + *, + env: Mapping[str, str], + source: str, + server_name: str | None, + warnings: list[MCPConfigWarning], +) -> str: + def replace(match: re.Match[str]) -> str: + name = match.group(1) + default = match.group(2) + current = env.get(name) + if current is not None and (current != "" or default is None): + return current + if default is not None: + return default + + warnings.append( + MCPConfigWarning( + source=source, + server_name=server_name, + code="missing_env", + message=_("Environment variable {name!r} is not set for MCP config.").format(name=name), + ) + ) + return match.group(0) + + return _ENV_REF_RE.sub(replace, value) diff --git a/src/iac_code/mcp/errors.py b/src/iac_code/mcp/errors.py new file mode 100644 index 00000000..25c8dd88 --- /dev/null +++ b/src/iac_code/mcp/errors.py @@ -0,0 +1,17 @@ +from __future__ import annotations + + +class MCPError(Exception): + """Base exception for iac-code MCP integration failures.""" + + +class MCPConnectionError(MCPError): + """Raised when an MCP server connection fails.""" + + +class MCPNeedsAuthError(MCPConnectionError): + """Raised when a remote MCP server requires OAuth authentication.""" + + +class MCPElicitationUnsupportedError(MCPError): + """Raised when an MCP server asks for unsupported user elicitation.""" diff --git a/src/iac_code/mcp/manager.py b/src/iac_code/mcp/manager.py new file mode 100644 index 00000000..683f6b1b --- /dev/null +++ b/src/iac_code/mcp/manager.py @@ -0,0 +1,498 @@ +from __future__ import annotations + +import asyncio +import hashlib +import inspect +import re +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any, Awaitable, Callable + +from loguru import logger + +from iac_code.i18n import _ +from iac_code.mcp.client import MCPClientAdapter, MCPClientProtocol +from iac_code.mcp.errors import MCPConnectionError, MCPElicitationUnsupportedError, MCPNeedsAuthError +from iac_code.mcp.oauth import MCPNeedsAuthCache, oauth_scope_identity +from iac_code.mcp.types import ( + MCPConnectionState, + MCPPromptRecord, + MCPResourceRecord, + MCPToolRecord, + MCPTransport, + ScopedMCPServerConfig, +) +from iac_code.utils.public_errors import sanitize_public_text + + +@dataclass +class MCPConnectionRecord: + scoped_config: ScopedMCPServerConfig + state: MCPConnectionState = MCPConnectionState.PENDING + client: MCPClientProtocol | None = None + error: str | None = None + retry_count: int = 0 + tools: list[MCPToolRecord] = field(default_factory=list) + resources: list[MCPResourceRecord] = field(default_factory=list) + prompts: list[MCPPromptRecord] = field(default_factory=list) + capability_errors: dict[str, str] = field(default_factory=dict) + + @property + def name(self) -> str: + return self.scoped_config.name + + +ClientFactory = Callable[[ScopedMCPServerConfig], MCPClientProtocol] +ChangeListener = Callable[[str, str], Awaitable[None] | None] + + +class MCPManager: + def __init__( + self, + configs: list[ScopedMCPServerConfig], + *, + client_factory: ClientFactory | None = None, + roots: list[str | Path] | None = None, + max_reconnect_attempts: int = 2, + connect_timeout_seconds: float = 20.0, + operation_timeout_seconds: float | None = None, + max_concurrent_connections: int = 8, + needs_auth_cache: MCPNeedsAuthCache | None = None, + session_id: str | None = None, + ) -> None: + self._roots = [Path(root) for root in roots or []] + self._client_factory = client_factory or self._default_client_factory + self._max_reconnect_attempts = max_reconnect_attempts + self._connect_timeout_seconds = connect_timeout_seconds + self._operation_timeout_seconds = operation_timeout_seconds or connect_timeout_seconds + self._max_concurrent_connections = max_concurrent_connections + self._needs_auth_cache = needs_auth_cache or MCPNeedsAuthCache() + self._session_id = session_id + self._change_listeners: list[ChangeListener] = [] + self._connections = { + config.name: MCPConnectionRecord(scoped_config=config) for config in configs if config.approved + } + + async def connect_all(self) -> None: + records = list(self._connections.values()) + if self._max_concurrent_connections <= 1: + for record in records: + await self._connect(record) + else: + semaphore = asyncio.Semaphore(self._max_concurrent_connections) + + async def connect_record(record: MCPConnectionRecord) -> None: + async with semaphore: + await self._connect(record) + + await asyncio.gather(*(connect_record(record) for record in records)) + self._assign_unique_public_names() + + async def disconnect_all(self) -> None: + for record in self._connections.values(): + try: + if record.client is not None: + await record.client.close() + except Exception as exc: + logger.debug( + "MCP server {!r} close failed: {}", + record.name, + sanitize_public_text(str(exc) or exc.__class__.__name__), + ) + finally: + record.client = None + record.state = MCPConnectionState.DISABLED + record.tools = [] + record.resources = [] + record.prompts = [] + record.capability_errors = {} + + async def reconnect_failed(self, server_name: str) -> None: + record = self.connection(server_name) + if record.state is not MCPConnectionState.FAILED: + return + if record.scoped_config.transport not in {MCPTransport.HTTP, MCPTransport.SSE}: + return + if record.retry_count >= self._max_reconnect_attempts: + return + + record.retry_count += 1 + await self.reconnect(server_name) + + async def reconnect(self, server_name: str) -> None: + record = self.connection(server_name) + self._needs_auth_cache.clear(server_name) + await self._connect(record) + self._assign_unique_public_names() + await self._notify_changed(server_name, "tools") + await self._notify_changed(server_name, "resources") + await self._notify_changed(server_name, "prompts") + + def connection(self, server_name: str) -> MCPConnectionRecord: + return self._connections[server_name] + + def connection_state(self, server_name: str) -> MCPConnectionState: + return self.connection(server_name).state + + def list_connections(self) -> list[MCPConnectionRecord]: + return list(self._connections.values()) + + def list_tools(self) -> list[MCPToolRecord]: + return [tool for record in self._connections.values() for tool in record.tools] + + def list_resources(self) -> list[MCPResourceRecord]: + return [resource for record in self._connections.values() for resource in record.resources] + + def list_prompts(self) -> list[MCPPromptRecord]: + return [prompt for record in self._connections.values() for prompt in record.prompts] + + def needs_auth_servers(self) -> list[str]: + return [record.name for record in self._connections.values() if record.state is MCPConnectionState.NEEDS_AUTH] + + def add_change_listener(self, listener: ChangeListener) -> None: + self._change_listeners.append(listener) + + @property + def operation_timeout_seconds(self) -> float: + return self._operation_timeout_seconds + + async def call_tool( + self, + server_name: str, + tool_name: str, + arguments: dict[str, Any], + *, + progress_callback: Any = None, + meta: dict[str, Any] | None = None, + ) -> Any: + record = self.connection(server_name) + client = _require_client(record) + return await client.call_tool(tool_name, arguments=arguments, progress_callback=progress_callback, meta=meta) + + async def read_resource(self, uri: str, server_name: str) -> tuple[str, Any]: + record = self.connection(server_name) + client = _require_client(record) + return record.name, await client.read_resource(uri) + + async def get_prompt(self, server_name: str, prompt_name: str, arguments: dict[str, str]) -> Any: + record = self.connection(server_name) + client = _require_client(record) + return await client.get_prompt(prompt_name, arguments=arguments) + + async def handle_list_changed(self, server_name: str, *, capability: str) -> None: + record = self.connection(server_name) + if record.state is not MCPConnectionState.CONNECTED or record.client is None: + return + if capability == "tools": + await self._refresh_tools(record) + elif capability == "resources": + await self._refresh_resources(record) + elif capability == "prompts": + await self._refresh_prompts(record) + self._assign_unique_public_names() + await self._notify_changed(server_name, capability) + + async def _notify_changed(self, server_name: str, capability: str) -> None: + for listener in list(self._change_listeners): + result = listener(server_name, capability) + if inspect.isawaitable(result): + await result + + async def list_roots(self) -> list[str]: + return [root.resolve().as_uri() for root in self._roots] + + async def request_elicitation(self, server_name: str, params: MappingOrDict) -> None: + raise MCPElicitationUnsupportedError( + _("MCP server {server!r} requested elicitation, but iac-code does not support elicitation yet.").format( + server=server_name + ) + ) + + async def _connect(self, record: MCPConnectionRecord) -> None: + if record.client is not None: + await record.client.close() + record.client = None + + cached_auth = self._needs_auth_cache.get(record.name) + if cached_auth is not None: + record.state = MCPConnectionState.NEEDS_AUTH + record.error = cached_auth.reason + record.tools = [] + record.resources = [] + record.prompts = [] + record.capability_errors = {} + return + + client = self._client_factory(record.scoped_config) + try: + await asyncio.wait_for(client.connect(), timeout=self._connect_timeout_seconds) + record.client = client + record.state = MCPConnectionState.CONNECTED + record.error = None + record.capability_errors = {} + self._needs_auth_cache.clear(record.name) + await self._refresh_discovery(record) + except MCPNeedsAuthError as exc: + reason = str(exc) or "authentication required" + self._needs_auth_cache.mark(record.name, reason) + record.state = MCPConnectionState.NEEDS_AUTH + record.error = reason + record.tools = [] + record.resources = [] + record.prompts = [] + record.capability_errors = {} + try: + await client.close() + except Exception: + pass + except Exception as exc: + with_context = str(exc) or exc.__class__.__name__ + record.state = MCPConnectionState.FAILED + record.error = with_context + record.tools = [] + record.resources = [] + record.prompts = [] + record.capability_errors = {} + logger.warning( + "MCP server {!r} connection failed: {}", + record.name, + sanitize_public_text(with_context), + ) + try: + await client.close() + except Exception: + pass + + async def _refresh_discovery(self, record: MCPConnectionRecord) -> None: + await self._refresh_tools(record) + await self._refresh_resources(record) + await self._refresh_prompts(record) + + async def _refresh_tools(self, record: MCPConnectionRecord) -> None: + client = _require_client(record) + try: + raw_tools = _extract_items( + await asyncio.wait_for(client.list_tools(), timeout=self._operation_timeout_seconds), + "tools", + ) + except Exception as exc: + _record_capability_error(record, "tools", exc) + record.tools = [] + return + record.capability_errors.pop("tools", None) + record.tools = [ + MCPToolRecord( + server_name=record.name, + tool_name=str(_get_value(tool, "name", "")), + public_name=_public_tool_name(record.name, str(_get_value(tool, "name", ""))), + description=_get_value(tool, "description"), + input_schema=_get_value(tool, "inputSchema", _get_value(tool, "input_schema", {})) or {}, + annotations=_get_value(tool, "annotations", {}) or {}, + meta=_get_value(tool, "_meta", _get_value(tool, "meta", {})) or {}, + ) + for tool in raw_tools + if _get_value(tool, "name") + ] + + async def _refresh_resources(self, record: MCPConnectionRecord) -> None: + client = _require_client(record) + try: + raw_resources = _extract_items( + await asyncio.wait_for(client.list_resources(), timeout=self._operation_timeout_seconds), + "resources", + ) + except Exception as exc: + _record_capability_error(record, "resources", exc) + record.resources = [] + return + record.capability_errors.pop("resources", None) + record.resources = [ + MCPResourceRecord( + server_name=record.name, + uri=str(_get_value(resource, "uri", "")), + name=_get_value(resource, "name"), + title=_get_value(resource, "title"), + description=_get_value(resource, "description"), + mime_type=_get_value(resource, "mimeType", _get_value(resource, "mime_type")), + annotations=_get_value(resource, "annotations", {}) or {}, + meta=_get_value(resource, "_meta", _get_value(resource, "meta", {})) or {}, + ) + for resource in raw_resources + if _get_value(resource, "uri") + ] + + async def _refresh_prompts(self, record: MCPConnectionRecord) -> None: + client = _require_client(record) + try: + raw_prompts = _extract_items( + await asyncio.wait_for(client.list_prompts(), timeout=self._operation_timeout_seconds), + "prompts", + ) + except Exception as exc: + _record_capability_error(record, "prompts", exc) + record.prompts = [] + return + record.capability_errors.pop("prompts", None) + record.prompts = [ + MCPPromptRecord( + server_name=record.name, + prompt_name=str(_get_value(prompt, "name", "")), + public_name=_public_prompt_name(record.name, str(_get_value(prompt, "name", ""))), + description=_get_value(prompt, "description"), + arguments=_get_value(prompt, "arguments", {}) or {}, + meta=_get_value(prompt, "_meta", _get_value(prompt, "meta", {})) or {}, + ) + for prompt in raw_prompts + if _get_value(prompt, "name") + ] + + def _default_client_factory(self, scoped_config: ScopedMCPServerConfig) -> MCPClientProtocol: + async def on_list_changed(capability: str) -> None: + await self.handle_list_changed(scoped_config.name, capability=capability) + + return MCPClientAdapter( + scoped_config.config, + roots=self._roots, + scope=oauth_scope_identity( + scoped_config.scope, + source_path=scoped_config.source_path, + session_id=self._session_id, + ), + list_changed_callback=on_list_changed, + ) + + def _assign_unique_public_names(self) -> None: + tool_groups: dict[str, list[MCPToolRecord]] = {} + for record in self._connections.values(): + for tool in record.tools: + tool_groups.setdefault(_public_tool_name(tool.server_name, tool.tool_name), []).append(tool) + + replacements: dict[tuple[str, str], str] = {} + for public_name, tools in tool_groups.items(): + if len(tools) <= 1: + tool = tools[0] + replacements[(tool.server_name, tool.tool_name)] = public_name + continue + for tool in tools: + replacements[(tool.server_name, tool.tool_name)] = "{}_{}".format( + public_name, + _short_digest(tool.server_name, tool.tool_name), + ) + + for record in self._connections.values(): + if not record.tools: + continue + record.tools = [ + replace(tool, public_name=replacements.get((tool.server_name, tool.tool_name), tool.public_name)) + for tool in record.tools + ] + + command_groups: dict[str, list[tuple[str, str, Any]]] = {} + for record in self._connections.values(): + for prompt in record.prompts: + command_groups.setdefault( + _public_prompt_name(prompt.server_name, prompt.prompt_name), + [], + ).append(("prompt", prompt.prompt_name, prompt)) + for resource in record.resources: + if resource.is_skill_resource: + command_groups.setdefault( + _public_resource_name(resource.server_name, resource.name or "skill"), + [], + ).append(("resource", resource.uri, resource)) + + command_replacements: dict[tuple[str, str, str], str] = {} + for public_name, entries in command_groups.items(): + if len(entries) <= 1: + kind, identifier, item = entries[0] + command_replacements[(kind, item.server_name, identifier)] = public_name + continue + for kind, identifier, item in entries: + command_replacements[(kind, item.server_name, identifier)] = "{}_{}".format( + public_name, + _short_digest(kind, item.server_name, identifier), + ) + + for record in self._connections.values(): + if record.prompts: + record.prompts = [ + replace( + prompt, + public_name=command_replacements.get( + ("prompt", prompt.server_name, prompt.prompt_name), + prompt.public_name, + ), + ) + for prompt in record.prompts + ] + if record.resources: + record.resources = [ + replace( + resource, + public_name=( + command_replacements.get(("resource", resource.server_name, resource.uri)) + if resource.is_skill_resource + else resource.public_name + ), + ) + for resource in record.resources + ] + + +MappingOrDict = dict[str, Any] + + +def _require_client(record: MCPConnectionRecord) -> MCPClientProtocol: + if record.client is None: + raise MCPConnectionError(_("MCP server {server!r} is not connected.").format(server=record.name)) + return record.client + + +def _extract_items(value: Any, field_name: str) -> list[Any]: + if isinstance(value, list): + return value + items = _get_value(value, field_name) + if isinstance(items, list): + return items + return [] + + +def _record_capability_error(record: MCPConnectionRecord, capability: str, exc: Exception) -> None: + message = sanitize_public_text(str(exc) or exc.__class__.__name__) + record.error = message + record.capability_errors[capability] = message + logger.warning( + "MCP server {!r} {} discovery failed: {}", + record.name, + capability, + message, + ) + + +def _get_value(value: Any, key: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(key, default) + if key == "_meta": + return getattr(value, "meta", default) + return getattr(value, key, default) + + +def _public_tool_name(server_name: str, tool_name: str) -> str: + return "mcp__{}__{}".format(_safe_identifier(server_name), _safe_identifier(tool_name)) + + +def _public_prompt_name(server_name: str, prompt_name: str) -> str: + return "mcp__{}__{}".format(_safe_identifier(server_name), _safe_identifier(prompt_name)) + + +def _public_resource_name(server_name: str, resource_name: str) -> str: + return "mcp__{}__{}".format(_safe_identifier(server_name), _safe_identifier(resource_name)) + + +def _safe_identifier(value: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_]+", "_", value).strip("_") + return safe or "mcp" + + +def _short_digest(*parts: str) -> str: + return hashlib.sha256("\0".join(parts).encode("utf-8")).hexdigest()[:8] diff --git a/src/iac_code/mcp/oauth.py b/src/iac_code/mcp/oauth.py new file mode 100644 index 00000000..419e0b0e --- /dev/null +++ b/src/iac_code/mcp/oauth.py @@ -0,0 +1,705 @@ +from __future__ import annotations + +import asyncio +import base64 +import concurrent.futures +import hashlib +import json +import posixpath +import secrets +import threading +import time +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Awaitable, Callable +from urllib.error import HTTPError +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from urllib.request import Request, urlopen + +from iac_code.i18n import _ +from iac_code.mcp.errors import MCPNeedsAuthError +from iac_code.mcp.storage import MCPSecretStorage +from iac_code.mcp.types import MCPConfigScope, MCPServerConfig + + +@dataclass(frozen=True) +class NeedsAuthEntry: + server_name: str + reason: str + expires_at: float + + +class MCPNeedsAuthCache: + def __init__(self, *, ttl_seconds: int = 900, now: Callable[[], float] | None = None) -> None: + self._ttl_seconds = ttl_seconds + self._now = now or time.time + self._entries: dict[str, NeedsAuthEntry] = {} + + def mark(self, server_name: str, reason: str) -> None: + self._entries[server_name] = NeedsAuthEntry( + server_name=server_name, + reason=reason, + expires_at=self._now() + self._ttl_seconds, + ) + + def get(self, server_name: str) -> NeedsAuthEntry | None: + entry = self._entries.get(server_name) + if entry is None: + return None + if entry.expires_at <= self._now(): + self._entries.pop(server_name, None) + return None + return entry + + def clear(self, server_name: str) -> None: + self._entries.pop(server_name, None) + + +class TokenRefreshCoordinator: + def __init__(self) -> None: + self._lock = threading.Lock() + self._inflight: dict[str, concurrent.futures.Future[Any]] = {} + + async def refresh(self, key: str, refresh_func: Callable[[], Awaitable[Any]]) -> Any: + owner = False + with self._lock: + future = self._inflight.get(key) + if future is None: + future = concurrent.futures.Future() + self._inflight[key] = future + owner = True + if not owner: + return await asyncio.shield(asyncio.wrap_future(future)) + try: + result = await refresh_func() + except BaseException as exc: + if not future.cancelled(): + try: + future.set_exception(exc) + except concurrent.futures.InvalidStateError: + pass + raise + else: + if not future.cancelled(): + try: + future.set_result(result) + except concurrent.futures.InvalidStateError: + pass + return result + finally: + with self._lock: + if self._inflight.get(key) is future: + self._inflight.pop(key, None) + + +_DEFAULT_REFRESH_COORDINATOR = TokenRefreshCoordinator() + + +@dataclass(frozen=True) +class OAuthMetadata: + authorization_endpoint: str + token_endpoint: str + scopes_supported: list[str] + + +@dataclass(frozen=True) +class OAuthFlowResult: + authorization_url: str + access_token_key: str + refresh_token_key: str | None = None + + +class OAuthTokenError(RuntimeError): + def __init__(self, error: str, description: str = "", *, status_code: int | None = None) -> None: + self.error = error + self.description = description + self.status_code = status_code + message = error if not description else "{}: {}".format(error, description) + super().__init__(message) + + +@dataclass +class OAuthPendingFlow: + config: MCPServerConfig + storage: MCPSecretStorage + metadata: OAuthMetadata + callback: "_LoopbackCallback" + redirect_uri: str + authorization_url: str + verifier: str + scope: MCPConfigScope | str | None = None + timeout_seconds: float = 120.0 + browser_opened: bool = False + + def wait(self) -> OAuthFlowResult: + try: + code = self.callback.wait_for_code(self.timeout_seconds) + finally: + self.callback.close() + return _exchange_authorization_code( + self.config, + storage=self.storage, + scope=self.scope, + metadata=self.metadata, + redirect_uri=self.redirect_uri, + verifier=self.verifier, + code=code, + authorization_url=self.authorization_url, + ) + + +def build_oauth_discovery_urls(config: MCPServerConfig) -> list[str]: + if not config.url: + return [] + parsed = urlparse(config.url) + origin = urlunparse((parsed.scheme, parsed.netloc, "", "", "", "")) + urls: list[str] = [] + if config.oauth and config.oauth.auth_server_metadata_url: + urls.append(config.oauth.auth_server_metadata_url) + urls.append(origin + "/.well-known/oauth-protected-resource") + urls.append(origin + "/.well-known/oauth-authorization-server") + parent = posixpath.dirname(parsed.path.rstrip("/")) + if parent and parent != "/": + urls.append(origin + parent + "/.well-known/oauth-authorization-server") + return _dedupe(urls) + + +def build_authorization_url( + config: MCPServerConfig, + *, + authorization_endpoint: str, + redirect_uri: str, + state: str, + code_challenge: str, + scopes: list[str] | None = None, +) -> str: + client_id = config.oauth.client_id if config.oauth else None + query = { + "response_type": "code", + "client_id": client_id or "", + "redirect_uri": redirect_uri, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + if scopes: + query["scope"] = " ".join(scopes) + separator = "&" if "?" in authorization_endpoint else "?" + return authorization_endpoint + separator + urlencode(query) + + +def discover_oauth_metadata( + config: MCPServerConfig, + *, + http_get_json: Callable[[str], dict[str, Any]] | None = None, +) -> OAuthMetadata: + getter = http_get_json or _get_json + protected_resource_authorization_servers: list[str] = [] + for url in build_oauth_discovery_urls(config): + try: + data = getter(url) + except Exception: + continue + authorization_endpoint = data.get("authorization_endpoint") + token_endpoint = data.get("token_endpoint") + if isinstance(authorization_endpoint, str) and isinstance(token_endpoint, str): + scopes = data.get("scopes_supported", []) + return OAuthMetadata( + authorization_endpoint=authorization_endpoint, + token_endpoint=token_endpoint, + scopes_supported=[str(scope) for scope in scopes] if isinstance(scopes, list) else [], + ) + authorization_servers = data.get("authorization_servers") + if isinstance(authorization_servers, list): + protected_resource_authorization_servers.extend( + server for server in authorization_servers if isinstance(server, str) + ) + + for auth_server in protected_resource_authorization_servers: + metadata_url = auth_server.rstrip("/") + "/.well-known/oauth-authorization-server" + try: + data = getter(metadata_url) + except Exception: + continue + authorization_endpoint = data.get("authorization_endpoint") + token_endpoint = data.get("token_endpoint") + if isinstance(authorization_endpoint, str) and isinstance(token_endpoint, str): + scopes = data.get("scopes_supported", []) + return OAuthMetadata( + authorization_endpoint=authorization_endpoint, + token_endpoint=token_endpoint, + scopes_supported=[str(scope) for scope in scopes] if isinstance(scopes, list) else [], + ) + + raise RuntimeError(_("Could not discover OAuth metadata for MCP server {server!r}.").format(server=config.name)) + + +def create_oauth_authorization_url( + config: MCPServerConfig, + *, + redirect_uri: str, + state: str | None = None, + code_verifier: str | None = None, + metadata: OAuthMetadata | None = None, +) -> tuple[str, str, str]: + state_value = state or secrets.token_urlsafe(24) + verifier = code_verifier or secrets.token_urlsafe(48) + metadata_value = metadata or discover_oauth_metadata(config) + url = build_authorization_url( + config, + authorization_endpoint=metadata_value.authorization_endpoint, + redirect_uri=redirect_uri, + state=state_value, + code_challenge=_code_challenge(verifier), + scopes=["mcp"] if "mcp" in metadata_value.scopes_supported else None, + ) + return url, state_value, verifier + + +def run_oauth_loopback_flow( + config: MCPServerConfig, + *, + storage: MCPSecretStorage, + scope: MCPConfigScope | str | None = None, + open_browser: Callable[[str], bool] | None = None, + timeout_seconds: float = 120.0, +) -> OAuthFlowResult: + return start_oauth_loopback_flow( + config, + storage=storage, + scope=scope, + open_browser=open_browser, + timeout_seconds=timeout_seconds, + ).wait() + + +def start_oauth_loopback_flow( + config: MCPServerConfig, + *, + storage: MCPSecretStorage, + scope: MCPConfigScope | str | None = None, + open_browser: Callable[[str], bool] | None = None, + timeout_seconds: float = 120.0, +) -> OAuthPendingFlow: + metadata = discover_oauth_metadata(config) + callback_port = config.oauth.callback_port if config.oauth and config.oauth.callback_port else 0 + callback = _LoopbackCallback(callback_port) + redirect_uri = callback.redirect_uri + auth_url, state, verifier = create_oauth_authorization_url( + config, + redirect_uri=redirect_uri, + metadata=metadata, + ) + callback.expected_state = state + try: + opener = open_browser or _open_browser + browser_opened = bool(opener(auth_url)) + except Exception: + browser_opened = False + return OAuthPendingFlow( + config=config, + storage=storage, + metadata=metadata, + callback=callback, + redirect_uri=redirect_uri, + authorization_url=auth_url, + verifier=verifier, + scope=scope, + timeout_seconds=timeout_seconds, + browser_opened=browser_opened, + ) + + +def _exchange_authorization_code( + config: MCPServerConfig, + *, + storage: MCPSecretStorage, + scope: MCPConfigScope | str | None, + metadata: OAuthMetadata, + redirect_uri: str, + verifier: str, + code: str, + authorization_url: str, +) -> OAuthFlowResult: + token_response = _post_token( + metadata.token_endpoint, + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": config.oauth.client_id if config.oauth and config.oauth.client_id else "", + "code_verifier": verifier, + **_client_secret_payload(config, storage, scope=scope), + }, + ) + access_token = token_response.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise RuntimeError( + _("OAuth token response for MCP server {server!r} did not include an access token.").format( + server=config.name + ) + ) + + access_key = oauth_storage_key(config, "access_token", scope=scope) + storage.set_secret(access_key, access_token) + refresh_key = None + refresh_token = token_response.get("refresh_token") + if isinstance(refresh_token, str) and refresh_token: + refresh_key = oauth_storage_key(config, "refresh_token", scope=scope) + storage.set_secret(refresh_key, refresh_token) + expires_in = token_response.get("expires_in") + if isinstance(expires_in, int | float): + storage.set_secret(oauth_storage_key(config, "expires_at", scope=scope), str(time.time() + float(expires_in))) + + return OAuthFlowResult( + authorization_url=authorization_url, + access_token_key=access_key, + refresh_token_key=refresh_key, + ) + + +def get_oauth_access_token( + config: MCPServerConfig, + *, + storage: MCPSecretStorage, + scope: MCPConfigScope | str | None = None, + now: Callable[[], float] | None = None, + refresh_margin_seconds: float = 60.0, +) -> str | None: + access_key = oauth_storage_key(config, "access_token", scope=scope) + access_token = storage.get_secret(access_key) + if not access_token: + return None + + expires_at = _parse_expires_at(storage.get_secret(oauth_storage_key(config, "expires_at", scope=scope))) + refresh_token = storage.get_secret(oauth_storage_key(config, "refresh_token", scope=scope)) + clock = now or time.time + if refresh_token and expires_at is not None and expires_at <= clock() + refresh_margin_seconds: + return refresh_oauth_access_token(config, storage=storage, scope=scope, refresh_token=refresh_token) + return access_token + + +async def get_oauth_access_token_async( + config: MCPServerConfig, + *, + storage: MCPSecretStorage, + scope: MCPConfigScope | str | None = None, + now: Callable[[], float] | None = None, + refresh_margin_seconds: float = 60.0, + refresh_coordinator: TokenRefreshCoordinator | None = None, +) -> str | None: + access_key = oauth_storage_key(config, "access_token", scope=scope) + access_token = storage.get_secret(access_key) + if not access_token: + return None + + expires_at = _parse_expires_at(storage.get_secret(oauth_storage_key(config, "expires_at", scope=scope))) + refresh_token = storage.get_secret(oauth_storage_key(config, "refresh_token", scope=scope)) + clock = now or time.time + if not refresh_token or expires_at is None or expires_at > clock() + refresh_margin_seconds: + return access_token + + coordinator = refresh_coordinator or _DEFAULT_REFRESH_COORDINATOR + + async def refresh() -> str: + return await asyncio.to_thread( + _refresh_oauth_access_token_with_lock, + config, + storage=storage, + scope=scope, + refresh_token=refresh_token, + now=clock, + refresh_margin_seconds=refresh_margin_seconds, + ) + + return await coordinator.refresh(access_key, refresh) + + +def _refresh_oauth_access_token_with_lock( + config: MCPServerConfig, + *, + storage: MCPSecretStorage, + scope: MCPConfigScope | str | None, + refresh_token: str, + now: Callable[[], float], + refresh_margin_seconds: float, +) -> str: + access_key = oauth_storage_key(config, "access_token", scope=scope) + with storage.lock(access_key): + access_token = storage.get_secret(access_key) + expires_at = _parse_expires_at(storage.get_secret(oauth_storage_key(config, "expires_at", scope=scope))) + if access_token and expires_at is not None and expires_at > now() + refresh_margin_seconds: + return access_token + return refresh_oauth_access_token(config, storage=storage, scope=scope, refresh_token=refresh_token) + + +def refresh_oauth_access_token( + config: MCPServerConfig, + *, + storage: MCPSecretStorage, + scope: MCPConfigScope | str | None = None, + refresh_token: str | None = None, +) -> str: + token = refresh_token or storage.get_secret(oauth_storage_key(config, "refresh_token", scope=scope)) + if not token: + raise RuntimeError(_("No refresh token is available for MCP server {server!r}.").format(server=config.name)) + metadata = discover_oauth_metadata(config) + try: + token_response = _post_token( + metadata.token_endpoint, + { + "grant_type": "refresh_token", + "refresh_token": token, + "client_id": config.oauth.client_id if config.oauth and config.oauth.client_id else "", + **_client_secret_payload(config, storage, scope=scope), + }, + ) + except Exception as exc: + if _requires_reauth(exc): + _clear_oauth_tokens(config, storage=storage, scope=scope) + raise MCPNeedsAuthError( + _("MCP server {server!r} requires authentication: {error}").format( + server=config.name, + error=exc, + ) + ) from exc + raise + access_token = token_response.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise RuntimeError( + _("OAuth refresh response for MCP server {server!r} did not include an access token.").format( + server=config.name + ) + ) + storage.set_secret(oauth_storage_key(config, "access_token", scope=scope), access_token) + next_refresh_token = token_response.get("refresh_token") + if isinstance(next_refresh_token, str) and next_refresh_token: + storage.set_secret(oauth_storage_key(config, "refresh_token", scope=scope), next_refresh_token) + expires_in = token_response.get("expires_in") + if isinstance(expires_in, int | float): + storage.set_secret(oauth_storage_key(config, "expires_at", scope=scope), str(time.time() + float(expires_in))) + return access_token + + +def oauth_storage_key(config: MCPServerConfig, kind: str, *, scope: MCPConfigScope | str | None = None) -> str: + material = "\0".join([_normalized_server_name(config.name), _scope_value(scope), config.content_signature(), kind]) + digest = hashlib.sha256(material.encode("utf-8")).hexdigest() + return "mcp:{}:{}".format(kind, digest) + + +def oauth_scope_identity( + scope: MCPConfigScope | str | None, + *, + source_path: str | Path | None = None, + session_id: str | None = None, +) -> MCPConfigScope | str | None: + if scope is None: + return None + parsed_scope = scope if isinstance(scope, MCPConfigScope) else None + if parsed_scope is None: + try: + parsed_scope = MCPConfigScope(str(scope)) + except ValueError: + return scope + if parsed_scope is MCPConfigScope.USER: + return parsed_scope + if parsed_scope in {MCPConfigScope.SESSION, MCPConfigScope.DYNAMIC}: + if session_id is None: + return parsed_scope + return "{}:{}".format(parsed_scope.value, session_id) + if source_path is not None: + return "{}:{}".format(parsed_scope.value, Path(source_path).expanduser().as_posix()) + return parsed_scope + + +def clear_oauth_state( + config: MCPServerConfig, + *, + storage: MCPSecretStorage, + scope: MCPConfigScope | str | None = None, + revoke: Callable[[str], None] | None = None, +) -> None: + access_key = oauth_storage_key(config, "access_token", scope=scope) + access_token = storage.get_secret(access_key) + if access_token and revoke is not None: + try: + revoke(access_token) + except Exception: + pass + for kind in ("access_token", "refresh_token", "expires_at", "client_secret"): + storage.delete_secret(oauth_storage_key(config, kind, scope=scope)) + + +def _clear_oauth_tokens( + config: MCPServerConfig, + *, + storage: MCPSecretStorage, + scope: MCPConfigScope | str | None = None, +) -> None: + for kind in ("access_token", "refresh_token", "expires_at"): + storage.delete_secret(oauth_storage_key(config, kind, scope=scope)) + + +def _dedupe(values: list[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + if value not in seen: + result.append(value) + seen.add(value) + return result + + +class _LoopbackCallback: + def __init__(self, port: int) -> None: + self.expected_state: str | None = None + self._event = threading.Event() + self._code: str | None = None + self._error: str | None = None + self._server = ThreadingHTTPServer(("127.0.0.1", port), self._handler()) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + + @property + def redirect_uri(self) -> str: + return "http://127.0.0.1:{}/callback".format(self._server.server_address[1]) + + def wait_for_code(self, timeout_seconds: float) -> str: + if not self._event.wait(timeout_seconds): + raise TimeoutError(_("Timed out waiting for MCP OAuth callback.")) + if self._error: + raise RuntimeError(self._error) + if not self._code: + raise RuntimeError(_("OAuth callback did not include a code.")) + return self._code + + def close(self) -> None: + if not self._event.is_set(): + self._error = _("OAuth flow closed.") + self._event.set() + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=1) + + def _handler(self): + outer = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path != "/callback": + self.send_error(404) + return + query = parse_qs(parsed.query) + state = query.get("state", [""])[0] + if outer.expected_state and state != outer.expected_state: + outer._error = _("OAuth callback state did not match.") + elif "error" in query: + outer._error = query.get("error_description", query["error"])[0] + else: + outer._code = query.get("code", [""])[0] + outer._event.set() + body = _("MCP authentication complete. You can close this window.").encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: + return + + return Handler + + +def _code_challenge(verifier: str) -> str: + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + +def _get_json(url: str) -> dict[str, Any]: + with urlopen(url, timeout=10) as response: + data = json.loads(response.read().decode("utf-8")) + if not isinstance(data, dict): + raise RuntimeError(_("OAuth metadata endpoint did not return an object.")) + return data + + +def _post_token(url: str, data: dict[str, str]) -> dict[str, Any]: + payload = urlencode(data).encode("utf-8") + request = Request( + url, + data=payload, + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + method="POST", + ) + try: + with urlopen(request, timeout=10) as response: + parsed = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + error = "http_{}".format(exc.code) + description = body + try: + parsed_error = json.loads(body) + except Exception: + parsed_error = None + if isinstance(parsed_error, dict): + error = str(parsed_error.get("error") or error) + description = str(parsed_error.get("error_description") or parsed_error.get("message") or description) + raise OAuthTokenError(error, description, status_code=exc.code) from exc + if not isinstance(parsed, dict): + raise RuntimeError(_("OAuth token endpoint did not return an object.")) + return parsed + + +def _requires_reauth(exc: BaseException) -> bool: + if isinstance(exc, OAuthTokenError): + if exc.status_code in {401, 403}: + return True + if exc.error in {"invalid_grant", "invalid_token"}: + return True + text = "{} {}".format(exc.__class__.__name__, str(exc)).lower() + return "invalid_grant" in text or "invalid_token" in text or "unauthorized" in text or "forbidden" in text + + +def _client_secret_payload( + config: MCPServerConfig, + storage: MCPSecretStorage, + *, + scope: MCPConfigScope | str | None = None, +) -> dict[str, str]: + secret = None + if config.oauth and config.oauth.client_secret_env: + import os + + secret = os.environ.get(config.oauth.client_secret_env) + if not secret: + secret = storage.get_secret(oauth_storage_key(config, "client_secret", scope=scope)) + return {"client_secret": secret} if secret else {} + + +def _parse_expires_at(value: str | None) -> float | None: + if value is None: + return None + try: + return float(value) + except ValueError: + return None + + +def _scope_value(scope: MCPConfigScope | str | None) -> str: + if isinstance(scope, MCPConfigScope): + return scope.value + return scope or "unspecified" + + +def _normalized_server_name(name: str) -> str: + return name.strip().lower() + + +def _open_browser(url: str) -> bool: + import webbrowser + + return bool(webbrowser.open(url)) diff --git a/src/iac_code/mcp/output.py b/src/iac_code/mcp/output.py new file mode 100644 index 00000000..b30fda0a --- /dev/null +++ b/src/iac_code/mcp/output.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import re +from typing import Any, Mapping + +from iac_code.config import get_config_dir +from iac_code.i18n import _ +from iac_code.tools.base import ToolResult +from iac_code.utils.file_security import ensure_private_dir +from iac_code.utils.state_io import atomic_write_bytes + + +def convert_mcp_tool_result( + result: Any, + *, + server_name: str, + tool_name: str, + session_id: str, +) -> ToolResult: + """Convert an MCP tool result into iac-code's model-visible ToolResult.""" + + artifacts: list[dict[str, Any]] = [] + sections: list[str] = [] + + for index, block in enumerate(_get_value(result, "content", []) or []): + converted = _convert_content_block( + block, + server_name=server_name, + tool_name=tool_name, + session_id=session_id, + index=index, + artifacts=artifacts, + ) + if converted: + sections.append(converted) + + structured_content = _get_value(result, "structuredContent") + if structured_content is not None: + sections.append(_("Structured content:\n{content}").format(content=_json_dumps(structured_content))) + + is_error = bool(_get_value(result, "isError", False)) + meta = _get_value(result, "_meta") + if meta is None: + meta = _get_value(result, "meta", {}) + + metadata = { + "mcp": { + "server_name": server_name, + "tool_name": tool_name, + "is_error": is_error, + "meta": meta or {}, + "artifacts": artifacts, + } + } + content = "\n\n".join(section for section in sections if section).strip() + if not content: + content = _("MCP tool returned no content.") + return ToolResult(content=content, is_error=is_error, metadata=metadata) + + +def _convert_content_block( + block: Any, + *, + server_name: str, + tool_name: str, + session_id: str, + index: int, + artifacts: list[dict[str, Any]], +) -> str: + block_type = _get_value(block, "type") + if block_type == "text": + return str(_get_value(block, "text", "")) + + if block_type in {"image", "audio"}: + return _store_base64_artifact( + _get_value(block, "data", ""), + mime_type=str(_get_value(block, "mimeType", "application/octet-stream")), + kind=str(block_type), + server_name=server_name, + tool_name=tool_name, + session_id=session_id, + index=index, + artifacts=artifacts, + ) + + if block_type == "resource": + resource = _get_value(block, "resource", {}) + text = _get_value(resource, "text") + uri = str(_get_value(resource, "uri", "")) + mime_type = _get_value(resource, "mimeType") + if text is not None: + header = _("Resource from MCP server {server!r}\nURI: {uri}").format(server=server_name, uri=uri) + if mime_type: + header = _("{header}\nMIME: {mime_type}").format(header=header, mime_type=mime_type) + return "{}\n\n{}".format(header, text) + + blob = _get_value(resource, "blob") + if blob is not None: + return _store_base64_artifact( + blob, + mime_type=str(mime_type or "application/octet-stream"), + kind="resource", + server_name=server_name, + tool_name=tool_name, + session_id=session_id, + index=index, + artifacts=artifacts, + uri=uri, + ) + + if block_type == "resource_link": + name = str(_get_value(block, "name", "") or _("(unnamed)")) + uri = str(_get_value(block, "uri", "")) + mime_type = _get_value(block, "mimeType") + details = [_("Resource link: {name}").format(name=name), _("URI: {uri}").format(uri=uri)] + if mime_type: + details.append(_("MIME: {mime_type}").format(mime_type=mime_type)) + return "\n".join(details) + + return _("Unsupported MCP content block:\n{content}").format(content=_json_dumps(_to_jsonable(block))) + + +def _store_base64_artifact( + encoded: object, + *, + mime_type: str, + kind: str, + server_name: str, + tool_name: str, + session_id: str, + index: int, + artifacts: list[dict[str, Any]], + uri: str | None = None, +) -> str: + if not isinstance(encoded, str): + raise ValueError(_("MCP {kind} content must contain base64 string data.").format(kind=kind)) + + data = base64.b64decode(encoded, validate=True) + digest = hashlib.sha256(data).hexdigest()[:16] + extension = _extension_for_mime_type(mime_type) + directory = ( + get_config_dir() + / "tool-results" + / session_id + / "mcp" + / _safe_path_segment(server_name) + / _safe_path_segment(tool_name) + ) + ensure_private_dir(directory) + path = directory / "{:02d}-{}-{}{}".format(index, _safe_path_segment(kind), digest, extension) + atomic_write_bytes(path, data) + + artifact_id = "{}/{}/{}".format( + _safe_path_segment(server_name), + _safe_path_segment(tool_name), + path.name, + ) + artifact = { + "id": artifact_id, + "kind": kind, + "mime_type": mime_type, + "path": str(path), + "size": len(data), + } + if uri: + artifact["uri"] = uri + artifacts.append(artifact) + return _("Saved {mime_type} artifact as {artifact_id} ({bytes} bytes).").format( + mime_type=mime_type, + artifact_id=artifact_id, + bytes=len(data), + ) + + +def _get_value(value: Any, key: str, default: Any = None) -> Any: + if isinstance(value, Mapping): + return value.get(key, default) + if key == "_meta": + return getattr(value, "meta", default) + return getattr(value, key, default) + + +def _to_jsonable(value: Any) -> Any: + if hasattr(value, "model_dump"): + return value.model_dump(by_alias=True, mode="json") + if isinstance(value, Mapping): + return {str(key): _to_jsonable(item) for key, item in value.items()} + if isinstance(value, list): + return [_to_jsonable(item) for item in value] + if isinstance(value, tuple): + return [_to_jsonable(item) for item in value] + return value + + +def _json_dumps(value: Any) -> str: + return json.dumps(_to_jsonable(value), ensure_ascii=False, indent=2, sort_keys=True) + + +def _safe_path_segment(value: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip(".-") + return safe or "mcp" + + +def _extension_for_mime_type(mime_type: str) -> str: + normalized = mime_type.split(";", 1)[0].strip().lower() + return { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/webp": ".webp", + "image/gif": ".gif", + "audio/mpeg": ".mp3", + "audio/wav": ".wav", + "audio/ogg": ".ogg", + "application/json": ".json", + "application/octet-stream": ".bin", + "text/plain": ".txt", + "text/markdown": ".md", + "text/yaml": ".yml", + }.get(normalized, ".bin") diff --git a/src/iac_code/mcp/prompts.py b/src/iac_code/mcp/prompts.py new file mode 100644 index 00000000..9102cf3f --- /dev/null +++ b/src/iac_code/mcp/prompts.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from iac_code.commands.registry import CommandRegistry, PromptCommand +from iac_code.i18n import _ +from iac_code.mcp.types import MCPConfigWarning, MCPPromptRecord +from iac_code.skills.frontmatter import SkillFrontmatter +from iac_code.skills.skill_definition import SkillContext, SkillDefinition +from iac_code.types.skill_source import SkillSource + + +def register_mcp_prompt_commands(command_registry: CommandRegistry, manager: Any) -> list[MCPConfigWarning]: + warnings: list[MCPConfigWarning] = [] + for record in manager.list_prompts(): + existing = command_registry.get(record.public_name) + if existing is not None and not _is_mcp_prompt_command(existing): + warnings.append( + MCPConfigWarning( + source="mcp", + server_name=record.server_name, + code="command_conflict", + message=_("MCP prompt command {command!r} conflicts with an existing command.").format( + command=record.public_name + ), + ) + ) + continue + + frontmatter = SkillFrontmatter( + description=record.description or "", + when_to_use=record.description or "", + argument_hint=_argument_hint(record), + arguments=_argument_names(record), + ) + skill = SkillDefinition( + name=record.public_name, + description=record.description or "", + frontmatter=frontmatter, + content="", + source=SkillSource.PROJECT, + file_path="mcp://{}/prompt/{}".format(record.server_name, record.prompt_name), + content_length=0, + _prompt_provider=_MCPPromptProvider(manager=manager, record=record), + ) + command_registry.register( + PromptCommand( + name=record.public_name, + description=record.description or _("MCP prompt {prompt}").format(prompt=record.prompt_name), + skill=skill, + source=SkillSource.PROJECT, + ) + ) + return warnings + + +@dataclass +class _MCPPromptProvider: + manager: Any + record: MCPPromptRecord + + async def get_prompt(self, args: str, context: SkillContext) -> str: + arguments = _parse_prompt_args(args) + for name in _required_arguments(self.record): + if name not in arguments or arguments[name] == "": + raise ValueError(_("Missing required MCP prompt argument: {name}").format(name=name)) + result = await self.manager.get_prompt(self.record.server_name, self.record.prompt_name, arguments) + return _render_prompt_result(result) + + +def _required_arguments(record: MCPPromptRecord) -> list[str]: + if isinstance(record.arguments, dict): + return [ + name + for name, schema in record.arguments.items() + if isinstance(schema, dict) and schema.get("required") is True + ] + if isinstance(record.arguments, list): + return [ + str(_get_value(argument, "name", "")) + for argument in record.arguments + if _get_value(argument, "name") and _get_value(argument, "required") is True + ] + return [] + + +def _argument_names(record: MCPPromptRecord) -> list[str]: + if isinstance(record.arguments, dict): + return [str(key) for key in record.arguments] + if isinstance(record.arguments, list): + return [str(_get_value(argument, "name", "")) for argument in record.arguments if _get_value(argument, "name")] + return [] + + +def _argument_hint(record: MCPPromptRecord) -> str: + required = _required_arguments(record) + if required: + return " ".join("{}=".format(name) for name in required) + return "" + + +def _parse_prompt_args(args: str) -> dict[str, str]: + stripped = args.strip() + if not stripped: + return {} + if stripped.startswith("{"): + data = json.loads(stripped) + if not isinstance(data, dict): + raise ValueError(_("MCP prompt arguments JSON must be an object.")) + return {str(key): str(value) for key, value in data.items()} + + parsed: dict[str, str] = {} + current_key: str | None = None + for part in _split_prompt_arg_tokens(stripped): + if _starts_key_value(part): + key, value = part.split("=", 1) + if not key: + raise ValueError(_("MCP prompt arguments must use key=value syntax.")) + parsed[key] = value + current_key = key + continue + if current_key is None: + raise ValueError(_("MCP prompt arguments must use key=value syntax.")) + parsed[current_key] = "{} {}".format(parsed[current_key], part) + return parsed + + +def _split_prompt_arg_tokens(value: str) -> list[str]: + tokens: list[str] = [] + current: list[str] = [] + quote: str | None = None + i = 0 + while i < len(value): + char = value[i] + if quote is not None: + if char == "\\" and i + 1 < len(value) and value[i + 1] == quote: + current.append(quote) + i += 2 + continue + if char == quote: + quote = None + i += 1 + continue + current.append(char) + i += 1 + continue + if char in {"'", '"'}: + quote = char + i += 1 + continue + if char.isspace(): + if current: + tokens.append("".join(current)) + current = [] + i += 1 + continue + current.append(char) + i += 1 + if quote is not None: + raise ValueError(_("MCP prompt arguments contain an unterminated quoted value.")) + if current: + tokens.append("".join(current)) + return tokens + + +def _starts_key_value(value: str) -> bool: + if "=" not in value: + return False + key, _value = value.split("=", 1) + if not key: + return True + return all(not char.isspace() for char in key) + + +def _render_prompt_result(result: Any) -> str: + messages = _get_value(result, "messages", []) + parts: list[str] = [] + for message in messages: + role = _get_value(message, "role", "user") + content = _get_value(message, "content", "") + text = _content_text(content) + if text: + parts.append("{}: {}".format(role, text)) + return "\n\n".join(parts) + + +def _is_mcp_prompt_command(command: Any) -> bool: + if not isinstance(command, PromptCommand): + return False + skill = command.skill + return bool(skill is not None and str(skill.file_path).startswith("mcp://")) + + +def _content_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, dict): + if content.get("type") == "text": + return str(content.get("text", "")) + return json.dumps(content, ensure_ascii=False, sort_keys=True) + if isinstance(content, list): + return "\n".join(_content_text(item) for item in content) + return str(content) + + +def _get_value(value: Any, key: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(key, default) + return getattr(value, key, default) diff --git a/src/iac_code/mcp/skills.py b/src/iac_code/mcp/skills.py new file mode 100644 index 00000000..a43261bb --- /dev/null +++ b/src/iac_code/mcp/skills.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import asyncio +import re +from typing import Any + +from iac_code.commands.registry import CommandRegistry, PromptCommand +from iac_code.i18n import _ +from iac_code.mcp.types import MCPConfigWarning +from iac_code.skills.frontmatter import parse_frontmatter +from iac_code.skills.skill_definition import SkillDefinition +from iac_code.types.skill_source import SkillSource +from iac_code.utils.public_errors import sanitize_public_text + +_MAX_SKILL_DESCRIPTION_CHARS = 256 +_MAX_SKILL_BODY_CHARS = 20_000 + + +async def register_mcp_skill_commands(command_registry: CommandRegistry, manager: Any) -> list[MCPConfigWarning]: + warnings: list[MCPConfigWarning] = [] + for resource in manager.list_resources(): + if not resource.is_skill_resource: + continue + command_name = resource.public_name or _resource_command_name(resource) + existing = command_registry.get(command_name) + if existing is not None and not _is_mcp_prompt_command(existing): + warnings.append( + MCPConfigWarning( + source="mcp", + server_name=resource.server_name, + code="command_conflict", + message=_("MCP skill command {command!r} conflicts with an existing command.").format( + command=command_name + ), + ) + ) + continue + + try: + server_name, result = await asyncio.wait_for( + manager.read_resource(resource.uri, server_name=resource.server_name), + timeout=getattr(manager, "operation_timeout_seconds", 20.0), + ) + except Exception as exc: + warnings.append( + MCPConfigWarning( + source="mcp", + server_name=resource.server_name, + code="skill_read_failed", + message=_("MCP skill command {command!r} could not be loaded: {error}").format( + command=command_name, + error=sanitize_public_text(str(exc) or exc.__class__.__name__), + ), + ) + ) + continue + text = _first_text_content(result) + frontmatter, content = parse_frontmatter(text) + frontmatter.allowed_tools = [] + frontmatter.auto_trigger = {} + frontmatter.paths = [] + description = frontmatter.description or resource.description or resource.title or resource.name or "" + truncated = False + if len(description) > _MAX_SKILL_DESCRIPTION_CHARS: + description = description[:_MAX_SKILL_DESCRIPTION_CHARS] + frontmatter.description = description + truncated = True + if len(content) > _MAX_SKILL_BODY_CHARS: + content = content[:_MAX_SKILL_BODY_CHARS] + truncated = True + if truncated: + warnings.append( + MCPConfigWarning( + source="mcp", + server_name=resource.server_name, + code="skill_truncated", + message=_("MCP skill {command!r} was truncated to fit safety limits.").format(command=command_name), + ) + ) + skill = SkillDefinition( + name=command_name, + description=description, + frontmatter=frontmatter, + content=content, + source=SkillSource.PROJECT, + file_path="mcp://{}/{}".format(server_name, resource.uri), + skill_root="", + content_length=len(content), + ) + command_registry.register( + PromptCommand( + name=command_name, + description=description, + skill=skill, + source=SkillSource.PROJECT, + ) + ) + return warnings + + +def _first_text_content(result: Any) -> str: + contents = _get_value(result, "contents", []) + for content in contents: + text = _get_value(content, "text") + if text is not None: + return str(text) + return "" + + +def _get_value(value: Any, key: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(key, default) + return getattr(value, key, default) + + +def _is_mcp_prompt_command(command: Any) -> bool: + if not isinstance(command, PromptCommand): + return False + skill = command.skill + return bool(skill is not None and str(skill.file_path).startswith("mcp://")) + + +def _safe_identifier(value: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_]+", "_", value).strip("_") + return safe or "mcp" + + +def _resource_command_name(resource: Any) -> str: + return "mcp__{}__{}".format( + _safe_identifier(resource.server_name), + _safe_identifier(resource.name or "skill"), + ) diff --git a/src/iac_code/mcp/storage.py b/src/iac_code/mcp/storage.py new file mode 100644 index 00000000..e772da08 --- /dev/null +++ b/src/iac_code/mcp/storage.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json +import os +import sys +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +from cryptography.fernet import Fernet, InvalidToken + +from iac_code.config import get_config_dir +from iac_code.utils.file_security import ensure_private_dir, ensure_private_file +from iac_code.utils.state_io import atomic_write_bytes + +_FALLBACK_STORE_LOCK = "__fallback_store__" + + +class MCPSecretStorage: + def __init__(self, *, keyring_backend: Any | None = None, service_name: str = "iac-code:mcp") -> None: + if keyring_backend is None and os.environ.get("IAC_CODE_MCP_DISABLE_KEYRING") != "1": + try: + import keyring + + keyring_backend = keyring + except Exception: + keyring_backend = None + self._keyring = keyring_backend + self._service_name = service_name + + def set_secret(self, key: str, value: str) -> None: + if self._try_keyring_set(key, value): + return + with self.lock(_FALLBACK_STORE_LOCK): + data = self._load_fallback() + data[key] = value + self._save_fallback(data) + + def get_secret(self, key: str) -> str | None: + value = self._try_keyring_get(key) + if value is not None: + return value + with self.lock(_FALLBACK_STORE_LOCK): + return self._load_fallback().get(key) + + def delete_secret(self, key: str) -> None: + self._try_keyring_delete(key) + with self.lock(_FALLBACK_STORE_LOCK): + data = self._load_fallback() + if key in data: + data.pop(key) + self._save_fallback(data) + + @contextmanager + def lock(self, key: str) -> Iterator[None]: + ensure_private_dir(_fallback_dir()) + path = _fallback_dir() / "locks" / "{}.lock".format(_safe_lock_name(key)) + ensure_private_dir(path.parent) + with _locked_file(path): + yield + + def _try_keyring_set(self, key: str, value: str) -> bool: + if self._keyring is None: + return False + try: + self._keyring.set_password(self._service_name, key, value) + return True + except Exception: + return False + + def _try_keyring_get(self, key: str) -> str | None: + if self._keyring is None: + return None + try: + return self._keyring.get_password(self._service_name, key) + except Exception: + return None + + def _try_keyring_delete(self, key: str) -> None: + if self._keyring is None: + return + try: + self._keyring.delete_password(self._service_name, key) + except Exception: + return + + def _load_fallback(self) -> dict[str, str]: + path = _fallback_path() + if not path.exists(): + return {} + try: + decrypted = _fernet().decrypt(path.read_bytes()) + data = json.loads(decrypted.decode("utf-8")) + except (OSError, InvalidToken, json.JSONDecodeError, UnicodeDecodeError): + return {} + return {str(key): str(value) for key, value in data.items()} if isinstance(data, dict) else {} + + def _save_fallback(self, data: dict[str, str]) -> None: + ensure_private_dir(_fallback_dir()) + payload = json.dumps(data, ensure_ascii=False, sort_keys=True).encode("utf-8") + atomic_write_bytes(_fallback_path(), _fernet().encrypt(payload)) + ensure_private_file(_fallback_path()) + + +def _fallback_dir() -> Path: + return get_config_dir() / "mcp" + + +def _fallback_path() -> Path: + return _fallback_dir() / "secrets.json.enc" + + +def _key_path() -> Path: + return _fallback_dir() / "secrets.key" + + +def _fernet() -> Fernet: + path = _key_path() + ensure_private_dir(path.parent) + key_lock = _fallback_dir() / "locks" / "secrets-key.lock" + with _locked_file(key_lock): + if path.exists(): + key = path.read_bytes() + else: + key = Fernet.generate_key() + atomic_write_bytes(path, key) + ensure_private_file(path) + return Fernet(key) + + +@contextmanager +def _locked_file(path: Path) -> Iterator[None]: + ensure_private_dir(path.parent) + with path.open("a+b") as handle: + if sys.platform == "win32": + try: + import msvcrt + except ImportError: # pragma: no cover - defensive for unusual Python builds. + yield + return + handle.seek(0, os.SEEK_END) + if handle.tell() == 0: + handle.write(b"\0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + try: + yield + finally: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + return + + try: + import fcntl + except ImportError: # pragma: no cover - non-Windows platforms normally provide fcntl. + yield + return + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + yield + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def _safe_lock_name(value: str) -> str: + import hashlib + + return hashlib.sha256(value.encode("utf-8")).hexdigest() diff --git a/src/iac_code/mcp/tools.py b/src/iac_code/mcp/tools.py new file mode 100644 index 00000000..87c92de9 --- /dev/null +++ b/src/iac_code/mcp/tools.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import inspect +import json +import re +from typing import Any, Callable + +from iac_code.i18n import _ +from iac_code.mcp.output import convert_mcp_tool_result +from iac_code.mcp.types import MCPResourceRecord, MCPToolRecord +from iac_code.tools.base import Tool, ToolContext, ToolResult +from iac_code.types.stream_events import MCPProgressEvent + + +class MCPTool(Tool): + def __init__(self, *, manager: Any, record: MCPToolRecord, session_id: str) -> None: + self._manager = manager + self._record = record + self._session_id = session_id + + @property + def name(self) -> str: + return self._record.public_name + + @property + def description(self) -> str: + description = self._record.description or _("MCP tool {tool!r} from server {server!r}.").format( + tool=self._record.tool_name, + server=self._record.server_name, + ) + return description[:4000] + + @property + def input_schema(self) -> dict[str, Any]: + if isinstance(self._record.input_schema, dict) and self._record.input_schema: + return dict(self._record.input_schema) + return {"type": "object", "properties": {}} + + @property + def timeout(self) -> float | None: + return 600.0 + + def is_read_only(self, input: dict | None = None) -> bool: + return self._record.annotations.get("readOnlyHint") is True + + def is_concurrency_safe(self, tool_input: dict[str, Any]) -> bool: + return self.is_read_only(tool_input) + + def is_destructive(self, input: dict | None = None) -> bool: + return self._record.annotations.get("destructiveHint") is True + + def needs_event_queue(self) -> bool: + return True + + def user_facing_name(self, input: dict | None = None) -> str: + return _("MCP {server}:{tool}").format(server=self._record.server_name, tool=self._record.tool_name) + + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + meta = {"iac_code/toolUseId": context.tool_use_id} if context.tool_use_id else None + progress_callback = self._build_progress_callback(context) if context.event_queue is not None else None + result = await self._manager.call_tool( + self._record.server_name, + self._record.tool_name, + tool_input, + progress_callback=progress_callback, + meta=meta, + ) + return convert_mcp_tool_result( + result, + server_name=self._record.server_name, + tool_name=self._record.tool_name, + session_id=self._session_id, + ) + + def _build_progress_callback(self, context: ToolContext): + queue = context.event_queue + assert queue is not None + + async def progress_callback(progress: Any, total: Any = None, message: Any = None) -> None: + progress_value = _progress_field( + progress, + "progress", + progress if isinstance(progress, int | float) else None, + ) + total_value = _progress_field(progress, "total", total) + message_value = _progress_field(progress, "message", message) + await queue.put( + MCPProgressEvent( + server_name=self._record.server_name, + tool_name=self._record.tool_name, + progress=_to_float(progress_value), + total=_to_float(total_value), + message=str(message_value) if message_value is not None else None, + tool_use_id=context.tool_use_id, + ) + ) + + return progress_callback + + +class ListMCPResourcesTool(Tool): + name = "list_mcp_resources" + description = _("List resources exposed by connected MCP servers.") + input_schema = { + "type": "object", + "properties": { + "server": {"type": "string", "description": _("Optional MCP server name filter.")}, + }, + "additionalProperties": False, + } + + def __init__(self, *, manager: Any) -> None: + self._manager = manager + + def is_read_only(self, input: dict | None = None) -> bool: + return True + + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + server_filter = tool_input.get("server") + resources = [ + resource + for resource in self._manager.list_resources() + if not server_filter or resource.server_name == server_filter + ] + if not resources: + return ToolResult.success(_("No MCP resources are currently available.")) + lines = [_format_resource_line(resource) for resource in resources] + return ToolResult.success("\n".join(lines)) + + +class ReadMCPResourceTool(Tool): + name = "read_mcp_resource" + description = _("Read a resource exposed by a connected MCP server.") + input_schema = { + "type": "object", + "properties": { + "uri": {"type": "string"}, + "server": {"type": "string"}, + }, + "required": ["server", "uri"], + "additionalProperties": False, + } + + def __init__(self, *, manager: Any, session_id: str) -> None: + self._manager = manager + self._session_id = session_id + + def is_read_only(self, input: dict | None = None) -> bool: + return True + + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + server_name, result = await self._manager.read_resource(tool_input["uri"], server_name=tool_input["server"]) + contents = _get_value(result, "contents", []) + content_blocks = [{"type": "resource", "resource": content} for content in contents] + return convert_mcp_tool_result( + {"content": content_blocks}, + server_name=server_name, + tool_name="read_resource", + session_id=self._session_id, + ) + + +class MCPAuthenticateTool(Tool): + description = _("Start authentication for an MCP server.") + input_schema = {"type": "object", "properties": {}, "additionalProperties": False} + + def __init__( + self, + *, + server_name: str, + auth_url_factory: Callable[[str], Any] | None = None, + auth_flow: Callable[[str], Any] | None = None, + ) -> None: + self._server_name = server_name + self._auth_url_factory = auth_url_factory + self._auth_flow = auth_flow + + @property + def name(self) -> str: + return "mcp__{}__authenticate".format(_safe_identifier(self._server_name)) + + async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + if self._auth_flow is not None: + message = self._auth_flow(self._server_name) + if inspect.isawaitable(message): + message = await message + return ToolResult.success(str(message)) + + if self._auth_url_factory is None: + return ToolResult.error( + _("No MCP authentication flow is configured for {server!r}.").format(server=self._server_name) + ) + + auth_url = self._auth_url_factory(self._server_name) + if inspect.isawaitable(auth_url): + auth_url = await auth_url + return ToolResult.success( + _("Open this URL to authenticate MCP server {server!r}:\n{url}").format( + server=self._server_name, + url=auth_url, + ) + ) + + +def _format_resource_line(resource: MCPResourceRecord) -> str: + parts = [resource.server_name, resource.uri] + if resource.name: + parts.append(resource.name) + if resource.mime_type: + parts.append(resource.mime_type) + return " | ".join(parts) + + +def _progress_field(value: Any, key: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(key, default) + return getattr(value, key, default) + + +def _to_float(value: Any) -> float | None: + if isinstance(value, int | float): + return float(value) + return None + + +def _get_value(value: Any, key: str, default: Any = None) -> Any: + if isinstance(value, dict): + return value.get(key, default) + if hasattr(value, "model_dump") and key == "contents": + dumped = value.model_dump(by_alias=True, mode="json") + return dumped.get(key, default) + return getattr(value, key, default) + + +def _safe_identifier(value: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_]+", "_", value).strip("_") + return safe or "mcp" + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) diff --git a/src/iac_code/mcp/types.py b/src/iac_code/mcp/types.py new file mode 100644 index 00000000..b2ae32ee --- /dev/null +++ b/src/iac_code/mcp/types.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Mapping, Sequence, cast + +from iac_code.i18n import _ + + +class MCPConfigError(ValueError): + """Raised when an MCP server config cannot be normalized.""" + + +class MCPTransport(str, Enum): + STDIO = "stdio" + HTTP = "http" + SSE = "sse" + + @classmethod + def from_value(cls, value: str, *, server_name: str) -> "MCPTransport": + try: + return cls(value) + except ValueError as exc: + supported = ", ".join(transport.value for transport in cls) + raise MCPConfigError( + _( + "Unsupported MCP transport {transport!r} for server {server!r}. Supported transports: {supported}." + ).format(transport=value, server=server_name, supported=supported) + ) from exc + + +class MCPConfigScope(str, Enum): + USER = "user" + PROJECT = "project" + LOCAL = "local" + SESSION = "session" + DYNAMIC = "dynamic" + + @property + def precedence(self) -> int: + return { + "user": 10, + "project": 20, + "local": 30, + "session": 40, + "dynamic": 40, + }[self.value] + + +class MCPConnectionState(str, Enum): + CONNECTED = "connected" + FAILED = "failed" + NEEDS_AUTH = "needs_auth" + PENDING = "pending" + DISABLED = "disabled" + + +@dataclass(frozen=True) +class MCPConfigWarning: + source: str + message: str + server_name: str | None = None + code: str = "warning" + + +@dataclass(frozen=True) +class MCPOAuthConfig: + client_id: str | None = None + client_secret_env: str | None = None + callback_port: int | None = None + auth_server_metadata_url: str | None = None + + @classmethod + def from_mapping(cls, server_name: str, value: object) -> "MCPOAuthConfig": + if value is None: + return cls() + if not isinstance(value, Mapping): + raise MCPConfigError(_("MCP server {server!r} oauth config must be an object.").format(server=server_name)) + data = cast(Mapping[str, Any], value) + + if "clientSecret" in data: + raise MCPConfigError( + _( + "MCP server {server!r} uses oauth.clientSecret, but plaintext client secrets are not supported. " + "Use oauth.clientSecretEnv instead." + ).format(server=server_name) + ) + + supported = {"clientId", "clientSecretEnv", "callbackPort", "authServerMetadataUrl"} + unknown = sorted(str(key) for key in data if key not in supported) + if unknown: + raise MCPConfigError( + _("MCP server {server!r} has unsupported oauth fields: {fields}.").format( + server=server_name, + fields=", ".join(unknown), + ) + ) + + callback_port = data.get("callbackPort") + if callback_port is not None and not isinstance(callback_port, int): + raise MCPConfigError( + _("MCP server {server!r} oauth.callbackPort must be an integer.").format(server=server_name) + ) + + return cls( + client_id=_optional_str(data.get("clientId"), "oauth.clientId", server_name), + client_secret_env=_optional_str(data.get("clientSecretEnv"), "oauth.clientSecretEnv", server_name), + callback_port=callback_port, + auth_server_metadata_url=_optional_str( + data.get("authServerMetadataUrl"), + "oauth.authServerMetadataUrl", + server_name, + ), + ) + + +@dataclass(frozen=True) +class MCPServerConfig: + name: str + transport: MCPTransport + command: str | None = None + args: tuple[str, ...] = () + env: dict[str, str] = field(default_factory=dict) + url: str | None = None + headers: dict[str, str] = field(default_factory=dict) + oauth: MCPOAuthConfig | None = None + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_mapping(cls, name: str, value: Mapping[str, Any]) -> "MCPServerConfig": + if not isinstance(value, Mapping): + raise MCPConfigError(_("MCP server {server!r} config must be an object.").format(server=name)) + + _reject_unsupported_fields(name, value) + type_value = value.get("type") + if type_value is None: + if "command" in value: + type_value = MCPTransport.STDIO.value + else: + raise MCPConfigError( + _("MCP server {server!r} requires a type unless a stdio command is provided.").format(server=name) + ) + if not isinstance(type_value, str): + raise MCPConfigError(_("MCP server {server!r} type must be a string.").format(server=name)) + + transport = MCPTransport.from_value(type_value, server_name=name) + env = _string_mapping(value.get("env", {}), "env", name) + headers = _string_mapping(value.get("headers", {}), "headers", name) + oauth = None + if "oauth" in value: + oauth = MCPOAuthConfig.from_mapping(name, value.get("oauth")) + + if transport is MCPTransport.STDIO: + command = _required_str(value.get("command"), "command", name) + return cls( + name=name, + transport=transport, + command=command, + args=_string_sequence(value.get("args", ()), "args", name), + env=env, + oauth=oauth, + raw=dict(value), + ) + + url = _required_str(value.get("url"), "url", name) + return cls( + name=name, + transport=transport, + url=url, + headers=headers, + oauth=oauth, + raw=dict(value), + ) + + def content_signature(self) -> str: + oauth = None + if self.oauth is not None: + oauth = { + "clientId": self.oauth.client_id, + "clientCredentialEnvConfigured": self.oauth.client_secret_env is not None, + "callbackPort": self.oauth.callback_port, + "authServerMetadataUrl": self.oauth.auth_server_metadata_url, + } + material = { + "transport": self.transport.value, + "command": self.command, + "args": list(self.args), + "env": _signature_mapping(self.env), + "url": self.url, + "headers": _signature_mapping(self.headers), + "oauth": oauth, + } + if self.transport is MCPTransport.STDIO: + prefix = "stdio" + else: + prefix = "url" + import hashlib + import json + + data = json.dumps(material, sort_keys=True, separators=(",", ":")).encode("utf-8") + digest = hashlib.pbkdf2_hmac("sha256", data, b"iac-code-mcp-config-signature-v1", 100_000).hex() + return "{}:{}".format(prefix, digest) + + +@dataclass(frozen=True) +class ScopedMCPServerConfig: + config: MCPServerConfig + scope: MCPConfigScope + source_path: str | None = None + approved: bool = True + warning: MCPConfigWarning | None = None + + @property + def name(self) -> str: + return self.config.name + + @property + def transport(self) -> MCPTransport: + return self.config.transport + + @property + def precedence(self) -> int: + return self.scope.precedence + + +@dataclass(frozen=True) +class MCPToolRecord: + server_name: str + tool_name: str + public_name: str + description: str | None = None + input_schema: Mapping[str, Any] = field(default_factory=dict) + annotations: Mapping[str, Any] = field(default_factory=dict) + meta: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class MCPResourceRecord: + server_name: str + uri: str + name: str | None = None + public_name: str | None = None + title: str | None = None + description: str | None = None + mime_type: str | None = None + annotations: Mapping[str, Any] = field(default_factory=dict) + meta: Mapping[str, Any] = field(default_factory=dict) + + @property + def is_skill_resource(self) -> bool: + return self.uri.startswith("skill://") + + +@dataclass(frozen=True) +class MCPPromptRecord: + server_name: str + prompt_name: str + public_name: str + description: str | None = None + arguments: Mapping[str, Any] = field(default_factory=dict) + meta: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class MCPSkillRecord: + server_name: str + name: str + public_name: str + resource_uri: str + description: str | None = None + mime_type: str | None = "text/markdown" + meta: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class MCPConnectionMetadata: + state: MCPConnectionState + server_name: str + capabilities: Mapping[str, Any] = field(default_factory=dict) + server_info: Mapping[str, Any] = field(default_factory=dict) + instructions: str | None = None + stderr_tail: str | None = None + retry_count: int = 0 + config_signature: str | None = None + + +def _reject_unsupported_fields(server_name: str, value: Mapping[str, Any]) -> None: + supported = {"type", "command", "args", "env", "url", "headers", "oauth"} + unsupported = sorted(str(key) for key in value if key not in supported) + if not unsupported: + return + + if "headersHelper" in unsupported: + raise MCPConfigError( + _( + "MCP server {server!r} uses unsupported field 'headersHelper'. Static headers are supported; " + "dynamic headers need a later trusted-execution design." + ).format(server=server_name) + ) + + raise MCPConfigError( + _("MCP server {server!r} has unsupported config fields: {fields}.").format( + server=server_name, + fields=", ".join(unsupported), + ) + ) + + +_SIGNATURE_REDACTED_VALUE = "[redacted]" +_SENSITIVE_CONFIG_NAME_PARTS = ( + "authorization", + "api-key", + "apikey", + "accesskeysecret", + "access_key_secret", + "client_secret", + "password", + "secret", + "token", +) +_SENSITIVE_CONFIG_VALUE_PREFIXES = ("bearer ", "basic ") + + +def _signature_mapping(values: Mapping[str, str]) -> dict[str, str]: + return { + key: _SIGNATURE_REDACTED_VALUE if _is_sensitive_config_entry(key, value) else value + for key, value in values.items() + } + + +def _is_sensitive_config_entry(key: str, value: str) -> bool: + normalized_key = key.replace("-", "_").lower() + if any(part in normalized_key for part in _SENSITIVE_CONFIG_NAME_PARTS): + return True + normalized_value = value.strip().lower() + return normalized_value.startswith(_SENSITIVE_CONFIG_VALUE_PREFIXES) + + +def _required_str(value: object, field_name: str, server_name: str) -> str: + if not isinstance(value, str) or not value: + raise MCPConfigError( + _("MCP server {server!r} requires a {field} string.").format(server=server_name, field=field_name) + ) + return value + + +def _optional_str(value: object, field_name: str, server_name: str) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise MCPConfigError( + _("MCP server {server!r} field {field} must be a string.").format( + server=server_name, + field=field_name, + ) + ) + return value + + +def _string_sequence(value: object, field_name: str, server_name: str) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str) or not isinstance(value, Sequence): + raise MCPConfigError( + _("MCP server {server!r} field {field} must be a list of strings.").format( + server=server_name, + field=field_name, + ) + ) + result: list[str] = [] + for item in value: + if not isinstance(item, str): + raise MCPConfigError( + _("MCP server {server!r} field {field} must be a list of strings.").format( + server=server_name, + field=field_name, + ) + ) + result.append(item) + return tuple(result) + + +def _string_mapping(value: object, field_name: str, server_name: str) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise MCPConfigError( + _("MCP server {server!r} field {field} must be an object of string values.").format( + server=server_name, + field=field_name, + ) + ) + result: dict[str, str] = {} + for key, item in value.items(): + if not isinstance(key, str) or not isinstance(item, str): + raise MCPConfigError( + _("MCP server {server!r} field {field} must be an object of string values.").format( + server=server_name, + field=field_name, + ) + ) + result[key] = item + return result diff --git a/src/iac_code/services/agent_factory.py b/src/iac_code/services/agent_factory.py index 847206df..c8fc0f58 100644 --- a/src/iac_code/services/agent_factory.py +++ b/src/iac_code/services/agent_factory.py @@ -1,9 +1,13 @@ from __future__ import annotations +import asyncio +import contextlib import os +import threading import uuid -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime +from pathlib import Path from typing import Any @@ -17,6 +21,9 @@ class AgentFactoryOptions: cli_disallowed_tools: list[str] | None = None cli_permission_mode: str | None = None resume_messages: list | None = None + mcp_configs: list[dict[str, Any]] | None = None + mcp_manager_factory: Any = None + mcp_interactive_project_approval: bool = False @dataclass @@ -29,6 +36,20 @@ class AgentRuntime: task_manager: Any memory_manager: Any legacy_memory_manager: Any + mcp_manager: Any | None = None + mcp_config_warnings: list[Any] | None = None + _mcp_change_listeners: list[Any] = field(default_factory=list, repr=False) + _mcp_auth_tasks: set[asyncio.Task[Any]] = field(default_factory=set, repr=False) + _mcp_auth_flows: set[Any] = field(default_factory=set, repr=False) + + async def aclose(self) -> None: + await _close_mcp_auth_flows(self._mcp_auth_tasks, self._mcp_auth_flows) + if self.mcp_manager is not None: + with contextlib.suppress(Exception): + await self.mcp_manager.disconnect_all() + + def add_mcp_change_listener(self, listener: Any) -> None: + self._mcp_change_listeners.append(listener) def create_agent_runtime(options: AgentFactoryOptions) -> AgentRuntime: @@ -160,54 +181,456 @@ def build_base_system_prompt() -> str: ) ) - from iac_code.services.permissions.loader import load_permission_context - from iac_code.services.permissions.trusted_roots import build_session_trusted_read_directories - - permission_context = load_permission_context( - cwd, - cli_allowed=options.cli_allowed_tools, - cli_disallowed=options.cli_disallowed_tools, - cli_mode=options.cli_permission_mode, + mcp_manager = None + mcp_config_warnings: list[Any] = [] + runtime_mcp_change_listeners: list[Any] = [] + mcp_auth_tasks: set[asyncio.Task[Any]] = set() + mcp_auth_flows: set[Any] = set() + from iac_code.mcp.config import load_mcp_configs, resolve_mcp_workspace_root + from iac_code.mcp.manager import MCPManager + + mcp_workspace_root = resolve_mcp_workspace_root(Path(cwd)) + mcp_load_result = load_mcp_configs( + cwd=Path(cwd), + workspace_root=mcp_workspace_root, + session_configs=_session_mcp_configs(options.mcp_configs), + include_pending_project=options.mcp_interactive_project_approval, ) - permission_context.trusted_read_directories.extend(build_session_trusted_read_directories(session_id)) + mcp_config_warnings = mcp_load_result.warnings + setup_complete = False + try: + if mcp_load_result.servers: + if options.mcp_manager_factory is not None: + mcp_manager = options.mcp_manager_factory(mcp_load_result.servers, [mcp_workspace_root]) + else: + mcp_manager = MCPManager(mcp_load_result.servers, roots=[mcp_workspace_root], session_id=session_id) + _run_async_blocking(mcp_manager.connect_all()) + mcp_config_warnings.extend(_mcp_connection_warnings(mcp_manager)) + scoped_configs_by_name = {server.name: server for server in mcp_load_result.servers} + registered_mcp_tool_names: set[str] = set() + registered_mcp_command_names: set[str] = set() + registered_mcp_auth_tool_names: set[str] = set() + registered_mcp_auth_tool_names = _sync_mcp_auth_tools( + tool_registry, + scoped_configs_by_name, + mcp_manager, + registered_mcp_auth_tool_names, + auth_tasks=mcp_auth_tasks, + auth_flows=mcp_auth_flows, + session_id=session_id, + ) + registered_mcp_tool_names = _sync_mcp_tool_registry( + tool_registry, + mcp_manager, + session_id, + registered_mcp_tool_names, + ) + registered_mcp_command_names, command_warnings = _run_async_blocking( + _sync_mcp_command_registry(command_registry, mcp_manager, registered_mcp_command_names) + ) + mcp_config_warnings.extend(command_warnings) + + async def on_mcp_changed(server_name: str, capability: str) -> None: + nonlocal registered_mcp_tool_names, registered_mcp_command_names, registered_mcp_auth_tool_names + registered_mcp_auth_tool_names = _sync_mcp_auth_tools( + tool_registry, + scoped_configs_by_name, + mcp_manager, + registered_mcp_auth_tool_names, + auth_tasks=mcp_auth_tasks, + auth_flows=mcp_auth_flows, + session_id=session_id, + ) + if capability in {"tools", "resources", "auth"}: + registered_mcp_tool_names = _sync_mcp_tool_registry( + tool_registry, + mcp_manager, + session_id, + registered_mcp_tool_names, + ) + if capability in {"prompts", "resources"}: + registered_mcp_command_names, warnings = await _sync_mcp_command_registry( + command_registry, + mcp_manager, + registered_mcp_command_names, + ) + mcp_config_warnings.extend(warnings) + _append_new_mcp_connection_warnings(mcp_config_warnings, mcp_manager) + skill_commands = command_registry.get_model_invocable_skills() + skill_listing_holder["value"] = build_skill_listing(skill_commands) + agent_loop.set_auto_trigger_skills(skill_commands) + agent_loop.set_provider(provider_manager, system_prompt=build_agent_system_prompt()) + for listener in list(runtime_mcp_change_listeners): + result = listener(server_name, capability) + if asyncio.iscoroutine(result): + await result + + from iac_code.services.permissions.loader import load_permission_context + from iac_code.services.permissions.trusted_roots import build_session_trusted_read_directories + + permission_context = load_permission_context( + cwd, + cli_allowed=options.cli_allowed_tools, + cli_disallowed=options.cli_disallowed_tools, + cli_mode=options.cli_permission_mode, + ) + permission_context.trusted_read_directories.extend(build_session_trusted_read_directories(session_id)) - if hasattr(tool_registry, "get"): - agent_tool = tool_registry.get("agent") - if agent_tool is not None and hasattr(agent_tool, "_permission_context"): - setattr(agent_tool, "_permission_context", permission_context) + if hasattr(tool_registry, "get"): + agent_tool = tool_registry.get("agent") + if agent_tool is not None and hasattr(agent_tool, "_permission_context"): + setattr(agent_tool, "_permission_context", permission_context) - skill_listing = build_skill_listing(command_registry.get_model_invocable_skills()) + skill_listing_holder = {"value": build_skill_listing(command_registry.get_model_invocable_skills())} - def build_agent_system_prompt() -> str: - return build_system_prompt( + def build_agent_system_prompt() -> str: + return build_system_prompt( + cwd=cwd, + memory_context=memory_runtime.build_memory_context(), + skill_listing=skill_listing_holder["value"], + current_time=runtime_current_time, + ) + + agent_loop = AgentLoop( + provider_manager=provider_manager, + system_prompt=build_agent_system_prompt(), + tool_registry=tool_registry, + session_storage=session_storage, + session_id=session_id, + resume_messages=options.resume_messages, + max_turns=options.max_turns, cwd=cwd, - memory_context=memory_runtime.build_memory_context(), - skill_listing=skill_listing, - current_time=runtime_current_time, + permission_context=permission_context, + auto_trigger_skills=command_registry.get_model_invocable_skills(), + memory_recall_service=memory_recall_service, + system_prompt_refresher=build_agent_system_prompt, ) + if mcp_manager is not None: + add_change_listener = getattr(mcp_manager, "add_change_listener", None) + if add_change_listener is not None: + add_change_listener(on_mcp_changed) + + runtime = AgentRuntime( + agent_loop=agent_loop, + session_id=session_id, + tool_registry=tool_registry, + provider_manager=provider_manager, + command_registry=command_registry, + task_manager=task_manager, + memory_manager=memory_manager, + legacy_memory_manager=legacy_memory_manager, + mcp_manager=mcp_manager, + mcp_config_warnings=mcp_config_warnings, + _mcp_change_listeners=runtime_mcp_change_listeners, + _mcp_auth_tasks=mcp_auth_tasks, + _mcp_auth_flows=mcp_auth_flows, + ) + setup_complete = True + return runtime + finally: + if not setup_complete: + _cleanup_mcp_runtime_setup(mcp_manager, mcp_auth_tasks, mcp_auth_flows) + + +def _session_mcp_configs(configs: list[dict[str, Any]] | None) -> dict[str, dict[str, Any]] | None: + if not configs: + return None + normalized: dict[str, dict[str, Any]] = {} + for config in configs: + name = config.get("name") + if not isinstance(name, str) or not name: + continue + normalized[name] = {key: value for key, value in config.items() if key != "name"} + return normalized + + +def _mcp_connection_warnings(mcp_manager: Any) -> list[Any]: + from iac_code.i18n import _ + from iac_code.mcp.types import MCPConfigWarning, MCPConnectionState + from iac_code.utils.public_errors import sanitize_public_text + + list_connections = getattr(mcp_manager, "list_connections", None) + if list_connections is None: + return [] + warnings: list[Any] = [] + for record in list_connections(): + state = getattr(record, "state", None) + if state not in {MCPConnectionState.FAILED, MCPConnectionState.NEEDS_AUTH}: + continue + server_name = getattr(record, "name", None) + state_value = getattr(state, "value", str(state)) + error = sanitize_public_text(getattr(record, "error", None) or state_value) + code = "needs_auth" if state is MCPConnectionState.NEEDS_AUTH else "connection_failed" + if state is MCPConnectionState.NEEDS_AUTH: + message = _("MCP server {server!r} requires authentication: {error}").format( + server=server_name, + error=error, + ) + else: + message = _("MCP server {server!r} connection failed: {error}").format( + server=server_name, + error=error, + ) + warnings.append( + MCPConfigWarning( + source="mcp", + server_name=server_name, + code=code, + message=message, + ) + ) + for record in list_connections(): + server_name = getattr(record, "name", None) + capability_errors = getattr(record, "capability_errors", {}) or {} + for capability, error in capability_errors.items(): + sanitized = sanitize_public_text(error) + warnings.append( + MCPConfigWarning( + source="mcp", + server_name=server_name, + code="{}_failed".format(capability), + message=_("MCP server {server!r} {capability} discovery failed: {error}").format( + server=server_name, + capability=capability, + error=sanitized, + ), + ) + ) + return warnings + + +def _append_new_mcp_connection_warnings(existing: list[Any], mcp_manager: Any) -> list[Any]: + seen = {_mcp_warning_key(warning) for warning in existing} + added: list[Any] = [] + for warning in _mcp_connection_warnings(mcp_manager): + key = _mcp_warning_key(warning) + if key in seen: + continue + seen.add(key) + existing.append(warning) + added.append(warning) + return added + - agent_loop = AgentLoop( - provider_manager=provider_manager, - system_prompt=build_agent_system_prompt(), - tool_registry=tool_registry, - session_storage=session_storage, - session_id=session_id, - resume_messages=options.resume_messages, - max_turns=options.max_turns, - cwd=cwd, - permission_context=permission_context, - auto_trigger_skills=command_registry.get_model_invocable_skills(), - memory_recall_service=memory_recall_service, - system_prompt_refresher=build_agent_system_prompt, +def _mcp_warning_key(warning: Any) -> tuple[str, str, str]: + return ( + str(getattr(warning, "server_name", "")), + str(getattr(warning, "code", "")), + str(getattr(warning, "message", warning)), ) - return AgentRuntime( - agent_loop=agent_loop, - session_id=session_id, - tool_registry=tool_registry, - provider_manager=provider_manager, - command_registry=command_registry, - task_manager=task_manager, - memory_manager=memory_manager, - legacy_memory_manager=legacy_memory_manager, + +def _cleanup_mcp_runtime_setup(mcp_manager: Any, auth_tasks: set[asyncio.Task[Any]], auth_flows: set[Any]) -> None: + if auth_tasks or auth_flows: + with contextlib.suppress(Exception): + _run_async_blocking(_close_mcp_auth_flows(auth_tasks, auth_flows)) + if mcp_manager is not None: + disconnect_all = getattr(mcp_manager, "disconnect_all", None) + if callable(disconnect_all): + with contextlib.suppress(Exception): + _run_async_blocking(disconnect_all()) + + +def _run_async_blocking(coro): + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + + result: list[Any] = [] + error: list[BaseException] = [] + + def runner() -> None: + try: + result.append(asyncio.run(coro)) + except BaseException as exc: # pragma: no cover - exercised through caller failures. + error.append(exc) + + thread = threading.Thread(target=runner, daemon=True) + thread.start() + thread.join() + if error: + raise error[0] + return result[0] if result else None + + +def _sync_mcp_tool_registry( + tool_registry: Any, + mcp_manager: Any, + session_id: str, + registered_names: set[str], +) -> set[str]: + from iac_code.mcp.tools import ListMCPResourcesTool, MCPTool, ReadMCPResourceTool + + records = {record.public_name: record for record in mcp_manager.list_tools()} + for record in records.values(): + tool_registry.register(MCPTool(manager=mcp_manager, record=record, session_id=session_id)) + desired = set(records) + if mcp_manager.list_resources(): + if tool_registry.get("list_mcp_resources") is None: + tool_registry.register(ListMCPResourcesTool(manager=mcp_manager)) + if tool_registry.get("read_mcp_resource") is None: + tool_registry.register(ReadMCPResourceTool(manager=mcp_manager, session_id=session_id)) + desired.update({"list_mcp_resources", "read_mcp_resource"}) + for name in registered_names - desired: + tool_registry.unregister(name) + return desired + + +def _sync_mcp_auth_tools( + tool_registry: Any, + scoped_configs_by_name: dict[str, Any], + mcp_manager: Any, + registered_names: set[str], + *, + auth_tasks: set[asyncio.Task[Any]] | None = None, + auth_flows: set[Any] | None = None, + session_id: str | None = None, +) -> set[str]: + from iac_code.mcp.tools import MCPAuthenticateTool + + desired: dict[str, str] = {} + for server_name in getattr(mcp_manager, "needs_auth_servers", lambda: [])(): + desired[_mcp_auth_tool_name(server_name)] = server_name + + for name in registered_names - set(desired): + tool_registry.unregister(name) + for name, server_name in desired.items(): + tool_registry.register( + MCPAuthenticateTool( + server_name=server_name, + auth_flow=_mcp_auth_flow_factory( + scoped_configs_by_name, + mcp_manager, + auth_tasks=auth_tasks, + auth_flows=auth_flows, + session_id=session_id, + ), + ) + ) + return set(desired) + + +async def _sync_mcp_command_registry( + command_registry: Any, + mcp_manager: Any, + registered_names: set[str], +) -> tuple[set[str], list[Any]]: + from iac_code.mcp.prompts import register_mcp_prompt_commands + from iac_code.mcp.skills import register_mcp_skill_commands + + for name in registered_names: + command_registry.unregister(name) + warnings = register_mcp_prompt_commands(command_registry, mcp_manager) + warnings.extend(await register_mcp_skill_commands(command_registry, mcp_manager)) + current_names = _current_mcp_command_names(mcp_manager) + return {name for name in current_names if command_registry.get(name) is not None}, warnings + + +def _current_mcp_command_names(mcp_manager: Any) -> set[str]: + names = {record.public_name for record in mcp_manager.list_prompts()} + for resource in mcp_manager.list_resources(): + if resource.is_skill_resource: + names.add(resource.public_name or _mcp_resource_command_name(resource)) + return names + + +def _safe_mcp_identifier(value: str) -> str: + import re + + safe = re.sub(r"[^A-Za-z0-9_]+", "_", value).strip("_") + return safe or "mcp" + + +def _mcp_resource_command_name(resource: Any) -> str: + return "mcp__{}__{}".format( + _safe_mcp_identifier(resource.server_name), + _safe_mcp_identifier(resource.name or "skill"), ) + + +def _mcp_auth_tool_name(server_name: str) -> str: + return "mcp__{}__authenticate".format(_safe_mcp_identifier(server_name)) + + +def _mcp_auth_flow_factory( + scoped_configs_by_name: dict[str, Any], + mcp_manager: Any, + *, + auth_tasks: set[asyncio.Task[Any]] | None = None, + auth_flows: set[Any] | None = None, + session_id: str | None = None, +): + async def authenticate(server_name: str) -> str: + from iac_code.i18n import _ + from iac_code.mcp.oauth import oauth_scope_identity, start_oauth_loopback_flow + from iac_code.mcp.storage import MCPSecretStorage + + scoped = scoped_configs_by_name[server_name] + flow = await asyncio.to_thread( + start_oauth_loopback_flow, + scoped.config, + storage=MCPSecretStorage(), + scope=oauth_scope_identity( + scoped.scope, + source_path=getattr(scoped, "source_path", None), + session_id=session_id, + ), + ) + if auth_flows is not None: + auth_flows.add(flow) + task = asyncio.create_task(_complete_mcp_auth_flow(server_name, flow, mcp_manager, auth_flows=auth_flows)) + if auth_tasks is not None: + auth_tasks.add(task) + task.add_done_callback(auth_tasks.discard) + if flow.browser_opened: + return _("Opened MCP auth URL for {server!r}:\n{url}").format( + server=server_name, + url=flow.authorization_url, + ) + return _("Open this URL to authenticate MCP server {server!r}:\n{url}").format( + server=server_name, + url=flow.authorization_url, + ) + + return authenticate + + +async def _complete_mcp_auth_flow( + server_name: str, + flow: Any, + mcp_manager: Any, + *, + auth_flows: set[Any] | None = None, +) -> None: + try: + await asyncio.to_thread(flow.wait) + reconnect = getattr(mcp_manager, "reconnect", None) + if reconnect is not None: + await reconnect(server_name) + except Exception: + from loguru import logger + + logger.debug("MCP auth flow for '{}' did not complete.", server_name) + finally: + if auth_flows is not None: + auth_flows.discard(flow) + + +async def _close_mcp_auth_flows(auth_tasks: set[asyncio.Task[Any]], auth_flows: set[Any]) -> None: + for flow in list(auth_flows): + _close_mcp_auth_flow(flow) + for task in list(auth_tasks): + task.cancel() + if auth_tasks: + await asyncio.gather(*list(auth_tasks), return_exceptions=True) + auth_tasks.clear() + auth_flows.clear() + + +def _close_mcp_auth_flow(flow: Any) -> None: + callback = getattr(flow, "callback", None) + close = getattr(callback, "close", None) + if callable(close): + with contextlib.suppress(Exception): + close() diff --git a/src/iac_code/types/__init__.py b/src/iac_code/types/__init__.py index 31134b29..9d995587 100644 --- a/src/iac_code/types/__init__.py +++ b/src/iac_code/types/__init__.py @@ -5,6 +5,7 @@ AskUserQuestionEvent, CompactionEvent, ErrorEvent, + MCPProgressEvent, MessageEndEvent, MessageStartEvent, PermissionRequestEvent, @@ -24,6 +25,7 @@ "AskUserQuestionEvent", "CompactionEvent", "ErrorEvent", + "MCPProgressEvent", "MessageEndEvent", "MessageStartEvent", "PermissionMode", diff --git a/src/iac_code/types/stream_events.py b/src/iac_code/types/stream_events.py index db2879cd..46aa9299 100644 --- a/src/iac_code/types/stream_events.py +++ b/src/iac_code/types/stream_events.py @@ -231,6 +231,19 @@ class StackInstancesProgressEvent(ToolEmittedEvent): type: Literal["stack_instances_progress"] = "stack_instances_progress" +@dataclass +class MCPProgressEvent(ToolEmittedEvent): + """Real-time progress emitted by an MCP tool call.""" + + server_name: str + tool_name: str + progress: float | None = None + total: float | None = None + message: str | None = None + tool_use_id: str | None = None + type: Literal["mcp_progress"] = "mcp_progress" + + @dataclass class PlanStep: """A single step in an agent plan.""" @@ -314,6 +327,7 @@ class AskUserQuestionEvent(ToolEmittedEvent): ResourceObservedEvent, StackProgressEvent, StackInstancesProgressEvent, + MCPProgressEvent, PlanEvent, SubPipelineStreamEvent, DiagramEvent, diff --git a/src/iac_code/ui/renderer.py b/src/iac_code/ui/renderer.py index b034c927..d7fdb2fe 100644 --- a/src/iac_code/ui/renderer.py +++ b/src/iac_code/ui/renderer.py @@ -46,6 +46,7 @@ CompactionEvent, DiagramEvent, ErrorEvent, + MCPProgressEvent, MessageEndEvent, MessageStartEvent, PermissionRequestEvent, @@ -593,6 +594,18 @@ def _render_instances_progress(self, event: StackInstancesProgressEvent) -> Grou ) return Group(title, table) + def _render_mcp_progress(self, event: MCPProgressEvent) -> Text: + text = Text(" ⎿ ", style="dim") + parts = [_("MCP {server}:{tool}").format(server=event.server_name, tool=event.tool_name)] + if event.progress is not None and event.total is not None: + parts.append("{:g}/{:g}".format(event.progress, event.total)) + elif event.progress is not None: + parts.append("{:g}".format(event.progress)) + if event.message: + parts.append(event.message) + text.append(": ".join(parts), style="dim") + return text + def _render_diagram(self, event: DiagramEvent) -> Group: """Render an architecture diagram using termaid or fallback to code block.""" title = Text(f"▀ {event.candidate_name}", style="bold cyan") @@ -1315,6 +1328,22 @@ async def _rebuild_after_transcript(): _ensure_live() _update_live() + # ── MCP progress ─────────────────────────────── + elif isinstance(event, MCPProgressEvent): + rec = tool_records.get(event.tool_use_id or "") + if rec is None and event.tool_use_id is None: + matches = [ + item + for item in tool_records.values() + if item.tool_name.startswith("mcp__{}__".format(event.server_name)) and not item.done + ] + if len(matches) == 1: + rec = matches[0] + if rec: + rec.progress_renderable = self._render_mcp_progress(event) + _ensure_live() + _update_live() + # ── Architecture diagram ────────────────────── elif isinstance(event, DiagramEvent): await self._stop_refresh(refresh_task) diff --git a/src/iac_code/ui/repl.py b/src/iac_code/ui/repl.py index f5e7630c..79c7a5ac 100644 --- a/src/iac_code/ui/repl.py +++ b/src/iac_code/ui/repl.py @@ -218,8 +218,17 @@ def __init__( self.tool_registry.register(TaskGetTool(self._task_manager)) self.tool_registry.register(TaskStopTool(self._task_manager)) + self._mcp_manager = None + self.mcp_config_warnings = [] + self._mcp_warnings_printed_count = 0 + self._registered_mcp_tool_names: set[str] = set() + self._registered_mcp_command_names: set[str] = set() + self._registered_mcp_auth_tool_names: set[str] = set() + self._mcp_auth_tasks: set[asyncio.Task[Any]] = set() + self._mcp_auth_flows: set[Any] = set() cwd = os.getcwd() self.refresh_skills() + self._register_mcp_integrations() skill_commands = self.command_registry.get_model_invocable_skills() from iac_code.services.permissions.loader import load_permission_context @@ -321,6 +330,128 @@ def locked_skill_names(self): """Return skill names that cannot be disabled.""" return getattr(self, "_locked_skill_names", set()) + def _register_mcp_integrations(self) -> None: + from pathlib import Path + + from iac_code.mcp.config import load_mcp_configs, resolve_mcp_workspace_root + from iac_code.mcp.manager import MCPManager + from iac_code.services.agent_factory import ( + _append_new_mcp_connection_warnings, + _mcp_connection_warnings, + _run_async_blocking, + _sync_mcp_auth_tools, + _sync_mcp_command_registry, + _sync_mcp_tool_registry, + ) + + self._prompt_for_pending_project_mcp_servers() + mcp_workspace_root = resolve_mcp_workspace_root(Path(self._original_cwd)) + load_result = load_mcp_configs(cwd=Path(self._original_cwd), workspace_root=mcp_workspace_root) + self.mcp_config_warnings = list(load_result.warnings) + if not load_result.servers: + return + + self._mcp_manager = MCPManager(load_result.servers, roots=[mcp_workspace_root], session_id=self._session_id) + try: + _run_async_blocking(self._mcp_manager.connect_all()) + self.mcp_config_warnings.extend(_mcp_connection_warnings(self._mcp_manager)) + scoped_configs_by_name = {server.name: server for server in load_result.servers} + self._registered_mcp_auth_tool_names = _sync_mcp_auth_tools( + self.tool_registry, + scoped_configs_by_name, + self._mcp_manager, + self._registered_mcp_auth_tool_names, + auth_tasks=self._mcp_auth_tasks, + auth_flows=self._mcp_auth_flows, + session_id=self._session_id, + ) + self._registered_mcp_tool_names = _sync_mcp_tool_registry( + self.tool_registry, + self._mcp_manager, + self._session_id, + self._registered_mcp_tool_names, + ) + self._registered_mcp_command_names, command_warnings = _run_async_blocking( + _sync_mcp_command_registry(self.command_registry, self._mcp_manager, self._registered_mcp_command_names) + ) + self.mcp_config_warnings.extend(command_warnings) + + async def on_mcp_changed(server_name: str, capability: str) -> None: + _ = server_name + self._registered_mcp_auth_tool_names = _sync_mcp_auth_tools( + self.tool_registry, + scoped_configs_by_name, + self._mcp_manager, + self._registered_mcp_auth_tool_names, + auth_tasks=self._mcp_auth_tasks, + auth_flows=self._mcp_auth_flows, + session_id=self._session_id, + ) + if capability in {"tools", "resources", "auth"}: + self._registered_mcp_tool_names = _sync_mcp_tool_registry( + self.tool_registry, + self._mcp_manager, + self._session_id, + self._registered_mcp_tool_names, + ) + if capability in {"prompts", "resources"}: + self._registered_mcp_command_names, warnings = await _sync_mcp_command_registry( + self.command_registry, + self._mcp_manager, + self._registered_mcp_command_names, + ) + self.mcp_config_warnings.extend(warnings) + self._refresh_model_skill_listing() + _append_new_mcp_connection_warnings(self.mcp_config_warnings, self._mcp_manager) + self._print_mcp_config_warnings() + + add_change_listener = getattr(self._mcp_manager, "add_change_listener", None) + if add_change_listener is not None: + add_change_listener(on_mcp_changed) + except BaseException: + _run_async_blocking(self._close_mcp_manager()) + raise + + def _prompt_for_pending_project_mcp_servers(self) -> None: + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return + from pathlib import Path + + from iac_code.mcp.config import ( + approve_project_mcp_server, + load_mcp_configs, + reject_project_mcp_server, + resolve_mcp_workspace_root, + ) + from iac_code.mcp.types import MCPConfigScope + + root = resolve_mcp_workspace_root(Path(self._original_cwd)) + load_result = load_mcp_configs(cwd=Path(self._original_cwd), include_pending_project=True) + for server in load_result.servers: + if server.scope is not MCPConfigScope.PROJECT or server.approved or not server.source_path: + continue + answer = self.console.input( + _("Approve project MCP server {server!r} from {source}? [y/N] ").format( + server=server.name, + source=server.source_path, + ), + markup=False, + ) + if answer.strip().lower() in {"y", "yes"}: + approve_project_mcp_server( + server.name, + project_file=Path(server.source_path), + workspace_root=root, + config_signature=server.config.content_signature(), + ) + else: + reject_project_mcp_server( + server.name, + project_file=Path(server.source_path), + workspace_root=root, + config_signature=server.config.content_signature(), + ) + def refresh_cloud_tools(self) -> None: """Register cloud tools that are available with current cloud credentials.""" from iac_code.services.cloud_credentials import CloudCredentials @@ -354,7 +485,6 @@ def refresh_skills(self) -> None: """Rediscover skills and refresh enabled/disabled skill state.""" from iac_code.skills.bundled import init_bundled_skills from iac_code.skills.discovery import discover_all_skills - from iac_code.skills.listing import build_skill_listing from iac_code.skills.management import build_skill_management_state from iac_code.skills.settings import load_disabled_skills from iac_code.skills.skill_tool import SkillTool @@ -395,14 +525,43 @@ def refresh_skills(self) -> None: ), ) ) + if self._mcp_manager is not None: + from iac_code.services.agent_factory import _run_async_blocking, _sync_mcp_command_registry + + self._registered_mcp_command_names, warnings = _run_async_blocking( + _sync_mcp_command_registry( + self.command_registry, + self._mcp_manager, + self._registered_mcp_command_names, + ) + ) + self.mcp_config_warnings.extend(warnings) + + self._refresh_model_skill_listing() + + def _refresh_model_skill_listing(self) -> None: + from iac_code.skills.listing import build_skill_listing skill_commands = self.command_registry.get_model_invocable_skills() self._skill_listing = build_skill_listing(skill_commands) - if hasattr(self, "_agent_loop"): self._agent_loop.set_auto_trigger_skills(skill_commands) self._agent_loop.set_provider(self._provider_manager, system_prompt=self._build_current_system_prompt()) + def _print_mcp_config_warnings(self) -> None: + warnings = list(getattr(self, "mcp_config_warnings", []) or []) + start = getattr(self, "_mcp_warnings_printed_count", 0) + if start >= len(warnings): + return + for warning in warnings[start:]: + self.console.print( + "[yellow]{label}[/yellow] {message}".format( + label=_("MCP warning:"), + message=getattr(warning, "message", warning), + ) + ) + self._mcp_warnings_printed_count = len(warnings) + async def run(self, initial_prompt: str | None = None) -> None: """Run the REPL until the user exits. @@ -418,6 +577,7 @@ async def run(self, initial_prompt: str | None = None) -> None: self.console.print( render_welcome_banner(state.model, state.cwd, session_id=self._session_id, session_name=self._session_name) ) + self._print_mcp_config_warnings() if self._resume_messages: self._replay_resume_messages(self._resume_messages) self.console.print() # blank line before first new user turn @@ -552,6 +712,7 @@ def _on_sigint() -> None: # Terminal fd became invalid (e.g. after double Ctrl+C during response) break finally: + await self._close_mcp_manager() # Persist a tail-readable last-prompt entry so the /resume picker # can show what the user was last doing without parsing the whole # JSONL. Best-effort — failures must not block shutdown. @@ -581,13 +742,31 @@ def _on_sigint() -> None: async def run_once(self, prompt: str) -> None: """Process a single prompt and exit (non-interactive mode).""" - stripped_prompt = prompt.strip() - if stripped_prompt.startswith("!"): - await self._handle_shell_escape(stripped_prompt) - elif self.command_registry.is_command(prompt): - await self._handle_command(prompt) - else: - await self._handle_chat(prompt) + try: + stripped_prompt = prompt.strip() + if stripped_prompt.startswith("!"): + await self._handle_shell_escape(stripped_prompt) + elif self.command_registry.is_command(prompt): + await self._handle_command(prompt) + else: + await self._handle_chat(prompt) + finally: + await self._close_mcp_manager() + + async def _close_mcp_manager(self) -> None: + from iac_code.services.agent_factory import _close_mcp_auth_flows + + auth_tasks = getattr(self, "_mcp_auth_tasks", set()) + auth_flows = getattr(self, "_mcp_auth_flows", set()) + await _close_mcp_auth_flows(auth_tasks, auth_flows) + manager = getattr(self, "_mcp_manager", None) + if manager is None: + return + self._mcp_manager = None + try: + await manager.disconnect_all() + except Exception: + logger.debug("MCP manager close failed", exc_info=True) def _handle_startup_update(self) -> PendingUpdate | None: """Prompt for a cached update before the welcome banner.""" @@ -1128,6 +1307,7 @@ def _refresh_banner(self) -> None: self.console.print( render_welcome_banner(state.model, state.cwd, session_id=self._session_id, session_name=self._session_name) ) + self._print_mcp_config_warnings() messages = self._agent_loop.context_manager.get_messages() if not messages and not self._command_log and not self._streaming_error_log: return diff --git a/src/iac_code/ui/stream_accumulator.py b/src/iac_code/ui/stream_accumulator.py index 170fc44c..5b4aeadb 100644 --- a/src/iac_code/ui/stream_accumulator.py +++ b/src/iac_code/ui/stream_accumulator.py @@ -11,7 +11,9 @@ from dataclasses import dataclass from typing import Any +from iac_code.i18n import _ from iac_code.types.stream_events import ( + MCPProgressEvent, StreamEvent, SubAgentToolEvent, TextDeltaEvent, @@ -162,6 +164,20 @@ def process(self, event: StreamEvent) -> str: ) return "sub_agent" + if isinstance(event, MCPProgressEvent): + rec = self.tool_records.get(event.tool_use_id or "") + if rec is None and event.tool_use_id is None: + matches = [ + item + for item in self.tool_records.values() + if item.tool_name.startswith("mcp__{}__".format(event.server_name)) and not item.done + ] + if len(matches) == 1: + rec = matches[0] + if rec: + rec.progress_renderable = _format_mcp_progress(event) + return "tool_update" + return "none" def finalize_text(self) -> None: @@ -181,3 +197,14 @@ def _finalize_thinking(self) -> None: def completed_segments(self) -> list[RenderSegment]: """Return segments where all tools are done (safe to flush).""" return [s for s in self.segments if s.kind == "text" or (s.kind == "tool" and s.tool and s.tool.done)] + + +def _format_mcp_progress(event: MCPProgressEvent) -> str: + parts = [_("MCP {server}:{tool}").format(server=event.server_name, tool=event.tool_name)] + if event.progress is not None and event.total is not None: + parts.append("{:g}/{:g}".format(event.progress, event.total)) + elif event.progress is not None: + parts.append("{:g}".format(event.progress)) + if event.message: + parts.append(event.message) + return ": ".join(parts) diff --git a/tests/a2a/test_events.py b/tests/a2a/test_events.py index 622a1943..a8d1f7a0 100644 --- a/tests/a2a/test_events.py +++ b/tests/a2a/test_events.py @@ -6,6 +6,7 @@ from iac_code.a2a.exposure import A2AExposureType from iac_code.types.stream_events import ( ErrorEvent, + MCPProgressEvent, MessageEndEvent, PermissionRequestEvent, TextDeltaEvent, @@ -62,6 +63,35 @@ async def test_permission_request_is_denied_by_default_and_truncated() -> None: assert len(dumped["metadata"]["iac_code"]["permission"]["toolInput"]["cmd"]) == _METADATA_MAX_CHARS +@pytest.mark.asyncio +async def test_mcp_progress_publishes_tool_trace_metadata() -> None: + queue = FakeEventQueue() + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=MCPProgressEvent( + server_name="live", + tool_name="echo", + progress=1, + total=2, + message="halfway", + tool_use_id="tool-1", + ), + ) + + dumped = dump(queue.events[0]) + tool = dumped["metadata"]["iac_code"]["tool"] + assert tool["status"] == "progress" + assert tool["toolUseId"] == "tool-1" + assert tool["mcp"]["serverName"] == "live" + assert tool["mcp"]["toolName"] == "echo" + assert tool["mcp"]["progress"] == 1 + assert tool["mcp"]["total"] == 2 + assert tool["mcp"]["message"] == "halfway" + + @pytest.mark.asyncio async def test_permission_request_tool_input_redacts_secret_values() -> None: queue = FakeEventQueue() diff --git a/tests/a2a/test_executor.py b/tests/a2a/test_executor.py index ad9d5c13..01ba1bb0 100644 --- a/tests/a2a/test_executor.py +++ b/tests/a2a/test_executor.py @@ -65,6 +65,68 @@ async def test_executor_runs_prompt_and_finishes_input_required( assert "".join(record.output_text) == "hi" +@pytest.mark.asyncio +async def test_executor_publishes_mcp_warnings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + runtime = FakeRuntime( + agent_loop=FakeAgentLoop([TextDeltaEvent(text="hi")]), + session_id="session-1", + mcp_config_warnings=[ + SimpleNamespace(server_name="broken", code="connection_failed", message="MCP server failed") + ], + ) + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + + warning_events = [ + dump(event) + for event in queue.events + if isinstance(event, TaskStatusUpdateEvent) + and "mcpWarning" in dump(event).get("metadata", {}).get("iac_code", {}) + ] + assert len(warning_events) == 1 + assert warning_events[0]["status"]["message"]["parts"][0]["text"] == "MCP warning: MCP server failed" + assert warning_events[0]["metadata"]["iac_code"]["mcpWarning"]["code"] == "connection_failed" + + +@pytest.mark.asyncio +async def test_executor_closes_pipeline_runtime_when_replacing_with_normal_runtime( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_executor import A2APipelineRuntime + + old_agent_runtime = SimpleNamespace(closed=False) + + async def old_aclose() -> None: + old_agent_runtime.closed = True + + old_agent_runtime.aclose = old_aclose + old_pipeline_runtime = A2APipelineRuntime(agent_runtime=old_agent_runtime) + new_runtime = FakeRuntime(agent_loop=FakeAgentLoop([TextDeltaEvent(text="hi")]), session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: new_runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda sid: old_pipeline_runtime, + ) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + + await executor.execute( + FakeRequestContext(task_id="task-1", context_id="ctx-1", metadata={"iac_code": {"cwd": str(tmp_path)}}), + FakeEventQueue(), + ) + + assert old_agent_runtime.closed is True + assert store._contexts["ctx-1"].runtime is new_runtime + + @pytest.mark.asyncio async def test_executor_exposes_iac_code_session_id_in_status_metadata( monkeypatch: pytest.MonkeyPatch, tmp_path: Path @@ -852,6 +914,60 @@ async def deterministic_timeout(awaitable, timeout): assert "already working" in dumped["status"]["message"]["parts"][0]["text"] +@pytest.mark.asyncio +async def test_cancelling_during_context_lock_acquire_clears_active_task( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class ContendedLock: + def __init__(self) -> None: + self.acquire_requested = asyncio.Event() + self.acquire_waiter = asyncio.get_running_loop().create_future() + + def acquire(self) -> asyncio.Future[bool]: + self.acquire_requested.set() + return self.acquire_waiter + + def release(self) -> None: + raise AssertionError("release should not be called when acquire is cancelled") + + runtime = FakeRuntime(agent_loop=FakeAgentLoop([TextDeltaEvent(text="never")]), session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda sid: runtime, + ) + lock = ContendedLock() + ctx.lock = lock + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + + run_task = asyncio.create_task( + executor.execute( + FakeRequestContext( + task_id="task-lock-cancel", + context_id="ctx-1", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, + ) + ) + await lock.acquire_requested.wait() + task_record = await store.get_or_create_task(task_id="task-lock-cancel", context_id="ctx-1") + assert task_record.active_task is run_task + + run_task.cancel() + with pytest.raises(asyncio.CancelledError): + await run_task + + assert task_record.active_task is None + assert task_record.state == "canceled" + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_CANCELED" + + @pytest.mark.asyncio async def test_independent_contexts_execute_concurrently(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: prompts: list[str] = [] diff --git a/tests/a2a/test_pipeline_events.py b/tests/a2a/test_pipeline_events.py index e18058b1..401bc186 100644 --- a/tests/a2a/test_pipeline_events.py +++ b/tests/a2a/test_pipeline_events.py @@ -11,6 +11,7 @@ from iac_code.types.stream_events import ( CandidateDetailEvent, DiagramEvent, + MCPProgressEvent, PermissionRequestEvent, SubPipelineStreamEvent, TextDeltaEvent, @@ -58,6 +59,30 @@ def test_pipeline_started_has_stable_envelope() -> None: assert envelope["data"]["totalSteps"] == 4 +def test_mcp_progress_event_has_tool_progress_envelope() -> None: + translator = PipelineEventTranslator(_ctx()) + + envelope = translator.translate( + MCPProgressEvent( + server_name="live", + tool_name="echo", + progress=1, + total=2, + message="halfway", + tool_use_id="tool-1", + ) + )[0] + + assert envelope["eventType"] == "tool_progress" + assert envelope["scope"] == "pipeline" + assert envelope["data"]["toolUseId"] == "tool-1" + assert envelope["data"]["serverName"] == "live" + assert envelope["data"]["mcpToolName"] == "echo" + assert envelope["data"]["progress"] == 1 + assert envelope["data"]["total"] == 2 + assert envelope["data"]["message"] == "halfway" + + def test_pipeline_warning_translates_to_non_terminal_envelope() -> None: translator = PipelineEventTranslator(_ctx()) diff --git a/tests/a2a/test_pipeline_executor.py b/tests/a2a/test_pipeline_executor.py index 7b8d6d04..09fec86f 100644 --- a/tests/a2a/test_pipeline_executor.py +++ b/tests/a2a/test_pipeline_executor.py @@ -455,6 +455,44 @@ async def test_executor_runs_pipeline_mode(monkeypatch: pytest.MonkeyPatch, tmp_ assert "".join(record.output_text) == "pipeline output" +@pytest.mark.asyncio +async def test_pipeline_executor_publishes_mcp_warnings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + fake_pipeline = FakePipeline( + [ + PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=1717821601.0, + data={"total_steps": 1}, + ), + ], + session_dir=tmp_path / "sidecar", + ) + runtime = _fake_runtime() + runtime.mcp_config_warnings = [ + SimpleNamespace(server_name="broken", code="connection_failed", message="MCP server failed") + ] + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", lambda *args, **kwargs: fake_pipeline) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + + warning_events = [ + dump(event) + for event in queue.events + if isinstance(event, TaskStatusUpdateEvent) + and "mcpWarning" in dump(event).get("metadata", {}).get("iac_code", {}) + ] + assert len(warning_events) == 1 + assert warning_events[0]["status"]["message"]["parts"][0]["text"] == "MCP warning: MCP server failed" + assert warning_events[0]["metadata"]["iac_code"]["mcpWarning"]["serverName"] == "broken" + + @pytest.mark.asyncio async def test_executor_publishes_normal_handoff_ready_after_completed_pipeline( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/a2a/test_task_store.py b/tests/a2a/test_task_store.py index 2dd8ac60..821ae88a 100644 --- a/tests/a2a/test_task_store.py +++ b/tests/a2a/test_task_store.py @@ -1,4 +1,8 @@ import asyncio +import threading +import time +from collections.abc import Callable +from types import SimpleNamespace import pytest from a2a.auth.user import User @@ -53,6 +57,15 @@ def timestamp_with_nanos(seconds: int, nanos: int) -> Timestamp: return value +async def wait_until(condition: Callable[[], bool], *, timeout: float = 1.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if condition(): + return + await asyncio.sleep(0.01) + assert condition() + + def sdk_task( task_id: str, *, @@ -81,6 +94,103 @@ async def test_context_reuses_runtime_until_evicted() -> None: assert again.runtime == context.runtime +@pytest.mark.asyncio +async def test_context_runtime_factory_runs_outside_mutation_lock() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) + + def slow_runtime_factory(session_id: str): + time.sleep(0.2) + return f"rt-{session_id}" + + start = time.monotonic() + context_task = asyncio.create_task( + store.get_or_create_context(context_id="ctx-1", cwd="/tmp", runtime_factory=slow_runtime_factory) + ) + await asyncio.sleep(0.01) + + await asyncio.wait_for(store.save(sdk_task("task-while-runtime-starts")), timeout=0.1) + save_elapsed = time.monotonic() - start + + context = await context_task + assert context.runtime == f"rt-{context.session_id}" + assert save_elapsed < 0.15 + + +@pytest.mark.asyncio +async def test_cancelled_context_runtime_creation_does_not_poison_follow_up() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) + release = threading.Event() + call_index = 0 + runtimes = [] + + def runtime_factory(session_id: str): + nonlocal call_index + call_index += 1 + index = call_index + release.wait(timeout=2) + runtime = SimpleNamespace(index=index, session_id=session_id, closed=False) + + async def aclose() -> None: + runtime.closed = True + + runtime.aclose = aclose + runtimes.append(runtime) + return runtime + + first = asyncio.create_task( + store.get_or_create_context(context_id="ctx-cancel", cwd="/tmp", runtime_factory=runtime_factory) + ) + await asyncio.sleep(0.01) + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + + second = asyncio.create_task( + store.get_or_create_context(context_id="ctx-cancel", cwd="/tmp", runtime_factory=runtime_factory) + ) + release.set() + context = await asyncio.wait_for(second, timeout=1) + + def cancelled_runtime_closed() -> bool: + runtime = next((runtime for runtime in runtimes if runtime.index == 1), None) + return runtime is not None and runtime.closed is True + + await wait_until(cancelled_runtime_closed) + + assert context.runtime.index == 2 + assert context.runtime.closed is False + + +@pytest.mark.asyncio +async def test_stop_cleanup_loop_discards_pending_context_runtime() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) + release = threading.Event() + runtimes = [] + + def runtime_factory(session_id: str): + release.wait(timeout=2) + runtime = SimpleNamespace(session_id=session_id, close_count=0) + + async def aclose() -> None: + runtime.close_count += 1 + + runtime.aclose = aclose + runtimes.append(runtime) + return runtime + + pending = asyncio.create_task( + store.get_or_create_context(context_id="ctx-stop", cwd="/tmp", runtime_factory=runtime_factory) + ) + await asyncio.sleep(0.01) + + await store.stop_cleanup_loop() + release.set() + + with pytest.raises(ValueError, match="not found"): + await asyncio.wait_for(pending, timeout=1) + assert runtimes[0].close_count == 1 + + @pytest.mark.asyncio async def test_context_rejects_workspace_change() -> None: store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) @@ -101,6 +211,40 @@ async def test_expired_task_rejects_follow_up() -> None: await store.ensure_task_not_expired("task-1") +@pytest.mark.asyncio +async def test_cleanup_disconnects_mcp_manager_on_evicted_runtime() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=0, cleanup_interval_seconds=300) + manager = SimpleNamespace(disconnected=False) + + async def disconnect_all() -> None: + manager.disconnected = True + + manager.disconnect_all = disconnect_all + runtime = SimpleNamespace(mcp_manager=manager) + await store.get_or_create_context(context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: runtime) + + await store.cleanup_once(now_offset_seconds=1) + + assert manager.disconnected is True + + +@pytest.mark.asyncio +async def test_cleanup_disconnects_nested_pipeline_agent_runtime_mcp_manager() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=0, cleanup_interval_seconds=300) + manager = SimpleNamespace(disconnected=False) + + async def disconnect_all() -> None: + manager.disconnected = True + + manager.disconnect_all = disconnect_all + runtime = SimpleNamespace(agent_runtime=SimpleNamespace(mcp_manager=manager)) + await store.get_or_create_context(context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: runtime) + + await store.cleanup_once(now_offset_seconds=1) + + assert manager.disconnected is True + + @pytest.mark.asyncio async def test_cleanup_removes_expired_sdk_tasks_after_tombstone_window() -> None: store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=0, cleanup_interval_seconds=300) diff --git a/tests/acp/test_convert.py b/tests/acp/test_convert.py index 47c15662..072bbded 100644 --- a/tests/acp/test_convert.py +++ b/tests/acp/test_convert.py @@ -9,6 +9,7 @@ from iac_code.types.stream_events import ( CompactionEvent, ErrorEvent, + MCPProgressEvent, MessageEndEvent, MessageStartEvent, PermissionRequestEvent, @@ -300,6 +301,49 @@ def test_stack_instances_progress_event_returns_empty() -> None: assert updates == [] +def test_mcp_progress_event_to_tool_call_progress() -> None: + converter = ACPEventConverter(turn_id="turn-1") + + updates = converter.event_to_updates( + MCPProgressEvent( + server_name="live", + tool_name="echo", + progress=1, + total=2, + message="halfway", + tool_use_id="tool-1", + ) + ) + + assert len(updates) == 1 + update = updates[0] + assert isinstance(update, acp.schema.ToolCallProgress) + assert update.tool_call_id == "turn-1/tool-1" + assert update.status == "in_progress" + assert update.content[0].content.text == "MCP live:echo: 1/2: halfway" + + +def test_mcp_progress_event_redacts_public_message_text() -> None: + converter = ACPEventConverter(turn_id="turn-1") + + updates = converter.event_to_updates( + MCPProgressEvent( + server_name="live", + tool_name="echo", + progress=1, + total=2, + message="api_key=sk-live-secret path=/Users/alice/.iac-code/settings.yml", + tool_use_id="tool-1", + ) + ) + + text = updates[0].content[0].content.text + assert "sk-live-secret" not in text + assert "/Users/alice" not in text + assert "api_key=[REDACTED]" in text + assert "path=[PATH]" in text + + # --------------------------------------------------------------------------- # PermissionRequestEvent returns empty # --------------------------------------------------------------------------- @@ -851,6 +895,8 @@ def test_tool_kind_suffix_heuristics_for_dynamic_names() -> None: assert _tool_kind("aliyun_api") == "execute" assert _tool_kind("foo_api") == "execute" assert _tool_kind("bar_doc_search") == "fetch" + assert _tool_kind("mcp__ros__plan") == "execute" + assert _tool_kind("mcp__ros__search") == "fetch" def test_tool_kind_falls_back_to_other_for_unknown_tool() -> None: diff --git a/tests/acp/test_mcp.py b/tests/acp/test_mcp.py index 20dd2567..127fa6ac 100644 --- a/tests/acp/test_mcp.py +++ b/tests/acp/test_mcp.py @@ -175,6 +175,7 @@ class FakeRuntime: session_id = "test-session" agent_loop = FakeLoop() tool_registry = None + mcp_manager = None def _patch_runtime(monkeypatch): @@ -261,6 +262,23 @@ async def test_resume_session_with_mcp_servers(self, monkeypatch) -> None: assert session.mcp_configs[0]["name"] == "resumed-sse" assert session.mcp_configs[0]["type"] == "sse" + @pytest.mark.asyncio + async def test_fork_session_with_mcp_servers(self, monkeypatch) -> None: + _patch_runtime(monkeypatch) + server = ACPServer() + conn = FakeConn() + server.on_connect(conn) + await server.initialize(protocol_version=1, client_capabilities=acp.schema.ClientCapabilities()) + source = await server.new_session(cwd="/tmp") + + http = _make_http_server(name="forked-http") + forked = await server.fork_session(cwd="/tmp", session_id=source.session_id, mcp_servers=[http]) + + session = server.sessions[forked.session_id] + assert len(session.mcp_configs) == 1 + assert session.mcp_configs[0]["name"] == "forked-http" + assert session.mcp_configs[0]["type"] == "http" + # =========================================================================== # C. Capability declaration scenarios @@ -284,8 +302,8 @@ async def test_mcp_capabilities_reflect_actual(self) -> None: caps = resp.agent_capabilities.mcp_capabilities assert caps is not None - assert caps.http is False - assert caps.sse is False + assert caps.http is True + assert caps.sse is True # C-12: mcp_capabilities field exists and has correct format @pytest.mark.asyncio @@ -301,3 +319,31 @@ async def test_mcp_capabilities_is_valid_object(self) -> None: caps = resp.agent_capabilities.mcp_capabilities assert isinstance(caps, acp.schema.McpCapabilities) + + +class TestMcpSessionLifecycle: + """MCP manager lifecycle scenarios.""" + + @pytest.mark.asyncio + async def test_acp_session_close_disconnects_mcp_manager(self) -> None: + from iac_code.acp.session import ACPSession + + manager = FakeMCPManager() + session = ACPSession( + "mcp-session", + FakeLoop(), + FakeConn(), + mcp_manager=manager, + ) + + await session.close() + + assert manager.disconnected is True + + +class FakeMCPManager: + def __init__(self) -> None: + self.disconnected = False + + async def disconnect_all(self) -> None: + self.disconnected = True diff --git a/tests/acp/test_protocol_lifecycle.py b/tests/acp/test_protocol_lifecycle.py index d01bd0f9..d646d57c 100644 --- a/tests/acp/test_protocol_lifecycle.py +++ b/tests/acp/test_protocol_lifecycle.py @@ -174,7 +174,8 @@ async def test_initialize_advertises_only_implemented_initial_capabilities() -> assert response.agent_capabilities.load_session is True assert response.agent_capabilities.prompt_capabilities.embedded_context is True assert response.agent_capabilities.prompt_capabilities.image is False - assert response.agent_capabilities.mcp_capabilities.http is False + assert response.agent_capabilities.mcp_capabilities.http is True + assert response.agent_capabilities.mcp_capabilities.sse is True # auth_methods should declare supported provider env-var authentication assert len(response.auth_methods) > 0 for method in response.auth_methods: diff --git a/tests/acp/test_scenarios.py b/tests/acp/test_scenarios.py index 7fdeef47..c88fafc0 100644 --- a/tests/acp/test_scenarios.py +++ b/tests/acp/test_scenarios.py @@ -28,6 +28,7 @@ import json import logging import os +import threading import time from unittest.mock import MagicMock @@ -57,7 +58,12 @@ ToolUseBlock, create_recalled_memory_message, ) +from iac_code.commands.registry import CommandRegistry, PromptCommand +from iac_code.mcp.types import MCPConfigWarning +from iac_code.skills.frontmatter import SkillFrontmatter +from iac_code.skills.skill_definition import SkillDefinition from iac_code.tools.base import Tool, ToolContext, ToolRegistry, ToolResult +from iac_code.types.skill_source import SkillSource from iac_code.types.stream_events import ( MessageEndEvent, TextDeltaEvent, @@ -92,6 +98,11 @@ def load_messages(self, messages: list[Message]) -> None: def get_messages(self) -> list[Message]: return list(self._messages) + def add_raw_message(self, message: dict) -> Message: + converted = Message(role=message.get("role", "user"), content=[TextBlock(text=str(message.get("content", "")))]) + self._messages.append(converted) + return converted + class FakeLoop: def __init__(self) -> None: @@ -102,6 +113,21 @@ async def run_streaming(self, prompt: str): yield MessageEndEvent(stop_reason="stop", usage=Usage()) +class PromptCommandFakeLoop: + def __init__(self) -> None: + self.context_manager = FakeContextManager() + self.continued = False + + async def run_streaming(self, prompt: str): + yield TextDeltaEvent(text=f"unexpected run: {prompt}") + yield MessageEndEvent(stop_reason="stop", usage=Usage()) + + async def continue_streaming(self): + self.continued = True + yield TextDeltaEvent(text="MCP prompt executed") + yield MessageEndEvent(stop_reason="stop", usage=Usage()) + + class SlowFakeLoop: """A loop that blocks so we can test cancellation.""" @@ -177,10 +203,17 @@ async def run_streaming(self, prompt: str): class _P14FakeRuntime: - def __init__(self, session_id: str = "phase14-s1", tool_registry: ToolRegistry | None = None) -> None: + def __init__( + self, + session_id: str = "phase14-s1", + tool_registry: ToolRegistry | None = None, + command_registry: CommandRegistry | None = None, + ) -> None: self.session_id = session_id self.tool_registry = tool_registry or ToolRegistry() self.agent_loop = _P14FakeLoop(self.tool_registry) + self.command_registry = command_registry + self.mcp_config_warnings = [] def _patch_server_p14(monkeypatch: pytest.MonkeyPatch, runtime: _P14FakeRuntime | None = None) -> None: @@ -1187,6 +1220,26 @@ async def test_close_session_logs_info(self, _patch_runtime, caplog) -> None: assert any("closed" in rec.message.lower() for rec in caplog.records) + @pytest.mark.asyncio + async def test_close_session_closes_agent_runtime(self, caplog) -> None: + """B8 addendum: ACP session close releases runtime-owned MCP resources.""" + + class _Runtime: + def __init__(self) -> None: + self.closed = False + + async def aclose(self) -> None: + self.closed = True + + runtime = _Runtime() + session = ACPSession("runtime-close", _P24FakeLoop(), FakeConn(), runtime=runtime) + + with caplog.at_level(logging.INFO, logger="iac_code.acp.session"): + await session.close() + + assert runtime.closed is True + assert session.is_closed is True + @pytest.mark.asyncio async def test_auth_error_logs_warning(self, caplog) -> None: """B9: Authentication error during prompt emits WARNING log.""" @@ -1445,6 +1498,230 @@ async def test_pushed_commands_match_registry(monkeypatch: pytest.MonkeyPatch) - assert "memory-folder" not in pushed_names +@pytest.mark.asyncio +async def test_pushed_commands_include_mcp_prompt_commands(monkeypatch: pytest.MonkeyPatch) -> None: + """MCP prompt/skill commands registered on the runtime should be advertised to ACP clients.""" + registry = CommandRegistry() + registry.register( + PromptCommand( + name="mcp__ros__review", + description="Review ROS template", + skill=SkillDefinition( + name="mcp__ros__review", + description="Review ROS template", + frontmatter=SkillFrontmatter(description="Review ROS template"), + content="", + source=SkillSource.PROJECT, + file_path="mcp://ros/prompt/review", + content_length=0, + ), + source=SkillSource.PROJECT, + ) + ) + runtime = _P14FakeRuntime(command_registry=registry) + _patch_server_p14(monkeypatch, runtime=runtime) + monkeypatch.setattr("iac_code.acp.server.get_active_provider_key", lambda: "dashscope") + conn = FakeConn() + server = ACPServer() + server.conn = conn + await server.initialize(protocol_version=1, client_capabilities=acp.schema.ClientCapabilities()) + + await server.new_session(cwd="/tmp") + + cmd_updates = [u for _, u in conn.updates if isinstance(u, acp.schema.AvailableCommandsUpdate)] + pushed_names = {cmd.name for cmd in cmd_updates[0].available_commands} + assert "mcp__ros__review" in pushed_names + + +@pytest.mark.asyncio +async def test_new_session_pushes_mcp_config_warnings(monkeypatch: pytest.MonkeyPatch) -> None: + """MCP connection/config warnings should be visible to ACP clients.""" + runtime = _P14FakeRuntime() + runtime.mcp_config_warnings = [ + MCPConfigWarning( + source="mcp", + server_name="broken", + code="connection_failed", + message="MCP server 'broken' connection failed: boom", + ) + ] + _patch_server_p14(monkeypatch, runtime=runtime) + conn = FakeConn() + server = ACPServer() + server.conn = conn + await server.initialize(protocol_version=1, client_capabilities=acp.schema.ClientCapabilities()) + + await server.new_session(cwd="/tmp") + + warning_texts = [ + update.content.text for _, update in conn.updates if isinstance(update, acp.schema.AgentMessageChunk) + ] + assert any("MCP server 'broken' connection failed" in text for text in warning_texts) + assert any(isinstance(update, acp.schema.AvailableCommandsUpdate) for _, update in conn.updates) + + +@pytest.mark.asyncio +async def test_mcp_change_listener_pushes_acp_updates_on_server_loop() -> None: + """MCP worker-thread list_changed callbacks should marshal ACP updates to the server loop.""" + + class ThreadRecordingConn(FakeConn): + def __init__(self) -> None: + super().__init__() + self.thread_ids: list[int] = [] + + async def session_update(self, session_id: str, update: object, **kwargs: object) -> None: + self.thread_ids.append(threading.get_ident()) + await super().session_update(session_id, update, **kwargs) + + class Runtime(_P14FakeRuntime): + def __init__(self) -> None: + super().__init__(session_id="mcp-change-loop") + self.listener = None + + def add_mcp_change_listener(self, listener) -> None: + self.listener = listener + + main_thread = threading.get_ident() + runtime = Runtime() + conn = ThreadRecordingConn() + server = ACPServer() + server.conn = conn + session = server._create_acp_session_from_runtime(runtime=runtime, mcp_configs=[]) + server.sessions[session.id] = session + + def run_listener_in_worker_thread() -> None: + asyncio.run(runtime.listener("ros", "prompts")) + + await asyncio.to_thread(run_listener_in_worker_thread) + + assert conn.thread_ids + assert set(conn.thread_ids) == {main_thread} + + +@pytest.mark.asyncio +async def test_mcp_change_listener_does_not_push_after_session_close() -> None: + class Runtime(_P14FakeRuntime): + def __init__(self) -> None: + super().__init__(session_id="mcp-change-closed") + self.listener = None + + def add_mcp_change_listener(self, listener) -> None: + self.listener = listener + + runtime = Runtime() + conn = FakeConn() + server = ACPServer() + server.conn = conn + session = server._create_acp_session_from_runtime(runtime=runtime, mcp_configs=[]) + server.sessions[session.id] = session + server.sessions.pop(session.id) + + await runtime.listener("ros", "prompts") + + assert conn.updates == [] + + +@pytest.mark.asyncio +async def test_mcp_change_listener_pushes_new_mcp_warnings() -> None: + class Runtime(_P14FakeRuntime): + def __init__(self) -> None: + super().__init__(session_id="mcp-change-warning") + self.listener = None + + def add_mcp_change_listener(self, listener) -> None: + self.listener = listener + + runtime = Runtime() + conn = FakeConn() + server = ACPServer() + server.conn = conn + session = server._create_acp_session_from_runtime(runtime=runtime, mcp_configs=[]) + server.sessions[session.id] = session + conn.updates.clear() + runtime.mcp_config_warnings.append( + MCPConfigWarning( + source="mcp", + server_name="broken", + code="prompts_failed", + message="MCP server 'broken' tools discovery failed", + ) + ) + + await runtime.listener("broken", "tools") + + warning_texts = [ + update.content.text for _, update in conn.updates if isinstance(update, acp.schema.AgentMessageChunk) + ] + assert warning_texts == ["MCP warning: MCP server 'broken' tools discovery failed"] + + +@pytest.mark.asyncio +async def test_new_session_runtime_creation_does_not_block_event_loop(monkeypatch: pytest.MonkeyPatch) -> None: + """Slow runtime creation should not block unrelated ACP event-loop work.""" + + def create_runtime(options): + time.sleep(0.2) + return _P14FakeRuntime(session_id=options.session_id or "slow-runtime") + + monkeypatch.setattr("iac_code.acp.server.load_saved_model", lambda: "fake-model") + monkeypatch.setattr("iac_code.acp.server.create_agent_runtime", create_runtime) + monkeypatch.setattr("iac_code.acp.server.replace_bash_with_acp_terminal", lambda *args, **kwargs: set()) + conn = FakeConn() + server = ACPServer() + server.conn = conn + await server.initialize(protocol_version=1, client_capabilities=acp.schema.ClientCapabilities()) + + start = time.monotonic() + session_task = asyncio.create_task(server.new_session(cwd="/tmp")) + await asyncio.sleep(0.01) + tick_elapsed = time.monotonic() - start + response = await session_task + + assert response.session_id == "slow-runtime" + assert tick_elapsed < 0.15 + + +@pytest.mark.asyncio +async def test_acp_executes_advertised_mcp_prompt_command() -> None: + registry = CommandRegistry() + registry.register( + PromptCommand( + name="mcp__ros__review", + description="Review ROS template", + skill=SkillDefinition( + name="mcp__ros__review", + description="Review ROS template", + frontmatter=SkillFrontmatter(description="Review ROS template"), + content="Review the template with these args: {{args}}", + source=SkillSource.PROJECT, + file_path="mcp://ros/prompt/review", + content_length=0, + ), + source=SkillSource.PROJECT, + ) + ) + conn = FakeConn() + loop = PromptCommandFakeLoop() + session = ACPSession("mcp-session", loop, conn, command_registry=registry) + + response = await session.prompt( + [acp.schema.TextContentBlock(type="text", text="/mcp__ros__review template=manual-real")] + ) + + assert response.stop_reason == "end_turn" + assert loop.continued is True + assert any( + "mcp__ros__review" in block.text for message in loop.context_manager.get_messages() for block in message.content + ) + text_chunks = [ + update.content.text + for _, update in conn.updates + if getattr(update, "session_update", None) == "agent_message_chunk" + ] + assert "MCP prompt executed" in "".join(text_chunks) + assert "not supported over ACP" not in "".join(text_chunks) + + @pytest.mark.asyncio async def test_resume_session_pushes_available_commands(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: """resume_session should push available_commands_update to the client.""" diff --git a/tests/acp/test_server_coverage.py b/tests/acp/test_server_coverage.py index c0df755b..8ce1d1b3 100644 --- a/tests/acp/test_server_coverage.py +++ b/tests/acp/test_server_coverage.py @@ -450,6 +450,7 @@ def fake_registry(): conn = FakeConn() server = ACPServer() server.conn = conn + server.sessions["sess-1"] = ACPSession("sess-1", FakeLoop(), conn, command_registry=fake_registry()) await server._push_available_commands("sess-1") @@ -476,6 +477,7 @@ def empty_registry(): conn = FakeConn() server = ACPServer() server.conn = conn + server.sessions["sess-1"] = ACPSession("sess-1", FakeLoop(), conn, command_registry=empty_registry()) await server._push_available_commands("sess-1") diff --git a/tests/cli/test_headless.py b/tests/cli/test_headless.py index 21051c5f..317a7315 100644 --- a/tests/cli/test_headless.py +++ b/tests/cli/test_headless.py @@ -19,6 +19,7 @@ from iac_code.skills.skill_definition import SkillDefinition from iac_code.types.stream_events import ( ErrorEvent, + MCPProgressEvent, MessageEndEvent, PermissionRequestEvent, StackInstancesProgressEvent, @@ -76,6 +77,75 @@ async def test_text_output(): assert "Hello, world!" in output +@pytest.mark.asyncio +async def test_headless_emits_mcp_config_warnings_to_progress_stream(): + stdout = io.StringIO() + progress = io.StringIO() + runner = HeadlessRunner( + model="test-model", + output_format=OutputFormat.TEXT, + output_stream=stdout, + progress_stream=progress, + ) + events = [MessageEndEvent(stop_reason="end_turn", usage=Usage())] + + def create_loop_with_mcp_warning(): + runner._mcp_config_warnings = [SimpleNamespace(message="Project MCP server 'pending' is pending approval.")] + mock_loop = AsyncMock() + mock_loop.run_streaming = lambda prompt: _fake_stream(*events) + return mock_loop + + with patch.object(runner, "_create_agent_loop", side_effect=create_loop_with_mcp_warning): + exit_code = await runner.run("test prompt") + + assert exit_code == EXIT_OK + assert "MCP warning: Project MCP server 'pending' is pending approval." in progress.getvalue() + + +@pytest.mark.asyncio +async def test_headless_emits_dynamic_mcp_config_warnings_to_progress_stream(): + stdout = io.StringIO() + progress = io.StringIO() + runner = HeadlessRunner( + model="test-model", + output_format=OutputFormat.TEXT, + output_stream=stdout, + progress_stream=progress, + ) + warnings = [] + + async def stream_with_dynamic_warning(prompt: str): + warnings.append(SimpleNamespace(message="MCP server 'broken' prompts discovery failed.")) + yield MessageEndEvent(stop_reason="end_turn", usage=Usage()) + + def create_loop_with_warning_reference(): + runner._mcp_config_warnings = warnings + mock_loop = AsyncMock() + mock_loop.run_streaming = stream_with_dynamic_warning + return mock_loop + + with patch.object(runner, "_create_agent_loop", side_effect=create_loop_with_warning_reference): + exit_code = await runner.run("test prompt") + + assert exit_code == EXIT_OK + assert "MCP warning: MCP server 'broken' prompts discovery failed." in progress.getvalue() + + +@pytest.mark.asyncio +async def test_headless_closes_agent_runtime_after_run(monkeypatch): + runner = _make_runner() + events = [MessageEndEvent(stop_reason="end_turn", usage=Usage())] + mock_loop = AsyncMock() + mock_loop.run_streaming = lambda prompt: _fake_stream(*events) + runtime = SimpleNamespace(agent_loop=mock_loop, mcp_config_warnings=[], aclose=AsyncMock()) + monkeypatch.setattr("iac_code.services.agent_factory.create_agent_runtime", lambda options: runtime) + + exit_code = await runner.run("test prompt") + + assert exit_code == EXIT_OK + runtime.aclose.assert_awaited_once() + + @pytest.mark.asyncio async def test_json_output(): """TextDeltaEvent + MessageEndEvent produces valid JSON output and exit code 0.""" @@ -665,6 +735,14 @@ async def test_verbose_progress_writes_subagent_and_stack_events(): instances=[], elapsed_seconds=4, ), + MCPProgressEvent( + server_name="live", + tool_name="echo", + progress=1, + total=2, + message="halfway", + tool_use_id="tu_mcp", + ), MessageEndEvent(stop_reason="end_turn", usage=Usage()), ] @@ -681,6 +759,7 @@ async def test_verbose_progress_writes_subagent_and_stack_events(): assert "Child tool finished: read_file" in progress_output assert "Stack stack: CREATE_IN_PROGRESS (42.5%)" in progress_output assert "Stack group group: RUNNING (67%)" in progress_output + assert "MCP live:echo: 1/2: halfway" in progress_output class FakeToolRegistry: diff --git a/tests/cli/test_mcp_command.py b/tests/cli/test_mcp_command.py new file mode 100644 index 00000000..14ee7372 --- /dev/null +++ b/tests/cli/test_mcp_command.py @@ -0,0 +1,571 @@ +import json +import sys +import threading +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlencode, urlparse + +import yaml +from typer.testing import CliRunner + +from iac_code.cli.main import app +from iac_code.mcp.config import load_mcp_configs +from iac_code.mcp.oauth import oauth_storage_key +from iac_code.mcp.storage import MCPSecretStorage +from iac_code.mcp.types import MCPConfigScope, MCPServerConfig + + +def test_mcp_add_list_get_and_remove_local_server(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runner = CliRunner() + + result = runner.invoke( + app, + [ + "mcp", + "add", + "local-server", + "--command", + "uvx", + "--arg", + "server", + "--env", + "FOO=bar", + "--scope", + "local", + ], + ) + + assert result.exit_code == 0, result.output + local_settings = yaml.safe_load((tmp_path / ".iac-code" / "settings.local.yml").read_text(encoding="utf-8")) + assert local_settings["mcpServers"]["local-server"] == { + "command": "uvx", + "args": ["server"], + "env": {"FOO": "bar"}, + } + + listed = runner.invoke(app, ["mcp", "list"]) + assert listed.exit_code == 0 + assert "local-server" in listed.output + assert "stdio" in listed.output + + fetched = runner.invoke(app, ["mcp", "get", "local-server", "--scope", "local"]) + assert fetched.exit_code == 0 + assert '"command": "uvx"' in fetched.output + + removed = runner.invoke(app, ["mcp", "remove", "local-server", "--scope", "local"]) + assert removed.exit_code == 0 + assert "Removed" in removed.output + local_settings = yaml.safe_load((tmp_path / ".iac-code" / "settings.local.yml").read_text(encoding="utf-8")) + assert "local-server" not in local_settings["mcpServers"] + + +def test_mcp_add_json_validates_and_does_not_write_plaintext_client_secret(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runner = CliRunner() + + result = runner.invoke( + app, + [ + "mcp", + "add-json", + "remote", + json.dumps( + { + "type": "http", + "url": "https://example.com/mcp", + "oauth": {"clientId": "client-id", "clientSecretEnv": "MCP_SECRET"}, + } + ), + "--scope", + "user", + ], + ) + + assert result.exit_code == 0, result.output + settings = yaml.safe_load((tmp_path / "config" / "settings.yml").read_text(encoding="utf-8")) + assert settings["mcpServers"]["remote"]["oauth"] == { + "clientId": "client-id", + "clientSecretEnv": "MCP_SECRET", + } + + bad = runner.invoke( + app, + [ + "mcp", + "add-json", + "bad", + json.dumps( + { + "type": "http", + "url": "https://example.com/mcp", + "oauth": {"clientSecret": "super-secret"}, + } + ), + "--scope", + "user", + ], + ) + + assert bad.exit_code != 0 + assert "oauth.clientSecret" in bad.output + assert "super-secret" not in (tmp_path / "config" / "settings.yml").read_text(encoding="utf-8") + + +def test_mcp_add_json_rejects_unsupported_transport_without_writing(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runner = CliRunner() + + result = runner.invoke( + app, + [ + "mcp", + "add-json", + "websocket", + json.dumps({"type": "ws", "url": "wss://example.com/mcp"}), + "--scope", + "user", + ], + ) + + assert result.exit_code != 0 + assert "Unsupported MCP transport" in result.output + assert not (tmp_path / "config" / "settings.yml").exists() + + +def test_mcp_get_redacts_secret_like_values(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runner = CliRunner() + settings_path = tmp_path / "config" / "settings.yml" + settings_path.parent.mkdir(parents=True) + settings_path.write_text( + yaml.safe_dump( + { + "mcpServers": { + "remote": { + "type": "http", + "url": "https://example.com/mcp", + "headers": {"Authorization": "Bearer token", "X-Org": "org"}, + "env": {"API_TOKEN": "secret"}, + } + } + }, + sort_keys=True, + ), + encoding="utf-8", + ) + + fetched = runner.invoke(app, ["mcp", "get", "remote", "--scope", "user"]) + + assert fetched.exit_code == 0, fetched.output + assert "Bearer token" not in fetched.output + assert '"Authorization": "[redacted]"' in fetched.output + assert '"X-Org": "org"' in fetched.output + + +def test_mcp_add_rejects_plaintext_secret_headers_and_env(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runner = CliRunner() + + header_result = runner.invoke( + app, + [ + "mcp", + "add", + "remote", + "--type", + "http", + "--url", + "https://example.com/mcp", + "--header", + "Authorization=Bearer plain-token", + "--scope", + "user", + ], + ) + + assert header_result.exit_code != 0 + assert "environment variable reference" in header_result.output + assert not (tmp_path / "config" / "settings.yml").exists() + + env_result = runner.invoke( + app, + [ + "mcp", + "add", + "local", + "--command", + "uvx", + "--env", + "API_TOKEN=plain-token", + "--scope", + "user", + ], + ) + + assert env_result.exit_code != 0 + assert "environment variable reference" in env_result.output + assert not (tmp_path / "config" / "settings.yml").exists() + + json_result = runner.invoke( + app, + [ + "mcp", + "add-json", + "json-remote", + json.dumps( + { + "type": "http", + "url": "https://example.com/mcp", + "headers": {"Authorization": "Bearer plain-token"}, + } + ), + "--scope", + "user", + ], + ) + + assert json_result.exit_code != 0 + assert "environment variable reference" in json_result.output + assert not (tmp_path / "config" / "settings.yml").exists() + + +def test_mcp_add_stores_direct_client_secret_outside_config(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_MCP_DISABLE_KEYRING", "1") + runner = CliRunner() + + result = runner.invoke( + app, + [ + "mcp", + "add", + "remote", + "--type", + "http", + "--url", + "https://example.com/mcp", + "--client-id", + "client-id", + "--client-secret", + "super-secret", + "--scope", + "user", + ], + ) + + assert result.exit_code == 0, result.output + settings_text = (tmp_path / "config" / "settings.yml").read_text(encoding="utf-8") + assert "super-secret" not in settings_text + config = MCPServerConfig.from_mapping( + "remote", + yaml.safe_load(settings_text)["mcpServers"]["remote"], + ) + assert ( + MCPSecretStorage().get_secret(oauth_storage_key(config, "client_secret", scope=MCPConfigScope.USER)) + == "super-secret" + ) + + +def test_mcp_add_prompts_for_client_secret_when_option_has_no_value(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_MCP_DISABLE_KEYRING", "1") + runner = CliRunner() + + result = runner.invoke( + app, + [ + "mcp", + "add", + "remote", + "--type", + "http", + "--url", + "https://example.com/mcp", + "--client-id", + "client-id", + "--scope", + "user", + "--client-secret", + ], + input="prompted-secret\n", + ) + + assert result.exit_code == 0, result.output + settings_text = (tmp_path / "config" / "settings.yml").read_text(encoding="utf-8") + assert "prompted-secret" not in settings_text + config = MCPServerConfig.from_mapping( + "remote", + yaml.safe_load(settings_text)["mcpServers"]["remote"], + ) + assert ( + MCPSecretStorage().get_secret(oauth_storage_key(config, "client_secret", scope=MCPConfigScope.USER)) + == "prompted-secret" + ) + + +def test_mcp_add_defaults_to_user_scope_outside_git_project(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runner = CliRunner() + + result = runner.invoke(app, ["mcp", "add", "global-server", "--command", "uvx"]) + + assert result.exit_code == 0, result.output + assert (tmp_path / "config" / "settings.yml").exists() + assert not (tmp_path / ".iac-code" / "settings.local.yml").exists() + + +def test_mcp_add_defaults_to_local_scope_inside_project(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + (tmp_path / ".git").mkdir() + runner = CliRunner() + + result = runner.invoke(app, ["mcp", "add", "project-server", "--command", "uvx"]) + + assert result.exit_code == 0, result.output + assert (tmp_path / ".iac-code" / "settings.local.yml").exists() + assert not (tmp_path / "config" / "settings.yml").exists() + + +def test_mcp_add_warns_for_bare_npx_on_windows(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(sys, "platform", "win32") + runner = CliRunner() + + result = runner.invoke(app, ["mcp", "add", "node-server", "--command", "npx", "--scope", "user"]) + + assert result.exit_code == 0, result.output + assert "cmd /c npx" in result.output + + +def test_mcp_project_approval_commands(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + (tmp_path / ".mcp.json").write_text('{"mcpServers": {"pending": {"command": "uvx"}}}', encoding="utf-8") + runner = CliRunner() + + assert load_mcp_configs(cwd=tmp_path, workspace_root=tmp_path, env={}).servers == [] + + approved = runner.invoke(app, ["mcp", "approve", "pending"]) + assert approved.exit_code == 0, approved.output + assert [server.name for server in load_mcp_configs(cwd=tmp_path, workspace_root=tmp_path, env={}).servers] == [ + "pending" + ] + + rejected = runner.invoke(app, ["mcp", "reject", "pending"]) + assert rejected.exit_code == 0, rejected.output + assert load_mcp_configs(cwd=tmp_path, workspace_root=tmp_path, env={}).servers == [] + + reset = runner.invoke(app, ["mcp", "reset-project-choices"]) + assert reset.exit_code == 0, reset.output + assert "Reset" in reset.output + + +def test_mcp_project_approval_invalid_config_reports_clean_error(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + (tmp_path / ".mcp.json").write_text( + '{"mcpServers": {"project-live": {"command": ""}}}', + encoding="utf-8", + ) + runner = CliRunner() + + for command in ("approve", "reject"): + result = runner.invoke(app, ["mcp", command, "project-live"]) + assert result.exit_code != 0 + assert "requires a command string" in result.output + assert "Traceback" not in result.output + + +def test_mcp_project_approval_from_child_directory_uses_workspace_root(monkeypatch, tmp_path: Path) -> None: + root = tmp_path / "repo" + child = root / "nested" + child.mkdir(parents=True) + (root / ".git").mkdir() + (root / ".mcp.json").write_text('{"mcpServers": {"pending": {"command": "uvx"}}}', encoding="utf-8") + monkeypatch.chdir(child) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + runner = CliRunner() + + approved = runner.invoke(app, ["mcp", "approve", "pending"]) + + assert approved.exit_code == 0, approved.output + assert [server.name for server in load_mcp_configs(cwd=child, workspace_root=root, env={}).servers] == ["pending"] + + +def test_mcp_auth_exchanges_loopback_code_and_stores_token(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_MCP_DISABLE_KEYRING", "1") + oauth_server = FakeOAuthServer() + callback_port = _free_port() + runner = CliRunner() + runner.invoke( + app, + [ + "mcp", + "add", + "remote", + "--type", + "http", + "--url", + "https://resource.example/mcp", + "--client-id", + "client-id", + "--callback-port", + str(callback_port), + "--auth-server-metadata-url", + oauth_server.metadata_url, + "--scope", + "user", + ], + ) + + def open_browser(url: str) -> bool: + urllib.request.urlopen(url, timeout=5).read() + return True + + monkeypatch.setitem(sys.modules, "webbrowser", type("FakeWebBrowser", (), {"open": staticmethod(open_browser)})) + + result = runner.invoke(app, ["mcp", "auth", "remote", "--scope", "user"]) + + assert result.exit_code == 0, result.output + assert "access-token" not in result.output + settings = yaml.safe_load((tmp_path / "config" / "settings.yml").read_text(encoding="utf-8")) + config = MCPServerConfig.from_mapping("remote", settings["mcpServers"]["remote"]) + storage = MCPSecretStorage() + assert storage.get_secret(oauth_storage_key(config, "access_token", scope=MCPConfigScope.USER)) == "access-token" + assert storage.get_secret(oauth_storage_key(config, "refresh_token", scope=MCPConfigScope.USER)) == "refresh-token" + assert oauth_server.last_token_request["code"] == ["code-1"] + assert oauth_server.last_token_request["client_id"] == ["client-id"] + + +def test_mcp_reset_auth_clears_stored_tokens(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_MCP_DISABLE_KEYRING", "1") + runner = CliRunner() + result = runner.invoke( + app, + [ + "mcp", + "add", + "remote", + "--type", + "http", + "--url", + "https://example.com/mcp", + "--client-id", + "client-id", + "--scope", + "user", + ], + ) + assert result.exit_code == 0, result.output + config = MCPServerConfig.from_mapping( + "remote", + yaml.safe_load((tmp_path / "config" / "settings.yml").read_text(encoding="utf-8"))["mcpServers"]["remote"], + ) + storage = MCPSecretStorage() + storage.set_secret(oauth_storage_key(config, "access_token", scope=MCPConfigScope.USER), "access") + storage.set_secret(oauth_storage_key(config, "refresh_token", scope=MCPConfigScope.USER), "refresh") + + reset = runner.invoke(app, ["mcp", "reset-auth", "remote", "--scope", "user"]) + + assert reset.exit_code == 0, reset.output + assert storage.get_secret(oauth_storage_key(config, "access_token", scope=MCPConfigScope.USER)) is None + assert storage.get_secret(oauth_storage_key(config, "refresh_token", scope=MCPConfigScope.USER)) is None + + +class FakeOAuthServer: + def __init__(self) -> None: + self.last_token_request: dict[str, list[str]] = {} + self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler()) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + self.base_url = "http://127.0.0.1:{}".format(self._server.server_address[1]) + self.metadata_url = self.base_url + "/.well-known/oauth-authorization-server" + + def _handler(self): + outer = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path == "/.well-known/oauth-authorization-server": + self._json( + { + "authorization_endpoint": outer.base_url + "/authorize", + "token_endpoint": outer.base_url + "/token", + } + ) + return + if parsed.path == "/authorize": + query = parse_qs(parsed.query) + redirect_uri = query["redirect_uri"][0] + state = query["state"][0] + try: + callback_url = outer._callback_url(redirect_uri, state) + except ValueError: + self.send_error(400) + return + urllib.request.urlopen(callback_url, timeout=5).read() + self._json({"ok": True}) + return + self.send_error(404) + + def do_POST(self) -> None: + if self.path != "/token": + self.send_error(404) + return + length = int(self.headers.get("Content-Length", "0")) + outer.last_token_request = parse_qs(self.rfile.read(length).decode("utf-8")) + self._json( + { + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_in": 3600, + "token_type": "Bearer", + } + ) + + def log_message(self, format: str, *args: object) -> None: + return + + def _json(self, payload: dict[str, object]) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + return Handler + + def _callback_url(self, redirect_uri: str, state: str) -> str: + parsed = urlparse(redirect_uri) + if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost"}: + raise ValueError + if parsed.port is None or parsed.path != "/callback": + raise ValueError + query = urlencode({"code": "code-1", "state": state}) + return "http://127.0.0.1:{}{}?{}".format(parsed.port, parsed.path, query) + + +def _free_port() -> int: + server = ThreadingHTTPServer(("127.0.0.1", 0), BaseHTTPRequestHandler) + try: + return int(server.server_address[1]) + finally: + server.server_close() diff --git a/tests/mcp/test_client.py b/tests/mcp/test_client.py new file mode 100644 index 00000000..a803969c --- /dev/null +++ b/tests/mcp/test_client.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import asyncio +import threading +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from iac_code.mcp.client import MCPClientAdapter +from iac_code.mcp.errors import MCPConnectionError +from iac_code.mcp.oauth import oauth_storage_key +from iac_code.mcp.types import MCPConfigScope, MCPServerConfig + + +@pytest.mark.asyncio +async def test_stdio_adapter_initializes_sdk_session_with_roots(monkeypatch, tmp_path: Path) -> None: + seen: dict[str, Any] = {} + + @asynccontextmanager + async def fake_stdio_client(params, errlog=None): + seen["stdio_params"] = params + seen["errlog"] = errlog + yield object(), object() + + class FakeClientSession: + def __init__(self, read_stream, write_stream, list_roots_callback=None): + seen["read_stream"] = read_stream + seen["write_stream"] = write_stream + seen["list_roots_callback"] = list_roots_callback + self.closed = False + + async def __aenter__(self): + seen["session"] = self + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + self.closed = True + + async def initialize(self): + seen["initialized"] = True + + async def list_tools(self): + return [{"name": "plan"}] + + import mcp.client.session as session_module + import mcp.client.stdio as stdio_module + + monkeypatch.setattr(stdio_module, "stdio_client", fake_stdio_client) + monkeypatch.setattr(session_module, "ClientSession", FakeClientSession) + + adapter = MCPClientAdapter( + MCPServerConfig.from_mapping( + "local", + { + "command": "uvx", + "args": ["server"], + "env": {"API_KEY": "fake"}, + }, + ), + roots=[tmp_path / "repo"], + ) + + await adapter.connect() + + assert seen["stdio_params"].command == "uvx" + assert seen["stdio_params"].args == ["server"] + assert seen["stdio_params"].env["API_KEY"] == "fake" + assert "PATH" in seen["stdio_params"].env + assert seen["errlog"] is not None + assert seen["initialized"] is True + assert await adapter.list_tools() == [{"name": "plan"}] + roots = await seen["list_roots_callback"](None) + assert len(roots.roots) == 1 + assert str(roots.roots[0].uri).startswith("file://") + + await adapter.close() + assert seen["session"].closed is True + + +@pytest.mark.asyncio +async def test_stdio_adapter_does_not_inherit_secret_process_environment(monkeypatch) -> None: + seen: dict[str, Any] = {} + monkeypatch.setenv("IAC_CODE_API_KEY", "real-secret") + monkeypatch.setenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "cloud-secret") + monkeypatch.setenv("PATH", "/usr/bin") + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080") + monkeypatch.setenv("HTTP_PROXY", "http://user:pass@proxy.example:8080") + monkeypatch.setenv("SSL_CERT_FILE", "/tmp/cacert.pem") + + @asynccontextmanager + async def fake_stdio_client(params, errlog=None): + seen["stdio_params"] = params + yield object(), object() + + class FakeClientSession: + def __init__(self, read_stream, write_stream, list_roots_callback=None): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + + async def initialize(self): + return None + + import mcp.client.session as session_module + import mcp.client.stdio as stdio_module + + monkeypatch.setattr(stdio_module, "stdio_client", fake_stdio_client) + monkeypatch.setattr(session_module, "ClientSession", FakeClientSession) + + adapter = MCPClientAdapter( + MCPServerConfig.from_mapping( + "local", + { + "command": "uvx", + "env": {"API_TOKEN": "explicit-token"}, + }, + ), + ) + + await adapter.connect() + + env = seen["stdio_params"].env + assert env["PATH"] == "/usr/bin" + assert env["HTTPS_PROXY"] == "http://proxy.example:8080" + assert env["SSL_CERT_FILE"] == "/tmp/cacert.pem" + assert env["API_TOKEN"] == "explicit-token" + assert "HTTP_PROXY" not in env + assert "IAC_CODE_API_KEY" not in env + assert "ALIBABA_CLOUD_ACCESS_KEY_SECRET" not in env + + await adapter.close() + + +@pytest.mark.asyncio +async def test_timed_out_session_operation_does_not_stop_worker(monkeypatch) -> None: + release = threading.Event() + + @asynccontextmanager + async def fake_stdio_client(params, errlog=None): + yield object(), object() + + class FakeClientSession: + def __init__(self, read_stream, write_stream, list_roots_callback=None): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + + async def initialize(self): + return None + + async def list_resources(self): + await asyncio.to_thread(release.wait, 2) + return [{"uri": "resource://slow"}] + + async def list_tools(self): + return [{"name": "plan"}] + + import mcp.client.session as session_module + import mcp.client.stdio as stdio_module + + monkeypatch.setattr(stdio_module, "stdio_client", fake_stdio_client) + monkeypatch.setattr(session_module, "ClientSession", FakeClientSession) + + adapter = MCPClientAdapter(MCPServerConfig.from_mapping("local", {"command": "uvx"})) + await adapter.connect() + try: + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(adapter.list_resources(), timeout=0.01) + release.set() + await asyncio.sleep(0.05) + + assert await adapter.list_tools() == [{"name": "plan"}] + finally: + await adapter.close() + + +def test_stdio_adapter_operations_survive_event_loop_boundary(monkeypatch) -> None: + seen: dict[str, Any] = {} + + @asynccontextmanager + async def fake_stdio_client(params, errlog=None): + yield object(), object() + + class FakeClientSession: + def __init__(self, read_stream, write_stream, list_roots_callback=None): + self.closed = False + + async def __aenter__(self): + seen["session"] = self + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + self.closed = True + + async def initialize(self): + return None + + async def list_tools(self): + return [{"name": "plan"}] + + import mcp.client.session as session_module + import mcp.client.stdio as stdio_module + + monkeypatch.setattr(stdio_module, "stdio_client", fake_stdio_client) + monkeypatch.setattr(session_module, "ClientSession", FakeClientSession) + + adapter = MCPClientAdapter(MCPServerConfig.from_mapping("local", {"command": "uvx"})) + + asyncio.run(adapter.connect()) + try: + assert asyncio.run(asyncio.wait_for(adapter.list_tools(), timeout=1.0)) == [{"name": "plan"}] + finally: + asyncio.run(adapter.close()) + + assert seen["session"].closed is True + + +@pytest.mark.asyncio +async def test_adapter_forwards_list_changed_notifications(monkeypatch) -> None: + changed: list[str] = [] + seen: dict[str, Any] = {} + + @asynccontextmanager + async def fake_stdio_client(params, errlog=None): + yield object(), object() + + class FakeClientSession: + def __init__(self, read_stream, write_stream, list_roots_callback=None): + self.original_notifications = 0 + + async def __aenter__(self): + seen["session"] = self + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + + async def initialize(self): + return None + + async def _received_notification(self, notification): + self.original_notifications += 1 + + import mcp.client.session as session_module + import mcp.client.stdio as stdio_module + + monkeypatch.setattr(stdio_module, "stdio_client", fake_stdio_client) + monkeypatch.setattr(session_module, "ClientSession", FakeClientSession) + + adapter = MCPClientAdapter( + MCPServerConfig.from_mapping("local", {"command": "uvx"}), + list_changed_callback=lambda capability: changed.append(capability), + ) + await adapter.connect() + + await seen["session"]._received_notification( + SimpleNamespace(root=SimpleNamespace(method="notifications/tools/list_changed")) + ) + + assert seen["session"].original_notifications == 1 + assert changed == ["tools"] + + +@pytest.mark.asyncio +async def test_stdio_adapter_captures_bounded_stderr_on_connect_failure(monkeypatch) -> None: + @asynccontextmanager + async def fake_stdio_client(params, errlog=None): + assert errlog is not None + errlog.write("debug line\n") + errlog.write("fatal line\n") + yield object(), object() + + class FakeClientSession: + def __init__(self, read_stream, write_stream, list_roots_callback=None): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + + async def initialize(self): + raise RuntimeError("init failed") + + import mcp.client.session as session_module + import mcp.client.stdio as stdio_module + + monkeypatch.setattr(stdio_module, "stdio_client", fake_stdio_client) + monkeypatch.setattr(session_module, "ClientSession", FakeClientSession) + + adapter = MCPClientAdapter(MCPServerConfig.from_mapping("local", {"command": "uvx"})) + + with pytest.raises(MCPConnectionError, match="fatal line"): + await adapter.connect() + + +@pytest.mark.asyncio +async def test_stdio_adapter_close_cleans_up_worker_after_connect_timeout(monkeypatch) -> None: + initialized = threading.Event() + closed = threading.Event() + + @asynccontextmanager + async def fake_stdio_client(params, errlog=None): + yield object(), object() + + class FakeClientSession: + def __init__(self, read_stream, write_stream, list_roots_callback=None): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + closed.set() + + async def initialize(self): + initialized.set() + await asyncio.Event().wait() + + import mcp.client.session as session_module + import mcp.client.stdio as stdio_module + + monkeypatch.setattr(stdio_module, "stdio_client", fake_stdio_client) + monkeypatch.setattr(session_module, "ClientSession", FakeClientSession) + + adapter = MCPClientAdapter(MCPServerConfig.from_mapping("local", {"command": "uvx"})) + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(adapter.connect(), timeout=0.1) + + assert initialized.wait(timeout=1) + await adapter.close() + assert closed.wait(timeout=1) + + +@pytest.mark.asyncio +async def test_http_adapter_injects_stored_oauth_bearer_token(monkeypatch) -> None: + seen: dict[str, Any] = {} + config = MCPServerConfig.from_mapping( + "remote", + { + "type": "http", + "url": "https://example.com/mcp", + "headers": {"X-Org": "iac"}, + "oauth": {"clientId": "client-id"}, + }, + ) + storage = FakeSecretStorage() + storage.set_secret(oauth_storage_key(config, "access_token", scope=MCPConfigScope.USER), "stored-token") + + @asynccontextmanager + async def fake_streamablehttp_client(url, headers=None): + seen["url"] = url + seen["headers"] = headers + yield object(), object(), None + + class FakeClientSession: + def __init__(self, read_stream, write_stream, list_roots_callback=None): + self.closed = False + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + self.closed = True + + async def initialize(self): + return None + + import mcp.client.session as session_module + import mcp.client.streamable_http as http_module + + monkeypatch.setattr(http_module, "streamablehttp_client", fake_streamablehttp_client) + monkeypatch.setattr(session_module, "ClientSession", FakeClientSession) + + adapter = MCPClientAdapter(config, scope=MCPConfigScope.USER, secret_storage=storage) + await adapter.connect() + + assert seen["url"] == "https://example.com/mcp" + assert seen["headers"] == {"X-Org": "iac", "Authorization": "Bearer stored-token"} + + await adapter.close() + + +class FakeSecretStorage: + def __init__(self) -> None: + self._values: dict[str, str] = {} + + def set_secret(self, key: str, value: str) -> None: + self._values[key] = value + + def get_secret(self, key: str) -> str | None: + return self._values.get(key) + + def delete_secret(self, key: str) -> None: + self._values.pop(key, None) diff --git a/tests/mcp/test_config.py b/tests/mcp/test_config.py new file mode 100644 index 00000000..cf91aae6 --- /dev/null +++ b/tests/mcp/test_config.py @@ -0,0 +1,271 @@ +import json +import os +import stat +from pathlib import Path + +import pytest +import yaml + +from iac_code.mcp.config import approve_project_mcp_server, load_mcp_configs, write_mcp_server_config +from iac_code.mcp.types import MCPConfigError, MCPConfigScope + + +def test_load_mcp_configs_merges_sources_by_precedence_and_deduplicates(monkeypatch, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".iac-code").mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + + _write_yaml( + config_dir / "settings.yml", + { + "mcpServers": { + "shared": {"command": "user-cmd"}, + "same-user": {"command": "same", "args": ["server"]}, + } + }, + ) + project_file = repo / ".mcp.json" + _write_json( + project_file, + { + "mcpServers": { + "shared": {"command": "project-cmd"}, + "project-only": {"command": "${PROJECT_CMD:-project-server}"}, + } + }, + ) + approve_project_mcp_server("shared", project_file=project_file, workspace_root=repo) + approve_project_mcp_server("project-only", project_file=project_file, workspace_root=repo) + _write_yaml( + repo / ".iac-code" / "settings.local.yml", + { + "mcpServers": { + "shared": {"command": "local-cmd"}, + "same-local": {"command": "same", "args": ["server"]}, + } + }, + ) + + result = load_mcp_configs( + cwd=repo, + workspace_root=repo, + session_configs={ + "shared": {"command": "session-cmd"}, + "session-only": {"type": "http", "url": "https://example.com/mcp"}, + }, + env={}, + ) + + by_name = result.by_name() + assert by_name["shared"].scope is MCPConfigScope.SESSION + assert by_name["shared"].config.command == "session-cmd" + assert by_name["project-only"].config.command == "project-server" + assert by_name["same-local"].scope is MCPConfigScope.LOCAL + assert "same-user" not in by_name + assert any(warning.code == "duplicate_config" and "same-user" in warning.message for warning in result.warnings) + + +def test_project_discovery_searches_root_to_leaf_and_stops_at_workspace_root(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + workspace_root = tmp_path / "repo" + nested = workspace_root / "services" / "api" + nested.mkdir(parents=True) + outside_file = tmp_path / ".mcp.json" + root_file = workspace_root / ".mcp.json" + child_file = workspace_root / "services" / ".mcp.json" + + _write_json(outside_file, {"mcpServers": {"outside": {"command": "outside"}}}) + _write_json( + root_file, + {"mcpServers": {"shared": {"command": "root"}, "root-only": {"command": "root-only"}}}, + ) + _write_json( + child_file, + {"mcpServers": {"shared": {"command": "child"}, "child-only": {"command": "child-only"}}}, + ) + for name, path in [ + ("shared", root_file), + ("root-only", root_file), + ("shared", child_file), + ("child-only", child_file), + ]: + approve_project_mcp_server(name, project_file=path, workspace_root=workspace_root) + + result = load_mcp_configs(cwd=nested, workspace_root=workspace_root, env={}) + + by_name = result.by_name() + assert set(by_name) == {"shared", "root-only", "child-only"} + assert by_name["shared"].config.command == "child" + assert "outside" not in by_name + + +def test_pending_project_servers_are_reported_until_approved(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + repo = tmp_path / "repo" + repo.mkdir() + project_file = repo / ".mcp.json" + _write_json(project_file, {"mcpServers": {"pending": {"command": "uvx"}}}) + + result = load_mcp_configs(cwd=repo, workspace_root=repo, env={}) + + assert result.servers == [] + assert [pending.name for pending in result.pending] == ["pending"] + + approve_project_mcp_server("pending", project_file=project_file, workspace_root=repo) + approved = load_mcp_configs(cwd=repo, workspace_root=repo, env={}) + + assert [server.name for server in approved.servers] == ["pending"] + assert "approved" not in project_file.read_text(encoding="utf-8") + assert (tmp_path / "config" / "mcp" / "project-approvals.json").exists() + + +def test_project_approval_is_invalidated_when_config_changes(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + repo = tmp_path / "repo" + repo.mkdir() + project_file = repo / ".mcp.json" + _write_json(project_file, {"mcpServers": {"server": {"command": "uvx", "args": ["safe"]}}}) + approve_project_mcp_server("server", project_file=project_file, workspace_root=repo) + assert [server.name for server in load_mcp_configs(cwd=repo, workspace_root=repo, env={}).servers] == ["server"] + + _write_json(project_file, {"mcpServers": {"server": {"command": "uvx", "args": ["changed"]}}}) + changed = load_mcp_configs(cwd=repo, workspace_root=repo, env={}) + + assert changed.servers == [] + assert [pending.name for pending in changed.pending] == ["server"] + + +def test_content_signature_distinguishes_env_headers_and_oauth(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + _write_yaml( + tmp_path / "config" / "settings.yml", + { + "mcpServers": { + "first": {"command": "uvx", "env": {"TENANT": "a"}}, + "second": {"command": "uvx", "env": {"TENANT": "b"}}, + "remote-a": {"type": "http", "url": "https://example.com/mcp", "headers": {"X-Org": "a"}}, + "remote-b": {"type": "http", "url": "https://example.com/mcp", "headers": {"X-Org": "b"}}, + } + }, + ) + + result = load_mcp_configs(cwd=tmp_path, workspace_root=tmp_path, env={}) + + assert set(result.by_name()) == {"first", "second", "remote-a", "remote-b"} + + +def test_invalid_server_config_skips_only_that_server(monkeypatch, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + _write_yaml( + config_dir / "settings.yml", + { + "mcpServers": { + "good": {"command": "uvx"}, + "bad": {"type": "ws", "url": "wss://example.com"}, + } + }, + ) + + result = load_mcp_configs(cwd=tmp_path, workspace_root=tmp_path, env={}) + + assert [server.name for server in result.servers] == ["good"] + assert any(warning.server_name == "bad" and warning.code == "invalid_config" for warning in result.warnings) + + +def test_missing_env_reference_skips_server_instead_of_passing_placeholder(monkeypatch, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + _write_yaml( + config_dir / "settings.yml", + {"mcpServers": {"missing": {"command": "${MISSING_MCP_COMMAND}"}, "good": {"command": "uvx"}}}, + ) + + result = load_mcp_configs(cwd=tmp_path, workspace_root=tmp_path, env={}) + + assert [server.name for server in result.servers] == ["good"] + assert any(warning.server_name == "missing" and warning.code == "missing_env" for warning in result.warnings) + + +def test_write_mcp_server_config_targets_scope_files_and_validates_before_write(monkeypatch, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + + write_mcp_server_config("user-server", {"command": "uvx"}, scope=MCPConfigScope.USER, cwd=repo) + write_mcp_server_config("local-server", {"command": "python"}, scope=MCPConfigScope.LOCAL, cwd=repo) + write_mcp_server_config( + "project-server", + {"type": "sse", "url": "https://example.com/sse"}, + scope=MCPConfigScope.PROJECT, + cwd=repo, + ) + + assert _read_yaml(config_dir / "settings.yml")["mcpServers"]["user-server"]["command"] == "uvx" + assert _read_yaml(repo / ".iac-code" / "settings.local.yml")["mcpServers"]["local-server"]["command"] == "python" + assert json.loads((repo / ".mcp.json").read_text(encoding="utf-8"))["mcpServers"]["project-server"] == { + "type": "sse", + "url": "https://example.com/sse", + } + + with pytest.raises(MCPConfigError): + write_mcp_server_config("bad", {"type": "ws", "url": "wss://example.com"}, scope=MCPConfigScope.USER, cwd=repo) + assert "bad" not in _read_yaml(config_dir / "settings.yml")["mcpServers"] + + with pytest.raises(MCPConfigError): + write_mcp_server_config("bad name", {"command": "uvx"}, scope=MCPConfigScope.USER, cwd=repo) + + with pytest.raises(MCPConfigError): + write_mcp_server_config("mcp__reserved", {"command": "uvx"}, scope=MCPConfigScope.USER, cwd=repo) + + +def test_write_mcp_server_config_preserves_private_permissions(monkeypatch, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + + path = write_mcp_server_config("user-server", {"command": "uvx"}, scope=MCPConfigScope.USER, cwd=repo) + + assert path.exists() + assert path.parent.exists() + if os.name != "nt": + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + + +def test_session_injected_invalid_config_is_reported_without_blocking_valid_servers( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + + result = load_mcp_configs( + cwd=tmp_path, + workspace_root=tmp_path, + session_configs={ + "good": {"type": "http", "url": "https://example.com/mcp"}, + "bad": {"type": "ws", "url": "wss://example.com/mcp"}, + }, + env={}, + ) + + assert [server.name for server in result.servers] == ["good"] + assert any(warning.server_name == "bad" and warning.code == "invalid_config" for warning in result.warnings) + + +def _write_yaml(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(value), encoding="utf-8") + + +def _read_yaml(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") diff --git a/tests/mcp/test_e2e.py b/tests/mcp/test_e2e.py new file mode 100644 index 00000000..13c166da --- /dev/null +++ b/tests/mcp/test_e2e.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import socket +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + +import pytest + +from iac_code.commands.registry import CommandRegistry, PromptCommand +from iac_code.mcp.manager import MCPManager +from iac_code.mcp.oauth import oauth_storage_key +from iac_code.mcp.skills import register_mcp_skill_commands +from iac_code.mcp.storage import MCPSecretStorage +from iac_code.mcp.tools import MCPTool, ReadMCPResourceTool +from iac_code.mcp.types import MCPConfigScope, MCPConnectionState, MCPServerConfig, ScopedMCPServerConfig +from iac_code.skills.skill_definition import SkillContext +from iac_code.tools.base import ToolContext + + +@pytest.mark.asyncio +async def test_stdio_mcp_server_e2e_tools_resources_prompts_and_skills(tmp_path: Path) -> None: + script = _write_fastmcp_server(tmp_path) + manager = MCPManager( + [_scoped("stdio-e2e", {"command": sys.executable, "args": [str(script), "stdio"]})], + roots=[tmp_path], + ) + + await manager.connect_all() + try: + assert manager.connection_state("stdio-e2e") is MCPConnectionState.CONNECTED + assert [tool.public_name for tool in manager.list_tools()] == ["mcp__stdio_e2e__echo"] + + tool = MCPTool(manager=manager, record=manager.list_tools()[0], session_id="e2e-session") + tool_result = await tool.execute(tool_input={"text": "hello"}, context=ToolContext(tool_use_id="tool-1")) + assert tool_result.is_error is False + assert "echo:hello" in tool_result.content + + resource_tool = ReadMCPResourceTool(manager=manager, session_id="e2e-session") + resource_result = await resource_tool.execute( + tool_input={"server": "stdio-e2e", "uri": "resource://ros/template"}, + context=ToolContext(), + ) + assert "kind: ros" in resource_result.content + + assert [prompt.public_name for prompt in manager.list_prompts()] == ["mcp__stdio_e2e__review"] + + registry = CommandRegistry() + warnings = await register_mcp_skill_commands(registry, manager) + assert warnings == [] + command = registry.get("mcp__stdio_e2e__vpc") + assert isinstance(command, PromptCommand) + prompt = await command.skill.get_prompt("", SkillContext(cwd=str(tmp_path))) + assert "Remote VPC skill" in prompt + finally: + await manager.disconnect_all() + + +@pytest.mark.asyncio +async def test_default_sdk_clients_connect_with_bounded_concurrency_and_disconnect(tmp_path: Path) -> None: + script = _write_fastmcp_server(tmp_path) + manager = MCPManager( + [ + _scoped("stdio-one", {"command": sys.executable, "args": [str(script), "stdio"]}), + _scoped("stdio-two", {"command": sys.executable, "args": [str(script), "stdio"]}), + ], + roots=[tmp_path], + max_concurrent_connections=2, + ) + + await manager.connect_all() + try: + assert manager.connection_state("stdio-one") is MCPConnectionState.CONNECTED + assert manager.connection_state("stdio-two") is MCPConnectionState.CONNECTED + finally: + await manager.disconnect_all() + + +@pytest.mark.asyncio +async def test_mixed_stdio_and_http_servers_connect_concurrently(tmp_path: Path) -> None: + script = _write_fastmcp_server(tmp_path) + port = _free_port() + process = subprocess.Popen( + [sys.executable, str(script), "http", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + try: + _wait_for_port(port, process) + manager = MCPManager( + [ + _scoped("local-e2e", {"command": sys.executable, "args": [str(script), "stdio"]}), + _scoped("remote-e2e", {"type": "http", "url": f"http://127.0.0.1:{port}/mcp"}), + ], + roots=[tmp_path], + max_concurrent_connections=2, + ) + + await manager.connect_all() + + try: + assert manager.connection_state("local-e2e") is MCPConnectionState.CONNECTED + assert manager.connection_state("remote-e2e") is MCPConnectionState.CONNECTED + finally: + await manager.disconnect_all() + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("transport", "path"), + [ + ("http", "/mcp"), + ("sse", "/sse"), + ], +) +async def test_remote_mcp_server_e2e_http_and_sse(tmp_path: Path, transport: str, path: str) -> None: + script = _write_fastmcp_server(tmp_path) + port = _free_port() + process = subprocess.Popen( + [sys.executable, str(script), transport, str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + try: + _wait_for_port(port, process) + manager = MCPManager( + [_scoped("remote-e2e", {"type": transport, "url": f"http://127.0.0.1:{port}{path}"})], + roots=[tmp_path], + ) + await manager.connect_all() + assert manager.connection_state("remote-e2e") is MCPConnectionState.CONNECTED + result = await manager.call_tool("remote-e2e", "echo", {"text": transport}) + assert _first_text(result) == f"echo:{transport}" + await manager.disconnect_all() + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +@pytest.mark.asyncio +async def test_remote_mcp_server_e2e_oauth_bearer_token_connect(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_MCP_DISABLE_KEYRING", "1") + script = _write_auth_fastmcp_server(tmp_path) + port = _free_port() + process = subprocess.Popen( + [sys.executable, str(script), str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + try: + _wait_for_port(port, process) + scoped = _scoped( + "auth-remote", + { + "type": "http", + "url": f"http://127.0.0.1:{port}/mcp", + "oauth": {"clientId": "client-id"}, + }, + ) + storage = MCPSecretStorage() + storage.set_secret( + oauth_storage_key(scoped.config, "access_token", scope=MCPConfigScope.SESSION), + "access-token", + ) + manager = MCPManager([scoped], roots=[tmp_path]) + + await manager.connect_all() + + assert manager.connection_state("auth-remote") is MCPConnectionState.CONNECTED + result = await manager.call_tool("auth-remote", "echo", {"text": "oauth"}) + assert _first_text(result) == "echo:oauth" + await manager.disconnect_all() + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def _write_fastmcp_server(tmp_path: Path) -> Path: + script = tmp_path / "fastmcp_server.py" + script.write_text( + """ +from __future__ import annotations + +import sys +from mcp.server.fastmcp import FastMCP + +transport = sys.argv[1] +port = int(sys.argv[2]) if len(sys.argv) > 2 else 0 +mcp = FastMCP("iac-code-e2e", host="127.0.0.1", port=port) + +@mcp.tool() +def echo(text: str) -> str: + return "echo:" + text + +@mcp.resource("resource://ros/template", name="template", mime_type="text/plain") +def template() -> str: + return "kind: ros" + +@mcp.resource("skill://ros/vpc", name="vpc", mime_type="text/markdown") +def skill() -> str: + return "---\\ndescription: Remote VPC skill\\n---\\n# Remote VPC skill" + +@mcp.prompt() +def review(template: str = "demo") -> str: + return "Review " + template + +if transport == "stdio": + mcp.run("stdio") +elif transport == "http": + mcp.run("streamable-http") +elif transport == "sse": + mcp.run("sse") +else: + raise SystemExit("unknown transport") +""", + encoding="utf-8", + ) + return script + + +def _write_auth_fastmcp_server(tmp_path: Path) -> Path: + script = tmp_path / "auth_fastmcp_server.py" + script.write_text( + """ +from __future__ import annotations + +import sys +from mcp.server.auth.provider import AccessToken +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp import FastMCP + +port = int(sys.argv[1]) + +class Verifier: + async def verify_token(self, token: str): + if token != "access-token": + return None + return AccessToken(token=token, client_id="client-id", scopes=["mcp"]) + +mcp = FastMCP( + "iac-code-auth-e2e", + host="127.0.0.1", + port=port, + token_verifier=Verifier(), + auth=AuthSettings( + issuer_url=f"http://127.0.0.1:{port}", + resource_server_url=f"http://127.0.0.1:{port}", + required_scopes=["mcp"], + ), +) + +@mcp.tool() +def echo(text: str) -> str: + return "echo:" + text + +mcp.run("streamable-http") +""", + encoding="utf-8", + ) + return script + + +def _scoped(name: str, config: dict[str, Any]) -> ScopedMCPServerConfig: + return ScopedMCPServerConfig( + config=MCPServerConfig.from_mapping(name, config), + scope=MCPConfigScope.SESSION, + ) + + +def _first_text(result: Any) -> str: + content = getattr(result, "content", None) + if content is None and isinstance(result, dict): + content = result.get("content") + first = content[0] + if isinstance(first, dict): + return first["text"] + return getattr(first, "text") + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _wait_for_port(port: int, process: subprocess.Popen[str]) -> None: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if process.poll() is not None: + stderr = process.stderr.read() if process.stderr else "" + raise RuntimeError(f"MCP e2e server exited early: {stderr}") + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return + except OSError: + time.sleep(0.05) + stderr = process.stderr.read() if process.stderr else "" + raise TimeoutError(f"MCP e2e server did not listen on port {port}: {stderr}") diff --git a/tests/mcp/test_env_expansion.py b/tests/mcp/test_env_expansion.py new file mode 100644 index 00000000..27bad9fb --- /dev/null +++ b/tests/mcp/test_env_expansion.py @@ -0,0 +1,75 @@ +from iac_code.mcp.env_expansion import expand_env + + +def test_expand_env_replaces_variables_and_defaults_inside_strings() -> None: + expanded, warnings = expand_env( + { + "command": "${MCP_COMMAND}", + "args": ["--region", "${REGION:-cn-hangzhou}", "--label=${ORG}-${ENV:-dev}"], + }, + env={"MCP_COMMAND": "uvx", "ORG": "iac"}, + source="settings.yml", + server_name="ros", + ) + + assert expanded == { + "command": "uvx", + "args": ["--region", "cn-hangzhou", "--label=iac-dev"], + } + assert warnings == [] + + +def test_expand_env_walks_nested_lists_and_dicts_without_touching_non_strings() -> None: + expanded, warnings = expand_env( + { + "headers": { + "Authorization": "Bearer ${TOKEN}", + "X-Enabled": True, + }, + "oauth": { + "callbackPort": 3118, + "clientSecretEnv": "${SECRET_ENV_NAME}", + }, + }, + env={"TOKEN": "secret-token", "SECRET_ENV_NAME": "MCP_SECRET"}, + source=".mcp.json", + server_name="remote", + ) + + assert expanded == { + "headers": { + "Authorization": "Bearer secret-token", + "X-Enabled": True, + }, + "oauth": { + "callbackPort": 3118, + "clientSecretEnv": "MCP_SECRET", + }, + } + assert warnings == [] + + +def test_missing_variables_are_preserved_and_reported() -> None: + expanded, warnings = expand_env( + { + "env": { + "API_KEY": "${MISSING_API_KEY}", + "OPTIONAL": "${OPTIONAL:-fallback}", + }, + }, + env={}, + source="/repo/.mcp.json", + server_name="aliyun", + ) + + assert expanded == { + "env": { + "API_KEY": "${MISSING_API_KEY}", + "OPTIONAL": "fallback", + }, + } + assert len(warnings) == 1 + assert warnings[0].source == "/repo/.mcp.json" + assert warnings[0].server_name == "aliyun" + assert warnings[0].code == "missing_env" + assert "MISSING_API_KEY" in warnings[0].message diff --git a/tests/mcp/test_manager.py b/tests/mcp/test_manager.py new file mode 100644 index 00000000..efbd0816 --- /dev/null +++ b/tests/mcp/test_manager.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any +from unittest.mock import Mock + +import pytest + +from iac_code.mcp import manager as manager_module +from iac_code.mcp.errors import MCPConnectionError, MCPElicitationUnsupportedError, MCPNeedsAuthError +from iac_code.mcp.manager import MCPManager +from iac_code.mcp.types import MCPConfigScope, MCPConnectionState, MCPServerConfig, ScopedMCPServerConfig +from iac_code.services.agent_factory import _mcp_connection_warnings + + +@pytest.mark.asyncio +async def test_connect_all_discovers_tools_and_isolates_failed_servers() -> None: + good = _scoped("good", {"command": "uvx"}) + bad = _scoped("bad", {"command": "bad"}) + clients = { + "good": FakeClient(tools=[{"name": "plan", "description": "Plan", "inputSchema": {"type": "object"}}]), + "bad": FakeClient(fail_connect=True), + } + manager = MCPManager([good, bad], client_factory=lambda config: clients[config.name]) + + await manager.connect_all() + + assert manager.connection_state("good") is MCPConnectionState.CONNECTED + assert manager.connection_state("bad") is MCPConnectionState.FAILED + assert manager.connection("bad").error + assert [tool.public_name for tool in manager.list_tools()] == ["mcp__good__plan"] + assert manager.list_tools()[0].input_schema == {"type": "object"} + + +@pytest.mark.asyncio +async def test_connection_failure_logs_redacted_diagnostic(monkeypatch: pytest.MonkeyPatch) -> None: + scoped = _scoped("broken", {"command": "bad"}) + client = FakeClient( + connect_error=MCPConnectionError( + "failed at /Users/alice/.iac-code/settings.yml; Authorization: Bearer sk-live-secret; api_key=plain-secret" + ) + ) + manager = MCPManager([scoped], client_factory=lambda config: client) + warning = Mock() + monkeypatch.setattr(manager_module, "logger", Mock(warning=warning), raising=False) + + await manager.connect_all() + + assert manager.connection_state("broken") is MCPConnectionState.FAILED + assert warning.call_count == 1 + logged = " ".join(str(part) for call in warning.call_args_list for part in (*call.args, call.kwargs)) + assert "broken" in logged + assert "connection failed" in logged + assert "sk-live-secret" not in logged + assert "plain-secret" not in logged + assert "Authorization: Bearer" not in logged + + +@pytest.mark.asyncio +async def test_discovery_failure_for_optional_capability_does_not_fail_tools_only_server() -> None: + scoped = _scoped("tools-only", {"command": "uvx"}) + client = FakeClient( + tools=[{"name": "plan", "description": "Plan", "inputSchema": {"type": "object"}}], + fail_resources=True, + fail_prompts=True, + ) + manager = MCPManager([scoped], client_factory=lambda config: client) + + await manager.connect_all() + + assert manager.connection_state("tools-only") is MCPConnectionState.CONNECTED + assert [tool.public_name for tool in manager.list_tools()] == ["mcp__tools_only__plan"] + assert "resources" in manager.connection("tools-only").capability_errors + assert "prompts" in manager.connection("tools-only").capability_errors + + +@pytest.mark.asyncio +async def test_discovery_timeout_records_capability_warning_without_hanging() -> None: + scoped = _scoped("slow", {"command": "uvx"}) + client = FakeClient( + tools=[{"name": "plan", "description": "Plan", "inputSchema": {"type": "object"}}], + resources_delay=0.2, + ) + manager = MCPManager( + [scoped], + client_factory=lambda config: client, + connect_timeout_seconds=0.01, + ) + + await asyncio.wait_for(manager.connect_all(), timeout=1) + + assert manager.connection_state("slow") is MCPConnectionState.CONNECTED + assert [tool.public_name for tool in manager.list_tools()] == ["mcp__slow__plan"] + assert "resources" in manager.connection("slow").capability_errors + warnings = _mcp_connection_warnings(manager) + assert any(warning.code == "resources_failed" and warning.server_name == "slow" for warning in warnings) + + +@pytest.mark.asyncio +async def test_one_resource_discovery_failure_does_not_hide_other_server_resources() -> None: + failing = _scoped("failing", {"command": "uvx"}) + good = _scoped("good", {"command": "uvx"}) + clients = { + "failing": FakeClient(fail_resources=True), + "good": FakeClient(resources=[{"uri": "resource://good/template", "name": "template"}]), + } + manager = MCPManager([failing, good], client_factory=lambda config: clients[config.name]) + + await manager.connect_all() + + assert manager.connection_state("failing") is MCPConnectionState.CONNECTED + assert manager.connection_state("good") is MCPConnectionState.CONNECTED + assert [resource.uri for resource in manager.list_resources()] == ["resource://good/template"] + + +@pytest.mark.asyncio +async def test_handle_list_changed_refreshes_discovery_cache() -> None: + scoped = _scoped("ros", {"command": "uvx"}) + client = FakeClient(tools=[{"name": "first", "inputSchema": {"type": "object"}}]) + manager = MCPManager([scoped], client_factory=lambda config: client) + await manager.connect_all() + + client.tools = [{"name": "second", "description": "Second", "inputSchema": {"type": "object"}}] + await manager.handle_list_changed("ros", capability="tools") + + assert [tool.tool_name for tool in manager.list_tools()] == ["second"] + assert client.list_tools_calls == 2 + + +@pytest.mark.asyncio +async def test_handle_resources_list_changed_refreshes_resource_cache() -> None: + scoped = _scoped("ros", {"command": "uvx"}) + client = FakeClient(resources=[{"uri": "resource://first", "name": "first"}]) + manager = MCPManager([scoped], client_factory=lambda config: client) + await manager.connect_all() + + client.resources = [{"uri": "resource://second", "name": "second"}] + await manager.handle_list_changed("ros", capability="resources") + + assert [resource.uri for resource in manager.list_resources()] == ["resource://second"] + assert client.list_resources_calls == 2 + + +@pytest.mark.asyncio +async def test_handle_prompts_list_changed_refreshes_prompt_cache() -> None: + scoped = _scoped("ros", {"command": "uvx"}) + client = FakeClient(prompts=[{"name": "first", "description": "First"}]) + manager = MCPManager([scoped], client_factory=lambda config: client) + await manager.connect_all() + + client.prompts = [{"name": "second", "description": "Second"}] + await manager.handle_list_changed("ros", capability="prompts") + + assert [prompt.prompt_name for prompt in manager.list_prompts()] == ["second"] + assert client.list_prompts_calls == 2 + + +@pytest.mark.asyncio +async def test_manager_exposes_roots_and_rejects_elicitation(tmp_path: Path) -> None: + manager = MCPManager([], roots=[tmp_path / "repo", tmp_path / "shared"]) + + assert await manager.list_roots() == [ + (tmp_path / "repo").resolve().as_uri(), + (tmp_path / "shared").resolve().as_uri(), + ] + with pytest.raises(MCPElicitationUnsupportedError): + await manager.request_elicitation("server", {"message": "Need input"}) + + +@pytest.mark.asyncio +async def test_disconnect_all_closes_connected_clients() -> None: + scoped = _scoped("ros", {"command": "uvx"}) + client = FakeClient() + manager = MCPManager([scoped], client_factory=lambda config: client) + await manager.connect_all() + + await manager.disconnect_all() + + assert client.closed is True + assert manager.connection_state("ros") is MCPConnectionState.DISABLED + + +@pytest.mark.asyncio +async def test_disconnect_all_continues_after_client_close_failure() -> None: + first = _scoped("first", {"command": "uvx"}) + second = _scoped("second", {"command": "uvx"}) + clients = { + "first": FakeClient(close_error=RuntimeError("close failed")), + "second": FakeClient(), + } + manager = MCPManager([first, second], client_factory=lambda config: clients[config.name]) + await manager.connect_all() + + await manager.disconnect_all() + + assert clients["first"].closed is True + assert clients["second"].closed is True + assert manager.connection_state("first") is MCPConnectionState.DISABLED + assert manager.connection_state("second") is MCPConnectionState.DISABLED + + +@pytest.mark.asyncio +async def test_remote_reconnect_is_bounded() -> None: + scoped = _scoped("remote", {"type": "http", "url": "https://example.com/mcp"}) + attempts = 0 + + def factory(config): + nonlocal attempts + attempts += 1 + return FakeClient(fail_connect=True) + + manager = MCPManager([scoped], client_factory=factory, max_reconnect_attempts=1) + + await manager.connect_all() + await manager.reconnect_failed("remote") + await manager.reconnect_failed("remote") + + assert attempts == 2 + assert manager.connection("remote").retry_count == 1 + assert manager.connection_state("remote") is MCPConnectionState.FAILED + + +@pytest.mark.asyncio +async def test_successful_reconnect_refreshes_discovery_and_notifies_listeners() -> None: + scoped = _scoped("remote", {"type": "http", "url": "https://example.com/mcp"}) + clients = [ + FakeClient(fail_connect=True), + FakeClient( + tools=[{"name": "search", "inputSchema": {"type": "object"}}], + resources=[{"uri": "resource://template", "name": "template"}], + prompts=[{"name": "review"}], + ), + ] + notifications: list[tuple[str, str]] = [] + + def factory(config): + return clients.pop(0) + + manager = MCPManager([scoped], client_factory=factory, max_reconnect_attempts=1) + manager.add_change_listener(lambda server, capability: notifications.append((server, capability))) + + await manager.connect_all() + await manager.reconnect_failed("remote") + + assert manager.connection_state("remote") is MCPConnectionState.CONNECTED + assert [tool.tool_name for tool in manager.list_tools()] == ["search"] + assert [resource.uri for resource in manager.list_resources()] == ["resource://template"] + assert [prompt.prompt_name for prompt in manager.list_prompts()] == ["review"] + assert notifications == [ + ("remote", "tools"), + ("remote", "resources"), + ("remote", "prompts"), + ] + + +@pytest.mark.asyncio +async def test_remote_401_moves_connection_to_needs_auth() -> None: + scoped = _scoped("remote", {"type": "http", "url": "https://example.com/mcp", "oauth": {"clientId": "id"}}) + manager = MCPManager( + [scoped], + client_factory=lambda config: FakeClient(needs_auth=True), + ) + + await manager.connect_all() + + assert manager.connection_state("remote") is MCPConnectionState.NEEDS_AUTH + assert "authentication" in (manager.connection("remote").error or "") + + +@pytest.mark.asyncio +async def test_needs_auth_cache_skips_repeated_connect_attempt_until_reconnect() -> None: + scoped = _scoped("remote", {"type": "http", "url": "https://example.com/mcp", "oauth": {"clientId": "id"}}) + attempts = 0 + + def factory(config): + nonlocal attempts + attempts += 1 + return FakeClient(needs_auth=True) + + manager = MCPManager([scoped], client_factory=factory) + + await manager.connect_all() + await manager.connect_all() + + assert attempts == 1 + assert manager.connection_state("remote") is MCPConnectionState.NEEDS_AUTH + + await manager.reconnect("remote") + + assert attempts == 2 + + +@pytest.mark.asyncio +async def test_public_tool_names_are_made_unique_when_normalization_collides() -> None: + first = _scoped("a-b", {"command": "uvx"}) + second = _scoped("a_b", {"command": "uvx"}) + clients = { + "a-b": FakeClient(tools=[{"name": "search", "inputSchema": {"type": "object"}}]), + "a_b": FakeClient(tools=[{"name": "search", "inputSchema": {"type": "object"}}]), + } + manager = MCPManager([first, second], client_factory=lambda config: clients[config.name]) + + await manager.connect_all() + + public_names = [tool.public_name for tool in manager.list_tools()] + assert len(public_names) == 2 + assert len(set(public_names)) == 2 + assert all(name.startswith("mcp__a_b__search_") for name in public_names) + + +@pytest.mark.asyncio +async def test_public_prompt_names_are_made_unique_when_normalization_collides() -> None: + first = _scoped("a-b", {"command": "uvx"}) + second = _scoped("a_b", {"command": "uvx"}) + clients = { + "a-b": FakeClient(prompts=[{"name": "review"}]), + "a_b": FakeClient(prompts=[{"name": "review"}]), + } + manager = MCPManager([first, second], client_factory=lambda config: clients[config.name]) + + await manager.connect_all() + + public_names = [prompt.public_name for prompt in manager.list_prompts()] + assert len(public_names) == 2 + assert len(set(public_names)) == 2 + assert all(name.startswith("mcp__a_b__review_") for name in public_names) + + +@pytest.mark.asyncio +async def test_public_skill_resource_names_are_made_unique_when_normalization_collides() -> None: + first = _scoped("a-b", {"command": "uvx"}) + second = _scoped("a_b", {"command": "uvx"}) + clients = { + "a-b": FakeClient(resources=[{"uri": "skill://a-b/vpc", "name": "vpc"}]), + "a_b": FakeClient(resources=[{"uri": "skill://a_b/vpc", "name": "vpc"}]), + } + manager = MCPManager([first, second], client_factory=lambda config: clients[config.name]) + + await manager.connect_all() + + public_names = [resource.public_name for resource in manager.list_resources() if resource.is_skill_resource] + assert len(public_names) == 2 + assert len(set(public_names)) == 2 + assert all(name is not None and name.startswith("mcp__a_b__vpc_") for name in public_names) + + +@pytest.mark.asyncio +async def test_prompt_and_skill_resource_public_names_share_one_command_namespace() -> None: + scoped = _scoped("live", {"command": "uvx"}) + client = FakeClient( + prompts=[{"name": "review"}], + resources=[{"uri": "skill://live/review", "name": "review"}], + ) + manager = MCPManager([scoped], client_factory=lambda config: client) + + await manager.connect_all() + + prompt_names = [prompt.public_name for prompt in manager.list_prompts()] + skill_names = [resource.public_name for resource in manager.list_resources() if resource.is_skill_resource] + assert len(set(prompt_names + skill_names)) == 2 + assert all(name.startswith("mcp__live__review_") for name in prompt_names + skill_names if name is not None) + + +@pytest.mark.asyncio +async def test_connect_all_uses_bounded_concurrency() -> None: + running = 0 + peak = 0 + + def factory(config): + async def on_connect() -> None: + nonlocal running, peak + running += 1 + peak = max(peak, running) + await asyncio.sleep(0.02) + running -= 1 + + return FakeClient(tools=[{"name": "plan", "inputSchema": {"type": "object"}}], on_connect=on_connect) + + manager = MCPManager( + [_scoped("one", {"command": "uvx"}), _scoped("two", {"command": "uvx"}), _scoped("three", {"command": "uvx"})], + client_factory=factory, + max_concurrent_connections=2, + ) + + await manager.connect_all() + + assert peak == 2 + + +def _scoped(name: str, config: dict[str, Any]) -> ScopedMCPServerConfig: + return ScopedMCPServerConfig( + config=MCPServerConfig.from_mapping(name, config), + scope=MCPConfigScope.SESSION, + ) + + +class FakeClient: + def __init__( + self, + *, + tools: list[dict[str, Any]] | None = None, + resources: list[dict[str, Any]] | None = None, + prompts: list[dict[str, Any]] | None = None, + fail_connect: bool = False, + needs_auth: bool = False, + fail_resources: bool = False, + fail_prompts: bool = False, + resources_delay: float = 0, + close_error: Exception | None = None, + connect_error: Exception | None = None, + on_connect: Any = None, + ) -> None: + self.tools = tools or [] + self.resources = resources or [] + self.prompts = prompts or [] + self.fail_connect = fail_connect + self.needs_auth = needs_auth + self.fail_resources = fail_resources + self.fail_prompts = fail_prompts + self.resources_delay = resources_delay + self.close_error = close_error + self.connect_error = connect_error + self.on_connect = on_connect + self.closed = False + self.list_tools_calls = 0 + self.list_resources_calls = 0 + self.list_prompts_calls = 0 + + async def connect(self) -> None: + if self.on_connect is not None: + await self.on_connect() + if self.needs_auth: + raise MCPNeedsAuthError("authentication required") + if self.connect_error is not None: + raise self.connect_error + if self.fail_connect: + raise MCPConnectionError("connect failed") + + async def close(self) -> None: + self.closed = True + if self.close_error is not None: + raise self.close_error + + async def list_tools(self) -> list[dict[str, Any]]: + self.list_tools_calls += 1 + return self.tools + + async def call_tool(self, name: str, arguments: dict[str, Any] | None = None, **kwargs: Any) -> dict[str, Any]: + return {"content": [{"type": "text", "text": name}], "arguments": arguments or {}} + + async def list_resources(self) -> list[dict[str, Any]]: + self.list_resources_calls += 1 + if self.resources_delay: + await asyncio.sleep(self.resources_delay) + if self.fail_resources: + raise MCPConnectionError("resources unsupported") + return self.resources + + async def read_resource(self, uri: str) -> dict[str, Any]: + return {"contents": [{"uri": uri, "text": "resource"}]} + + async def list_prompts(self) -> list[dict[str, Any]]: + self.list_prompts_calls += 1 + if self.fail_prompts: + raise MCPConnectionError("prompts unsupported") + return self.prompts + + async def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> dict[str, Any]: + return {"description": name, "messages": [], "arguments": arguments or {}} diff --git a/tests/mcp/test_oauth.py b/tests/mcp/test_oauth.py new file mode 100644 index 00000000..2940f27d --- /dev/null +++ b/tests/mcp/test_oauth.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +import asyncio +import concurrent.futures +import threading +from urllib.parse import parse_qs, urlparse + +import pytest + +import iac_code.mcp.oauth as oauth_module +from iac_code.mcp.errors import MCPNeedsAuthError +from iac_code.mcp.oauth import ( + MCPNeedsAuthCache, + OAuthMetadata, + TokenRefreshCoordinator, + build_authorization_url, + build_oauth_discovery_urls, + clear_oauth_state, + get_oauth_access_token_async, + oauth_scope_identity, + oauth_storage_key, + refresh_oauth_access_token, +) +from iac_code.mcp.storage import MCPSecretStorage +from iac_code.mcp.types import MCPConfigScope, MCPServerConfig + + +def test_secret_storage_uses_keyring_first() -> None: + keyring = FakeKeyring() + storage = MCPSecretStorage(keyring_backend=keyring) + + storage.set_secret("token-key", "secret-token") + + assert keyring.values[("iac-code:mcp", "token-key")] == "secret-token" + assert storage.get_secret("token-key") == "secret-token" + storage.delete_secret("token-key") + assert storage.get_secret("token-key") is None + + +def test_secret_storage_falls_back_to_encrypted_file(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + storage = MCPSecretStorage(keyring_backend=FailingKeyring()) + + storage.set_secret("token-key", "secret-token") + + assert storage.get_secret("token-key") == "secret-token" + stored_bytes = (tmp_path / "config" / "mcp" / "secrets.json.enc").read_bytes() + assert b"secret-token" not in stored_bytes + storage.delete_secret("token-key") + assert storage.get_secret("token-key") is None + + +def test_oauth_discovery_url_order_prefers_configured_metadata() -> None: + config = MCPServerConfig.from_mapping( + "remote", + { + "type": "http", + "url": "https://example.com/path/mcp", + "oauth": { + "clientId": "client-id", + "authServerMetadataUrl": "https://auth.example/.well-known/oauth-authorization-server", + }, + }, + ) + + assert build_oauth_discovery_urls(config) == [ + "https://auth.example/.well-known/oauth-authorization-server", + "https://example.com/.well-known/oauth-protected-resource", + "https://example.com/.well-known/oauth-authorization-server", + "https://example.com/path/.well-known/oauth-authorization-server", + ] + + +def test_oauth_discovery_follows_protected_resource_authorization_servers() -> None: + config = MCPServerConfig.from_mapping( + "remote", + {"type": "http", "url": "https://resource.example/path/mcp", "oauth": {"clientId": "client-id"}}, + ) + + def get_json(url: str) -> dict[str, object]: + if url == "https://resource.example/.well-known/oauth-protected-resource": + return {"authorization_servers": ["https://auth.example"]} + if url == "https://auth.example/.well-known/oauth-authorization-server": + return { + "authorization_endpoint": "https://auth.example/authorize", + "token_endpoint": "https://auth.example/token", + "scopes_supported": ["mcp"], + } + raise RuntimeError(url) + + metadata = oauth_module.discover_oauth_metadata(config, http_get_json=get_json) + + assert metadata == OAuthMetadata( + authorization_endpoint="https://auth.example/authorize", + token_endpoint="https://auth.example/token", + scopes_supported=["mcp"], + ) + + +def test_oauth_discovery_uses_path_aware_legacy_fallback() -> None: + config = MCPServerConfig.from_mapping( + "remote", + {"type": "http", "url": "https://resource.example/mcp/v1", "oauth": {"clientId": "client-id"}}, + ) + + def get_json(url: str) -> dict[str, object]: + if url == "https://resource.example/mcp/.well-known/oauth-authorization-server": + return { + "authorization_endpoint": "https://resource.example/mcp/authorize", + "token_endpoint": "https://resource.example/mcp/token", + } + raise RuntimeError(url) + + metadata = oauth_module.discover_oauth_metadata(config, http_get_json=get_json) + + assert metadata.authorization_endpoint == "https://resource.example/mcp/authorize" + assert metadata.token_endpoint == "https://resource.example/mcp/token" + + +def test_build_authorization_url_includes_pkce_and_loopback_redirect() -> None: + config = MCPServerConfig.from_mapping( + "remote", + { + "type": "http", + "url": "https://example.com/mcp", + "oauth": {"clientId": "client-id"}, + }, + ) + + url = build_authorization_url( + config, + authorization_endpoint="https://auth.example/authorize", + redirect_uri="http://127.0.0.1:3118/callback", + state="state-1", + code_challenge="challenge-1", + scopes=["mcp"], + ) + + parsed = urlparse(url) + query = parse_qs(parsed.query) + assert parsed.scheme == "https" + assert parsed.netloc == "auth.example" + assert query["response_type"] == ["code"] + assert query["client_id"] == ["client-id"] + assert query["redirect_uri"] == ["http://127.0.0.1:3118/callback"] + assert query["state"] == ["state-1"] + assert query["code_challenge"] == ["challenge-1"] + assert query["code_challenge_method"] == ["S256"] + assert query["scope"] == ["mcp"] + + +def test_needs_auth_cache_expires_and_can_be_cleared() -> None: + now = 1000.0 + cache = MCPNeedsAuthCache(ttl_seconds=60, now=lambda: now) + + cache.mark("remote", "401") + assert cache.get("remote").reason == "401" + + now = 1061.0 + assert cache.get("remote") is None + + cache.mark("remote", "missing-token") + cache.clear("remote") + assert cache.get("remote") is None + + +@pytest.mark.asyncio +async def test_token_refresh_coordinator_deduplicates_concurrent_refreshes() -> None: + calls = 0 + coordinator = TokenRefreshCoordinator() + + async def refresh(): + nonlocal calls + calls += 1 + await asyncio.sleep(0.01) + return "new-token" + + first, second = await asyncio.gather(coordinator.refresh("remote", refresh), coordinator.refresh("remote", refresh)) + + assert first == "new-token" + assert second == "new-token" + assert calls == 1 + + +@pytest.mark.asyncio +async def test_token_refresh_waiter_cancellation_does_not_cancel_shared_refresh() -> None: + calls = 0 + release = asyncio.Event() + coordinator = TokenRefreshCoordinator() + + async def refresh(): + nonlocal calls + calls += 1 + await release.wait() + return "new-token" + + owner = asyncio.create_task(coordinator.refresh("remote", refresh)) + while calls == 0: + await asyncio.sleep(0) + + waiter = asyncio.create_task(coordinator.refresh("remote", refresh)) + await asyncio.sleep(0) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + + release.set() + + assert await owner == "new-token" + assert calls == 1 + + +def test_token_refresh_coordinator_deduplicates_across_event_loops() -> None: + calls = 0 + calls_lock = threading.Lock() + release = threading.Event() + coordinator = TokenRefreshCoordinator() + + async def refresh(): + nonlocal calls + with calls_lock: + calls += 1 + await asyncio.to_thread(release.wait, 2) + return "new-token" + + def run_refresh() -> str: + return asyncio.run(coordinator.refresh("remote", refresh)) + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(run_refresh) + second = executor.submit(run_refresh) + while True: + with calls_lock: + if calls: + break + release.wait(0.01) + release.set() + + assert first.result(timeout=1) == "new-token" + assert second.result(timeout=1) == "new-token" + assert calls == 1 + + +@pytest.mark.asyncio +async def test_get_oauth_access_token_async_refreshes_once_for_concurrent_callers(monkeypatch) -> None: + config = MCPServerConfig.from_mapping( + "remote", + { + "type": "http", + "url": "https://example.com/mcp", + "oauth": {"clientId": "client-id"}, + }, + ) + storage = MCPSecretStorage(keyring_backend=FakeKeyring()) + storage.set_secret(oauth_storage_key(config, "access_token", scope="user"), "old-token") + storage.set_secret(oauth_storage_key(config, "refresh_token", scope="user"), "refresh-token") + storage.set_secret(oauth_storage_key(config, "expires_at", scope="user"), "100") + calls = 0 + + monkeypatch.setattr( + oauth_module, + "discover_oauth_metadata", + lambda _config: OAuthMetadata( + authorization_endpoint="https://auth.example/authorize", + token_endpoint="https://auth.example/token", + scopes_supported=[], + ), + ) + + def post_token(url: str, data: dict[str, str]) -> dict[str, object]: + nonlocal calls + calls += 1 + return {"access_token": "new-token", "expires_in": 3600} + + monkeypatch.setattr(oauth_module, "_post_token", post_token) + coordinator = TokenRefreshCoordinator() + + first, second = await asyncio.gather( + get_oauth_access_token_async( + config, + storage=storage, + scope="user", + now=lambda: 200, + refresh_coordinator=coordinator, + ), + get_oauth_access_token_async( + config, + storage=storage, + scope="user", + now=lambda: 200, + refresh_coordinator=coordinator, + ), + ) + + assert first == "new-token" + assert second == "new-token" + assert calls == 1 + + +def test_oauth_storage_keys_are_isolated_by_scope() -> None: + config = MCPServerConfig.from_mapping("remote", {"type": "http", "url": "https://example.com/mcp"}) + + assert oauth_storage_key(config, "access_token", scope="user") != oauth_storage_key( + config, + "access_token", + scope="local", + ) + + +def test_oauth_storage_keys_are_isolated_by_scope_identity() -> None: + config = MCPServerConfig.from_mapping("remote", {"type": "http", "url": "https://example.com/mcp"}) + + assert oauth_storage_key(config, "access_token", scope="session:one") != oauth_storage_key( + config, + "access_token", + scope="session:two", + ) + assert oauth_storage_key(config, "access_token", scope="project:/repo/one/.mcp.json") != oauth_storage_key( + config, + "access_token", + scope="project:/repo/two/.mcp.json", + ) + + +def test_oauth_scope_identity_preserves_user_and_isolates_session_and_project() -> None: + assert oauth_scope_identity(MCPConfigScope.USER, session_id="one") is MCPConfigScope.USER + assert oauth_scope_identity(MCPConfigScope.SESSION, session_id="one") == "session:one" + assert oauth_scope_identity(MCPConfigScope.SESSION, session_id="two") == "session:two" + assert oauth_scope_identity(MCPConfigScope.PROJECT, source_path="/repo/.mcp.json") == "project:/repo/.mcp.json" + assert ( + oauth_scope_identity(MCPConfigScope.LOCAL, source_path="/repo/.iac-code/settings.local.yml") + == "local:/repo/.iac-code/settings.local.yml" + ) + + +def test_client_secret_env_is_resolved_only_for_token_requests(monkeypatch) -> None: + monkeypatch.setenv("MCP_CLIENT_SECRET", "env-secret") + config = MCPServerConfig.from_mapping( + "remote", + { + "type": "http", + "url": "https://example.com/mcp", + "oauth": {"clientId": "client-id", "clientSecretEnv": "MCP_CLIENT_SECRET"}, + }, + ) + captured: dict[str, str] = {} + + monkeypatch.setattr( + oauth_module, + "discover_oauth_metadata", + lambda config: oauth_module.OAuthMetadata( + authorization_endpoint="https://auth.example/authorize", + token_endpoint="https://auth.example/token", + scopes_supported=[], + ), + ) + + def post_token(url: str, data: dict[str, str]) -> dict[str, object]: + captured.update(data) + return {"access_token": "new-token", "expires_in": 3600} + + monkeypatch.setattr(oauth_module, "_post_token", post_token) + + assert ( + refresh_oauth_access_token( + config, + storage=MCPSecretStorage(keyring_backend=FailingKeyring()), + scope="user", + refresh_token="refresh-token", + ) + == "new-token" + ) + + assert captured["client_id"] == "client-id" + assert captured["client_secret"] == "env-secret" + assert captured["refresh_token"] == "refresh-token" + + +def test_invalid_grant_refresh_clears_tokens_and_requests_reauth(monkeypatch) -> None: + config = MCPServerConfig.from_mapping( + "remote", + {"type": "http", "url": "https://example.com/mcp", "oauth": {"clientId": "client-id"}}, + ) + storage = MCPSecretStorage(keyring_backend=FakeKeyring()) + storage.set_secret(oauth_storage_key(config, "access_token", scope="user"), "old-access") + storage.set_secret(oauth_storage_key(config, "refresh_token", scope="user"), "old-refresh") + storage.set_secret(oauth_storage_key(config, "expires_at", scope="user"), "100") + monkeypatch.setattr( + oauth_module, + "discover_oauth_metadata", + lambda config: oauth_module.OAuthMetadata( + authorization_endpoint="https://auth.example/authorize", + token_endpoint="https://auth.example/token", + scopes_supported=[], + ), + ) + + def post_token(url: str, data: dict[str, str]) -> dict[str, object]: + raise RuntimeError("invalid_grant: refresh token expired") + + monkeypatch.setattr(oauth_module, "_post_token", post_token) + + with pytest.raises(MCPNeedsAuthError, match="invalid_grant"): + refresh_oauth_access_token(config, storage=storage, scope="user", refresh_token="old-refresh") + + assert storage.get_secret(oauth_storage_key(config, "access_token", scope="user")) is None + assert storage.get_secret(oauth_storage_key(config, "refresh_token", scope="user")) is None + assert storage.get_secret(oauth_storage_key(config, "expires_at", scope="user")) is None + + +def test_clear_oauth_state_deletes_local_state_even_when_revocation_fails() -> None: + keyring = FakeKeyring() + storage = MCPSecretStorage(keyring_backend=keyring) + config = MCPServerConfig.from_mapping("remote", {"type": "http", "url": "https://example.com/mcp"}) + storage.set_secret(oauth_storage_key(config, "access_token", scope="user"), "access") + storage.set_secret(oauth_storage_key(config, "refresh_token", scope="user"), "refresh") + + def revoke(_token: str) -> None: + raise RuntimeError("revocation failed") + + clear_oauth_state(config, storage=storage, scope="user", revoke=revoke) + + assert storage.get_secret(oauth_storage_key(config, "access_token", scope="user")) is None + assert storage.get_secret(oauth_storage_key(config, "refresh_token", scope="user")) is None + + +class FakeKeyring: + def __init__(self) -> None: + self.values: dict[tuple[str, str], str] = {} + + def set_password(self, service_name: str, username: str, password: str) -> None: + self.values[(service_name, username)] = password + + def get_password(self, service_name: str, username: str) -> str | None: + return self.values.get((service_name, username)) + + def delete_password(self, service_name: str, username: str) -> None: + self.values.pop((service_name, username), None) + + +class FailingKeyring: + def set_password(self, service_name: str, username: str, password: str) -> None: + raise RuntimeError("keyring unavailable") + + def get_password(self, service_name: str, username: str) -> str | None: + raise RuntimeError("keyring unavailable") + + def delete_password(self, service_name: str, username: str) -> None: + raise RuntimeError("keyring unavailable") diff --git a/tests/mcp/test_output.py b/tests/mcp/test_output.py new file mode 100644 index 00000000..95729a78 --- /dev/null +++ b/tests/mcp/test_output.py @@ -0,0 +1,122 @@ +import base64 +from pathlib import Path + +from iac_code.mcp.output import convert_mcp_tool_result + + +def test_convert_mcp_result_includes_text_structured_content_and_meta(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + + result = convert_mcp_tool_result( + { + "content": [{"type": "text", "text": "created VPC template"}], + "structuredContent": {"template": {"ROSTemplateFormatVersion": "2015-09-01"}}, + "_meta": {"traceId": "trace-1"}, + }, + server_name="ros", + tool_name="generate_template", + session_id="session-1", + ) + + assert result.is_error is False + assert "created VPC template" in result.content + assert '"ROSTemplateFormatVersion": "2015-09-01"' in result.content + assert result.metadata == { + "mcp": { + "server_name": "ros", + "tool_name": "generate_template", + "is_error": False, + "meta": {"traceId": "trace-1"}, + "artifacts": [], + } + } + + +def test_convert_mcp_result_includes_resource_text_and_resource_links(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + + result = convert_mcp_tool_result( + { + "content": [ + { + "type": "resource", + "resource": { + "uri": "skill://ros/vpc", + "mimeType": "text/markdown", + "text": "# VPC\nUse vSwitches deliberately.", + }, + }, + { + "type": "resource_link", + "uri": "file:///tmp/template.yml", + "name": "template.yml", + "mimeType": "text/yaml", + }, + ] + }, + server_name="ros", + tool_name="read_context", + session_id="session-1", + ) + + assert "Resource from MCP server 'ros'" in result.content + assert "URI: skill://ros/vpc" in result.content + assert "# VPC" in result.content + assert "Resource link: template.yml" in result.content + assert "file:///tmp/template.yml" in result.content + assert "text/yaml" in result.content + + +def test_convert_mcp_result_stores_binary_content_without_exposing_base64(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + image_data = base64.b64encode(b"fake-png").decode("ascii") + blob_data = base64.b64encode(b"resource-bytes").decode("ascii") + + result = convert_mcp_tool_result( + { + "content": [ + {"type": "image", "data": image_data, "mimeType": "image/png"}, + { + "type": "resource", + "resource": { + "uri": "file:///tmp/archive.bin", + "mimeType": "application/octet-stream", + "blob": blob_data, + }, + }, + ] + }, + server_name="ros", + tool_name="render", + session_id="session-1", + ) + + assert image_data not in result.content + assert blob_data not in result.content + assert str(tmp_path) not in result.content + assert "Saved image/png artifact" in result.content + assert "Saved application/octet-stream artifact" in result.content + + artifacts = result.metadata["mcp"]["artifacts"] + assert len(artifacts) == 2 + artifact_paths = [Path(artifact["path"]) for artifact in artifacts] + artifact_root = tmp_path / "config" / "tool-results" / "session-1" / "mcp" + assert all(path.exists() for path in artifact_paths) + assert all(str(path).startswith(str(artifact_root)) for path in artifact_paths) + assert artifact_paths[0].read_bytes() == b"fake-png" + assert artifact_paths[1].read_bytes() == b"resource-bytes" + assert artifacts[1]["uri"] == "file:///tmp/archive.bin" + + +def test_convert_mcp_is_error_maps_to_tool_result_error(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + + result = convert_mcp_tool_result( + {"content": [{"type": "text", "text": "remote tool failed"}], "isError": True}, + server_name="ros", + tool_name="apply", + session_id="session-1", + ) + + assert result.is_error is True + assert "remote tool failed" in result.content diff --git a/tests/mcp/test_prompts.py b/tests/mcp/test_prompts.py new file mode 100644 index 00000000..61f6ffc0 --- /dev/null +++ b/tests/mcp/test_prompts.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import pytest +from mcp import types + +from iac_code.commands.registry import CommandRegistry, LocalCommand, PromptCommand +from iac_code.mcp.manager import MCPManager +from iac_code.mcp.prompts import _parse_prompt_args, register_mcp_prompt_commands +from iac_code.mcp.types import MCPConfigScope, MCPPromptRecord, MCPServerConfig, ScopedMCPServerConfig +from iac_code.skills.frontmatter import SkillFrontmatter +from iac_code.skills.skill_definition import SkillContext, SkillDefinition +from iac_code.types.skill_source import SkillSource + + +@pytest.mark.asyncio +async def test_register_mcp_prompt_command_validates_arguments_and_calls_manager() -> None: + registry = CommandRegistry() + manager = FakePromptManager() + + warnings = register_mcp_prompt_commands(registry, manager) + + assert warnings == [] + command = registry.get("mcp__ros__review") + assert isinstance(command, PromptCommand) + prompt = await command.skill.get_prompt('{"template": "vpc.yml"}', SkillContext(cwd="/repo")) + + assert manager.called_with == {"server_name": "ros", "prompt_name": "review", "arguments": {"template": "vpc.yml"}} + assert "Review vpc.yml" in prompt + + with pytest.raises(ValueError, match="Missing required MCP prompt argument"): + await command.skill.get_prompt("{}", SkillContext(cwd="/repo")) + + +def test_register_mcp_prompt_skips_non_prompt_command_conflicts() -> None: + registry = CommandRegistry() + registry.register(LocalCommand(name="mcp__ros__review", description="built in")) + + warnings = register_mcp_prompt_commands(registry, FakePromptManager()) + + assert registry.get("mcp__ros__review").description == "built in" + assert len(warnings) == 1 + assert warnings[0].code == "command_conflict" + + +def test_register_mcp_prompt_skips_local_prompt_command_conflicts() -> None: + registry = CommandRegistry() + registry.register(_local_prompt_command("mcp__ros__review", description="local skill")) + + warnings = register_mcp_prompt_commands(registry, FakePromptManager()) + + assert registry.get("mcp__ros__review").description == "local skill" + assert len(warnings) == 1 + assert warnings[0].code == "command_conflict" + + +def test_parse_prompt_args_preserves_windows_paths() -> None: + args = _parse_prompt_args(r"path=C:\Users\alice\file.txt script=C:\Program Files\node\server.js") + + assert args == { + "path": r"C:\Users\alice\file.txt", + "script": r"C:\Program Files\node\server.js", + } + + +def test_parse_prompt_args_preserves_quoted_backslashes_and_spaces() -> None: + args = _parse_prompt_args(r'path="C:\Program Files\node\server.js" region=cn-hangzhou') + + assert args["path"] == r"C:\Program Files\node\server.js" + assert args["region"] == "cn-hangzhou" + + +@pytest.mark.asyncio +async def test_register_mcp_prompt_command_supports_sdk_argument_lists() -> None: + registry = CommandRegistry() + manager = FakePromptManager( + arguments=[ + types.PromptArgument(name="template", description="Template path", required=True), + types.PromptArgument(name="region", description="Region", required=False), + ] + ) + + warnings = register_mcp_prompt_commands(registry, manager) + + assert warnings == [] + command = registry.get("mcp__ros__review") + assert isinstance(command, PromptCommand) + assert command.skill.frontmatter.argument_hint == "template=" + assert command.skill.frontmatter.arguments == ["template", "region"] + + with pytest.raises(ValueError, match="Missing required MCP prompt argument"): + await command.skill.get_prompt("region=cn-hangzhou", SkillContext(cwd="/repo")) + + +@pytest.mark.asyncio +async def test_manager_generated_prompt_name_matches_mcp_tool_naming() -> None: + scoped = ScopedMCPServerConfig( + config=MCPServerConfig.from_mapping("ros", {"command": "uvx"}), + scope=MCPConfigScope.SESSION, + ) + manager = MCPManager( + [scoped], + client_factory=lambda config: FakePromptClient(), + ) + + await manager.connect_all() + + assert [prompt.public_name for prompt in manager.list_prompts()] == ["mcp__ros__review"] + + +def _local_prompt_command(name: str, *, description: str) -> PromptCommand: + return PromptCommand( + name=name, + description=description, + skill=SkillDefinition( + name=name, + description=description, + frontmatter=SkillFrontmatter(description=description), + content="local content", + source=SkillSource.PROJECT, + file_path="/repo/.iac-code/skills/local/SKILL.md", + content_length=13, + ), + source=SkillSource.PROJECT, + ) + + +class FakePromptClient: + async def connect(self) -> None: + return None + + async def close(self) -> None: + return None + + async def list_tools(self) -> list[dict]: + return [] + + async def call_tool(self, name: str, arguments=None, **kwargs): + return {} + + async def list_resources(self) -> list[dict]: + return [] + + async def read_resource(self, uri: str): + return {"contents": []} + + async def list_prompts(self) -> list[dict]: + return [{"name": "review", "arguments": []}] + + async def get_prompt(self, name: str, arguments=None): + return {"messages": []} + + +class FakePromptManager: + def __init__(self, *, arguments=None) -> None: + self.called_with = {} + self.arguments = arguments if arguments is not None else {"template": {"required": True}} + + def list_prompts(self) -> list[MCPPromptRecord]: + return [ + MCPPromptRecord( + server_name="ros", + prompt_name="review", + public_name="mcp__ros__review", + description="Review template", + arguments=self.arguments, + ) + ] + + async def get_prompt(self, server_name: str, prompt_name: str, arguments: dict[str, str]): + self.called_with = { + "server_name": server_name, + "prompt_name": prompt_name, + "arguments": arguments, + } + return {"messages": [{"role": "user", "content": {"type": "text", "text": "Review " + arguments["template"]}}]} diff --git a/tests/mcp/test_runtime_integration.py b/tests/mcp/test_runtime_integration.py new file mode 100644 index 00000000..a0bcfa47 --- /dev/null +++ b/tests/mcp/test_runtime_integration.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +import asyncio +import threading +from pathlib import Path +from typing import Any + +import pytest + +from iac_code.mcp.types import MCPConnectionState, MCPPromptRecord, MCPResourceRecord, MCPToolRecord +from iac_code.services.agent_factory import ( + AgentFactoryOptions, + AgentRuntime, + _mcp_auth_flow_factory, + create_agent_runtime, +) +from iac_code.tools.base import ToolContext + + +def test_create_runtime_registers_discovered_mcp_tools_and_resource_tools(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeMCPManager( + tools=[ + MCPToolRecord( + server_name="ros", + tool_name="plan", + public_name="mcp__ros__plan", + input_schema={"type": "object"}, + ) + ], + resources=[MCPResourceRecord(server_name="ros", uri="skill://ros/vpc", name="vpc")], + prompts=[ + MCPPromptRecord( + server_name="ros", + prompt_name="review", + public_name="mcp__ros__review", + arguments={}, + ) + ], + ) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[{"name": "ros", "command": "uvx"}], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + assert manager.connected is True + assert runtime.mcp_manager is manager + assert runtime.tool_registry.get("mcp__ros__plan") is not None + assert runtime.tool_registry.get("list_mcp_resources") is not None + assert runtime.tool_registry.get("read_mcp_resource") is not None + assert runtime.command_registry.get("mcp__ros__review") is not None + assert runtime.command_registry.get("mcp__ros__vpc") is not None + + +@pytest.mark.asyncio +async def test_agent_runtime_aclose_disconnects_mcp_manager(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeMCPManager(tools=[MCPToolRecord(server_name="ros", tool_name="plan", public_name="mcp__ros__plan")]) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[{"name": "ros", "command": "uvx"}], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + await runtime.aclose() + + assert manager.disconnected is True + + +@pytest.mark.asyncio +async def test_agent_runtime_aclose_cancels_pending_mcp_auth_flow(monkeypatch) -> None: + closed = threading.Event() + + class FakeCallback: + def close(self) -> None: + closed.set() + + class FakeFlow: + authorization_url = "https://auth.example/authorize" + browser_opened = False + + def __init__(self) -> None: + self.callback = FakeCallback() + + def wait(self) -> None: + closed.wait(timeout=5) + raise RuntimeError("flow closed") + + class FakeManager: + disconnected = False + + async def reconnect(self, server_name: str) -> None: + raise AssertionError("closed auth flow should not reconnect") + + async def disconnect_all(self) -> None: + self.disconnected = True + + import iac_code.mcp.oauth as oauth_module + + fake_flow = FakeFlow() + monkeypatch.setattr(oauth_module, "start_oauth_loopback_flow", lambda *args, **kwargs: fake_flow) + auth_tasks: set[asyncio.Task[Any]] = set() + auth_flows: set[Any] = set() + manager = FakeManager() + auth_flow = _mcp_auth_flow_factory( + {"live": type("Scoped", (), {"config": object(), "scope": "user"})()}, + manager, + auth_tasks=auth_tasks, + auth_flows=auth_flows, + ) + + message = await auth_flow("live") + assert "https://auth.example/authorize" in message + assert fake_flow in auth_flows + assert len(auth_tasks) == 1 + + runtime = AgentRuntime( + agent_loop=object(), + session_id="session-1", + tool_registry=object(), + provider_manager=object(), + command_registry=object(), + task_manager=object(), + memory_manager=object(), + legacy_memory_manager=object(), + mcp_manager=manager, + _mcp_auth_tasks=auth_tasks, + _mcp_auth_flows=auth_flows, + ) + + await runtime.aclose() + + assert closed.is_set() + assert not auth_tasks + assert not auth_flows + assert manager.disconnected is True + + +@pytest.mark.asyncio +async def test_runtime_mcp_list_changed_refreshes_registered_tools(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeMCPManager( + tools=[ + MCPToolRecord( + server_name="ros", + tool_name="plan", + public_name="mcp__ros__plan", + input_schema={"type": "object"}, + ) + ] + ) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[{"name": "ros", "command": "uvx"}], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + assert runtime.tool_registry.get("mcp__ros__plan") is not None + manager._tools = [ + MCPToolRecord( + server_name="ros", + tool_name="apply", + public_name="mcp__ros__apply", + input_schema={"type": "object"}, + ) + ] + + await manager.listeners[0]("ros", "tools") + + assert runtime.tool_registry.get("mcp__ros__plan") is None + assert runtime.tool_registry.get("mcp__ros__apply") is not None + + +@pytest.mark.asyncio +async def test_runtime_mcp_resources_changed_unregisters_resource_tools_when_empty(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeMCPManager( + resources=[MCPResourceRecord(server_name="ros", uri="resource://template", name="template")] + ) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[{"name": "ros", "command": "uvx"}], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + assert runtime.tool_registry.get("list_mcp_resources") is not None + assert runtime.tool_registry.get("read_mcp_resource") is not None + manager._resources = [] + + await manager.listeners[0]("ros", "resources") + + assert runtime.tool_registry.get("list_mcp_resources") is None + assert runtime.tool_registry.get("read_mcp_resource") is None + + +@pytest.mark.asyncio +async def test_runtime_mcp_prompts_changed_refreshes_prompt_commands(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeMCPManager( + prompts=[MCPPromptRecord(server_name="ros", prompt_name="review", public_name="mcp__ros__review")] + ) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[{"name": "ros", "command": "uvx"}], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + assert runtime.command_registry.get("mcp__ros__review") is not None + manager._prompts = [MCPPromptRecord(server_name="ros", prompt_name="deploy", public_name="mcp__ros__deploy")] + + await manager.listeners[0]("ros", "prompts") + + assert runtime.command_registry.get("mcp__ros__review") is None + assert runtime.command_registry.get("mcp__ros__deploy") is not None + assert "mcp__ros__review" not in runtime.agent_loop.system_prompt + assert "mcp__ros__deploy" in runtime.agent_loop.system_prompt + mcp_auto_trigger_names = [ + command.name for command in runtime.agent_loop._auto_trigger_skills if command.name.startswith("mcp__") + ] + assert mcp_auto_trigger_names == ["mcp__ros__deploy"] + + +@pytest.mark.asyncio +async def test_runtime_mcp_resources_changed_refreshes_skill_commands(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeMCPManager(resources=[MCPResourceRecord(server_name="ros", uri="skill://ros/vpc", name="vpc")]) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[{"name": "ros", "command": "uvx"}], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + assert runtime.command_registry.get("mcp__ros__vpc") is not None + manager._resources = [] + + await manager.listeners[0]("ros", "resources") + + assert runtime.command_registry.get("mcp__ros__vpc") is None + + +def test_create_runtime_registers_auth_tool_for_needs_auth_server(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeMCPManager(states={"remote": MCPConnectionState.NEEDS_AUTH}) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[ + { + "name": "remote", + "type": "http", + "url": "https://example.com/mcp", + "oauth": {"clientId": "client-id"}, + } + ], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + assert runtime.tool_registry.get("mcp__remote__authenticate") is not None + + +@pytest.mark.asyncio +async def test_runtime_auth_tool_runs_oauth_flow_and_reconnects(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + calls: dict[str, Any] = {} + manager = FakeMCPManager(states={"remote": MCPConnectionState.NEEDS_AUTH}) + + class FakePendingFlow: + authorization_url = "https://auth.example/authorize" + browser_opened = False + + def wait(self): + calls["waited"] = True + + def fake_flow(config, *, storage, scope, **kwargs): + calls["server"] = config.name + calls["scope"] = scope + return FakePendingFlow() + + monkeypatch.setattr("iac_code.mcp.oauth.start_oauth_loopback_flow", fake_flow) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[ + { + "name": "remote", + "type": "http", + "url": "https://example.com/mcp", + "oauth": {"clientId": "client-id"}, + } + ], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + tool = runtime.tool_registry.get("mcp__remote__authenticate") + assert tool is not None + result = await tool.execute(tool_input={}, context=ToolContext()) + + assert result.is_error is False + assert "https://auth.example/authorize" in result.content + assert calls["server"] == "remote" + assert calls["scope"] == "session:session-1" + + for _ in range(20): + if manager.reconnected: + break + await asyncio.sleep(0.01) + + assert calls["waited"] is True + assert manager.reconnected == ["remote"] + assert manager.connection_state("remote") is MCPConnectionState.CONNECTED + assert runtime.tool_registry.get("mcp__remote__authenticate") is None + assert runtime.tool_registry.get("mcp__remote__search") is not None + + +def test_create_runtime_skips_unapproved_project_mcp_configs(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + (tmp_path / ".mcp.json").write_text('{"mcpServers": {"pending": {"command": "uvx"}}}', encoding="utf-8") + called = False + + def factory(configs, roots): + nonlocal called + called = True + return FakeMCPManager() + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_manager_factory=factory, + ) + ) + + assert called is False + assert runtime.mcp_manager is None + assert runtime.tool_registry.get("mcp__pending__anything") is None + assert any(warning.code == "pending_approval" for warning in runtime.mcp_config_warnings) + + +def test_create_runtime_exposes_mcp_connection_failures_as_warnings(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeMCPManager( + states={"broken": MCPConnectionState.FAILED}, + errors={"broken": "Authorization: Bearer secret"}, + ) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[{"name": "broken", "command": "uvx"}], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + warnings = [warning for warning in runtime.mcp_config_warnings if warning.code == "connection_failed"] + assert len(warnings) == 1 + assert warnings[0].server_name == "broken" + assert "secret" not in warnings[0].message + assert "[REDACTED]" in warnings[0].message + + +def test_create_runtime_disconnects_mcp_manager_when_setup_fails_after_connect(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeMCPManager(tools=[MCPToolRecord(server_name="ros", tool_name="plan", public_name="mcp__ros__plan")]) + + def fail_load_permission_context(*args, **kwargs): + raise RuntimeError("permission setup failed") + + monkeypatch.setattr("iac_code.services.permissions.loader.load_permission_context", fail_load_permission_context) + + with pytest.raises(RuntimeError, match="permission setup failed"): + create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[{"name": "ros", "command": "uvx"}], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + assert manager.connected is True + assert manager.disconnected is True + + +@pytest.mark.asyncio +async def test_runtime_mcp_list_changed_appends_discovery_warnings(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeMCPManager(prompts=[MCPPromptRecord(server_name="ros", prompt_name="review", public_name="review")]) + + runtime = create_agent_runtime( + AgentFactoryOptions( + model="qwen3.7-max", + session_id="session-1", + cwd=str(tmp_path), + mcp_configs=[{"name": "ros", "command": "uvx"}], + mcp_manager_factory=lambda configs, roots: manager, + ) + ) + + manager.capability_errors = {"ros": {"prompts": "Authorization: Bearer secret"}} + + await manager.listeners[0]("ros", "prompts") + + warnings = [warning for warning in runtime.mcp_config_warnings if warning.code == "prompts_failed"] + assert len(warnings) == 1 + assert warnings[0].server_name == "ros" + assert "secret" not in warnings[0].message + assert "[REDACTED]" in warnings[0].message + + +class FakeMCPManager: + def __init__( + self, + *, + tools: list[MCPToolRecord] | None = None, + resources: list[MCPResourceRecord] | None = None, + prompts: list[MCPPromptRecord] | None = None, + states: dict[str, MCPConnectionState] | None = None, + errors: dict[str, str] | None = None, + capability_errors: dict[str, dict[str, str]] | None = None, + ) -> None: + self._tools = tools or [] + self._resources = resources or [] + self._prompts = prompts or [] + self._states = states or {} + self._errors = errors or {} + self.capability_errors = capability_errors or {} + self.connected = False + self.disconnected = False + self.reconnected: list[str] = [] + self.listeners: list[Any] = [] + + async def connect_all(self) -> None: + self.connected = True + + async def disconnect_all(self) -> None: + self.disconnected = True + + def list_tools(self) -> list[MCPToolRecord]: + return self._tools + + def list_resources(self) -> list[MCPResourceRecord]: + return self._resources + + def list_prompts(self) -> list[Any]: + return self._prompts + + def connection_state(self, server_name: str) -> MCPConnectionState: + return self._states.get(server_name, MCPConnectionState.CONNECTED) + + def list_connections(self) -> list[Any]: + records = [ + type( + "Connection", + (), + { + "name": name, + "state": state, + "error": self._errors.get(name), + "capability_errors": self.capability_errors.get(name, {}), + }, + )() + for name, state in self._states.items() + ] + for name, errors in self.capability_errors.items(): + if name not in self._states: + records.append( + type( + "Connection", + (), + { + "name": name, + "state": MCPConnectionState.CONNECTED, + "error": None, + "capability_errors": errors, + }, + )() + ) + return records + + def needs_auth_servers(self) -> list[str]: + return [name for name, state in self._states.items() if state is MCPConnectionState.NEEDS_AUTH] + + async def reconnect(self, server_name: str) -> None: + self.reconnected.append(server_name) + self._states[server_name] = MCPConnectionState.CONNECTED + self._tools = [ + MCPToolRecord( + server_name=server_name, + tool_name="search", + public_name="mcp__remote__search", + input_schema={"type": "object"}, + ) + ] + for listener in self.listeners: + await listener(server_name, "tools") + + def add_change_listener(self, listener) -> None: + self.listeners.append(listener) + + async def read_resource(self, uri: str, server_name: str | None = None): + return ( + server_name or "ros", + { + "contents": [ + { + "uri": uri, + "mimeType": "text/markdown", + "text": "---\ndescription: VPC guidance\n---\n# VPC", + } + ] + }, + ) diff --git a/tests/mcp/test_skills.py b/tests/mcp/test_skills.py new file mode 100644 index 00000000..d024437a --- /dev/null +++ b/tests/mcp/test_skills.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import pytest + +from iac_code.commands.registry import CommandRegistry, LocalCommand, PromptCommand +from iac_code.mcp.skills import register_mcp_skill_commands +from iac_code.mcp.types import MCPResourceRecord +from iac_code.skills.frontmatter import SkillFrontmatter +from iac_code.skills.skill_definition import SkillDefinition +from iac_code.types.skill_source import SkillSource + + +@pytest.mark.asyncio +async def test_register_mcp_skill_reads_skill_resource_without_local_expansion() -> None: + registry = CommandRegistry() + manager = FakeSkillManager() + + warnings = await register_mcp_skill_commands(registry, manager) + + assert warnings == [] + command = registry.get("mcp__ros__vpc") + assert isinstance(command, PromptCommand) + assert command.skill.name == "mcp__ros__vpc" + assert command.skill.description == "VPC guidance" + assert command.skill.file_path == "mcp://ros/skill://ros/vpc" + assert command.skill.skill_root == "" + assert command.skill.frontmatter.allowed_tools == [] + assert command.skill.frontmatter.auto_trigger == {} + assert "```!bash" in command.skill.content + + +@pytest.mark.asyncio +async def test_register_mcp_skill_skips_conflicting_local_command() -> None: + registry = CommandRegistry() + registry.register(LocalCommand(name="mcp__ros__vpc", description="built in")) + + warnings = await register_mcp_skill_commands(registry, FakeSkillManager()) + + assert registry.get("mcp__ros__vpc").description == "built in" + assert len(warnings) == 1 + assert warnings[0].code == "command_conflict" + + +@pytest.mark.asyncio +async def test_register_mcp_skill_skips_conflicting_local_prompt_command() -> None: + registry = CommandRegistry() + registry.register(_local_prompt_command("mcp__ros__vpc", description="local skill")) + + warnings = await register_mcp_skill_commands(registry, FakeSkillManager()) + + assert registry.get("mcp__ros__vpc").description == "local skill" + assert len(warnings) == 1 + assert warnings[0].code == "command_conflict" + + +@pytest.mark.asyncio +async def test_register_mcp_skill_warns_and_skips_unreadable_resource() -> None: + registry = CommandRegistry() + + warnings = await register_mcp_skill_commands(registry, FakeSkillManager(read_error=RuntimeError("read failed"))) + + assert registry.get("mcp__ros__vpc") is None + assert len(warnings) == 1 + assert warnings[0].code == "skill_read_failed" + + +@pytest.mark.asyncio +async def test_register_mcp_skill_limits_remote_description_and_body() -> None: + registry = CommandRegistry() + manager = FakeSkillManager(text=("---\ndescription: {}\n---\n{}").format("d" * 600, "body\n" * 5000)) + + warnings = await register_mcp_skill_commands(registry, manager) + + command = registry.get("mcp__ros__vpc") + assert isinstance(command, PromptCommand) + assert len(command.skill.description) <= 256 + assert command.skill.content_length <= 20000 + assert len(command.skill.content) <= 20000 + assert any(warning.code == "skill_truncated" for warning in warnings) + + +def _local_prompt_command(name: str, *, description: str) -> PromptCommand: + return PromptCommand( + name=name, + description=description, + skill=SkillDefinition( + name=name, + description=description, + frontmatter=SkillFrontmatter(description=description), + content="local content", + source=SkillSource.PROJECT, + file_path="/repo/.iac-code/skills/local/SKILL.md", + content_length=13, + ), + source=SkillSource.PROJECT, + ) + + +class FakeSkillManager: + def __init__(self, text: str | None = None, read_error: Exception | None = None) -> None: + self.text = text + self.read_error = read_error + + def list_resources(self) -> list[MCPResourceRecord]: + return [ + MCPResourceRecord( + server_name="ros", + uri="skill://ros/vpc", + name="vpc", + mime_type="text/markdown", + ) + ] + + async def read_resource(self, uri: str, server_name: str | None = None): + if self.read_error is not None: + raise self.read_error + return ( + server_name or "ros", + { + "contents": [ + { + "uri": uri, + "mimeType": "text/markdown", + "text": self.text + or ( + "---\n" + "description: VPC guidance\n" + "allowed_tools:\n" + " - bash(*)\n" + "auto_trigger:\n" + " script: run.py\n" + "---\n" + "# VPC\n" + "```!bash\n" + "echo should not be granted automatically\n" + "```" + ), + } + ] + }, + ) diff --git a/tests/mcp/test_storage.py b/tests/mcp/test_storage.py new file mode 100644 index 00000000..d66ed1f0 --- /dev/null +++ b/tests/mcp/test_storage.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path + +from iac_code.mcp.storage import MCPSecretStorage + + +def test_fallback_secret_store_uses_lock_for_file_io(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_MCP_DISABLE_KEYRING", "1") + storage = MCPSecretStorage() + lock_calls: list[str] = [] + + @contextmanager + def fake_lock(key: str): + lock_calls.append(key) + yield + + monkeypatch.setattr(storage, "lock", fake_lock) + + storage.set_secret("mcp:access_token:test", "token") + assert storage.get_secret("mcp:access_token:test") == "token" + storage.delete_secret("mcp:access_token:test") + + assert lock_calls == ["__fallback_store__", "__fallback_store__", "__fallback_store__"] diff --git a/tests/mcp/test_tools.py b/tests/mcp/test_tools.py new file mode 100644 index 00000000..50b53fde --- /dev/null +++ b/tests/mcp/test_tools.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import inspect +from pathlib import Path +from typing import Any + +import pytest + +from iac_code.mcp.tools import ( + ListMCPResourcesTool, + MCPAuthenticateTool, + MCPProgressEvent, + MCPTool, + ReadMCPResourceTool, +) +from iac_code.mcp.types import MCPResourceRecord, MCPToolRecord +from iac_code.tools.base import ToolContext + + +def test_mcp_tool_uses_discovered_schema_and_annotations() -> None: + tool = MCPTool( + manager=FakeManager(), + record=MCPToolRecord( + server_name="ros", + tool_name="plan", + public_name="mcp__ros__plan", + description="Plan resources", + input_schema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}, + annotations={"readOnlyHint": True, "destructiveHint": False}, + ), + session_id="session-1", + ) + + assert tool.name == "mcp__ros__plan" + assert tool.description == "Plan resources" + assert tool.input_schema["properties"]["name"]["type"] == "string" + assert tool.is_read_only({}) is True + assert tool.is_concurrency_safe({}) is True + assert tool.is_destructive({}) is False + assert tool.needs_event_queue() is True + assert tool.user_facing_name({}) == "MCP ros:plan" + assert tool.validate_input({"name": "vpc"}) == (True, "") + valid, error = tool.validate_input({}) + assert valid is False + assert "'name' is a required property" in error + + +@pytest.mark.asyncio +async def test_mcp_tool_execute_forwards_call_metadata_progress_and_converts_result( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeManager() + event_queue = __import__("asyncio").Queue() + tool = MCPTool( + manager=manager, + record=MCPToolRecord( + server_name="ros", + tool_name="generate", + public_name="mcp__ros__generate", + input_schema={"type": "object"}, + annotations={"readOnlyHint": False, "destructiveHint": True}, + ), + session_id="session-1", + ) + + result = await tool.execute( + tool_input={"resource": "vpc"}, + context=ToolContext(tool_use_id="tool-use-1", event_queue=event_queue), + ) + + assert manager.called_with["server_name"] == "ros" + assert manager.called_with["tool_name"] == "generate" + assert manager.called_with["arguments"] == {"resource": "vpc"} + assert manager.called_with["meta"] == {"iac_code/toolUseId": "tool-use-1"} + assert result.is_error is False + assert "generated" in result.content + assert '"id": "vpc-1"' in result.content + + event = event_queue.get_nowait() + assert isinstance(event, MCPProgressEvent) + assert event.server_name == "ros" + assert event.tool_name == "generate" + assert event.tool_use_id == "tool-use-1" + assert event.message == "halfway" + + +@pytest.mark.asyncio +async def test_resource_tools_list_and_read_resources(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + manager = FakeManager() + + listed = await ListMCPResourcesTool(manager=manager).execute(tool_input={}, context=ToolContext()) + assert "skill://ros/vpc" in listed.content + assert "text/markdown" in listed.content + + filtered = await ListMCPResourcesTool(manager=manager).execute( + tool_input={"server": "other"}, + context=ToolContext(), + ) + assert filtered.content == "No MCP resources are currently available." + + assert ReadMCPResourceTool.input_schema["required"] == ["server", "uri"] + read = await ReadMCPResourceTool(manager=manager, session_id="session-1").execute( + tool_input={"server": "ros", "uri": "skill://ros/vpc"}, + context=ToolContext(), + ) + assert "Resource from MCP server 'ros'" in read.content + assert "Use a VPC" in read.content + + +@pytest.mark.asyncio +async def test_authenticate_tool_returns_authorization_url() -> None: + tool = MCPAuthenticateTool( + server_name="remote", auth_url_factory=lambda server_name: "https://auth.example/authorize" + ) + + result = await tool.execute(tool_input={}, context=ToolContext()) + + assert tool.name == "mcp__remote__authenticate" + assert result.is_error is False + assert "https://auth.example/authorize" in result.content + + +@pytest.mark.asyncio +async def test_authenticate_tool_runs_configured_auth_flow() -> None: + called: list[str] = [] + + async def auth_flow(server_name: str) -> str: + called.append(server_name) + return "authenticated {}".format(server_name) + + tool = MCPAuthenticateTool(server_name="remote", auth_flow=auth_flow) + + result = await tool.execute(tool_input={}, context=ToolContext()) + + assert called == ["remote"] + assert result.is_error is False + assert result.content == "authenticated remote" + + +class FakeManager: + def __init__(self) -> None: + self.called_with: dict[str, Any] = {} + + async def call_tool( + self, + server_name: str, + tool_name: str, + arguments: dict[str, Any], + *, + progress_callback=None, + meta: dict[str, Any] | None = None, + ) -> dict[str, Any]: + self.called_with = { + "server_name": server_name, + "tool_name": tool_name, + "arguments": arguments, + "meta": meta, + } + if progress_callback is not None: + maybe_awaitable = progress_callback({"progress": 0.5, "total": 1.0, "message": "halfway"}) + if inspect.isawaitable(maybe_awaitable): + await maybe_awaitable + return { + "content": [{"type": "text", "text": "generated"}], + "structuredContent": {"id": "vpc-1"}, + } + + def list_resources(self) -> list[MCPResourceRecord]: + return [ + MCPResourceRecord( + server_name="ros", + uri="skill://ros/vpc", + name="vpc", + mime_type="text/markdown", + ) + ] + + async def read_resource(self, uri: str, server_name: str | None = None) -> tuple[str, dict[str, Any]]: + return ( + "ros", + { + "contents": [ + { + "uri": uri, + "mimeType": "text/markdown", + "text": "Use a VPC", + } + ] + }, + ) diff --git a/tests/mcp/test_types.py b/tests/mcp/test_types.py new file mode 100644 index 00000000..e7ec8d0f --- /dev/null +++ b/tests/mcp/test_types.py @@ -0,0 +1,203 @@ +import pytest + +from iac_code.mcp.types import ( + MCPConfigError, + MCPConfigScope, + MCPConnectionState, + MCPOAuthConfig, + MCPPromptRecord, + MCPResourceRecord, + MCPServerConfig, + MCPSkillRecord, + MCPToolRecord, + MCPTransport, + ScopedMCPServerConfig, +) + + +def test_stdio_config_defaults_to_stdio_when_command_is_present() -> None: + config = MCPServerConfig.from_mapping( + "terraform", + { + "command": "uvx", + "args": ["terraform-mcp-server"], + "env": {"ALIBABA_CLOUD_REGION": "cn-hangzhou"}, + }, + ) + + assert config.name == "terraform" + assert config.transport == MCPTransport.STDIO + assert config.command == "uvx" + assert config.args == ("terraform-mcp-server",) + assert config.env == {"ALIBABA_CLOUD_REGION": "cn-hangzhou"} + assert config.content_signature().startswith("stdio:") + assert "terraform-mcp-server" not in config.content_signature() + changed_env = MCPServerConfig.from_mapping( + "terraform", + { + "command": "uvx", + "args": ["terraform-mcp-server"], + "env": {"ALIBABA_CLOUD_REGION": "cn-shanghai"}, + }, + ) + assert changed_env.content_signature() != config.content_signature() + + +@pytest.mark.parametrize("transport", [MCPTransport.HTTP, MCPTransport.SSE]) +def test_remote_config_parses_url_headers_and_oauth(transport: MCPTransport) -> None: + config = MCPServerConfig.from_mapping( + "remote", + { + "type": transport.value, + "url": "https://example.com/mcp", + "headers": {"X-Org": "iac"}, + "oauth": { + "clientId": "client-id", + "clientSecretEnv": "MCP_CLIENT_SECRET", + "callbackPort": 3118, + "authServerMetadataUrl": "https://example.com/.well-known/oauth-authorization-server", + }, + }, + ) + + assert config.transport == transport + assert config.url == "https://example.com/mcp" + assert config.headers == {"X-Org": "iac"} + assert config.oauth == MCPOAuthConfig( + client_id="client-id", + client_secret_env="MCP_CLIENT_SECRET", + callback_port=3118, + auth_server_metadata_url="https://example.com/.well-known/oauth-authorization-server", + ) + assert config.content_signature().startswith("url:") + assert "https://example.com/mcp" not in config.content_signature() + changed_header = MCPServerConfig.from_mapping( + "remote", + { + "type": transport.value, + "url": "https://example.com/mcp", + "headers": {"X-Org": "other"}, + }, + ) + assert changed_header.content_signature() != config.content_signature() + + +def test_content_signature_redacts_secret_like_values() -> None: + config = MCPServerConfig.from_mapping( + "remote", + { + "type": "http", + "url": "https://example.com/mcp", + "headers": {"Authorization": "Bearer token-a", "X-Org": "iac"}, + "env": {"API_TOKEN": "token-a", "TENANT": "a"}, + "oauth": {"clientId": "client-id", "clientSecretEnv": "MCP_CLIENT_SECRET_A"}, + }, + ) + changed_secret_values = MCPServerConfig.from_mapping( + "remote", + { + "type": "http", + "url": "https://example.com/mcp", + "headers": {"Authorization": "Bearer token-b", "X-Org": "iac"}, + "env": {"API_TOKEN": "token-b", "TENANT": "a"}, + "oauth": {"clientId": "client-id", "clientSecretEnv": "MCP_CLIENT_SECRET_B"}, + }, + ) + changed_public_values = MCPServerConfig.from_mapping( + "remote", + { + "type": "http", + "url": "https://example.com/mcp", + "headers": {"Authorization": "Bearer token-a", "X-Org": "other"}, + "env": {"API_TOKEN": "token-a", "TENANT": "b"}, + "oauth": {"clientId": "client-id", "clientSecretEnv": "MCP_CLIENT_SECRET_A"}, + }, + ) + + assert changed_secret_values.content_signature() == config.content_signature() + assert changed_public_values.content_signature() != config.content_signature() + + +def test_invalid_server_configs_fail_with_actionable_errors() -> None: + with pytest.raises(MCPConfigError, match="Unsupported MCP transport"): + MCPServerConfig.from_mapping("websocket", {"type": "ws", "url": "wss://example.com"}) + + with pytest.raises(MCPConfigError, match="requires a command"): + MCPServerConfig.from_mapping("stdio", {"type": "stdio"}) + + with pytest.raises(MCPConfigError, match="requires a url"): + MCPServerConfig.from_mapping("remote", {"type": "http"}) + + with pytest.raises(MCPConfigError, match="oauth.clientSecret"): + MCPServerConfig.from_mapping( + "remote", + { + "type": "http", + "url": "https://example.com/mcp", + "oauth": {"clientSecret": "do-not-store-plaintext"}, + }, + ) + + +def test_scopes_connection_states_and_precedence_labels_are_stable() -> None: + assert [scope.value for scope in MCPConfigScope] == ["user", "project", "local", "session", "dynamic"] + assert MCPConfigScope.SESSION.precedence > MCPConfigScope.LOCAL.precedence + assert MCPConfigScope.LOCAL.precedence > MCPConfigScope.PROJECT.precedence + assert MCPConfigScope.PROJECT.precedence > MCPConfigScope.USER.precedence + assert MCPConfigScope.DYNAMIC.precedence == MCPConfigScope.SESSION.precedence + + assert [state.value for state in MCPConnectionState] == [ + "connected", + "failed", + "needs_auth", + "pending", + "disabled", + ] + + +def test_scoped_config_keeps_source_metadata_and_defaults() -> None: + server_config = MCPServerConfig.from_mapping("local", {"command": "python", "args": ["server.py"]}) + scoped = ScopedMCPServerConfig(config=server_config, scope=MCPConfigScope.LOCAL, source_path="/repo/.iac-code") + + assert scoped.name == "local" + assert scoped.transport == MCPTransport.STDIO + assert scoped.approved is True + assert scoped.source_path == "/repo/.iac-code" + assert scoped.warning is None + + +def test_discovery_records_preserve_server_origin_and_public_names() -> None: + tool = MCPToolRecord( + server_name="ros", + tool_name="generate_template", + public_name="mcp__ros__generate_template", + description="Generate ROS templates", + input_schema={"type": "object"}, + annotations={"readOnlyHint": True}, + ) + resource = MCPResourceRecord( + server_name="ros", + uri="skill://ros/vpc", + name="vpc", + title="VPC Skill", + mime_type="text/markdown", + ) + prompt = MCPPromptRecord( + server_name="ros", + prompt_name="review", + public_name="mcp:ros:review", + description="Review infrastructure", + arguments={"template": {"required": True}}, + ) + skill = MCPSkillRecord( + server_name="ros", + name="vpc", + public_name="mcp:ros:vpc", + resource_uri="skill://ros/vpc", + description="VPC best practices", + ) + + assert tool.public_name == "mcp__ros__generate_template" + assert resource.is_skill_resource is True + assert prompt.arguments["template"]["required"] is True + assert skill.resource_uri == "skill://ros/vpc" diff --git a/tests/ui/test_repl_integration.py b/tests/ui/test_repl_integration.py index 01b9de84..34d82c33 100644 --- a/tests/ui/test_repl_integration.py +++ b/tests/ui/test_repl_integration.py @@ -107,6 +107,176 @@ def test_task_tools_registered(self, mock_mm, mock_ss, mock_pm): assert repl.tool_registry.get("task_list") is not None assert repl.tool_registry.get("task_stop") is not None + @patch("iac_code.ui.repl.ProviderManager") + @patch("iac_code.ui.repl.SessionStorage") + @patch("iac_code.ui.repl.MemoryManager") + def test_mcp_tools_registered_in_interactive_repl(self, mock_mm, mock_ss, mock_pm, monkeypatch, tmp_path): + from iac_code.mcp.types import MCPToolRecord + from iac_code.ui.repl import InlineREPL + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + (tmp_path / ".iac-code").mkdir() + (tmp_path / ".iac-code" / "settings.local.yml").write_text( + "mcpServers:\n ros:\n command: uvx\n", + encoding="utf-8", + ) + manager = SimpleNamespace( + connect_all=AsyncMock(), + list_tools=Mock( + return_value=[ + MCPToolRecord( + server_name="ros", + tool_name="plan", + public_name="mcp__ros__plan", + input_schema={"type": "object"}, + ) + ] + ), + list_resources=Mock(return_value=[]), + list_prompts=Mock(return_value=[]), + needs_auth_servers=Mock(return_value=[]), + ) + monkeypatch.setattr("iac_code.mcp.manager.MCPManager", lambda configs, roots, **kwargs: manager) + + repl = InlineREPL(model="claude-sonnet-4-6") + + assert manager.connect_all.await_count == 1 + assert repl.tool_registry.get("mcp__ros__plan") is not None + + @patch("iac_code.ui.repl.ProviderManager") + @patch("iac_code.ui.repl.SessionStorage") + @patch("iac_code.ui.repl.MemoryManager") + def test_mcp_init_failure_disconnects_manager(self, mock_mm, mock_ss, mock_pm, monkeypatch, tmp_path): + from iac_code.ui.repl import InlineREPL + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + (tmp_path / ".iac-code").mkdir() + (tmp_path / ".iac-code" / "settings.local.yml").write_text( + "mcpServers:\n ros:\n command: uvx\n", + encoding="utf-8", + ) + manager = SimpleNamespace( + connect_all=AsyncMock(), + disconnect_all=AsyncMock(), + list_tools=Mock(side_effect=RuntimeError("tool sync failed")), + list_resources=Mock(return_value=[]), + list_prompts=Mock(return_value=[]), + needs_auth_servers=Mock(return_value=[]), + ) + monkeypatch.setattr("iac_code.mcp.manager.MCPManager", lambda configs, roots, **kwargs: manager) + + with pytest.raises(RuntimeError, match="tool sync failed"): + InlineREPL(model="claude-sonnet-4-6") + + manager.disconnect_all.assert_awaited_once() + + @patch("iac_code.ui.repl.ProviderManager") + @patch("iac_code.ui.repl.SessionStorage") + @patch("iac_code.ui.repl.MemoryManager") + def test_refresh_skills_preserves_mcp_prompt_commands(self, mock_mm, mock_ss, mock_pm, monkeypatch, tmp_path): + from iac_code.mcp.types import MCPPromptRecord + from iac_code.ui.repl import InlineREPL + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + (tmp_path / ".iac-code").mkdir() + (tmp_path / ".iac-code" / "settings.local.yml").write_text( + "mcpServers:\n ros:\n command: uvx\n", + encoding="utf-8", + ) + manager = SimpleNamespace( + connect_all=AsyncMock(), + list_tools=Mock(return_value=[]), + list_resources=Mock(return_value=[]), + list_prompts=Mock( + return_value=[ + MCPPromptRecord( + server_name="ros", + prompt_name="review", + public_name="mcp__ros__review", + description="Review template", + ) + ] + ), + needs_auth_servers=Mock(return_value=[]), + add_change_listener=Mock(), + ) + monkeypatch.setattr("iac_code.mcp.manager.MCPManager", lambda configs, roots, **kwargs: manager) + + repl = InlineREPL(model="claude-sonnet-4-6") + assert repl.command_registry.get("mcp__ros__review") is not None + + repl.refresh_skills() + + assert repl.command_registry.get("mcp__ros__review") is not None + assert "mcp__ros__review" in repl._skill_listing + + @patch("iac_code.ui.repl.ProviderManager") + @patch("iac_code.ui.repl.SessionStorage") + @patch("iac_code.ui.repl.MemoryManager") + def test_repl_prompts_for_project_mcp_approval_before_connecting( + self, + mock_mm, + mock_ss, + mock_pm, + monkeypatch, + tmp_path, + ): + from iac_code.mcp.types import MCPToolRecord + from iac_code.ui.repl import InlineREPL + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + (tmp_path / ".git").mkdir() + (tmp_path / ".mcp.json").write_text( + '{"mcpServers": {"project-ros": {"command": "uvx"}}}', + encoding="utf-8", + ) + connected_names = [] + prompts = [] + manager = SimpleNamespace( + connect_all=AsyncMock(), + list_tools=Mock( + return_value=[ + MCPToolRecord( + server_name="project-ros", + tool_name="plan", + public_name="mcp__project_ros__plan", + input_schema={"type": "object"}, + ) + ] + ), + list_resources=Mock(return_value=[]), + list_prompts=Mock(return_value=[]), + needs_auth_servers=Mock(return_value=[]), + ) + + def make_manager(configs, roots, **kwargs): + connected_names.extend(config.name for config in configs) + return manager + + prompt_kwargs = [] + + def approve_input(console, prompt, **kwargs): + prompts.append(prompt) + prompt_kwargs.append(kwargs) + return "y" + + monkeypatch.setattr("iac_code.mcp.manager.MCPManager", make_manager) + monkeypatch.setattr("iac_code.ui.repl.Console.input", approve_input) + + repl = InlineREPL(model="claude-sonnet-4-6") + + assert prompts == ["Approve project MCP server 'project-ros' from {}? [y/N] ".format(tmp_path / ".mcp.json")] + assert prompt_kwargs == [{"markup": False}] + assert connected_names == ["project-ros"] + assert manager.connect_all.await_count == 1 + assert repl.tool_registry.get("mcp__project_ros__plan") is not None + UUID4_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") @@ -449,6 +619,30 @@ def test_swap_session_refreshes_session_name_and_renders_banner(): repl.console.print.assert_called_once_with("banner") +def test_print_mcp_config_warnings_prints_each_warning_once(): + from iac_code.mcp.types import MCPConfigWarning + from iac_code.ui.repl import InlineREPL + + repl = InlineREPL.__new__(InlineREPL) + repl.console = SimpleNamespace(print=Mock()) + repl._mcp_warnings_printed_count = 0 + repl.mcp_config_warnings = [ + MCPConfigWarning(source="mcp", server_name="broken", code="connection_failed", message="first warning") + ] + + repl._print_mcp_config_warnings() + repl._print_mcp_config_warnings() + repl.mcp_config_warnings.append( + MCPConfigWarning(source="mcp", server_name="broken", code="resources_failed", message="second warning") + ) + repl._print_mcp_config_warnings() + + printed = [call.args[0] for call in repl.console.print.call_args_list] + assert len(printed) == 2 + assert "first warning" in printed[0] + assert "second warning" in printed[1] + + def test_swap_session_marks_completed_cleanup_prompt(tmp_path: Path): from iac_code.agent.message import Message from iac_code.pipeline.engine.cleanup import ( diff --git a/tests/ui/test_stream_accumulator.py b/tests/ui/test_stream_accumulator.py index 8abccf01..df5bb995 100644 --- a/tests/ui/test_stream_accumulator.py +++ b/tests/ui/test_stream_accumulator.py @@ -1,4 +1,4 @@ -from iac_code.types.stream_events import ToolResultEvent, ToolUseStartEvent +from iac_code.types.stream_events import MCPProgressEvent, ToolResultEvent, ToolUseStartEvent from iac_code.ui.stream_accumulator import StreamAccumulator @@ -30,3 +30,22 @@ def test_orphan_tool_result_fallback_allows_single_pending_tool_name() -> None: assert acc.tool_records["tool-a"].done is True assert acc.tool_records["tool-a"].result == "ok" + + +def test_mcp_progress_updates_matching_tool_record() -> None: + acc = StreamAccumulator() + acc.process(ToolUseStartEvent(tool_use_id="tool-a", name="mcp__live__echo")) + + action = acc.process( + MCPProgressEvent( + server_name="live", + tool_name="echo", + progress=1, + total=2, + message="halfway", + tool_use_id="tool-a", + ) + ) + + assert action == "tool_update" + assert acc.tool_records["tool-a"].progress_renderable == "MCP live:echo: 1/2: halfway" diff --git a/uv.lock b/uv.lock index 155cbd66..8e7b08e6 100644 --- a/uv.lock +++ b/uv.lock @@ -1267,6 +1267,7 @@ dependencies = [ { name = "jsonschema" }, { name = "keyring" }, { name = "loguru" }, + { name = "mcp" }, { name = "openai" }, { name = "opentelemetry-distro" }, { name = "opentelemetry-exporter-otlp" }, @@ -1340,6 +1341,7 @@ requires-dist = [ { name = "jsonschema", specifier = ">=4.20" }, { name = "keyring", specifier = ">=25.0" }, { name = "loguru", specifier = ">=0.7.0" }, + { name = "mcp", specifier = ">=1.28.0" }, { name = "openai", specifier = ">=1.50" }, { name = "opentelemetry-distro", specifier = ">=0.48b0" }, { name = "opentelemetry-exporter-otlp", specifier = ">=1.27.0" }, @@ -1645,6 +1647,31 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" }, ] +[[package]] +name = "mcp" +version = "1.28.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -2435,6 +2462,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/0c/75/e187b0ea247f71f2009d156df88b7d8449c52a38810c9a1bd55dd4871206/pydantic_core-2.46.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ef47ee0a3ac4c2bb25a083b3acafb171f65be4a0ac1e84edef79dd0016e25eaa" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -2456,6 +2497,11 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" }, ] +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyperclip" version = "1.11.0" @@ -2558,6 +2604,40 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" diff --git a/website/docs/mcp/capabilities.md b/website/docs/mcp/capabilities.md new file mode 100644 index 00000000..2d8d29db --- /dev/null +++ b/website/docs/mcp/capabilities.md @@ -0,0 +1,104 @@ +--- +sidebar_position: 3 +title: Tools, Resources, Prompts, and Skills +description: Understand how MCP capabilities appear inside IaC Code. +--- + +# Tools, Resources, Prompts, and Skills + +Connected MCP servers can expose four kinds of capabilities to IaC Code. + +## Tools + +Each MCP tool becomes an IaC Code tool: + +```text +mcp____ +``` + +Tool descriptions and JSON input schemas come from the MCP server. IaC Code forwards the model's tool input to the MCP server, then converts MCP content blocks into a normal tool result. + +MCP tool annotations are honored where possible: + +| MCP annotation | IaC Code behavior | +|---|---| +| `readOnlyHint: true` | The tool is treated as read-only and concurrency-safe. | +| `destructiveHint: true` | The tool is treated as destructive for permission decisions. | + +MCP tools still pass through IaC Code's existing permission system. Configure permission policy with normal `permissions` settings or CLI flags such as `--allowed-tools`, `--disallowed-tools`, and `--permission-mode`. + +MCP progress notifications are surfaced in interactive rendering, headless progress output, ACP tool progress updates, and A2A tool metadata. + +## Tool Results and Artifacts + +IaC Code converts MCP content blocks into model-visible text: + +| MCP content | IaC Code result | +|---|---| +| Text content | Included directly in the tool result. | +| `structuredContent` | Rendered as formatted JSON under a structured-content section. | +| Text resources | Rendered with server and URI provenance. | +| `resource_link` | Rendered as a resource link with URI and MIME type. | +| Image, audio, and blob data | Stored as private artifact files and referenced by artifact id. | + +Binary artifacts are stored below: + +```text +/tool-results//mcp/// +``` + +The model sees the artifact id and metadata, not raw base64 data. + +## Resources + +When any connected server exposes resources, IaC Code registers two global tools: + +| Tool | Purpose | +|---|---| +| `list_mcp_resources` | Lists resources from connected MCP servers. Optionally filter by server name. | +| `read_mcp_resource` | Reads one resource by `server` and `uri`. | + +Resource lines include server name, URI, optional resource name, and optional MIME type. + +## Prompts + +MCP prompts become slash commands: + +```text +/mcp____ key=value +``` + +When invoked, IaC Code calls MCP `prompts/get`, renders the returned prompt messages, injects the rendered prompt into the conversation, and lets the model continue. Prompt arguments can be passed as: + +```text +template_name=prod-vpc region=cn-hangzhou +``` + +or as JSON: + +```json +{"template_name": "prod-vpc", "region": "cn-hangzhou"} +``` + +Required prompt arguments are validated before the MCP call. Quoted values are supported, including Windows paths with backslashes. + +## Skills + +MCP resources with `skill://` URIs become skill commands: + +```text +$mcp____ +``` + +IaC Code reads the remote skill resource, parses frontmatter, and registers it as a normal skill command. Remote MCP skills are safety-limited: + +- Remote `allowed_tools` are cleared. +- Remote auto-trigger path rules are cleared. +- Remote skill body and description length are bounded. +- If the remote skill conflicts with an existing command, it is skipped with an MCP warning. + +MCP skill resources may be read during startup so the command can be registered before the user invokes it. + +## Dynamic Updates + +If an MCP server sends `tools/list_changed`, `resources/list_changed`, or `prompts/list_changed`, IaC Code refreshes the affected capability list and updates the tool or command registry. Refresh failures are reported as MCP warnings and do not stop the active session. diff --git a/website/docs/mcp/configuration.md b/website/docs/mcp/configuration.md new file mode 100644 index 00000000..7de598ff --- /dev/null +++ b/website/docs/mcp/configuration.md @@ -0,0 +1,127 @@ +--- +sidebar_position: 2 +title: MCP Configuration +description: Configure MCP servers through CLI commands, settings files, project files, and ACP sessions. +--- + +# MCP Configuration + +MCP servers are configured under the `mcpServers` object. IaC Code supports a Claude Code-compatible core schema for `stdio`, `http`, and `sse` servers. + +## Configuration Sources + +IaC Code reads MCP servers from these sources: + +| Source | Scope | File or entry point | Trust model | +|---|---|---|---| +| User settings | `user` | `~/.iac-code/settings.yml` or `IAC_CODE_CONFIG_DIR/settings.yml` | Trusted by the current user. | +| Project local settings | `local` | `/.iac-code/settings.local.yml` | Private to the local checkout. | +| Project MCP file | `project` | `/.mcp.json` | Shared with the project and requires local approval. | +| ACP session config | `session` | `mcp_servers` passed by an ACP client | Applies only to that ACP session runtime. | + +Precedence is user, project, local, then session. Later sources override earlier sources by server name. Equivalent configs are also deduplicated by content signature. + +Project `.mcp.json` files are discovered from the workspace root down to the current directory. Child project files override parent files by server name. + +## CLI Commands + +Use `iac-code mcp` to manage persisted MCP configuration: + +```bash +iac-code mcp add local-catalog \ + --scope local \ + --command python \ + --arg ./tools/catalog_mcp.py +``` + +```bash +iac-code mcp add remote-reviewer \ + --scope user \ + --type http \ + --url https://mcp.example.com/mcp \ + --header "Authorization=${MCP_REVIEWER_TOKEN}" +``` + +Available commands: + +| Command | Purpose | +|---|---| +| `iac-code mcp add` | Add a server from structured CLI flags. | +| `iac-code mcp add-json` | Add a server from a JSON object. | +| `iac-code mcp list` | List configured servers, scopes, transports, and approval status. | +| `iac-code mcp get` | Print one redacted server config. | +| `iac-code mcp remove` | Remove one server from a persisted scope. | +| `iac-code mcp approve` | Approve a project `.mcp.json` server. | +| `iac-code mcp reject` | Reject a project `.mcp.json` server. | +| `iac-code mcp reset-project-choices` | Clear stored project approval choices. | +| `iac-code mcp auth` | Start OAuth authentication for a server. | +| `iac-code mcp reset-auth` | Delete stored OAuth tokens and client secret for a server. | + +When `--scope` is omitted, IaC Code writes to `local` inside a project and `user` outside a project. + +## Stdio Servers + +Stdio servers launch a local command: + +```json +{ + "mcpServers": { + "catalog": { + "command": "python", + "args": ["./tools/catalog_mcp.py"], + "env": { + "CATALOG_ENV": "prod" + } + } + } +} +``` + +The `type` field can be omitted when `command` is present. IaC Code passes a safe inherited environment plus the server `env`. On Windows, prefer `cmd /c npx` instead of bare `npx` for Node-based servers. + +## HTTP and SSE Servers + +Remote servers require `type` and `url`: + +```json +{ + "mcpServers": { + "reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "${MCP_REVIEWER_TOKEN}" + } + } + } +} +``` + +Use `type: "sse"` for SSE servers. Static headers are supported. Dynamic `headersHelper` commands are rejected because they need a separate trusted-execution design. + +## Environment Expansion + +String values support: + +```text +${VAR} +${VAR:-default-value} +``` + +Missing variables without defaults produce an MCP warning and the affected server is skipped. Environment expansion applies recursively to strings inside lists and objects. + +Do not store plaintext secrets in headers or env values. Use environment-variable references or OAuth secret storage. + +## Project Approval + +Project `.mcp.json` can be committed to a repository, so IaC Code does not trust it automatically. + +Interactive REPL startup asks: + +```text +Approve project MCP server 'name' from /path/to/.mcp.json? [y/N] +``` + +Pressing Enter keeps the default `N` and rejects that exact project server config. Type `y` or `yes` to approve it. Approval is stored locally under the IaC Code config directory and includes the workspace path, project file path, server name, and config signature. If the `.mcp.json` server config changes, approval is invalidated and the server becomes pending again. + +Headless, ACP, and A2A startup never ask interactive approval questions. Pending project servers are skipped and reported as warnings. diff --git a/website/docs/mcp/oauth-and-security.md b/website/docs/mcp/oauth-and-security.md new file mode 100644 index 00000000..57c84a3f --- /dev/null +++ b/website/docs/mcp/oauth-and-security.md @@ -0,0 +1,110 @@ +--- +sidebar_position: 4 +title: OAuth and Security +description: Authenticate remote MCP servers and understand the MCP security model in IaC Code. +--- + +# OAuth and Security + +MCP can start local processes and call remote services, so IaC Code treats MCP configuration and authentication as security-sensitive. + +## OAuth + +Remote `http` and `sse` servers can use OAuth. Configure OAuth metadata in the server config: + +```json +{ + "mcpServers": { + "secure-reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "oauth": { + "clientId": "iac-code", + "clientSecretEnv": "MCP_CLIENT_SECRET", + "callbackPort": 38487, + "authServerMetadataUrl": "https://auth.example.com/.well-known/oauth-authorization-server" + } + } + } +} +``` + +Supported OAuth fields: + +| Field | Purpose | +|---|---| +| `clientId` | OAuth client id. | +| `clientSecretEnv` | Environment variable that contains the client secret. | +| `callbackPort` | Optional loopback callback port. Use `0` or omit it to choose a free port. | +| `authServerMetadataUrl` | Optional explicit authorization server metadata URL. | + +Plaintext `oauth.clientSecret` is rejected. Use `clientSecretEnv` or the secure CLI prompt. + +## Authenticating + +Run: + +```bash +iac-code mcp auth secure-reviewer --scope user +``` + +IaC Code opens or prints an authorization URL and starts a loopback callback server on `127.0.0.1`. After the provider redirects back with an authorization code, IaC Code exchanges it for tokens and stores them securely. + +If a server needs authentication during a normal session, IaC Code registers an authentication tool: + +```text +mcp____authenticate +``` + +The model can call that tool to provide the user with the OAuth URL. After the flow completes, IaC Code reconnects the MCP server and refreshes discovered capabilities. + +## Token Storage + +IaC Code stores OAuth tokens and MCP client secrets through `MCPSecretStorage`: + +1. It tries the operating-system keyring when available. +2. If keyring is disabled or unavailable, it stores encrypted fallback data under `/mcp/`. +3. File permissions are restricted for the fallback key and encrypted secret store. + +Set `IAC_CODE_MCP_DISABLE_KEYRING=1` to force encrypted fallback storage, which is useful for isolated tests. + +Use this command to clear stored auth state: + +```bash +iac-code mcp reset-auth secure-reviewer --scope user +``` + +## Project Trust + +Project `.mcp.json` files are not trusted automatically because a repository can add a `stdio` server that runs arbitrary local code. Interactive approval is per server config signature. Changing command, args, env, URL, headers, or OAuth config invalidates previous approval. + +Headless and protocol server modes skip unapproved project servers rather than prompting. + +## Secret Handling + +IaC Code protects secrets in several ways: + +- Config output from `iac-code mcp get` redacts keys that look like tokens, secrets, passwords, API keys, and authorization headers. +- Plaintext sensitive header or env values are rejected unless they use an environment-variable reference. +- MCP stdio servers inherit only an allowlist of safe environment variables plus the explicit server env. +- Proxy environment variables with embedded usernames or passwords are not inherited by stdio MCP servers. +- MCP artifact files are written under the private IaC Code runtime configuration directory. + +## Permissions + +MCP tools use the same permission framework as built-in tools. A remote MCP server cannot bypass IaC Code permission checks simply by advertising a tool. Keep these rules in mind: + +- Read-only MCP tools may be auto-allowed depending on the active permission policy. +- Destructive MCP tools should require approval unless explicitly allowed. +- In headless automation, combine `--permission-mode`, `--allowed-tools`, and `--disallowed-tools` to restrict what MCP tools can do. +- Remote MCP skills do not grant their own `allowed_tools`. + +## Unsupported Security-Sensitive Features + +IaC Code intentionally rejects or omits these MCP features for now: + +- `headersHelper` dynamic commands. +- MCP elicitation UI. +- WebSocket, IDE, and SDK transports. +- Enterprise managed MCP policy. +- IaC Code acting as an MCP server. diff --git a/website/docs/mcp/overview.md b/website/docs/mcp/overview.md new file mode 100644 index 00000000..a9012003 --- /dev/null +++ b/website/docs/mcp/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: MCP Integration +description: Use Model Context Protocol servers to extend IaC Code with external tools, resources, prompts, and skills. +--- + +# MCP Integration + +IaC Code can act as a Model Context Protocol (MCP) host. MCP servers extend the agent with external tools, resources, prompts, and reusable skills while still going through IaC Code's permission, session, logging, and output handling paths. + +Use MCP when you want IaC Code to call a local or remote capability that is not built into the product, such as a private template catalog, an internal deployment reviewer, an inventory query service, or a specialized cloud operation tool. + +## Supported Surfaces + +| Surface | MCP support | +|---|---| +| Interactive REPL | Loads user, local, and approved project servers. Prompts before trusting new project `.mcp.json` servers. | +| Non-interactive mode | Loads user, local, and approved project servers. Never prompts; pending project servers are skipped with warnings. | +| ACP server | Accepts session MCP server configs from ACP clients and exposes discovered MCP capabilities inside that session. | +| A2A server | Loads MCP through the normal runtime and can publish MCP warnings and tool progress in A2A task metadata. | +| Pipeline mode | Uses the same runtime integrations as normal mode, including MCP tool progress and warning propagation. | + +## Supported Capabilities + +| Capability | Status | +|---|---| +| `stdio` transport | Supported for local MCP server processes. | +| Streamable HTTP transport | Supported for remote MCP servers. | +| SSE transport | Supported for remote MCP servers. | +| MCP tools | Exposed as agent tools named `mcp____`. | +| MCP resources | Exposed through `list_mcp_resources` and `read_mcp_resource`. | +| MCP prompts | Exposed as slash commands named `mcp____`. | +| MCP `skill://` resources | Exposed as skill commands named `mcp____`. | +| OAuth loopback auth | Supported for remote servers with OAuth metadata. | +| `roots/list` | Supported. IaC Code returns the active workspace root as a file URI. | +| `list_changed` notifications | Supported for tools, resources, and prompts. Registrations refresh dynamically. | +| MCP elicitation | Not supported yet. Servers that request elicitation receive a clear unsupported error. | +| WebSocket, SDK, IDE transports | Not supported. | +| Dynamic `headersHelper` commands | Not supported. Use static headers or environment-variable references. | +| IaC Code as an MCP server | Not supported. IaC Code currently acts as an MCP host only. | + +## How It Works + +At runtime IaC Code: + +1. Loads MCP configuration from user, local, project, and session sources. +2. Expands `${VAR}` and `${VAR:-default}` references. +3. Skips unsafe or invalid servers with user-visible warnings. +4. Connects approved servers with bounded concurrency. +5. Discovers tools, resources, prompts, and `skill://` resources. +6. Registers those capabilities into the existing tool and command registries. +7. Converts MCP tool results into normal IaC Code tool results, storing binary artifacts under the runtime configuration directory. +8. Disconnects MCP clients when the REPL, headless run, ACP session, or A2A runtime closes. + +One failed MCP server does not block other configured servers. Connection and discovery failures stay visible as MCP warnings. + +## Naming + +MCP tools and commands are normalized into public names: + +```text +mcp____ +mcp____ +mcp____ +``` + +Characters outside letters, numbers, and underscores become underscores. If two discovered capabilities collide after normalization, IaC Code appends a short digest to keep names unique. + +## Related Pages + +- [MCP Configuration](./configuration.md) +- [Tools, Resources, Prompts, and Skills](./capabilities.md) +- [OAuth and Security](./oauth-and-security.md) +- [Troubleshooting](./troubleshooting.md) diff --git a/website/docs/mcp/troubleshooting.md b/website/docs/mcp/troubleshooting.md new file mode 100644 index 00000000..489f965a --- /dev/null +++ b/website/docs/mcp/troubleshooting.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 5 +title: MCP Troubleshooting +description: Diagnose MCP configuration, connection, authentication, and capability discovery problems. +--- + +# MCP Troubleshooting + +MCP warnings are non-fatal unless every capability you need is unavailable. A failed server should not prevent other MCP servers or built-in IaC Code tools from working. + +## Inspect Configuration + +List configured servers: + +```bash +iac-code mcp list +``` + +Inspect a redacted server config: + +```bash +iac-code mcp get my-server --scope local +``` + +Remove a bad server: + +```bash +iac-code mcp remove my-server --scope local +``` + +Clear project approval choices: + +```bash +iac-code mcp reset-project-choices +``` + +## Pending Project Server + +Symptom: + +```text +Project MCP server 'name' is pending approval. +``` + +Fix: + +```bash +iac-code mcp approve name +``` + +or start the interactive REPL in that project and answer `y` when prompted. Pressing Enter means `N` and rejects the server. + +If approval used to work but stopped, check whether `.mcp.json` changed. Approval is tied to the config signature. + +## Missing Environment Variable + +Symptom: + +```text +Environment variable 'TOKEN' is not set for MCP config. +``` + +Fix one of these: + +```bash +export TOKEN=... +``` + +or use a default: + +```json +"Authorization": "${TOKEN:-}" +``` + +Servers with missing required environment variables are skipped. + +## Connection Failed + +For stdio servers: + +- Verify `command` exists on `PATH`. +- Use absolute paths for scripts when launching from different directories. +- On Windows, run Node-based servers through `cmd /c npx`. +- Check that any required environment variables are configured. + +For HTTP or SSE servers: + +- Verify the URL and transport type. +- Check TLS and proxy settings. +- Confirm static headers are present and do not contain plaintext secrets. +- Run `iac-code mcp auth ` if the server requires OAuth. + +## Needs Authentication + +Symptom: + +```text +MCP server 'name' requires authentication. +``` + +Fix: + +```bash +iac-code mcp auth name --scope user +``` + +If the server uses OAuth refresh tokens and reauthentication is required, IaC Code clears stale tokens and asks for a fresh flow. + +## Capability Discovery Failed + +Symptoms can include: + +```text +MCP server 'name' tools discovery failed: ... +MCP server 'name' resources discovery failed: ... +MCP server 'name' prompts discovery failed: ... +``` + +The server connected, but one capability list failed. Other capabilities from the same server may still work. Fix the server-side error, then restart IaC Code or trigger a reconnect/auth refresh. + +## Resources Are Missing + +`list_mcp_resources` is registered only when at least one connected server exposes resources. If the tool is missing: + +- Confirm the server connected. +- Confirm the server supports `resources/list`. +- Check startup warnings for resource discovery errors. + +## Prompt or Skill Command Missing + +Prompt and skill commands appear only after successful discovery. Check: + +- The prompt or `skill://` resource exists on the MCP server. +- The normalized command name does not conflict with a built-in command. +- The remote skill resource can be read within the startup timeout. +- The skill description and body fit IaC Code safety limits. + +## Logs and Artifacts + +Runtime logs default to: + +```text +/logs/ +``` + +or `IAC_CODE_LOG_DIR` when set. + +MCP binary artifacts from tool results are stored under: + +```text +/tool-results//mcp/ +``` + +Avoid sharing config, log, or artifact directories without reviewing them for secrets. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current.json b/website/i18n/de/docusaurus-plugin-content-docs/current.json index a68d033c..4d575498 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/de/docusaurus-plugin-content-docs/current.json @@ -11,6 +11,10 @@ "message": "IaC Code verwenden", "description": "The label for category 'Using iac-code' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.MCP Integration": { + "message": "MCP-Integration", + "description": "The label for category 'MCP Integration' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Configuration": { "message": "Konfiguration", "description": "The label for category 'Configuration' in sidebar 'docsSidebar'" diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/capabilities.md new file mode 100644 index 00000000..192e7989 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -0,0 +1,104 @@ +--- +sidebar_position: 3 +title: Tools, Ressourcen, Prompts und Skills +description: Verstehen Sie, wie MCP-Funktionen in IaC Code erscheinen. +--- + +# Tools, Ressourcen, Prompts und Skills + +Verbundene MCP-Server können vier Arten von Funktionen für IaC Code bereitstellen. + +## Tools + +Jedes MCP-Tool wird zu einem IaC Code-Tool: + +```text +mcp____ +``` + +Tool-Beschreibungen und JSON-Eingabeschemas kommen vom MCP-Server. IaC Code leitet die Tool-Eingabe des Modells an den MCP-Server weiter und konvertiert anschließend MCP-Content-Blöcke in ein normales Tool-Ergebnis. + +MCP-Tool-Annotationen werden soweit möglich berücksichtigt: + +| MCP-Annotation | Verhalten in IaC Code | +|---|---| +| `readOnlyHint: true` | Das Tool gilt als read-only und parallelisierungssicher. | +| `destructiveHint: true` | Das Tool gilt für Berechtigungsentscheidungen als destruktiv. | + +MCP-Tools laufen weiterhin durch das bestehende Berechtigungssystem von IaC Code. Konfigurieren Sie die Policy mit normalen `permissions`-Settings oder CLI-Flags wie `--allowed-tools`, `--disallowed-tools` und `--permission-mode`. + +MCP-Fortschrittsbenachrichtigungen werden im interaktiven Rendering, in Headless-Fortschrittsausgaben, in ACP-Tool-Fortschrittsupdates und in A2A-Tool-Metadaten sichtbar. + +## Tool-Ergebnisse und Artefakte + +IaC Code konvertiert MCP-Content-Blöcke in modell-sichtbaren Text: + +| MCP-Inhalt | IaC Code-Ergebnis | +|---|---| +| Textinhalt | Direkt im Tool-Ergebnis enthalten. | +| `structuredContent` | Als formatiertes JSON in einem Structured-Content-Abschnitt gerendert. | +| Textressourcen | Mit Server- und URI-Herkunft gerendert. | +| `resource_link` | Als Ressourcenlink mit URI und MIME-Type gerendert. | +| Bild-, Audio- und Blob-Daten | Als private Artefaktdateien gespeichert und per Artefakt-ID referenziert. | + +Binärartefakte werden hier gespeichert: + +```text +/tool-results//mcp/// +``` + +Das Modell sieht Artefakt-ID und Metadaten, nicht rohe base64-Daten. + +## Ressourcen + +Sobald ein verbundener Server Ressourcen bereitstellt, registriert IaC Code zwei globale Tools: + +| Tool | Zweck | +|---|---| +| `list_mcp_resources` | Listet Ressourcen verbundener MCP-Server. Optional nach Servername filterbar. | +| `read_mcp_resource` | Liest eine Ressource per `server` und `uri`. | + +Ressourcenzeilen enthalten Servername, URI, optionalen Ressourcennamen und optionalen MIME-Type. + +## Prompts + +MCP-Prompts werden zu Slash-Commands: + +```text +/mcp____ key=value +``` + +Beim Aufruf ruft IaC Code MCP `prompts/get` auf, rendert die zurückgegebenen Prompt-Nachrichten, injiziert den gerenderten Prompt in die Konversation und lässt das Modell weiterarbeiten. Prompt-Argumente können so übergeben werden: + +```text +template_name=prod-vpc region=cn-hangzhou +``` + +oder als JSON: + +```json +{"template_name": "prod-vpc", "region": "cn-hangzhou"} +``` + +Pflichtargumente werden vor dem MCP-Aufruf validiert. Quoted values werden unterstützt, einschließlich Windows-Pfade mit Backslashes. + +## Skills + +MCP-Ressourcen mit `skill://`-URIs werden zu Skill-Commands: + +```text +$mcp____ +``` + +IaC Code liest die Remote-Skill-Ressource, parst das Frontmatter und registriert sie als normalen Skill-Command. Remote-MCP-Skills sind aus Sicherheitsgründen begrenzt: + +- Remote `allowed_tools` werden gelöscht. +- Remote Auto-Trigger-Pfadregeln werden gelöscht. +- Remote Skill-Body und Beschreibung sind längenbegrenzt. +- Bei Konflikt mit einem bestehenden Command wird der Remote-Skill mit einer MCP-Warnung übersprungen. + +MCP-Skill-Ressourcen können beim Start gelesen werden, damit der Command vor dem Benutzeraufruf registriert ist. + +## Dynamische Updates + +Wenn ein MCP-Server `tools/list_changed`, `resources/list_changed` oder `prompts/list_changed` sendet, aktualisiert IaC Code die betroffene Capability-Liste und die Tool- oder Command-Registry. Refresh-Fehler werden als MCP-Warnungen gemeldet und stoppen die aktive Sitzung nicht. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/configuration.md b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/configuration.md new file mode 100644 index 00000000..ddda0580 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/configuration.md @@ -0,0 +1,127 @@ +--- +sidebar_position: 2 +title: MCP-Konfiguration +description: Konfigurieren Sie MCP-Server über CLI-Commands, Settings-Dateien, Projektdateien und ACP-Sitzungen. +--- + +# MCP-Konfiguration + +MCP-Server werden im Objekt `mcpServers` konfiguriert. IaC Code unterstützt ein mit Claude Code kompatibles Kernschema für `stdio`-, `http`- und `sse`-Server. + +## Konfigurationsquellen + +IaC Code liest MCP-Server aus diesen Quellen: + +| Quelle | Scope | Datei oder Einstiegspunkt | Vertrauensmodell | +|---|---|---|---| +| Benutzer-Settings | `user` | `~/.iac-code/settings.yml` oder `IAC_CODE_CONFIG_DIR/settings.yml` | Vom aktuellen Benutzer vertraut. | +| Lokale Projekt-Settings | `local` | `/.iac-code/settings.local.yml` | Privat für den lokalen Checkout. | +| Projekt-MCP-Datei | `project` | `/.mcp.json` | Im Projekt geteilt und lokal genehmigungspflichtig. | +| ACP-Sitzungskonfiguration | `session` | `mcp_servers`, die von einem ACP-Client übergeben werden | Gilt nur für diese ACP-Sitzungs-Runtime. | + +Die Reihenfolge ist user, project, local, dann session. Spätere Quellen überschreiben frühere nach Servername. Gleichwertige Konfigurationen werden zusätzlich per Inhaltssignatur dedupliziert. + +Projektdateien `.mcp.json` werden von der Workspace-Root bis zum aktuellen Verzeichnis gesucht. Kind-Projektdateien überschreiben Eltern-Dateien nach Servername. + +## CLI-Commands + +Verwenden Sie `iac-code mcp`, um persistente MCP-Konfiguration zu verwalten: + +```bash +iac-code mcp add local-catalog \ + --scope local \ + --command python \ + --arg ./tools/catalog_mcp.py +``` + +```bash +iac-code mcp add remote-reviewer \ + --scope user \ + --type http \ + --url https://mcp.example.com/mcp \ + --header "Authorization=${MCP_REVIEWER_TOKEN}" +``` + +Verfügbare Commands: + +| Command | Zweck | +|---|---| +| `iac-code mcp add` | Fügt einen Server aus strukturierten CLI-Flags hinzu. | +| `iac-code mcp add-json` | Fügt einen Server aus einem JSON-Objekt hinzu. | +| `iac-code mcp list` | Listet konfigurierte Server, Scopes, Transporte und Genehmigungsstatus. | +| `iac-code mcp get` | Gibt eine geschwärzte Serverkonfiguration aus. | +| `iac-code mcp remove` | Entfernt einen Server aus einem persistenten Scope. | +| `iac-code mcp approve` | Genehmigt einen Projektserver aus `.mcp.json`. | +| `iac-code mcp reject` | Lehnt einen Projektserver aus `.mcp.json` ab. | +| `iac-code mcp reset-project-choices` | Löscht gespeicherte Projektgenehmigungen. | +| `iac-code mcp auth` | Startet OAuth-Authentifizierung für einen Server. | +| `iac-code mcp reset-auth` | Löscht gespeicherte OAuth-Tokens und das Client Secret eines Servers. | + +Wenn `--scope` fehlt, schreibt IaC Code innerhalb eines Projekts nach `local` und außerhalb eines Projekts nach `user`. + +## Stdio-Server + +Stdio-Server starten einen lokalen Befehl: + +```json +{ + "mcpServers": { + "catalog": { + "command": "python", + "args": ["./tools/catalog_mcp.py"], + "env": { + "CATALOG_ENV": "prod" + } + } + } +} +``` + +Das Feld `type` kann weggelassen werden, wenn `command` vorhanden ist. IaC Code übergibt eine sichere geerbte Umgebung plus das `env` des Servers. Unter Windows sollten Node-basierte Server mit `cmd /c npx` statt mit nacktem `npx` konfiguriert werden. + +## HTTP- und SSE-Server + +Entfernte Server benötigen `type` und `url`: + +```json +{ + "mcpServers": { + "reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "${MCP_REVIEWER_TOKEN}" + } + } + } +} +``` + +Verwenden Sie `type: "sse"` für SSE-Server. Statische Headers werden unterstützt. Dynamische `headersHelper`-Commands werden abgelehnt, weil sie ein eigenes Trusted-Execution-Design benötigen. + +## Umgebungsvariablen-Erweiterung + +Stringwerte unterstützen: + +```text +${VAR} +${VAR:-default-value} +``` + +Fehlende Variablen ohne Default erzeugen eine MCP-Warnung, und der betroffene Server wird übersprungen. Die Erweiterung gilt rekursiv für Strings in Listen und Objekten. + +Speichern Sie keine Klartext-Secrets in Headers oder env-Werten. Verwenden Sie Umgebungsvariablen-Referenzen oder OAuth-Secret-Storage. + +## Projektgenehmigung + +Projektdateien `.mcp.json` können ins Repository committed werden. Deshalb vertraut IaC Code ihnen nicht automatisch. + +Beim Start des interaktiven REPL fragt IaC Code: + +```text +Approve project MCP server 'name' from /path/to/.mcp.json? [y/N] +``` + +Enter übernimmt den Default `N` und lehnt genau diese Projektserver-Konfiguration ab. Geben Sie `y` oder `yes` ein, um sie zu genehmigen. Die Genehmigung wird lokal im IaC Code-Konfigurationsverzeichnis gespeichert und enthält Workspace-Pfad, Projektdateipfad, Servername und Konfigurationssignatur. Ändert sich die Serverkonfiguration in `.mcp.json`, wird die Genehmigung ungültig und der Server wird wieder pending. + +Headless-, ACP- und A2A-Starts stellen nie interaktive Genehmigungsfragen. Ausstehende Projektserver werden übersprungen und als Warnungen gemeldet. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md new file mode 100644 index 00000000..c8212153 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -0,0 +1,110 @@ +--- +sidebar_position: 4 +title: OAuth und Sicherheit +description: Authentifizieren Sie entfernte MCP-Server und verstehen Sie das MCP-Sicherheitsmodell in IaC Code. +--- + +# OAuth und Sicherheit + +MCP kann lokale Prozesse starten und entfernte Dienste aufrufen. Deshalb behandelt IaC Code MCP-Konfiguration und Authentifizierung als sicherheitsrelevant. + +## OAuth + +Entfernte `http`- und `sse`-Server können OAuth verwenden. Konfigurieren Sie OAuth-Metadaten in der Serverkonfiguration: + +```json +{ + "mcpServers": { + "secure-reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "oauth": { + "clientId": "iac-code", + "clientSecretEnv": "MCP_CLIENT_SECRET", + "callbackPort": 38487, + "authServerMetadataUrl": "https://auth.example.com/.well-known/oauth-authorization-server" + } + } + } +} +``` + +Unterstützte OAuth-Felder: + +| Feld | Zweck | +|---|---| +| `clientId` | OAuth-Client-ID. | +| `clientSecretEnv` | Umgebungsvariable mit dem Client Secret. | +| `callbackPort` | Optionaler Loopback-Callback-Port. Verwenden Sie `0` oder lassen Sie ihn weg, um einen freien Port zu wählen. | +| `authServerMetadataUrl` | Optionale explizite URL für Authorization-Server-Metadaten. | + +Klartext `oauth.clientSecret` wird abgelehnt. Verwenden Sie `clientSecretEnv` oder den sicheren CLI-Prompt. + +## Authentifizierung + +Führen Sie aus: + +```bash +iac-code mcp auth secure-reviewer --scope user +``` + +IaC Code öffnet oder druckt eine Authorization-URL und startet einen Loopback-Callback-Server auf `127.0.0.1`. Nachdem der Provider mit einem Authorization Code zurückleitet, tauscht IaC Code ihn gegen Tokens und speichert sie sicher. + +Wenn ein Server während einer normalen Sitzung Authentifizierung benötigt, registriert IaC Code ein Authentifizierungstool: + +```text +mcp____authenticate +``` + +Das Modell kann dieses Tool aufrufen, um dem Benutzer die OAuth-URL zu geben. Nach Abschluss des Flows verbindet IaC Code den MCP-Server erneut und aktualisiert entdeckte Funktionen. + +## Token-Speicherung + +IaC Code speichert OAuth-Tokens und MCP-Client-Secrets über `MCPSecretStorage`: + +1. Es versucht zuerst den Betriebssystem-Keyring, wenn verfügbar. +2. Wenn Keyring deaktiviert oder nicht verfügbar ist, speichert es verschlüsselte Fallback-Daten unter `/mcp/`. +3. Die Dateiberechtigungen für Fallback-Schlüssel und verschlüsselten Secret-Store werden eingeschränkt. + +Setzen Sie `IAC_CODE_MCP_DISABLE_KEYRING=1`, um verschlüsselten Fallback-Speicher zu erzwingen. Das ist für isolierte Tests nützlich. + +Mit diesem Command löschen Sie den gespeicherten Auth-Status: + +```bash +iac-code mcp reset-auth secure-reviewer --scope user +``` + +## Projektvertrauen + +Projektdateien `.mcp.json` werden nicht automatisch vertraut, weil ein Repository einen `stdio`-Server hinzufügen kann, der beliebigen lokalen Code ausführt. Interaktive Genehmigung ist an die Server-Konfigurationssignatur gebunden. Änderungen an command, args, env, URL, headers oder OAuth config machen frühere Genehmigungen ungültig. + +Headless- und Protokollservermodi überspringen nicht genehmigte Projektserver, statt nachzufragen. + +## Secret-Behandlung + +IaC Code schützt Secrets auf mehrere Arten: + +- Die Ausgabe von `iac-code mcp get` schwärzt Keys, die wie Tokens, Secrets, Passwörter, API-Keys oder Authorization-Headers aussehen. +- Klartextwerte in sensiblen Headers oder env-Einträgen werden abgelehnt, sofern sie keine Umgebungsvariablen-Referenz nutzen. +- MCP-stdio-Server erben nur eine Allowlist sicherer Umgebungsvariablen plus explizites Server-env. +- Proxy-Umgebungsvariablen mit Benutzernamen oder Passwörtern werden nicht an stdio-MCP-Server vererbt. +- MCP-Artefaktdateien werden im privaten Runtime-Konfigurationsverzeichnis von IaC Code geschrieben. + +## Berechtigungen + +MCP-Tools nutzen dasselbe Berechtigungssystem wie eingebaute Tools. Ein entfernter MCP-Server kann IaC Code-Berechtigungsprüfungen nicht umgehen, nur weil er ein Tool anbietet. Beachten Sie: + +- Read-only MCP-Tools können je nach aktiver Policy automatisch erlaubt werden. +- Destruktive MCP-Tools sollten Genehmigung erfordern, sofern sie nicht explizit erlaubt sind. +- Kombinieren Sie in Headless-Automation `--permission-mode`, `--allowed-tools` und `--disallowed-tools`, um MCP-Tools einzuschränken. +- Remote-MCP-Skills vergeben keine eigenen `allowed_tools`. + +## Nicht unterstützte sicherheitsrelevante Funktionen + +IaC Code lehnt oder lässt diese MCP-Funktionen derzeit bewusst aus: + +- Dynamische `headersHelper`-Commands. +- MCP-Elicitation-Oberfläche. +- WebSocket-, IDE- und SDK-Transporte. +- Enterprise Managed MCP Policy. +- IaC Code als MCP-Server. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/overview.md b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/overview.md new file mode 100644 index 00000000..996a6c08 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: MCP-Integration +description: Erweitern Sie IaC Code mit Model Context Protocol-Servern um externe Tools, Ressourcen, Prompts und Skills. +--- + +# MCP-Integration + +IaC Code kann als Host für das Model Context Protocol (MCP) arbeiten. MCP-Server erweitern den Agenten um externe Tools, Ressourcen, Prompts und wiederverwendbare Skills, laufen dabei aber weiter durch die Berechtigungs-, Sitzungs-, Logging- und Ausgabewege von IaC Code. + +Verwenden Sie MCP, wenn IaC Code lokale oder entfernte Funktionen aufrufen soll, die nicht eingebaut sind, zum Beispiel einen privaten Template-Katalog, einen internen Deployment-Reviewer, einen Inventar-Abfragedienst oder ein spezialisiertes Cloud-Operations-Tool. + +## Unterstützte Oberflächen + +| Oberfläche | MCP-Unterstützung | +|---|---| +| Interaktiver REPL | Lädt Benutzer-, lokale und genehmigte Projektserver. Fragt nach, bevor neue Projektserver aus `.mcp.json` vertraut werden. | +| Nicht-interaktiver Modus | Lädt Benutzer-, lokale und genehmigte Projektserver. Fragt nie interaktiv; ausstehende Projektserver werden mit Warnungen übersprungen. | +| ACP-Server | Nimmt MCP-Serverkonfigurationen von ACP-Clients pro Sitzung an und stellt die entdeckten MCP-Funktionen in dieser Sitzung bereit. | +| A2A-Server | Lädt MCP über den normalen Runtime-Pfad und kann MCP-Warnungen und Tool-Fortschritt in A2A-Task-Metadaten veröffentlichen. | +| Pipeline-Modus | Nutzt dieselben Runtime-Integrationen wie der Normalmodus, einschließlich MCP-Tool-Fortschritt und Warnungsweitergabe. | + +## Unterstützte Funktionen + +| Funktion | Status | +|---|---| +| `stdio`-Transport | Unterstützt lokale MCP-Serverprozesse. | +| Streamable HTTP-Transport | Unterstützt entfernte MCP-Server. | +| SSE-Transport | Unterstützt entfernte MCP-Server. | +| MCP-Tools | Werden als Agent-Tools mit Namen `mcp____` bereitgestellt. | +| MCP-Ressourcen | Werden über `list_mcp_resources` und `read_mcp_resource` bereitgestellt. | +| MCP-Prompts | Werden als Slash-Commands mit Namen `mcp____` bereitgestellt. | +| MCP-`skill://`-Ressourcen | Werden als Skill-Commands mit Namen `mcp____` bereitgestellt. | +| OAuth-Loopback-Authentifizierung | Unterstützt entfernte Server mit OAuth-Metadaten. | +| `roots/list` | Unterstützt. IaC Code gibt die aktive Workspace-Root als file-URI zurück. | +| `list_changed`-Benachrichtigungen | Unterstützt für Tools, Ressourcen und Prompts. Registrierungen werden dynamisch aktualisiert. | +| MCP-Elicitation | Noch nicht unterstützt. Server, die Elicitation anfordern, erhalten einen klaren Fehler. | +| WebSocket-, SDK- und IDE-Transporte | Nicht unterstützt. | +| Dynamische `headersHelper`-Commands | Nicht unterstützt. Verwenden Sie statische Headers oder Umgebungsvariablen-Referenzen. | +| IaC Code als MCP-Server | Nicht unterstützt. IaC Code arbeitet derzeit nur als MCP-Host. | + +## Ablauf + +Zur Laufzeit führt IaC Code diese Schritte aus: + +1. Lädt MCP-Konfiguration aus Benutzer-, lokalen, Projekt- und Sitzungsquellen. +2. Erweitert `${VAR}`- und `${VAR:-default}`-Referenzen. +3. Überspringt unsichere oder ungültige Server mit sichtbaren Warnungen. +4. Verbindet genehmigte Server mit begrenzter Parallelität. +5. Entdeckt Tools, Ressourcen, Prompts und `skill://`-Ressourcen. +6. Registriert diese Funktionen in den vorhandenen Tool- und Command-Registries. +7. Konvertiert MCP-Tool-Ergebnisse in normale IaC Code-Tool-Ergebnisse und speichert Binärartefakte im Runtime-Konfigurationsverzeichnis. +8. Trennt MCP-Clients, wenn REPL, Headless-Ausführung, ACP-Sitzung oder A2A-Runtime geschlossen werden. + +Ein fehlgeschlagener MCP-Server blockiert andere konfigurierte Server nicht. Verbindungs- und Discovery-Fehler bleiben als MCP-Warnungen sichtbar. + +## Namensgebung + +MCP-Tools und Commands werden zu öffentlichen Namen normalisiert: + +```text +mcp____ +mcp____ +mcp____ +``` + +Zeichen außerhalb von Buchstaben, Zahlen und Unterstrichen werden zu Unterstrichen. Wenn entdeckte Funktionen nach der Normalisierung kollidieren, hängt IaC Code einen kurzen Digest an, damit Namen eindeutig bleiben. + +## Zugehörige Seiten + +- [MCP-Konfiguration](./configuration.md) +- [Tools, Ressourcen, Prompts und Skills](./capabilities.md) +- [OAuth und Sicherheit](./oauth-and-security.md) +- [Fehlerbehebung](./troubleshooting.md) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md new file mode 100644 index 00000000..ed19d415 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 5 +title: MCP-Fehlerbehebung +description: Diagnostizieren Sie MCP-Konfigurations-, Verbindungs-, Authentifizierungs- und Discovery-Probleme. +--- + +# MCP-Fehlerbehebung + +MCP-Warnungen sind nicht fatal, sofern nicht jede benötigte Funktion fehlt. Ein fehlgeschlagener Server sollte andere MCP-Server oder eingebaute IaC Code-Tools nicht am Arbeiten hindern. + +## Konfiguration prüfen + +Konfigurierte Server auflisten: + +```bash +iac-code mcp list +``` + +Eine geschwärzte Serverkonfiguration anzeigen: + +```bash +iac-code mcp get my-server --scope local +``` + +Einen fehlerhaften Server entfernen: + +```bash +iac-code mcp remove my-server --scope local +``` + +Projektgenehmigungen löschen: + +```bash +iac-code mcp reset-project-choices +``` + +## Ausstehender Projektserver + +Symptom: + +```text +Project MCP server 'name' is pending approval. +``` + +Lösung: + +```bash +iac-code mcp approve name +``` + +oder starten Sie den interaktiven REPL im Projekt und antworten Sie bei der Frage mit `y`. Enter bedeutet `N` und lehnt den Server ab. + +Wenn die Genehmigung früher funktionierte, prüfen Sie, ob `.mcp.json` geändert wurde. Genehmigung ist an die Konfigurationssignatur gebunden. + +## Fehlende Umgebungsvariable + +Symptom: + +```text +Environment variable 'TOKEN' is not set for MCP config. +``` + +Eine dieser Lösungen verwenden: + +```bash +export TOKEN=... +``` + +oder einen Default setzen: + +```json +"Authorization": "${TOKEN:-}" +``` + +Server mit fehlenden erforderlichen Umgebungsvariablen werden übersprungen. + +## Verbindung fehlgeschlagen + +Für stdio-Server: + +- Prüfen Sie, dass `command` auf dem `PATH` existiert. +- Verwenden Sie absolute Skriptpfade, wenn aus verschiedenen Verzeichnissen gestartet wird. +- Unter Windows Node-basierte Server über `cmd /c npx` starten. +- Prüfen Sie erforderliche Umgebungsvariablen. + +Für HTTP- oder SSE-Server: + +- URL und Transporttyp prüfen. +- TLS- und Proxy-Einstellungen prüfen. +- Sicherstellen, dass statische Headers vorhanden sind und keine Klartext-Secrets enthalten. +- `iac-code mcp auth ` ausführen, wenn der Server OAuth verlangt. + +## Authentifizierung erforderlich + +Symptom: + +```text +MCP server 'name' requires authentication. +``` + +Lösung: + +```bash +iac-code mcp auth name --scope user +``` + +Wenn der Server OAuth-Refresh-Tokens nutzt und erneute Authentifizierung erforderlich ist, löscht IaC Code veraltete Tokens und fordert einen neuen Flow an. + +## Capability Discovery fehlgeschlagen + +Mögliche Symptome: + +```text +MCP server 'name' tools discovery failed: ... +MCP server 'name' resources discovery failed: ... +MCP server 'name' prompts discovery failed: ... +``` + +Der Server ist verbunden, aber eine Capability-Liste ist fehlgeschlagen. Andere Funktionen desselben Servers können weiter funktionieren. Beheben Sie den serverseitigen Fehler und starten Sie IaC Code neu oder lösen Sie reconnect/auth refresh aus. + +## Ressourcen fehlen + +`list_mcp_resources` wird nur registriert, wenn mindestens ein verbundener Server Ressourcen bereitstellt. Wenn das Tool fehlt: + +- Prüfen Sie, dass der Server verbunden ist. +- Prüfen Sie, dass der Server `resources/list` unterstützt. +- Prüfen Sie Startwarnungen auf Resource-Discovery-Fehler. + +## Prompt- oder Skill-Command fehlt + +Prompt- und Skill-Commands erscheinen erst nach erfolgreicher Discovery. Prüfen Sie: + +- Prompt oder `skill://`-Ressource existiert auf dem MCP-Server. +- Der normalisierte Command-Name kollidiert nicht mit einem built-in Command. +- Die Remote-Skill-Ressource kann innerhalb des Start-Timeouts gelesen werden. +- Skill-Beschreibung und Body passen in die Sicherheitslimits von IaC Code. + +## Logs und Artefakte + +Runtime-Logs liegen standardmäßig unter: + +```text +/logs/ +``` + +oder unter `IAC_CODE_LOG_DIR`, wenn gesetzt. + +Binärartefakte aus MCP-Tool-Ergebnissen werden gespeichert unter: + +```text +/tool-results//mcp/ +``` + +Teilen Sie config-, log- oder artifact-Verzeichnisse nicht, ohne sie vorher auf Secrets zu prüfen. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current.json b/website/i18n/es/docusaurus-plugin-content-docs/current.json index 025021e4..b24b182f 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/es/docusaurus-plugin-content-docs/current.json @@ -11,6 +11,10 @@ "message": "Usar IaC Code", "description": "The label for category 'Using iac-code' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.MCP Integration": { + "message": "Integración MCP", + "description": "The label for category 'MCP Integration' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Configuration": { "message": "Configuracion", "description": "The label for category 'Configuration' in sidebar 'docsSidebar'" diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/capabilities.md new file mode 100644 index 00000000..64b8fb85 --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -0,0 +1,104 @@ +--- +sidebar_position: 3 +title: Herramientas, recursos, prompts y skills +description: Entiende cómo aparecen las capacidades MCP dentro de IaC Code. +--- + +# Herramientas, recursos, prompts y skills + +Los servidores MCP conectados pueden exponer cuatro tipos de capacidades a IaC Code. + +## Herramientas + +Cada herramienta MCP se convierte en una herramienta de IaC Code: + +```text +mcp____ +``` + +Las descripciones de herramientas y los esquemas JSON de entrada vienen del servidor MCP. IaC Code reenvía la entrada de herramienta del modelo al servidor MCP y luego convierte los bloques de contenido MCP en un resultado normal. + +Las anotaciones MCP se respetan cuando es posible: + +| Anotación MCP | Comportamiento de IaC Code | +|---|---| +| `readOnlyHint: true` | La herramienta se trata como de solo lectura y segura para concurrencia. | +| `destructiveHint: true` | La herramienta se trata como destructiva en decisiones de permisos. | + +Las herramientas MCP siguen pasando por el sistema de permisos existente de IaC Code. Configura la política con settings normales de `permissions` o flags CLI como `--allowed-tools`, `--disallowed-tools` y `--permission-mode`. + +Las notificaciones de progreso MCP se muestran en el renderizado interactivo, la salida de progreso headless, las actualizaciones de progreso ACP y los metadatos de herramientas A2A. + +## Resultados de herramientas y artefactos + +IaC Code convierte bloques de contenido MCP en texto visible para el modelo: + +| Contenido MCP | Resultado de IaC Code | +|---|---| +| Contenido de texto | Incluido directamente en el resultado de herramienta. | +| `structuredContent` | Renderizado como JSON formateado en una sección de contenido estructurado. | +| Recursos de texto | Renderizados con procedencia de servidor y URI. | +| `resource_link` | Renderizado como enlace de recurso con URI y tipo MIME. | +| Datos de imagen, audio y blob | Guardados como archivos de artefacto privados y referenciados por id de artefacto. | + +Los artefactos binarios se guardan bajo: + +```text +/tool-results//mcp/// +``` + +El modelo ve el id de artefacto y metadatos, no datos base64 sin procesar. + +## Recursos + +Cuando cualquier servidor conectado expone recursos, IaC Code registra dos herramientas globales: + +| Herramienta | Propósito | +|---|---| +| `list_mcp_resources` | Lista recursos expuestos por servidores MCP conectados. Puede filtrar por nombre de servidor. | +| `read_mcp_resource` | Lee un recurso por `server` y `uri`. | + +Las líneas de recurso incluyen nombre de servidor, URI, nombre de recurso opcional y tipo MIME opcional. + +## Prompts + +Los prompts MCP se convierten en comandos slash: + +```text +/mcp____ key=value +``` + +Al invocarlo, IaC Code llama a MCP `prompts/get`, renderiza los mensajes de prompt devueltos, inyecta el prompt renderizado en la conversación y deja que el modelo continúe. Los argumentos se pueden pasar como: + +```text +template_name=prod-vpc region=cn-hangzhou +``` + +o como JSON: + +```json +{"template_name": "prod-vpc", "region": "cn-hangzhou"} +``` + +Los argumentos requeridos se validan antes de la llamada MCP. Se admiten valores entre comillas, incluidos paths de Windows con barras invertidas. + +## Skills + +Los recursos MCP con URI `skill://` se convierten en comandos de skill: + +```text +$mcp____ +``` + +IaC Code lee el recurso de skill remoto, analiza el frontmatter y lo registra como comando de skill normal. Los skills MCP remotos están limitados por seguridad: + +- Se eliminan los `allowed_tools` remotos. +- Se eliminan las reglas remotas de autoactivación por paths. +- El cuerpo y la descripción del skill remoto tienen límites de longitud. +- Si el skill remoto entra en conflicto con un comando existente, se omite con una advertencia MCP. + +Los recursos de skill MCP pueden leerse durante el inicio para que el comando esté registrado antes de que el usuario lo invoque. + +## Actualizaciones dinámicas + +Si un servidor MCP envía `tools/list_changed`, `resources/list_changed` o `prompts/list_changed`, IaC Code actualiza la lista de capacidades afectada y el registro de herramientas o comandos. Los fallos de actualización se reportan como advertencias MCP y no detienen la sesión activa. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/configuration.md b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/configuration.md new file mode 100644 index 00000000..afed5f51 --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/configuration.md @@ -0,0 +1,127 @@ +--- +sidebar_position: 2 +title: Configuración MCP +description: Configura servidores MCP mediante comandos CLI, archivos settings, archivos de proyecto y sesiones ACP. +--- + +# Configuración MCP + +Los servidores MCP se configuran bajo el objeto `mcpServers`. IaC Code admite un esquema central compatible con Claude Code para servidores `stdio`, `http` y `sse`. + +## Fuentes de configuración + +IaC Code lee servidores MCP desde estas fuentes: + +| Fuente | Scope | Archivo o punto de entrada | Modelo de confianza | +|---|---|---|---| +| Settings de usuario | `user` | `~/.iac-code/settings.yml` o `IAC_CODE_CONFIG_DIR/settings.yml` | De confianza para el usuario actual. | +| Settings locales de proyecto | `local` | `/.iac-code/settings.local.yml` | Privado del checkout local. | +| Archivo MCP de proyecto | `project` | `/.mcp.json` | Compartido con el proyecto y requiere aprobación local. | +| Configuración de sesión ACP | `session` | `mcp_servers` enviados por un cliente ACP | Solo aplica al runtime de esa sesión ACP. | + +La precedencia es user, project, local y luego session. Las fuentes posteriores sobrescriben las anteriores por nombre de servidor. Las configuraciones equivalentes también se deduplican por firma de contenido. + +Los archivos `.mcp.json` de proyecto se descubren desde la raíz del workspace hasta el directorio actual. Los archivos hijo sobrescriben a los padre por nombre de servidor. + +## Comandos CLI + +Usa `iac-code mcp` para administrar la configuración MCP persistida: + +```bash +iac-code mcp add local-catalog \ + --scope local \ + --command python \ + --arg ./tools/catalog_mcp.py +``` + +```bash +iac-code mcp add remote-reviewer \ + --scope user \ + --type http \ + --url https://mcp.example.com/mcp \ + --header "Authorization=${MCP_REVIEWER_TOKEN}" +``` + +Comandos disponibles: + +| Comando | Propósito | +|---|---| +| `iac-code mcp add` | Añade un servidor desde flags CLI estructurados. | +| `iac-code mcp add-json` | Añade un servidor desde un objeto JSON. | +| `iac-code mcp list` | Lista servidores configurados, scopes, transportes y estado de aprobación. | +| `iac-code mcp get` | Imprime una configuración de servidor con secretos redactados. | +| `iac-code mcp remove` | Elimina un servidor de un scope persistido. | +| `iac-code mcp approve` | Aprueba un servidor de proyecto `.mcp.json`. | +| `iac-code mcp reject` | Rechaza un servidor de proyecto `.mcp.json`. | +| `iac-code mcp reset-project-choices` | Borra las decisiones guardadas de aprobación de proyecto. | +| `iac-code mcp auth` | Inicia autenticación OAuth para un servidor. | +| `iac-code mcp reset-auth` | Elimina tokens OAuth y client secret guardados para un servidor. | + +Cuando se omite `--scope`, IaC Code escribe en `local` dentro de un proyecto y en `user` fuera de un proyecto. + +## Servidores Stdio + +Los servidores stdio lanzan un comando local: + +```json +{ + "mcpServers": { + "catalog": { + "command": "python", + "args": ["./tools/catalog_mcp.py"], + "env": { + "CATALOG_ENV": "prod" + } + } + } +} +``` + +El campo `type` puede omitirse cuando existe `command`. IaC Code pasa un entorno heredado seguro más el `env` del servidor. En Windows, prefiere `cmd /c npx` en lugar de `npx` directo para servidores basados en Node. + +## Servidores HTTP y SSE + +Los servidores remotos requieren `type` y `url`: + +```json +{ + "mcpServers": { + "reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "${MCP_REVIEWER_TOKEN}" + } + } + } +} +``` + +Usa `type: "sse"` para servidores SSE. Los headers estáticos son compatibles. Los comandos dinámicos `headersHelper` se rechazan porque requieren un diseño independiente de ejecución confiable. + +## Expansión de entorno + +Los valores string admiten: + +```text +${VAR} +${VAR:-default-value} +``` + +Las variables faltantes sin valor por defecto producen una advertencia MCP y el servidor afectado se omite. La expansión se aplica recursivamente a strings dentro de listas y objetos. + +No guardes secretos en texto claro en headers ni valores env. Usa referencias a variables de entorno o almacenamiento secreto OAuth. + +## Aprobación de proyecto + +Un `.mcp.json` de proyecto puede enviarse al repositorio, por lo que IaC Code no confía en él automáticamente. + +Al iniciar el REPL interactivo pregunta: + +```text +Approve project MCP server 'name' from /path/to/.mcp.json? [y/N] +``` + +Pulsar Enter mantiene el valor por defecto `N` y rechaza esa configuración exacta de servidor de proyecto. Escribe `y` o `yes` para aprobarla. La aprobación se guarda localmente bajo el directorio de configuración de IaC Code e incluye la ruta del workspace, la ruta del archivo de proyecto, el nombre del servidor y la firma de configuración. Si cambia la configuración del servidor en `.mcp.json`, la aprobación se invalida y el servidor vuelve a quedar pending. + +Los inicios headless, ACP y A2A nunca hacen preguntas interactivas de aprobación. Los servidores de proyecto pendientes se omiten y se reportan como advertencias. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md new file mode 100644 index 00000000..f1a7e5e0 --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -0,0 +1,110 @@ +--- +sidebar_position: 4 +title: OAuth y seguridad +description: Autentica servidores MCP remotos y entiende el modelo de seguridad MCP en IaC Code. +--- + +# OAuth y seguridad + +MCP puede iniciar procesos locales y llamar servicios remotos, por lo que IaC Code trata la configuración y autenticación MCP como sensibles para la seguridad. + +## OAuth + +Los servidores remotos `http` y `sse` pueden usar OAuth. Configura metadatos OAuth en la configuración del servidor: + +```json +{ + "mcpServers": { + "secure-reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "oauth": { + "clientId": "iac-code", + "clientSecretEnv": "MCP_CLIENT_SECRET", + "callbackPort": 38487, + "authServerMetadataUrl": "https://auth.example.com/.well-known/oauth-authorization-server" + } + } + } +} +``` + +Campos OAuth compatibles: + +| Campo | Propósito | +|---|---| +| `clientId` | Id de cliente OAuth. | +| `clientSecretEnv` | Variable de entorno que contiene el client secret. | +| `callbackPort` | Puerto loopback opcional. Usa `0` u omítelo para elegir un puerto libre. | +| `authServerMetadataUrl` | URL explícita opcional de metadatos del servidor de autorización. | + +`oauth.clientSecret` en texto claro se rechaza. Usa `clientSecretEnv` o el prompt CLI seguro. + +## Autenticación + +Ejecuta: + +```bash +iac-code mcp auth secure-reviewer --scope user +``` + +IaC Code abre o imprime una URL de autorización y arranca un servidor de callback loopback en `127.0.0.1`. Después de que el proveedor redirige con un código de autorización, IaC Code lo intercambia por tokens y los guarda de forma segura. + +Si un servidor necesita autenticación durante una sesión normal, IaC Code registra una herramienta de autenticación: + +```text +mcp____authenticate +``` + +El modelo puede llamar a esa herramienta para mostrar al usuario la URL OAuth. Cuando el flujo termina, IaC Code reconecta el servidor MCP y actualiza las capacidades descubiertas. + +## Almacenamiento de tokens + +IaC Code guarda tokens OAuth y secretos de cliente MCP con `MCPSecretStorage`: + +1. Intenta usar el keyring del sistema operativo cuando está disponible. +2. Si el keyring está desactivado o no disponible, guarda datos fallback cifrados bajo `/mcp/`. +3. Los permisos de archivo se restringen para la clave fallback y el almacén cifrado. + +Define `IAC_CODE_MCP_DISABLE_KEYRING=1` para forzar almacenamiento fallback cifrado, útil en pruebas aisladas. + +Usa este comando para borrar el estado de autenticación guardado: + +```bash +iac-code mcp reset-auth secure-reviewer --scope user +``` + +## Confianza de proyecto + +Los archivos de proyecto `.mcp.json` no se confían automáticamente porque un repositorio puede añadir un servidor `stdio` que ejecuta código local arbitrario. La aprobación interactiva se vincula a la firma de configuración del servidor. Cambiar command, args, env, URL, headers u OAuth config invalida la aprobación previa. + +Los modos headless y servidor de protocolo omiten servidores de proyecto no aprobados en lugar de pedir confirmación. + +## Manejo de secretos + +IaC Code protege secretos de varias maneras: + +- La salida de `iac-code mcp get` redacta claves que parecen tokens, secrets, passwords, API keys y authorization headers. +- Los valores sensibles de headers o env en texto claro se rechazan salvo que usen una referencia a variable de entorno. +- Los servidores MCP stdio heredan solo una allowlist de variables de entorno seguras más el env explícito del servidor. +- Las variables proxy con usernames o passwords no se heredan por servidores MCP stdio. +- Los archivos de artefactos MCP se escriben bajo el directorio privado de configuración runtime de IaC Code. + +## Permisos + +Las herramientas MCP usan el mismo marco de permisos que las herramientas integradas. Un servidor MCP remoto no puede saltarse las comprobaciones de permisos de IaC Code solo por anunciar una herramienta. Ten en cuenta: + +- Las herramientas MCP de solo lectura pueden autoaprobarse según la política activa. +- Las herramientas MCP destructivas deben requerir aprobación salvo que estén permitidas explícitamente. +- En automatización headless, combina `--permission-mode`, `--allowed-tools` y `--disallowed-tools` para restringir lo que pueden hacer las herramientas MCP. +- Los skills MCP remotos no conceden sus propios `allowed_tools`. + +## Funciones sensibles no compatibles + +IaC Code rechaza u omite deliberadamente estas funciones MCP por ahora: + +- Comandos dinámicos `headersHelper`. +- Interfaz de elicitation MCP. +- Transportes WebSocket, IDE y SDK. +- Política MCP empresarial administrada. +- IaC Code como servidor MCP. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/overview.md b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/overview.md new file mode 100644 index 00000000..5d8f69ab --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: Integración MCP +description: Usa servidores Model Context Protocol para ampliar IaC Code con herramientas, recursos, prompts y skills externos. +--- + +# Integración MCP + +IaC Code puede actuar como host de Model Context Protocol (MCP). Los servidores MCP amplían el agente con herramientas externas, recursos, prompts y skills reutilizables, sin salir de los flujos de permisos, sesión, registro y manejo de salida de IaC Code. + +Usa MCP cuando quieras que IaC Code llame a una capacidad local o remota que no viene integrada, como un catálogo privado de plantillas, un revisor interno de despliegues, un servicio de inventario o una herramienta especializada de operaciones cloud. + +## Superficies compatibles + +| Superficie | Compatibilidad MCP | +|---|---| +| REPL interactivo | Carga servidores de usuario, locales y de proyecto aprobados. Pregunta antes de confiar en nuevos servidores de proyecto `.mcp.json`. | +| Modo no interactivo | Carga servidores de usuario, locales y de proyecto aprobados. Nunca pregunta; los servidores de proyecto pendientes se omiten con advertencias. | +| Servidor ACP | Acepta configuraciones MCP de clientes ACP en la sesión y expone las capacidades MCP descubiertas dentro de esa sesión. | +| Servidor A2A | Carga MCP mediante el runtime normal y puede publicar advertencias MCP y progreso de herramientas en metadatos de tareas A2A. | +| Modo pipeline | Usa las mismas integraciones de runtime que el modo normal, incluido el progreso de herramientas MCP y la propagación de advertencias. | + +## Capacidades compatibles + +| Capacidad | Estado | +|---|---| +| Transporte `stdio` | Compatible con procesos MCP locales. | +| Transporte Streamable HTTP | Compatible con servidores MCP remotos. | +| Transporte SSE | Compatible con servidores MCP remotos. | +| Herramientas MCP | Se exponen como herramientas de agente llamadas `mcp____`. | +| Recursos MCP | Se exponen mediante `list_mcp_resources` y `read_mcp_resource`. | +| Prompts MCP | Se exponen como comandos slash llamados `mcp____`. | +| Recursos MCP `skill://` | Se exponen como comandos de skill llamados `mcp____`. | +| Autenticación OAuth loopback | Compatible con servidores remotos que tienen metadatos OAuth. | +| `roots/list` | Compatible. IaC Code devuelve la raíz activa del workspace como URI file. | +| Notificaciones `list_changed` | Compatibles para herramientas, recursos y prompts. Los registros se actualizan dinámicamente. | +| Elicitation MCP | Todavía no compatible. Los servidores que la soliciten reciben un error claro de no compatibilidad. | +| Transportes WebSocket, SDK e IDE | No compatibles. | +| Comandos dinámicos `headersHelper` | No compatibles. Usa headers estáticos o referencias a variables de entorno. | +| IaC Code como servidor MCP | No compatible. Actualmente IaC Code actúa solo como host MCP. | + +## Cómo funciona + +En tiempo de ejecución, IaC Code: + +1. Carga configuración MCP desde fuentes de usuario, locales, de proyecto y de sesión. +2. Expande referencias `${VAR}` y `${VAR:-default}`. +3. Omite servidores inseguros o inválidos con advertencias visibles para el usuario. +4. Conecta servidores aprobados con concurrencia limitada. +5. Descubre herramientas, recursos, prompts y recursos `skill://`. +6. Registra esas capacidades en los registros existentes de herramientas y comandos. +7. Convierte resultados de herramientas MCP en resultados normales de IaC Code y guarda artefactos binarios bajo el directorio de configuración runtime. +8. Desconecta clientes MCP cuando se cierran el REPL, la ejecución headless, la sesión ACP o el runtime A2A. + +Un servidor MCP fallido no bloquea a otros servidores configurados. Los errores de conexión y descubrimiento permanecen visibles como advertencias MCP. + +## Nombres + +Las herramientas y comandos MCP se normalizan en nombres públicos: + +```text +mcp____ +mcp____ +mcp____ +``` + +Los caracteres que no sean letras, números ni guiones bajos se convierten en guiones bajos. Si varias capacidades chocan tras la normalización, IaC Code añade un digest corto para mantener nombres únicos. + +## Páginas relacionadas + +- [Configuración MCP](./configuration.md) +- [Herramientas, recursos, prompts y skills](./capabilities.md) +- [OAuth y seguridad](./oauth-and-security.md) +- [Solución de problemas](./troubleshooting.md) diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md new file mode 100644 index 00000000..0453b03a --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 5 +title: Solución de problemas MCP +description: Diagnostica problemas de configuración, conexión, autenticación y descubrimiento de capacidades MCP. +--- + +# Solución de problemas MCP + +Las advertencias MCP no son fatales salvo que todas las capacidades que necesitas estén no disponibles. Un servidor fallido no debería impedir que funcionen otros servidores MCP o herramientas integradas de IaC Code. + +## Inspeccionar configuración + +Lista servidores configurados: + +```bash +iac-code mcp list +``` + +Inspecciona una configuración de servidor redactada: + +```bash +iac-code mcp get my-server --scope local +``` + +Elimina un servidor incorrecto: + +```bash +iac-code mcp remove my-server --scope local +``` + +Borra decisiones de aprobación de proyecto: + +```bash +iac-code mcp reset-project-choices +``` + +## Servidor de proyecto pendiente + +Síntoma: + +```text +Project MCP server 'name' is pending approval. +``` + +Solución: + +```bash +iac-code mcp approve name +``` + +o inicia el REPL interactivo en ese proyecto y responde `y` cuando lo pida. Pulsar Enter significa `N` y rechaza el servidor. + +Si la aprobación funcionaba y dejó de funcionar, comprueba si `.mcp.json` cambió. La aprobación está ligada a la firma de configuración. + +## Variable de entorno faltante + +Síntoma: + +```text +Environment variable 'TOKEN' is not set for MCP config. +``` + +Soluciona con una de estas opciones: + +```bash +export TOKEN=... +``` + +o usa un valor por defecto: + +```json +"Authorization": "${TOKEN:-}" +``` + +Los servidores con variables de entorno requeridas faltantes se omiten. + +## Fallo de conexión + +Para servidores stdio: + +- Verifica que `command` exista en `PATH`. +- Usa paths absolutos para scripts cuando se ejecutan desde distintos directorios. +- En Windows, ejecuta servidores Node con `cmd /c npx`. +- Comprueba que las variables de entorno requeridas estén configuradas. + +Para servidores HTTP o SSE: + +- Verifica la URL y el tipo de transporte. +- Revisa TLS y configuración de proxy. +- Confirma que los headers estáticos existan y no contengan secretos en texto claro. +- Ejecuta `iac-code mcp auth ` si el servidor requiere OAuth. + +## Necesita autenticación + +Síntoma: + +```text +MCP server 'name' requires authentication. +``` + +Solución: + +```bash +iac-code mcp auth name --scope user +``` + +Si el servidor usa refresh tokens OAuth y requiere reautenticación, IaC Code borra tokens obsoletos y solicita un flujo nuevo. + +## Falló el descubrimiento de capacidades + +Los síntomas pueden incluir: + +```text +MCP server 'name' tools discovery failed: ... +MCP server 'name' resources discovery failed: ... +MCP server 'name' prompts discovery failed: ... +``` + +El servidor se conectó, pero falló una lista de capacidades. Otras capacidades del mismo servidor pueden seguir funcionando. Corrige el error del lado del servidor y reinicia IaC Code o provoca un reconnect/auth refresh. + +## Faltan recursos + +`list_mcp_resources` se registra solo cuando al menos un servidor conectado expone recursos. Si falta la herramienta: + +- Confirma que el servidor esté conectado. +- Confirma que el servidor soporte `resources/list`. +- Revisa las advertencias de inicio por errores de discovery de recursos. + +## Falta un comando prompt o skill + +Los comandos de prompt y skill aparecen solo después de un descubrimiento exitoso. Revisa: + +- El prompt o recurso `skill://` existe en el servidor MCP. +- El nombre normalizado del comando no choca con un comando integrado. +- El recurso de skill remoto puede leerse dentro del timeout de inicio. +- La descripción y el cuerpo del skill caben en los límites de seguridad de IaC Code. + +## Logs y artefactos + +Los logs runtime van por defecto a: + +```text +/logs/ +``` + +o a `IAC_CODE_LOG_DIR` cuando está definido. + +Los artefactos binarios de resultados de herramientas MCP se guardan bajo: + +```text +/tool-results//mcp/ +``` + +Evita compartir directorios de config, logs o artefactos sin revisarlos antes por si contienen secretos. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current.json b/website/i18n/fr/docusaurus-plugin-content-docs/current.json index ac1f84af..866ce4ed 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current.json @@ -11,6 +11,10 @@ "message": "Utiliser IaC Code", "description": "The label for category 'Using iac-code' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.MCP Integration": { + "message": "Intégration MCP", + "description": "The label for category 'MCP Integration' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Configuration": { "message": "Configuration", "description": "The label for category 'Configuration' in sidebar 'docsSidebar'" diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/capabilities.md new file mode 100644 index 00000000..e9a9ffa5 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -0,0 +1,104 @@ +--- +sidebar_position: 3 +title: Outils, ressources, prompts et compétences +description: Comprendre comment les capacités MCP apparaissent dans IaC Code. +--- + +# Outils, ressources, prompts et compétences + +Les serveurs MCP connectés peuvent exposer quatre types de capacités à IaC Code. + +## Outils + +Chaque outil MCP devient un outil IaC Code : + +```text +mcp____ +``` + +La description de l'outil et le schéma JSON d'entrée viennent du serveur MCP. IaC Code transmet l'entrée d'outil du modèle au serveur MCP, puis convertit les blocs de contenu MCP en résultat d'outil normal. + +Les annotations MCP sont respectées quand c'est possible : + +| Annotation MCP | Comportement IaC Code | +|---|---| +| `readOnlyHint: true` | L'outil est traité comme lecture seule et sûr pour la concurrence. | +| `destructiveHint: true` | L'outil est traité comme destructif pour les décisions d'autorisation. | + +Les outils MCP passent toujours par le système d'autorisations existant de IaC Code. Configurez la politique avec les settings `permissions` ou les flags CLI comme `--allowed-tools`, `--disallowed-tools` et `--permission-mode`. + +Les notifications de progression MCP sont exposées dans le rendu interactif, la sortie de progression headless, les mises à jour de progression ACP et les métadonnées d'outil A2A. + +## Résultats d'outils et artefacts + +IaC Code convertit les blocs de contenu MCP en texte visible par le modèle : + +| Contenu MCP | Résultat IaC Code | +|---|---| +| Contenu texte | Inclus directement dans le résultat d'outil. | +| `structuredContent` | Rendu comme JSON formaté dans une section de contenu structuré. | +| Ressources texte | Rendues avec la provenance du serveur et de l'URI. | +| `resource_link` | Rendu comme lien de ressource avec URI et type MIME. | +| Images, audio et blobs | Stockés comme fichiers d'artefact privés et référencés par id d'artefact. | + +Les artefacts binaires sont stockés sous : + +```text +/tool-results//mcp/// +``` + +Le modèle voit l'id d'artefact et les métadonnées, pas les données base64 brutes. + +## Ressources + +Lorsqu'au moins un serveur connecté expose des ressources, IaC Code enregistre deux outils globaux : + +| Outil | Usage | +|---|---| +| `list_mcp_resources` | Liste les ressources des serveurs MCP connectés. Filtrage optionnel par nom de serveur. | +| `read_mcp_resource` | Lit une ressource avec `server` et `uri`. | + +Les lignes de ressource incluent le nom du serveur, l'URI, un nom de ressource optionnel et un type MIME optionnel. + +## Prompts + +Les prompts MCP deviennent des commandes slash : + +```text +/mcp____ key=value +``` + +À l'invocation, IaC Code appelle MCP `prompts/get`, rend les messages de prompt retournés, injecte le prompt rendu dans la conversation et laisse le modèle continuer. Les arguments peuvent être passés ainsi : + +```text +template_name=prod-vpc region=cn-hangzhou +``` + +ou en JSON : + +```json +{"template_name": "prod-vpc", "region": "cn-hangzhou"} +``` + +Les arguments obligatoires sont validés avant l'appel MCP. Les valeurs entre guillemets sont prises en charge, y compris les chemins Windows avec antislashs. + +## Compétences + +Les ressources MCP avec des URI `skill://` deviennent des commandes de compétence : + +```text +$mcp____ +``` + +IaC Code lit la ressource de compétence distante, analyse le frontmatter et l'enregistre comme commande de compétence normale. Les compétences MCP distantes sont limitées pour la sécurité : + +- Les `allowed_tools` distants sont effacés. +- Les règles d'auto-activation par chemins distants sont effacées. +- Le corps et la description de compétence distante sont bornés en longueur. +- Si la compétence distante entre en conflit avec une commande existante, elle est ignorée avec un avertissement MCP. + +Les ressources de compétence MCP peuvent être lues au démarrage pour que la commande soit enregistrée avant son invocation par l'utilisateur. + +## Mises à jour dynamiques + +Si un serveur MCP envoie `tools/list_changed`, `resources/list_changed` ou `prompts/list_changed`, IaC Code rafraîchit la liste de capacités concernée et met à jour le registre d'outils ou de commandes. Les échecs de rafraîchissement sont signalés comme avertissements MCP et n'arrêtent pas la session active. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/configuration.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/configuration.md new file mode 100644 index 00000000..46d33ed1 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/configuration.md @@ -0,0 +1,127 @@ +--- +sidebar_position: 2 +title: Configuration MCP +description: Configurez les serveurs MCP avec des commandes CLI, des fichiers settings, des fichiers projet et des sessions ACP. +--- + +# Configuration MCP + +Les serveurs MCP sont configurés sous l'objet `mcpServers`. IaC Code prend en charge un schéma central compatible avec Claude Code pour les serveurs `stdio`, `http` et `sse`. + +## Sources de configuration + +IaC Code lit les serveurs MCP depuis ces sources : + +| Source | Scope | Fichier ou point d'entrée | Modèle de confiance | +|---|---|---|---| +| Settings utilisateur | `user` | `~/.iac-code/settings.yml` ou `IAC_CODE_CONFIG_DIR/settings.yml` | Approuvé par l'utilisateur courant. | +| Settings locaux du projet | `local` | `/.iac-code/settings.local.yml` | Privé au checkout local. | +| Fichier MCP de projet | `project` | `/.mcp.json` | Partagé avec le projet et nécessite une approbation locale. | +| Configuration de session ACP | `session` | `mcp_servers` transmis par un client ACP | S'applique uniquement au runtime de cette session ACP. | + +La priorité est user, project, local, puis session. Les sources plus tardives remplacent les précédentes par nom de serveur. Les configurations équivalentes sont aussi dédupliquées par signature de contenu. + +Les fichiers `.mcp.json` de projet sont découverts depuis la racine du workspace jusqu'au répertoire courant. Les fichiers enfant remplacent les fichiers parent par nom de serveur. + +## Commandes CLI + +Utilisez `iac-code mcp` pour gérer la configuration MCP persistée : + +```bash +iac-code mcp add local-catalog \ + --scope local \ + --command python \ + --arg ./tools/catalog_mcp.py +``` + +```bash +iac-code mcp add remote-reviewer \ + --scope user \ + --type http \ + --url https://mcp.example.com/mcp \ + --header "Authorization=${MCP_REVIEWER_TOKEN}" +``` + +Commandes disponibles : + +| Commande | Usage | +|---|---| +| `iac-code mcp add` | Ajoute un serveur depuis des flags CLI structurés. | +| `iac-code mcp add-json` | Ajoute un serveur depuis un objet JSON. | +| `iac-code mcp list` | Liste les serveurs configurés, scopes, transports et états d'approbation. | +| `iac-code mcp get` | Affiche une configuration de serveur avec secrets masqués. | +| `iac-code mcp remove` | Supprime un serveur d'un scope persistant. | +| `iac-code mcp approve` | Approuve un serveur `.mcp.json` de projet. | +| `iac-code mcp reject` | Rejette un serveur `.mcp.json` de projet. | +| `iac-code mcp reset-project-choices` | Efface les choix d'approbation de projet enregistrés. | +| `iac-code mcp auth` | Démarre l'authentification OAuth pour un serveur. | +| `iac-code mcp reset-auth` | Supprime les tokens OAuth et le client secret enregistrés pour un serveur. | + +Quand `--scope` est omis, IaC Code écrit dans `local` à l'intérieur d'un projet et dans `user` en dehors d'un projet. + +## Serveurs Stdio + +Les serveurs stdio lancent une commande locale : + +```json +{ + "mcpServers": { + "catalog": { + "command": "python", + "args": ["./tools/catalog_mcp.py"], + "env": { + "CATALOG_ENV": "prod" + } + } + } +} +``` + +Le champ `type` peut être omis quand `command` est présent. IaC Code transmet un environnement hérité sûr plus le `env` du serveur. Sous Windows, préférez `cmd /c npx` à `npx` seul pour les serveurs Node. + +## Serveurs HTTP et SSE + +Les serveurs distants nécessitent `type` et `url` : + +```json +{ + "mcpServers": { + "reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "${MCP_REVIEWER_TOKEN}" + } + } + } +} +``` + +Utilisez `type: "sse"` pour les serveurs SSE. Les headers statiques sont pris en charge. Les commandes dynamiques `headersHelper` sont rejetées, car elles exigent une conception séparée d'exécution de confiance. + +## Expansion d'environnement + +Les chaînes prennent en charge : + +```text +${VAR} +${VAR:-default-value} +``` + +Les variables manquantes sans valeur par défaut produisent un avertissement MCP et le serveur concerné est ignoré. L'expansion s'applique récursivement aux chaînes dans les listes et objets. + +Ne stockez pas de secrets en clair dans les headers ou les valeurs env. Utilisez des références à des variables d'environnement ou le stockage secret OAuth. + +## Approbation de projet + +Un `.mcp.json` de projet peut être commité dans un dépôt ; IaC Code ne lui fait donc pas confiance automatiquement. + +Au démarrage du REPL interactif, IaC Code demande : + +```text +Approve project MCP server 'name' from /path/to/.mcp.json? [y/N] +``` + +Appuyer sur Entrée garde la valeur par défaut `N` et rejette cette configuration de serveur de projet. Tapez `y` ou `yes` pour l'approuver. L'approbation est stockée localement dans le répertoire de configuration IaC Code et inclut le chemin du workspace, le chemin du fichier projet, le nom du serveur et la signature de configuration. Si la configuration du serveur dans `.mcp.json` change, l'approbation est invalidée et le serveur redevient pending. + +Les démarrages headless, ACP et A2A ne posent jamais de question d'approbation interactive. Les serveurs de projet non approuvés sont ignorés et signalés comme avertissements. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md new file mode 100644 index 00000000..6734fe05 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -0,0 +1,110 @@ +--- +sidebar_position: 4 +title: OAuth et sécurité +description: Authentifiez les serveurs MCP distants et comprenez le modèle de sécurité MCP dans IaC Code. +--- + +# OAuth et sécurité + +MCP peut démarrer des processus locaux et appeler des services distants. IaC Code traite donc la configuration MCP et l'authentification comme sensibles. + +## OAuth + +Les serveurs distants `http` et `sse` peuvent utiliser OAuth. Configurez les métadonnées OAuth dans la configuration du serveur : + +```json +{ + "mcpServers": { + "secure-reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "oauth": { + "clientId": "iac-code", + "clientSecretEnv": "MCP_CLIENT_SECRET", + "callbackPort": 38487, + "authServerMetadataUrl": "https://auth.example.com/.well-known/oauth-authorization-server" + } + } + } +} +``` + +Champs OAuth pris en charge : + +| Champ | Usage | +|---|---| +| `clientId` | Identifiant client OAuth. | +| `clientSecretEnv` | Variable d'environnement contenant le secret client. | +| `callbackPort` | Port de callback loopback optionnel. Utilisez `0` ou omettez-le pour choisir un port libre. | +| `authServerMetadataUrl` | URL optionnelle explicite des métadonnées du serveur d'autorisation. | + +`oauth.clientSecret` en clair est rejeté. Utilisez `clientSecretEnv` ou le prompt CLI sécurisé. + +## Authentification + +Exécutez : + +```bash +iac-code mcp auth secure-reviewer --scope user +``` + +IaC Code ouvre ou affiche une URL d'autorisation et démarre un serveur de callback loopback sur `127.0.0.1`. Après la redirection du fournisseur avec un code d'autorisation, IaC Code l'échange contre des tokens et les stocke de façon sécurisée. + +Si un serveur a besoin d'authentification pendant une session normale, IaC Code enregistre un outil d'authentification : + +```text +mcp____authenticate +``` + +Le modèle peut appeler cet outil pour fournir l'URL OAuth à l'utilisateur. Une fois le flux terminé, IaC Code reconnecte le serveur MCP et rafraîchit les capacités découvertes. + +## Stockage des tokens + +IaC Code stocke les tokens OAuth et les secrets client MCP via `MCPSecretStorage` : + +1. Il essaie d'abord le keyring du système d'exploitation quand il est disponible. +2. Si le keyring est désactivé ou indisponible, il stocke des données fallback chiffrées sous `/mcp/`. +3. Les permissions de fichier sont restreintes pour la clé fallback et le magasin de secrets chiffré. + +Définissez `IAC_CODE_MCP_DISABLE_KEYRING=1` pour forcer le stockage fallback chiffré, utile pour les tests isolés. + +Utilisez cette commande pour effacer l'état d'authentification stocké : + +```bash +iac-code mcp reset-auth secure-reviewer --scope user +``` + +## Confiance de projet + +Les fichiers `.mcp.json` de projet ne sont pas approuvés automatiquement, car un dépôt peut ajouter un serveur `stdio` qui exécute du code local arbitraire. L'approbation interactive est liée à la signature de configuration du serveur. Modifier command, args, env, URL, headers ou OAuth config invalide l'approbation précédente. + +Les modes headless et serveur de protocole ignorent les serveurs de projet non approuvés au lieu de demander confirmation. + +## Gestion des secrets + +IaC Code protège les secrets de plusieurs façons : + +- La sortie de `iac-code mcp get` masque les clés qui ressemblent à des tokens, secrets, mots de passe, clés API et headers d'autorisation. +- Les valeurs sensibles de headers ou env en clair sont rejetées sauf si elles utilisent une référence à une variable d'environnement. +- Les serveurs MCP stdio héritent seulement d'une allowlist de variables d'environnement sûres plus l'env explicite du serveur. +- Les variables proxy contenant un nom d'utilisateur ou un mot de passe ne sont pas héritées par les serveurs MCP stdio. +- Les fichiers d'artefact MCP sont écrits sous le répertoire privé de configuration runtime de IaC Code. + +## Autorisations + +Les outils MCP utilisent le même système d'autorisations que les outils intégrés. Un serveur MCP distant ne peut pas contourner les contrôles d'IaC Code simplement en annonçant un outil. Gardez en tête : + +- Les outils MCP en lecture seule peuvent être autorisés automatiquement selon la politique active. +- Les outils MCP destructifs doivent demander approbation sauf s'ils sont explicitement autorisés. +- En automatisation headless, combinez `--permission-mode`, `--allowed-tools` et `--disallowed-tools` pour limiter ce que les outils MCP peuvent faire. +- Les compétences MCP distantes n'accordent pas leurs propres `allowed_tools`. + +## Fonctions sensibles non prises en charge + +IaC Code rejette ou omet volontairement ces fonctions MCP pour l'instant : + +- Commandes dynamiques `headersHelper`. +- Interface d'elicitation MCP. +- Transports WebSocket, IDE et SDK. +- Politique MCP d'entreprise gérée. +- IaC Code comme serveur MCP. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/overview.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/overview.md new file mode 100644 index 00000000..021a3534 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: Intégration MCP +description: Utilisez des serveurs Model Context Protocol pour étendre IaC Code avec des outils, ressources, prompts et compétences externes. +--- + +# Intégration MCP + +IaC Code peut agir comme hôte Model Context Protocol (MCP). Les serveurs MCP étendent l'agent avec des outils externes, des ressources, des prompts et des compétences réutilisables, tout en passant par les mécanismes d'autorisations, de session, de journalisation et de traitement de sortie de IaC Code. + +Utilisez MCP lorsque vous voulez que IaC Code appelle une capacité locale ou distante qui n'est pas intégrée au produit, par exemple un catalogue privé de modèles, un outil interne de revue de déploiement, un service d'inventaire ou un outil spécialisé d'opérations cloud. + +## Surfaces prises en charge + +| Surface | Prise en charge MCP | +|---|---| +| REPL interactif | Charge les serveurs utilisateur, locaux et de projet approuvés. Demande confirmation avant de faire confiance à de nouveaux serveurs de projet `.mcp.json`. | +| Mode non interactif | Charge les serveurs utilisateur, locaux et de projet approuvés. Ne demande jamais de confirmation ; les serveurs de projet en attente sont ignorés avec des avertissements. | +| Serveur ACP | Accepte les configurations MCP transmises par les clients ACP lors de la session et expose les capacités MCP découvertes dans cette session. | +| Serveur A2A | Charge MCP via le runtime normal et peut publier les avertissements MCP et la progression des outils dans les métadonnées de tâche A2A. | +| Mode pipeline | Utilise les mêmes intégrations runtime que le mode normal, avec propagation de la progression des outils MCP et des avertissements. | + +## Capacités prises en charge + +| Capacité | État | +|---|---| +| Transport `stdio` | Pris en charge pour les processus MCP locaux. | +| Transport Streamable HTTP | Pris en charge pour les serveurs MCP distants. | +| Transport SSE | Pris en charge pour les serveurs MCP distants. | +| Outils MCP | Exposés comme outils d'agent nommés `mcp____`. | +| Ressources MCP | Exposées via `list_mcp_resources` et `read_mcp_resource`. | +| Prompts MCP | Exposés comme commandes slash nommées `mcp____`. | +| Ressources MCP `skill://` | Exposées comme commandes de compétence nommées `mcp____`. | +| Authentification OAuth loopback | Prise en charge pour les serveurs distants avec métadonnées OAuth. | +| `roots/list` | Pris en charge. IaC Code renvoie la racine du workspace active sous forme d'URI file. | +| Notifications `list_changed` | Prises en charge pour les outils, ressources et prompts. Les enregistrements sont rafraîchis dynamiquement. | +| Elicitation MCP | Pas encore prise en charge. Les serveurs qui demandent l'elicitation reçoivent une erreur explicite. | +| Transports WebSocket, SDK, IDE | Non pris en charge. | +| Commandes dynamiques `headersHelper` | Non prises en charge. Utilisez des headers statiques ou des références à des variables d'environnement. | +| IaC Code comme serveur MCP | Non pris en charge. IaC Code agit actuellement uniquement comme hôte MCP. | + +## Fonctionnement + +À l'exécution, IaC Code : + +1. Charge la configuration MCP depuis les sources utilisateur, locales, projet et session. +2. Développe les références `${VAR}` et `${VAR:-default}`. +3. Ignore les serveurs non sûrs ou invalides avec des avertissements visibles par l'utilisateur. +4. Se connecte aux serveurs approuvés avec une concurrence bornée. +5. Découvre les outils, ressources, prompts et ressources `skill://`. +6. Enregistre ces capacités dans les registres d'outils et de commandes existants. +7. Convertit les résultats d'outils MCP en résultats d'outils IaC Code normaux et stocke les artefacts binaires dans le répertoire de configuration runtime. +8. Déconnecte les clients MCP quand le REPL, l'exécution headless, la session ACP ou le runtime A2A se ferme. + +L'échec d'un serveur MCP ne bloque pas les autres serveurs configurés. Les échecs de connexion et de découverte restent visibles comme avertissements MCP. + +## Nommage + +Les outils et commandes MCP sont normalisés en noms publics : + +```text +mcp____ +mcp____ +mcp____ +``` + +Les caractères autres que lettres, chiffres et underscores deviennent des underscores. Si plusieurs capacités entrent en collision après normalisation, IaC Code ajoute un court digest pour garder des noms uniques. + +## Pages associées + +- [Configuration MCP](./configuration.md) +- [Outils, ressources, prompts et compétences](./capabilities.md) +- [OAuth et sécurité](./oauth-and-security.md) +- [Dépannage](./troubleshooting.md) diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md new file mode 100644 index 00000000..5b8d95ec --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 5 +title: Dépannage MCP +description: Diagnostiquez les problèmes de configuration, connexion, authentification et découverte de capacités MCP. +--- + +# Dépannage MCP + +Les avertissements MCP ne sont pas fatals sauf si toutes les capacités dont vous avez besoin sont indisponibles. Un serveur défaillant ne doit pas empêcher les autres serveurs MCP ou les outils intégrés d'IaC Code de fonctionner. + +## Inspecter la configuration + +Lister les serveurs configurés : + +```bash +iac-code mcp list +``` + +Inspecter une configuration de serveur masquée : + +```bash +iac-code mcp get my-server --scope local +``` + +Supprimer un mauvais serveur : + +```bash +iac-code mcp remove my-server --scope local +``` + +Effacer les choix d'approbation de projet : + +```bash +iac-code mcp reset-project-choices +``` + +## Serveur de projet en attente + +Symptôme : + +```text +Project MCP server 'name' is pending approval. +``` + +Correction : + +```bash +iac-code mcp approve name +``` + +ou démarrez le REPL interactif dans ce projet et répondez `y` à la question. Appuyer sur Entrée signifie `N` et rejette le serveur. + +Si l'approbation fonctionnait puis a cessé de fonctionner, vérifiez si `.mcp.json` a changé. L'approbation est liée à la signature de configuration. + +## Variable d'environnement manquante + +Symptôme : + +```text +Environment variable 'TOKEN' is not set for MCP config. +``` + +Corrigez avec l'une de ces options : + +```bash +export TOKEN=... +``` + +ou utilisez une valeur par défaut : + +```json +"Authorization": "${TOKEN:-}" +``` + +Les serveurs avec des variables d'environnement requises manquantes sont ignorés. + +## Échec de connexion + +Pour les serveurs stdio : + +- Vérifiez que `command` existe sur le `PATH`. +- Utilisez des chemins absolus pour les scripts quand le lancement se fait depuis plusieurs répertoires. +- Sous Windows, lancez les serveurs Node avec `cmd /c npx`. +- Vérifiez que toutes les variables d'environnement requises sont configurées. + +Pour les serveurs HTTP ou SSE : + +- Vérifiez l'URL et le type de transport. +- Vérifiez TLS et les paramètres proxy. +- Confirmez que les headers statiques sont présents et ne contiennent pas de secrets en clair. +- Exécutez `iac-code mcp auth ` si le serveur exige OAuth. + +## Authentification requise + +Symptôme : + +```text +MCP server 'name' requires authentication. +``` + +Correction : + +```bash +iac-code mcp auth name --scope user +``` + +Si le serveur utilise des refresh tokens OAuth et exige une réauthentification, IaC Code efface les tokens obsolètes et demande un nouveau flux. + +## Échec de découverte de capacité + +Les symptômes peuvent inclure : + +```text +MCP server 'name' tools discovery failed: ... +MCP server 'name' resources discovery failed: ... +MCP server 'name' prompts discovery failed: ... +``` + +Le serveur est connecté, mais une liste de capacités a échoué. D'autres capacités du même serveur peuvent encore fonctionner. Corrigez l'erreur côté serveur, puis redémarrez IaC Code ou déclenchez un reconnect/auth refresh. + +## Ressources manquantes + +`list_mcp_resources` est enregistré seulement lorsqu'au moins un serveur connecté expose des ressources. Si l'outil manque : + +- Confirmez que le serveur est connecté. +- Confirmez que le serveur prend en charge `resources/list`. +- Vérifiez les avertissements de démarrage pour des erreurs de discovery de ressources. + +## Commande prompt ou compétence manquante + +Les commandes de prompt et de compétence apparaissent seulement après une discovery réussie. Vérifiez : + +- Le prompt ou la ressource `skill://` existe sur le serveur MCP. +- Le nom de commande normalisé n'entre pas en conflit avec une commande intégrée. +- La ressource de compétence distante peut être lue avant le timeout de démarrage. +- La description et le corps de compétence respectent les limites de sécurité d'IaC Code. + +## Logs et artefacts + +Les logs runtime se trouvent par défaut dans : + +```text +/logs/ +``` + +ou dans `IAC_CODE_LOG_DIR` si défini. + +Les artefacts binaires produits par les résultats d'outils MCP sont stockés sous : + +```text +/tool-results//mcp/ +``` + +Évitez de partager les répertoires config, log ou artefact sans vérifier qu'ils ne contiennent pas de secrets. diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current.json b/website/i18n/ja/docusaurus-plugin-content-docs/current.json index 0c91d9ae..b94e9bcd 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current.json @@ -11,6 +11,10 @@ "message": "IaC Code の使い方", "description": "The label for category 'Using iac-code' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.MCP Integration": { + "message": "MCP 連携", + "description": "The label for category 'MCP Integration' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Configuration": { "message": "設定", "description": "The label for category 'Configuration' in sidebar 'docsSidebar'" diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/capabilities.md new file mode 100644 index 00000000..a4cfb430 --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -0,0 +1,104 @@ +--- +sidebar_position: 3 +title: ツール、リソース、プロンプト、スキル +description: MCP 機能が IaC Code 内でどのように見えるかを説明します。 +--- + +# ツール、リソース、プロンプト、スキル + +接続済み MCP servers は 4 種類の機能を IaC Code に公開できます。 + +## Tools + +各 MCP tool は IaC Code tool になります。 + +```text +mcp____ +``` + +Tool description と JSON input schema は MCP server から取得されます。IaC Code はモデルの tool input を MCP server に転送し、MCP content blocks を通常の tool result に変換します。 + +可能な場合、MCP tool annotations は尊重されます。 + +| MCP annotation | IaC Code の動作 | +|---|---| +| `readOnlyHint: true` | tool は読み取り専用で並行実行安全として扱われます。 | +| `destructiveHint: true` | tool は権限判断で破壊的操作として扱われます。 | + +MCP tools も IaC Code の既存権限システムを通ります。通常の `permissions` settings、または `--allowed-tools`、`--disallowed-tools`、`--permission-mode` などの CLI flags で権限ポリシーを設定します。 + +MCP progress notifications は、対話型 rendering、headless progress output、ACP tool progress updates、A2A tool metadata に表示されます。 + +## Tool Results と Artifacts + +IaC Code は MCP content blocks をモデルに見えるテキストへ変換します。 + +| MCP content | IaC Code result | +|---|---| +| Text content | tool result に直接含めます。 | +| `structuredContent` | structured-content section に整形済み JSON として表示します。 | +| Text resources | server と URI の出所情報付きで表示します。 | +| `resource_link` | URI と MIME type 付きの resource link として表示します。 | +| Image、audio、blob data | 非公開 artifact ファイルとして保存し、artifact id で参照します。 | + +バイナリ artifacts は次の場所に保存されます。 + +```text +/tool-results//mcp/// +``` + +モデルが見るのは artifact id と metadata であり、raw base64 data ではありません。 + +## Resources + +接続済み server のいずれかが resources を公開すると、IaC Code は 2 つの global tools を登録します。 + +| Tool | 用途 | +|---|---| +| `list_mcp_resources` | 接続済み MCP servers の resources を一覧表示します。server name で任意に絞り込めます。 | +| `read_mcp_resource` | `server` と `uri` で 1 つの resource を読み取ります。 | + +Resource 行には server name、URI、任意の resource name、任意の MIME type が含まれます。 + +## Prompts + +MCP prompts は slash commands になります。 + +```text +/mcp____ key=value +``` + +呼び出すと、IaC Code は MCP `prompts/get` を呼び出し、返された prompt messages をレンダリングし、レンダリング済み prompt を会話に注入してモデルを続行させます。Prompt arguments は次の形式で渡せます。 + +```text +template_name=prod-vpc region=cn-hangzhou +``` + +または JSON で渡せます。 + +```json +{"template_name": "prod-vpc", "region": "cn-hangzhou"} +``` + +Required prompt arguments は MCP call の前に検証されます。引用符付きの値に対応し、バックスラッシュを含む Windows paths も扱えます。 + +## Skills + +`skill://` URI を持つ MCP resources は skill commands になります。 + +```text +$mcp____ +``` + +IaC Code は remote skill resource を読み取り、frontmatter を解析し、通常の skill command として登録します。Remote MCP skills には安全上の制限があります。 + +- Remote `allowed_tools` は消去されます。 +- Remote auto-trigger path rules は消去されます。 +- Remote skill body と description には長さ制限があります。 +- Remote skill が既存 command と衝突する場合、MCP warning とともにスキップされます。 + +MCP skill resources は、ユーザーが呼び出す前に command を登録できるよう、起動時に読み取られる場合があります。 + +## 動的更新 + +MCP server が `tools/list_changed`、`resources/list_changed`、`prompts/list_changed` を送信すると、IaC Code は対象の capability list を更新し、tool または command registry を更新します。更新失敗は MCP warning として報告され、アクティブなセッションを停止しません。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/configuration.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/configuration.md new file mode 100644 index 00000000..54670b0e --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/configuration.md @@ -0,0 +1,127 @@ +--- +sidebar_position: 2 +title: MCP 設定 +description: CLI コマンド、settings ファイル、プロジェクトファイル、ACP セッションで MCP server を設定します。 +--- + +# MCP 設定 + +MCP server は `mcpServers` オブジェクトの下に設定します。IaC Code は Claude Code と互換性のある中心的な schema をサポートし、`stdio`、`http`、`sse` servers に対応します。 + +## 設定ソース + +IaC Code は次のソースから MCP servers を読み込みます。 + +| ソース | Scope | ファイルまたは入口 | 信頼モデル | +|---|---|---|---| +| ユーザー settings | `user` | `~/.iac-code/settings.yml` または `IAC_CODE_CONFIG_DIR/settings.yml` | 現在のユーザーが信頼する設定。 | +| プロジェクトローカル settings | `local` | `/.iac-code/settings.local.yml` | ローカル checkout だけの非公開設定。 | +| プロジェクト MCP ファイル | `project` | `/.mcp.json` | プロジェクトで共有され、ローカル承認が必要。 | +| ACP セッション設定 | `session` | ACP client から渡される `mcp_servers` | その ACP session runtime のみに適用。 | + +優先順位は user、project、local、session です。後のソースは server name ごとに前のソースを上書きします。同等の設定は content signature でも重複排除されます。 + +プロジェクト `.mcp.json` ファイルは workspace root から現在のディレクトリまで探索されます。子プロジェクトのファイルは server name ごとに親のファイルを上書きします。 + +## CLI コマンド + +永続化された MCP 設定は `iac-code mcp` で管理します。 + +```bash +iac-code mcp add local-catalog \ + --scope local \ + --command python \ + --arg ./tools/catalog_mcp.py +``` + +```bash +iac-code mcp add remote-reviewer \ + --scope user \ + --type http \ + --url https://mcp.example.com/mcp \ + --header "Authorization=${MCP_REVIEWER_TOKEN}" +``` + +利用できるコマンド: + +| コマンド | 用途 | +|---|---| +| `iac-code mcp add` | 構造化された CLI flags から server を追加します。 | +| `iac-code mcp add-json` | JSON object から server を追加します。 | +| `iac-code mcp list` | 設定済み server、scope、transport、approval status を一覧表示します。 | +| `iac-code mcp get` | 1 つの server config を秘匿化して出力します。 | +| `iac-code mcp remove` | 永続化 scope から server を削除します。 | +| `iac-code mcp approve` | プロジェクト `.mcp.json` server を承認します。 | +| `iac-code mcp reject` | プロジェクト `.mcp.json` server を拒否します。 | +| `iac-code mcp reset-project-choices` | 保存された project approval choices を消去します。 | +| `iac-code mcp auth` | server の OAuth 認証を開始します。 | +| `iac-code mcp reset-auth` | server の保存済み OAuth tokens と client secret を削除します。 | + +`--scope` を省略した場合、IaC Code はプロジェクト内では `local`、プロジェクト外では `user` に書き込みます。 + +## Stdio Servers + +Stdio servers はローカルコマンドを起動します。 + +```json +{ + "mcpServers": { + "catalog": { + "command": "python", + "args": ["./tools/catalog_mcp.py"], + "env": { + "CATALOG_ENV": "prod" + } + } + } +} +``` + +`command` がある場合、`type` フィールドは省略できます。IaC Code は安全な継承環境と server の `env` を渡します。Windows では、Node-based server に裸の `npx` ではなく `cmd /c npx` を使うことを推奨します。 + +## HTTP と SSE Servers + +リモート server には `type` と `url` が必要です。 + +```json +{ + "mcpServers": { + "reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "${MCP_REVIEWER_TOKEN}" + } + } + } +} +``` + +SSE server には `type: "sse"` を使います。静的 headers はサポートされます。動的 `headersHelper` commands は、別途 trusted-execution design が必要なため拒否されます。 + +## 環境変数展開 + +文字列値では次を利用できます。 + +```text +${VAR} +${VAR:-default-value} +``` + +デフォルト値のない不足変数は MCP warning を発生させ、該当 server はスキップされます。環境変数展開は list と object 内の文字列にも再帰的に適用されます。 + +headers や env 値に plaintext secrets を保存しないでください。環境変数参照または OAuth secret storage を使ってください。 + +## プロジェクト承認 + +プロジェクト `.mcp.json` はリポジトリにコミットできるため、IaC Code は自動的には信頼しません。 + +対話型 REPL の起動時に次のように確認します。 + +```text +Approve project MCP server 'name' from /path/to/.mcp.json? [y/N] +``` + +Enter を押すと既定の `N` になり、そのプロジェクト server config を拒否します。承認するには `y` または `yes` を入力します。Approval は IaC Code config ディレクトリにローカル保存され、workspace path、project file path、server name、config signature を含みます。`.mcp.json` の server config が変わると approval は無効化され、server は再び pending になります。 + +Headless、ACP、A2A の起動時は対話的な approval 質問を行いません。未承認の project servers はスキップされ warning として報告されます。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md new file mode 100644 index 00000000..547879f2 --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -0,0 +1,110 @@ +--- +sidebar_position: 4 +title: OAuth とセキュリティ +description: リモート MCP server を認証し、IaC Code の MCP セキュリティモデルを理解します。 +--- + +# OAuth とセキュリティ + +MCP はローカルプロセスの起動やリモートサービス呼び出しができるため、IaC Code は MCP 設定と認証をセキュリティ上重要なものとして扱います。 + +## OAuth + +リモート `http` と `sse` servers は OAuth を利用できます。Server config に OAuth metadata を設定します。 + +```json +{ + "mcpServers": { + "secure-reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "oauth": { + "clientId": "iac-code", + "clientSecretEnv": "MCP_CLIENT_SECRET", + "callbackPort": 38487, + "authServerMetadataUrl": "https://auth.example.com/.well-known/oauth-authorization-server" + } + } + } +} +``` + +対応する OAuth fields: + +| Field | 用途 | +|---|---| +| `clientId` | OAuth client id。 | +| `clientSecretEnv` | client secret を含む環境変数。 | +| `callbackPort` | 任意の loopback callback port。`0` または省略で空きポートを選びます。 | +| `authServerMetadataUrl` | 任意の明示的な authorization server metadata URL。 | + +Plaintext `oauth.clientSecret` は拒否されます。`clientSecretEnv` または安全な CLI prompt を使ってください。 + +## 認証 + +次を実行します。 + +```bash +iac-code mcp auth secure-reviewer --scope user +``` + +IaC Code は authorization URL を開くか表示し、`127.0.0.1` で loopback callback server を起動します。Provider が authorization code 付きで戻ってくると、IaC Code は token と交換して安全に保存します。 + +通常セッション中に server が認証を必要とする場合、IaC Code は authentication tool を登録します。 + +```text +mcp____authenticate +``` + +モデルはこの tool を呼び出して、OAuth URL をユーザーに提示できます。フロー完了後、IaC Code は MCP server に再接続し、発見済み機能を更新します。 + +## Token Storage + +IaC Code は `MCPSecretStorage` で OAuth tokens と MCP client secrets を保存します。 + +1. 利用できる場合は OS keyring を優先します。 +2. keyring が無効または利用不可の場合、`/mcp/` に暗号化 fallback data を保存します。 +3. fallback key と暗号化 secret store には制限されたファイル権限を設定します。 + +隔離テストでは `IAC_CODE_MCP_DISABLE_KEYRING=1` を設定すると encrypted fallback storage を強制できます。 + +保存された auth state を消すには次を使います。 + +```bash +iac-code mcp reset-auth secure-reviewer --scope user +``` + +## プロジェクト信頼 + +プロジェクト `.mcp.json` は自動的には信頼されません。リポジトリが任意のローカルコードを実行する `stdio` server を追加できるためです。対話型 approval は server config signature ごとに紐づきます。command、args、env、URL、headers、OAuth config を変更すると以前の approval は無効になります。 + +Headless と protocol server modes は、未承認の project servers を確認せずスキップします。 + +## Secret Handling + +IaC Code は複数の方法で secrets を保護します。 + +- `iac-code mcp get` の config 出力では、token、secret、password、API key、authorization header に見える keys を秘匿化します。 +- 機密性の高い header または env 値の plaintext は、環境変数参照でない限り拒否されます。 +- MCP stdio servers は、安全な環境変数 allowlist と明示的な server env だけを継承します。 +- username または password を含む proxy 環境変数は stdio MCP servers に継承されません。 +- MCP artifact files は非公開の IaC Code runtime configuration directory に書き込まれます。 + +## 権限 + +MCP tools は組み込み tools と同じ権限フレームワークを使います。リモート MCP server は tool を広告するだけで IaC Code の権限チェックを回避することはできません。次の点に注意してください。 + +- Read-only MCP tools は、現在の権限ポリシーによって自動許可される場合があります。 +- Destructive MCP tools は明示的に許可されていない限り approval が必要です。 +- Headless automation では、`--permission-mode`、`--allowed-tools`、`--disallowed-tools` を組み合わせて MCP tools の操作範囲を制限します。 +- Remote MCP skills は独自の `allowed_tools` を付与しません。 + +## 未対応のセキュリティ関連機能 + +IaC Code は現在、次の MCP 機能を意図的に拒否または省略しています。 + +- `headersHelper` dynamic commands。 +- MCP elicitation UI。 +- WebSocket、IDE、SDK transports。 +- Enterprise managed MCP policy。 +- IaC Code を MCP server として実行すること。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/overview.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/overview.md new file mode 100644 index 00000000..bb9c9ded --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: MCP 連携 +description: Model Context Protocol サーバーを使って、外部ツール、リソース、プロンプト、スキルで IaC Code を拡張します。 +--- + +# MCP 連携 + +IaC Code は Model Context Protocol (MCP) のホストとして動作できます。MCP サーバーは、外部ツール、リソース、プロンプト、再利用可能なスキルでエージェントを拡張します。これらは引き続き IaC Code の権限、セッション、ログ、出力処理の経路を通ります。 + +製品に組み込まれていないローカルまたはリモート機能を IaC Code から呼び出したい場合に MCP を使います。例として、社内テンプレートカタログ、内部デプロイレビュー、インベントリ照会サービス、特殊なクラウド操作ツールがあります。 + +## 対応する利用面 + +| 利用面 | MCP 対応 | +|---|---| +| 対話型 REPL | ユーザー、ローカル、承認済みプロジェクトサーバーを読み込みます。新しいプロジェクト `.mcp.json` サーバーを信頼する前に確認します。 | +| 非対話モード | ユーザー、ローカル、承認済みプロジェクトサーバーを読み込みます。確認は行わず、保留中のプロジェクトサーバーは warning とともにスキップされます。 | +| ACP server | ACP client からセッション作成時に渡された MCP server 設定を受け取り、そのセッション内で発見した MCP 機能を公開します。 | +| A2A server | 通常の runtime 経由で MCP を読み込み、A2A task metadata に MCP warning とツール進捗を出力できます。 | +| Pipeline モード | normal モードと同じ runtime 連携を使い、MCP ツール進捗と warning の伝播を含みます。 | + +## 対応機能 + +| 機能 | 状態 | +|---|---| +| `stdio` transport | ローカル MCP server プロセスに対応。 | +| Streamable HTTP transport | リモート MCP server に対応。 | +| SSE transport | リモート MCP server に対応。 | +| MCP tools | `mcp____` という agent tool として公開。 | +| MCP resources | `list_mcp_resources` と `read_mcp_resource` で公開。 | +| MCP prompts | `mcp____` という slash command として公開。 | +| MCP `skill://` resources | `mcp____` という skill command として公開。 | +| OAuth loopback auth | OAuth metadata を持つリモートサーバーに対応。 | +| `roots/list` | 対応。IaC Code は現在の workspace root を file URI として返します。 | +| `list_changed` notifications | tools、resources、prompts に対応。登録情報は動的に更新されます。 | +| MCP elicitation | まだ未対応。elicitation を要求するサーバーには明確な未対応エラーを返します。 | +| WebSocket、SDK、IDE transports | 未対応。 | +| 動的 `headersHelper` commands | 未対応。静的 headers または環境変数参照を使ってください。 | +| IaC Code を MCP server として実行 | 未対応。現在の IaC Code は MCP host のみです。 | + +## 動作の流れ + +実行時、IaC Code は次の処理を行います。 + +1. ユーザー、ローカル、プロジェクト、セッションの各ソースから MCP 設定を読み込みます。 +2. `${VAR}` と `${VAR:-default}` 参照を展開します。 +3. 安全でない、または無効なサーバーをユーザーに見える warning とともにスキップします。 +4. 承認済みサーバーに制限付き並行数で接続します。 +5. tools、resources、prompts、`skill://` resources を発見します。 +6. それらの機能を既存の tool registry と command registry に登録します。 +7. MCP tool result を通常の IaC Code tool result に変換し、バイナリ artifact を runtime 設定ディレクトリに保存します。 +8. REPL、headless run、ACP session、A2A runtime の終了時に MCP client を切断します。 + +1 つの MCP server が失敗しても、他の設定済み server はブロックされません。接続と discovery の失敗は MCP warning として表示されます。 + +## 命名 + +MCP tools と commands は公開名に正規化されます。 + +```text +mcp____ +mcp____ +mcp____ +``` + +英数字とアンダースコア以外の文字はアンダースコアになります。正規化後に機能名が衝突した場合、IaC Code は短い digest を追加して名前を一意にします。 + +## 関連ページ + +- [MCP 設定](./configuration.md) +- [ツール、リソース、プロンプト、スキル](./capabilities.md) +- [OAuth とセキュリティ](./oauth-and-security.md) +- [トラブルシューティング](./troubleshooting.md) diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md new file mode 100644 index 00000000..fa758970 --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 5 +title: MCP トラブルシューティング +description: MCP 設定、接続、認証、機能 discovery の問題を診断します。 +--- + +# MCP トラブルシューティング + +MCP warnings は、必要な機能がすべて利用不可でない限り通常は致命的ではありません。1 つの server が失敗しても、他の MCP servers や IaC Code 組み込み tools は動作し続けるべきです。 + +## 設定を確認する + +設定済み servers を一覧表示します。 + +```bash +iac-code mcp list +``` + +秘匿化された server config を確認します。 + +```bash +iac-code mcp get my-server --scope local +``` + +問題のある server を削除します。 + +```bash +iac-code mcp remove my-server --scope local +``` + +project approval choices を消去します。 + +```bash +iac-code mcp reset-project-choices +``` + +## Project Server が Pending + +症状: + +```text +Project MCP server 'name' is pending approval. +``` + +修正: + +```bash +iac-code mcp approve name +``` + +または、そのプロジェクトで対話型 REPL を起動し、確認時に `y` と答えます。Enter は `N` を意味し、server を拒否します。 + +以前は approval が有効だったのに動かなくなった場合は、`.mcp.json` が変更されていないか確認してください。Approval は config signature に紐づきます。 + +## 環境変数がない + +症状: + +```text +Environment variable 'TOKEN' is not set for MCP config. +``` + +次のいずれかで修正します。 + +```bash +export TOKEN=... +``` + +または default を使います。 + +```json +"Authorization": "${TOKEN:-}" +``` + +必須環境変数が不足している servers はスキップされます。 + +## 接続失敗 + +stdio servers の場合: + +- `command` が `PATH` 上に存在することを確認します。 +- 別ディレクトリから起動する場合、スクリプトには絶対パスを使います。 +- Windows では Node-based servers を `cmd /c npx` 経由で実行します。 +- 必要な環境変数が設定されているか確認します。 + +HTTP または SSE servers の場合: + +- URL と transport type を確認します。 +- TLS と proxy settings を確認します。 +- 静的 headers があり、plaintext secrets を含んでいないことを確認します。 +- server が OAuth を要求する場合は `iac-code mcp auth ` を実行します。 + +## 認証が必要 + +症状: + +```text +MCP server 'name' requires authentication. +``` + +修正: + +```bash +iac-code mcp auth name --scope user +``` + +server が OAuth refresh tokens を使っていて再認証が必要な場合、IaC Code は古い tokens を消去し、新しいフローを要求します。 + +## Capability Discovery Failed + +症状の例: + +```text +MCP server 'name' tools discovery failed: ... +MCP server 'name' resources discovery failed: ... +MCP server 'name' prompts discovery failed: ... +``` + +server には接続できていますが、ある capability list が失敗しています。同じ server の他の機能は動作する場合があります。server 側のエラーを修正し、IaC Code を再起動するか reconnect/auth refresh を発生させてください。 + +## Resources が見つからない + +`list_mcp_resources` は、接続済み server の少なくとも 1 つが resources を公開している場合だけ登録されます。tool がない場合: + +- server が接続されていることを確認します。 +- server が `resources/list` をサポートしていることを確認します。 +- 起動時 warnings に resource discovery errors がないか確認します。 + +## Prompt または Skill Command が見つからない + +Prompt と skill commands は discovery 成功後にのみ現れます。次を確認してください。 + +- 対象の prompt または `skill://` resource が MCP server 上に存在する。 +- 正規化後の command name が built-in command と衝突していない。 +- Remote skill resource が起動時 timeout 内に読み取れる。 +- Skill description と body が IaC Code の安全制限内に収まっている。 + +## Logs と Artifacts + +Runtime logs の既定場所: + +```text +/logs/ +``` + +`IAC_CODE_LOG_DIR` が設定されている場合はそのディレクトリを使います。 + +MCP tool results の binary artifacts は次に保存されます。 + +```text +/tool-results//mcp/ +``` + +config、log、artifact directories を共有する前に、secrets が含まれていないか確認してください。 diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current.json b/website/i18n/pt/docusaurus-plugin-content-docs/current.json index 07710902..45770e4b 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current.json @@ -11,6 +11,10 @@ "message": "Usando o IaC Code", "description": "The label for category 'Using iac-code' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.MCP Integration": { + "message": "Integração MCP", + "description": "The label for category 'MCP Integration' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Configuration": { "message": "Configuracao", "description": "The label for category 'Configuration' in sidebar 'docsSidebar'" diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/capabilities.md new file mode 100644 index 00000000..63131350 --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -0,0 +1,104 @@ +--- +sidebar_position: 3 +title: Ferramentas, recursos, prompts e skills +description: Entenda como capacidades MCP aparecem dentro do IaC Code. +--- + +# Ferramentas, recursos, prompts e skills + +Servidores MCP conectados podem expor quatro tipos de capacidades ao IaC Code. + +## Ferramentas + +Cada ferramenta MCP se torna uma ferramenta do IaC Code: + +```text +mcp____ +``` + +Descrições de ferramentas e schemas JSON de entrada vêm do servidor MCP. O IaC Code encaminha a entrada da ferramenta do modelo ao servidor MCP e depois converte blocos de conteúdo MCP em um resultado normal de ferramenta. + +Anotações MCP são respeitadas quando possível: + +| Anotação MCP | Comportamento no IaC Code | +|---|---| +| `readOnlyHint: true` | A ferramenta é tratada como somente leitura e segura para concorrência. | +| `destructiveHint: true` | A ferramenta é tratada como destrutiva nas decisões de permissão. | + +Ferramentas MCP ainda passam pelo sistema de permissões existente do IaC Code. Configure a política com settings normais de `permissions` ou flags CLI como `--allowed-tools`, `--disallowed-tools` e `--permission-mode`. + +Notificações de progresso MCP são exibidas no render interativo, na saída de progresso headless, nas atualizações de progresso ACP e nos metadados de ferramenta A2A. + +## Resultados de ferramentas e artefatos + +O IaC Code converte blocos de conteúdo MCP em texto visível ao modelo: + +| Conteúdo MCP | Resultado do IaC Code | +|---|---| +| Conteúdo de texto | Incluído diretamente no resultado da ferramenta. | +| `structuredContent` | Renderizado como JSON formatado em uma seção de conteúdo estruturado. | +| Recursos de texto | Renderizados com proveniência de servidor e URI. | +| `resource_link` | Renderizado como link de recurso com URI e tipo MIME. | +| Dados de imagem, áudio e blob | Armazenados como arquivos privados de artefato e referenciados por id de artefato. | + +Artefatos binários são armazenados em: + +```text +/tool-results//mcp/// +``` + +O modelo vê o id do artefato e metadados, não dados base64 brutos. + +## Recursos + +Quando qualquer servidor conectado expõe recursos, o IaC Code registra duas ferramentas globais: + +| Ferramenta | Finalidade | +|---|---| +| `list_mcp_resources` | Lista recursos de servidores MCP conectados. Pode filtrar por nome de servidor. | +| `read_mcp_resource` | Lê um recurso por `server` e `uri`. | + +Linhas de recurso incluem nome do servidor, URI, nome de recurso opcional e tipo MIME opcional. + +## Prompts + +Prompts MCP se tornam comandos slash: + +```text +/mcp____ key=value +``` + +Ao invocar, o IaC Code chama MCP `prompts/get`, renderiza as mensagens de prompt retornadas, injeta o prompt renderizado na conversa e deixa o modelo continuar. Argumentos de prompt podem ser passados como: + +```text +template_name=prod-vpc region=cn-hangzhou +``` + +ou como JSON: + +```json +{"template_name": "prod-vpc", "region": "cn-hangzhou"} +``` + +Argumentos obrigatórios são validados antes da chamada MCP. Valores entre aspas são suportados, incluindo caminhos Windows com barras invertidas. + +## Skills + +Recursos MCP com URIs `skill://` se tornam comandos de skill: + +```text +$mcp____ +``` + +O IaC Code lê o recurso de skill remoto, analisa o frontmatter e o registra como um comando normal de skill. Skills MCP remotos têm limites de segurança: + +- `allowed_tools` remotos são limpos. +- Regras remotas de autoativação por paths são limpas. +- Corpo e descrição do skill remoto têm limites de tamanho. +- Se o skill remoto conflitar com um comando existente, ele é ignorado com um aviso MCP. + +Recursos de skill MCP podem ser lidos durante a inicialização para que o comando seja registrado antes da invocação pelo usuário. + +## Atualizações dinâmicas + +Se um servidor MCP envia `tools/list_changed`, `resources/list_changed` ou `prompts/list_changed`, o IaC Code atualiza a lista de capacidades afetada e o registro de ferramentas ou comandos. Falhas de atualização são relatadas como avisos MCP e não interrompem a sessão ativa. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/configuration.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/configuration.md new file mode 100644 index 00000000..ec56ef05 --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/configuration.md @@ -0,0 +1,127 @@ +--- +sidebar_position: 2 +title: Configuração MCP +description: Configure servidores MCP por comandos CLI, arquivos settings, arquivos de projeto e sessões ACP. +--- + +# Configuração MCP + +Servidores MCP são configurados no objeto `mcpServers`. O IaC Code suporta um schema central compatível com Claude Code para servidores `stdio`, `http` e `sse`. + +## Fontes de configuração + +O IaC Code lê servidores MCP destas fontes: + +| Fonte | Scope | Arquivo ou ponto de entrada | Modelo de confiança | +|---|---|---|---| +| Settings de usuário | `user` | `~/.iac-code/settings.yml` ou `IAC_CODE_CONFIG_DIR/settings.yml` | Confiável para o usuário atual. | +| Settings locais do projeto | `local` | `/.iac-code/settings.local.yml` | Privado do checkout local. | +| Arquivo MCP do projeto | `project` | `/.mcp.json` | Compartilhado com o projeto e exige aprovação local. | +| Configuração de sessão ACP | `session` | `mcp_servers` enviados por um cliente ACP | Aplica-se apenas ao runtime dessa sessão ACP. | + +A precedência é user, project, local e depois session. Fontes posteriores sobrescrevem fontes anteriores por nome de servidor. Configurações equivalentes também são deduplicadas por assinatura de conteúdo. + +Arquivos `.mcp.json` de projeto são descobertos da raiz do workspace até o diretório atual. Arquivos filhos sobrescrevem arquivos pais por nome de servidor. + +## Comandos CLI + +Use `iac-code mcp` para gerenciar a configuração MCP persistida: + +```bash +iac-code mcp add local-catalog \ + --scope local \ + --command python \ + --arg ./tools/catalog_mcp.py +``` + +```bash +iac-code mcp add remote-reviewer \ + --scope user \ + --type http \ + --url https://mcp.example.com/mcp \ + --header "Authorization=${MCP_REVIEWER_TOKEN}" +``` + +Comandos disponíveis: + +| Comando | Finalidade | +|---|---| +| `iac-code mcp add` | Adiciona um servidor usando flags CLI estruturados. | +| `iac-code mcp add-json` | Adiciona um servidor a partir de um objeto JSON. | +| `iac-code mcp list` | Lista servidores configurados, scopes, transportes e status de aprovação. | +| `iac-code mcp get` | Imprime uma configuração de servidor com segredos mascarados. | +| `iac-code mcp remove` | Remove um servidor de um scope persistido. | +| `iac-code mcp approve` | Aprova um servidor de projeto `.mcp.json`. | +| `iac-code mcp reject` | Rejeita um servidor de projeto `.mcp.json`. | +| `iac-code mcp reset-project-choices` | Limpa escolhas salvas de aprovação de projeto. | +| `iac-code mcp auth` | Inicia autenticação OAuth para um servidor. | +| `iac-code mcp reset-auth` | Exclui tokens OAuth e client secret armazenados para um servidor. | + +Quando `--scope` é omitido, o IaC Code grava em `local` dentro de um projeto e em `user` fora de um projeto. + +## Servidores Stdio + +Servidores stdio iniciam um comando local: + +```json +{ + "mcpServers": { + "catalog": { + "command": "python", + "args": ["./tools/catalog_mcp.py"], + "env": { + "CATALOG_ENV": "prod" + } + } + } +} +``` + +O campo `type` pode ser omitido quando `command` está presente. O IaC Code passa um ambiente herdado seguro mais o `env` do servidor. No Windows, prefira `cmd /c npx` em vez de `npx` puro para servidores baseados em Node. + +## Servidores HTTP e SSE + +Servidores remotos exigem `type` e `url`: + +```json +{ + "mcpServers": { + "reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "${MCP_REVIEWER_TOKEN}" + } + } + } +} +``` + +Use `type: "sse"` para servidores SSE. Headers estáticos são suportados. Comandos dinâmicos `headersHelper` são rejeitados porque exigem um design separado de execução confiável. + +## Expansão de ambiente + +Valores string suportam: + +```text +${VAR} +${VAR:-default-value} +``` + +Variáveis ausentes sem padrão geram um aviso MCP e o servidor afetado é ignorado. A expansão de ambiente é aplicada recursivamente a strings dentro de listas e objetos. + +Não armazene segredos em texto claro em headers ou valores env. Use referências a variáveis de ambiente ou armazenamento secreto OAuth. + +## Aprovação de projeto + +Um `.mcp.json` de projeto pode ser commitado no repositório, então o IaC Code não confia nele automaticamente. + +Na inicialização do REPL interativo, ele pergunta: + +```text +Approve project MCP server 'name' from /path/to/.mcp.json? [y/N] +``` + +Pressionar Enter mantém o padrão `N` e rejeita aquela configuração exata de servidor de projeto. Digite `y` ou `yes` para aprovar. A aprovação é armazenada localmente no diretório de configuração do IaC Code e inclui o caminho do workspace, o caminho do arquivo de projeto, o nome do servidor e a assinatura da configuração. Se a configuração do servidor em `.mcp.json` mudar, a aprovação é invalidada e o servidor volta a ficar pending. + +Inicializações headless, ACP e A2A nunca fazem perguntas interativas de aprovação. Servidores de projeto pendentes são ignorados e relatados como avisos. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md new file mode 100644 index 00000000..050851d0 --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -0,0 +1,110 @@ +--- +sidebar_position: 4 +title: OAuth e segurança +description: Autentique servidores MCP remotos e entenda o modelo de segurança MCP no IaC Code. +--- + +# OAuth e segurança + +MCP pode iniciar processos locais e chamar serviços remotos, então o IaC Code trata configuração e autenticação MCP como sensíveis para segurança. + +## OAuth + +Servidores remotos `http` e `sse` podem usar OAuth. Configure metadados OAuth na configuração do servidor: + +```json +{ + "mcpServers": { + "secure-reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "oauth": { + "clientId": "iac-code", + "clientSecretEnv": "MCP_CLIENT_SECRET", + "callbackPort": 38487, + "authServerMetadataUrl": "https://auth.example.com/.well-known/oauth-authorization-server" + } + } + } +} +``` + +Campos OAuth suportados: + +| Campo | Finalidade | +|---|---| +| `clientId` | Id do cliente OAuth. | +| `clientSecretEnv` | Variável de ambiente que contém o client secret. | +| `callbackPort` | Porta de callback loopback opcional. Use `0` ou omita para escolher uma porta livre. | +| `authServerMetadataUrl` | URL explícita opcional de metadados do servidor de autorização. | + +`oauth.clientSecret` em texto claro é rejeitado. Use `clientSecretEnv` ou o prompt CLI seguro. + +## Autenticação + +Execute: + +```bash +iac-code mcp auth secure-reviewer --scope user +``` + +O IaC Code abre ou imprime uma URL de autorização e inicia um servidor de callback loopback em `127.0.0.1`. Depois que o provedor redireciona com um código de autorização, o IaC Code troca o código por tokens e os armazena com segurança. + +Se um servidor precisar de autenticação durante uma sessão normal, o IaC Code registra uma ferramenta de autenticação: + +```text +mcp____authenticate +``` + +O modelo pode chamar essa ferramenta para fornecer a URL OAuth ao usuário. Depois que o fluxo termina, o IaC Code reconecta o servidor MCP e atualiza as capacidades descobertas. + +## Armazenamento de tokens + +O IaC Code armazena tokens OAuth e secrets de cliente MCP por meio de `MCPSecretStorage`: + +1. Tenta usar o keyring do sistema operacional quando disponível. +2. Se o keyring estiver desativado ou indisponível, armazena dados fallback criptografados em `/mcp/`. +3. Permissões de arquivo são restringidas para a chave fallback e o armazenamento criptografado. + +Defina `IAC_CODE_MCP_DISABLE_KEYRING=1` para forçar o armazenamento fallback criptografado, útil para testes isolados. + +Use este comando para limpar o estado de autenticação armazenado: + +```bash +iac-code mcp reset-auth secure-reviewer --scope user +``` + +## Confiança de projeto + +Arquivos `.mcp.json` de projeto não são confiados automaticamente, porque um repositório pode adicionar um servidor `stdio` que executa código local arbitrário. A aprovação interativa é vinculada à assinatura da configuração do servidor. Alterar command, args, env, URL, headers ou OAuth config invalida a aprovação anterior. + +Modos headless e servidor de protocolo ignoram servidores de projeto não aprovados em vez de pedir confirmação. + +## Tratamento de segredos + +O IaC Code protege segredos de várias formas: + +- A saída de `iac-code mcp get` mascara chaves que parecem tokens, secrets, passwords, API keys e authorization headers. +- Valores sensíveis de headers ou env em texto claro são rejeitados, a menos que usem referência a variável de ambiente. +- Servidores MCP stdio herdam apenas uma allowlist de variáveis de ambiente seguras mais o env explícito do servidor. +- Variáveis de proxy com usernames ou passwords não são herdadas por servidores MCP stdio. +- Arquivos de artefato MCP são escritos no diretório privado de configuração runtime do IaC Code. + +## Permissões + +Ferramentas MCP usam o mesmo framework de permissões das ferramentas nativas. Um servidor MCP remoto não consegue contornar verificações de permissão do IaC Code apenas anunciando uma ferramenta. Lembre-se: + +- Ferramentas MCP somente leitura podem ser autoaprovadas dependendo da política ativa. +- Ferramentas MCP destrutivas devem exigir aprovação, salvo se explicitamente permitidas. +- Em automação headless, combine `--permission-mode`, `--allowed-tools` e `--disallowed-tools` para restringir o que ferramentas MCP podem fazer. +- Skills MCP remotos não concedem seus próprios `allowed_tools`. + +## Recursos sensíveis não suportados + +O IaC Code rejeita ou omite intencionalmente estes recursos MCP por enquanto: + +- Comandos dinâmicos `headersHelper`. +- Interface de elicitation MCP. +- Transportes WebSocket, IDE e SDK. +- Política MCP empresarial gerenciada. +- IaC Code como servidor MCP. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/overview.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/overview.md new file mode 100644 index 00000000..06dcf761 --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: Integração MCP +description: Use servidores Model Context Protocol para estender o IaC Code com ferramentas, recursos, prompts e skills externos. +--- + +# Integração MCP + +O IaC Code pode atuar como host do Model Context Protocol (MCP). Servidores MCP estendem o agente com ferramentas externas, recursos, prompts e skills reutilizáveis, mantendo o fluxo de permissões, sessão, logs e tratamento de saída do IaC Code. + +Use MCP quando quiser que o IaC Code chame uma capacidade local ou remota que não é nativa do produto, como um catálogo privado de templates, um revisor interno de implantação, um serviço de inventário ou uma ferramenta especializada de operações em nuvem. + +## Superfícies compatíveis + +| Superfície | Suporte MCP | +|---|---| +| REPL interativo | Carrega servidores de usuário, locais e de projeto aprovados. Pergunta antes de confiar em novos servidores de projeto `.mcp.json`. | +| Modo não interativo | Carrega servidores de usuário, locais e de projeto aprovados. Nunca pergunta; servidores de projeto pendentes são ignorados com avisos. | +| Servidor ACP | Aceita configurações MCP de clientes ACP na sessão e expõe as capacidades MCP descobertas dentro dessa sessão. | +| Servidor A2A | Carrega MCP pelo runtime normal e pode publicar avisos MCP e progresso de ferramentas nos metadados de tarefas A2A. | +| Modo pipeline | Usa as mesmas integrações de runtime do modo normal, incluindo progresso de ferramentas MCP e propagação de avisos. | + +## Capacidades compatíveis + +| Capacidade | Status | +|---|---| +| Transporte `stdio` | Compatível com processos MCP locais. | +| Transporte Streamable HTTP | Compatível com servidores MCP remotos. | +| Transporte SSE | Compatível com servidores MCP remotos. | +| Ferramentas MCP | Expostas como ferramentas de agente chamadas `mcp____`. | +| Recursos MCP | Expostos por `list_mcp_resources` e `read_mcp_resource`. | +| Prompts MCP | Expostos como comandos slash chamados `mcp____`. | +| Recursos MCP `skill://` | Expostos como comandos de skill chamados `mcp____`. | +| Autenticação OAuth loopback | Compatível com servidores remotos que têm metadados OAuth. | +| `roots/list` | Compatível. O IaC Code retorna a raiz ativa do workspace como URI file. | +| Notificações `list_changed` | Compatíveis para ferramentas, recursos e prompts. Os registros são atualizados dinamicamente. | +| MCP elicitation | Ainda não compatível. Servidores que solicitam elicitation recebem um erro claro de não suporte. | +| Transportes WebSocket, SDK e IDE | Não compatíveis. | +| Comandos dinâmicos `headersHelper` | Não compatíveis. Use headers estáticos ou referências a variáveis de ambiente. | +| IaC Code como servidor MCP | Não compatível. Atualmente o IaC Code atua apenas como host MCP. | + +## Como funciona + +Em tempo de execução, o IaC Code: + +1. Carrega configuração MCP de fontes de usuário, locais, de projeto e de sessão. +2. Expande referências `${VAR}` e `${VAR:-default}`. +3. Ignora servidores inseguros ou inválidos com avisos visíveis ao usuário. +4. Conecta servidores aprovados com concorrência limitada. +5. Descobre ferramentas, recursos, prompts e recursos `skill://`. +6. Registra essas capacidades nos registros existentes de ferramentas e comandos. +7. Converte resultados de ferramentas MCP em resultados normais do IaC Code e armazena artefatos binários no diretório de configuração runtime. +8. Desconecta clientes MCP quando o REPL, a execução headless, a sessão ACP ou o runtime A2A é fechado. + +Um servidor MCP com falha não bloqueia outros servidores configurados. Falhas de conexão e descoberta continuam visíveis como avisos MCP. + +## Nomes + +Ferramentas e comandos MCP são normalizados como nomes públicos: + +```text +mcp____ +mcp____ +mcp____ +``` + +Caracteres fora de letras, números e sublinhados viram sublinhados. Se capacidades descobertas colidirem depois da normalização, o IaC Code adiciona um digest curto para manter nomes únicos. + +## Páginas relacionadas + +- [Configuração MCP](./configuration.md) +- [Ferramentas, recursos, prompts e skills](./capabilities.md) +- [OAuth e segurança](./oauth-and-security.md) +- [Solução de problemas](./troubleshooting.md) diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md new file mode 100644 index 00000000..fcc80fbf --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 5 +title: Solução de problemas MCP +description: Diagnostique problemas de configuração, conexão, autenticação e descoberta de capacidades MCP. +--- + +# Solução de problemas MCP + +Avisos MCP não são fatais a menos que toda capacidade necessária esteja indisponível. Um servidor com falha não deve impedir que outros servidores MCP ou ferramentas nativas do IaC Code funcionem. + +## Inspecionar configuração + +Liste servidores configurados: + +```bash +iac-code mcp list +``` + +Inspecione uma configuração de servidor mascarada: + +```bash +iac-code mcp get my-server --scope local +``` + +Remova um servidor incorreto: + +```bash +iac-code mcp remove my-server --scope local +``` + +Limpe escolhas de aprovação de projeto: + +```bash +iac-code mcp reset-project-choices +``` + +## Servidor de projeto pendente + +Sintoma: + +```text +Project MCP server 'name' is pending approval. +``` + +Correção: + +```bash +iac-code mcp approve name +``` + +ou inicie o REPL interativo nesse projeto e responda `y` quando solicitado. Pressionar Enter significa `N` e rejeita o servidor. + +Se a aprovação funcionava antes e parou, verifique se `.mcp.json` mudou. A aprovação é vinculada à assinatura da configuração. + +## Variável de ambiente ausente + +Sintoma: + +```text +Environment variable 'TOKEN' is not set for MCP config. +``` + +Corrija com uma destas opções: + +```bash +export TOKEN=... +``` + +ou use um padrão: + +```json +"Authorization": "${TOKEN:-}" +``` + +Servidores com variáveis de ambiente obrigatórias ausentes são ignorados. + +## Falha de conexão + +Para servidores stdio: + +- Verifique se `command` existe no `PATH`. +- Use caminhos absolutos para scripts ao iniciar de diretórios diferentes. +- No Windows, execute servidores Node por `cmd /c npx`. +- Verifique se variáveis de ambiente obrigatórias estão configuradas. + +Para servidores HTTP ou SSE: + +- Verifique a URL e o tipo de transporte. +- Verifique TLS e configurações de proxy. +- Confirme que headers estáticos existem e não contêm segredos em texto claro. +- Execute `iac-code mcp auth ` se o servidor exigir OAuth. + +## Precisa de autenticação + +Sintoma: + +```text +MCP server 'name' requires authentication. +``` + +Correção: + +```bash +iac-code mcp auth name --scope user +``` + +Se o servidor usa OAuth refresh tokens e exige reautenticação, o IaC Code limpa tokens obsoletos e solicita um novo fluxo. + +## Falha na descoberta de capacidade + +Os sintomas podem incluir: + +```text +MCP server 'name' tools discovery failed: ... +MCP server 'name' resources discovery failed: ... +MCP server 'name' prompts discovery failed: ... +``` + +O servidor conectou, mas uma lista de capacidades falhou. Outras capacidades do mesmo servidor ainda podem funcionar. Corrija o erro do lado do servidor e reinicie o IaC Code ou acione reconnect/auth refresh. + +## Recursos ausentes + +`list_mcp_resources` é registrado apenas quando pelo menos um servidor conectado expõe recursos. Se a ferramenta estiver ausente: + +- Confirme que o servidor conectou. +- Confirme que o servidor suporta `resources/list`. +- Verifique avisos de inicialização para erros de discovery de recursos. + +## Comando prompt ou skill ausente + +Comandos de prompt e skill aparecem apenas depois de discovery bem-sucedida. Verifique: + +- O prompt ou recurso `skill://` existe no servidor MCP. +- O nome de comando normalizado não conflita com um comando nativo. +- O recurso de skill remoto pode ser lido dentro do timeout de inicialização. +- A descrição e o corpo do skill cabem nos limites de segurança do IaC Code. + +## Logs e artefatos + +Logs runtime usam por padrão: + +```text +/logs/ +``` + +ou `IAC_CODE_LOG_DIR` quando definido. + +Artefatos binários de resultados de ferramentas MCP são armazenados em: + +```text +/tool-results//mcp/ +``` + +Evite compartilhar diretórios de config, log ou artefatos sem revisar se contêm segredos. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json index bfc236cb..4f5819db 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json @@ -11,6 +11,10 @@ "message": "使用 IaC Code", "description": "The label for category 'Using iac-code' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.MCP Integration": { + "message": "MCP 集成", + "description": "The label for category 'MCP Integration' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Configuration": { "message": "配置", "description": "The label for category 'Configuration' in sidebar 'docsSidebar'" diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/capabilities.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/capabilities.md new file mode 100644 index 00000000..43f68a7a --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/capabilities.md @@ -0,0 +1,104 @@ +--- +sidebar_position: 3 +title: 工具、资源、提示和技能 +description: 了解 MCP 能力如何出现在 IaC Code 中。 +--- + +# 工具、资源、提示和技能 + +已连接的 MCP server 可以向 IaC Code 暴露四类能力。 + +## Tools + +每个 MCP tool 都会变成一个 IaC Code tool: + +```text +mcp____ +``` + +Tool description 和 JSON input schema 来自 MCP server。IaC Code 会把模型的 tool input 转发给 MCP server,然后把 MCP content blocks 转换为普通 tool result。 + +IaC Code 会尽可能遵循 MCP tool annotations: + +| MCP annotation | IaC Code 行为 | +|---|---| +| `readOnlyHint: true` | tool 被视为只读且并发安全。 | +| `destructiveHint: true` | tool 在权限决策中被视为破坏性操作。 | + +MCP tools 仍然经过 IaC Code 现有权限系统。可以使用普通 `permissions` settings 或 CLI 参数配置权限策略,例如 `--allowed-tools`、`--disallowed-tools` 和 `--permission-mode`。 + +MCP progress notifications 会展示在交互式渲染、headless progress 输出、ACP tool progress updates 和 A2A tool metadata 中。 + +## Tool Results 和 Artifacts + +IaC Code 会把 MCP content blocks 转换为模型可见文本: + +| MCP content | IaC Code result | +|---|---| +| Text content | 直接包含在 tool result 中。 | +| `structuredContent` | 在 structured-content section 下渲染为格式化 JSON。 | +| Text resources | 带 server 和 URI 来源信息渲染。 | +| `resource_link` | 渲染为带 URI 和 MIME type 的 resource link。 | +| Image、audio 和 blob data | 存为私有 artifact 文件,并以 artifact id 引用。 | + +二进制 artifacts 存储在: + +```text +/tool-results//mcp/// +``` + +模型看到的是 artifact id 和 metadata,不是 raw base64 data。 + +## Resources + +只要有一个已连接 server 暴露 resources,IaC Code 就会注册两个全局 tools: + +| Tool | 用途 | +|---|---| +| `list_mcp_resources` | 列出已连接 MCP servers 的 resources。可按 server name 过滤。 | +| `read_mcp_resource` | 通过 `server` 和 `uri` 读取一个 resource。 | + +Resource 行包含 server name、URI、可选 resource name 和可选 MIME type。 + +## Prompts + +MCP prompts 会变成 slash commands: + +```text +/mcp____ key=value +``` + +调用时,IaC Code 会执行 MCP `prompts/get`,渲染返回的 prompt messages,把渲染后的 prompt 注入对话,然后让模型继续。Prompt arguments 可以这样传: + +```text +template_name=prod-vpc region=cn-hangzhou +``` + +也可以使用 JSON: + +```json +{"template_name": "prod-vpc", "region": "cn-hangzhou"} +``` + +Required prompt arguments 会在 MCP 调用前校验。支持 quoted values,也支持包含反斜杠的 Windows paths。 + +## Skills + +带 `skill://` URI 的 MCP resources 会变成 skill commands: + +```text +$mcp____ +``` + +IaC Code 会读取 remote skill resource,解析 frontmatter,并注册为普通 skill command。Remote MCP skills 有安全限制: + +- Remote `allowed_tools` 会被清空。 +- Remote auto-trigger path rules 会被清空。 +- Remote skill body 和 description 有长度上限。 +- 如果 remote skill 与现有 command 冲突,会被跳过并产生 MCP warning。 + +MCP skill resources 可能会在启动阶段被读取,这样用户调用前 command 就已经注册好。 + +## 动态更新 + +如果 MCP server 发送 `tools/list_changed`、`resources/list_changed` 或 `prompts/list_changed`,IaC Code 会刷新受影响的 capability list,并更新 tool 或 command registry。刷新失败会报告为 MCP warning,不会中断当前会话。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/configuration.md new file mode 100644 index 00000000..9e5a4a89 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/configuration.md @@ -0,0 +1,127 @@ +--- +sidebar_position: 2 +title: MCP 配置 +description: 通过 CLI 命令、settings 文件、项目文件和 ACP 会话配置 MCP server。 +--- + +# MCP 配置 + +MCP server 配置在 `mcpServers` 对象下。IaC Code 支持与 Claude Code 兼容的核心 schema,覆盖 `stdio`、`http` 和 `sse` servers。 + +## 配置来源 + +IaC Code 会从这些来源读取 MCP servers: + +| 来源 | Scope | 文件或入口 | 信任模型 | +|---|---|---|---| +| 用户 settings | `user` | `~/.iac-code/settings.yml` 或 `IAC_CODE_CONFIG_DIR/settings.yml` | 由当前用户信任。 | +| 项目本地 settings | `local` | `/.iac-code/settings.local.yml` | 仅当前本地 checkout 私有。 | +| 项目 MCP 文件 | `project` | `/.mcp.json` | 随项目共享,需要本地批准。 | +| ACP 会话配置 | `session` | ACP client 传入的 `mcp_servers` | 只作用于该 ACP session runtime。 | + +优先级为 user、project、local、session。后面的来源会按 server name 覆盖前面的来源。内容等价的配置还会按 content signature 去重。 + +项目 `.mcp.json` 文件会从 workspace root 向当前目录发现。子项目文件会按 server name 覆盖父级文件。 + +## CLI 命令 + +使用 `iac-code mcp` 管理持久化 MCP 配置: + +```bash +iac-code mcp add local-catalog \ + --scope local \ + --command python \ + --arg ./tools/catalog_mcp.py +``` + +```bash +iac-code mcp add remote-reviewer \ + --scope user \ + --type http \ + --url https://mcp.example.com/mcp \ + --header "Authorization=${MCP_REVIEWER_TOKEN}" +``` + +可用命令: + +| 命令 | 用途 | +|---|---| +| `iac-code mcp add` | 通过结构化 CLI 参数添加 server。 | +| `iac-code mcp add-json` | 通过 JSON object 添加 server。 | +| `iac-code mcp list` | 列出已配置 server、scope、transport 和 approval 状态。 | +| `iac-code mcp get` | 打印一个已脱敏的 server config。 | +| `iac-code mcp remove` | 从持久化 scope 中移除一个 server。 | +| `iac-code mcp approve` | 批准一个项目 `.mcp.json` server。 | +| `iac-code mcp reject` | 拒绝一个项目 `.mcp.json` server。 | +| `iac-code mcp reset-project-choices` | 清除已保存的项目 approval 选择。 | +| `iac-code mcp auth` | 为 server 启动 OAuth 认证。 | +| `iac-code mcp reset-auth` | 删除 server 已保存的 OAuth tokens 和 client secret。 | + +省略 `--scope` 时,IaC Code 在项目内写入 `local`,在项目外写入 `user`。 + +## Stdio Servers + +Stdio servers 会启动本地命令: + +```json +{ + "mcpServers": { + "catalog": { + "command": "python", + "args": ["./tools/catalog_mcp.py"], + "env": { + "CATALOG_ENV": "prod" + } + } + } +} +``` + +存在 `command` 时可以省略 `type` 字段。IaC Code 会传入安全的继承环境以及 server 自己的 `env`。在 Windows 上,Node-based server 建议使用 `cmd /c npx`,不要直接使用裸 `npx`。 + +## HTTP 和 SSE Servers + +远程 server 需要 `type` 和 `url`: + +```json +{ + "mcpServers": { + "reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "${MCP_REVIEWER_TOKEN}" + } + } + } +} +``` + +SSE server 使用 `type: "sse"`。支持静态 headers。动态 `headersHelper` commands 会被拒绝,因为它需要独立的可信执行设计。 + +## 环境变量展开 + +字符串值支持: + +```text +${VAR} +${VAR:-default-value} +``` + +没有默认值的缺失变量会产生 MCP warning,受影响的 server 会被跳过。环境变量展开会递归应用到 list 和 object 中的字符串。 + +不要把 plaintext secrets 存在 headers 或 env 值里。请使用环境变量引用或 OAuth secret storage。 + +## 项目批准 + +项目 `.mcp.json` 可以提交到仓库,因此 IaC Code 不会自动信任它。 + +交互式 REPL 启动时会询问: + +```text +Approve project MCP server 'name' from /path/to/.mcp.json? [y/N] +``` + +直接回车会采用默认 `N`,拒绝这个项目 server config。输入 `y` 或 `yes` 才会批准。Approval 保存在本地 IaC Code config 目录下,并包含 workspace path、project file path、server name 和 config signature。如果 `.mcp.json` 中的 server config 改变,approval 会失效,server 会重新变成 pending。 + +Headless、ACP 和 A2A 启动时不会进行交互式 approval 询问。未批准的项目 servers 会被跳过并报告 warning。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md new file mode 100644 index 00000000..b6812c92 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/oauth-and-security.md @@ -0,0 +1,110 @@ +--- +sidebar_position: 4 +title: OAuth 和安全 +description: 认证远程 MCP server,并了解 IaC Code 的 MCP 安全模型。 +--- + +# OAuth 和安全 + +MCP 可以启动本地进程并调用远程服务,因此 IaC Code 会把 MCP 配置和认证视为安全敏感内容。 + +## OAuth + +远程 `http` 和 `sse` servers 可以使用 OAuth。在 server config 中配置 OAuth metadata: + +```json +{ + "mcpServers": { + "secure-reviewer": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "oauth": { + "clientId": "iac-code", + "clientSecretEnv": "MCP_CLIENT_SECRET", + "callbackPort": 38487, + "authServerMetadataUrl": "https://auth.example.com/.well-known/oauth-authorization-server" + } + } + } +} +``` + +支持的 OAuth 字段: + +| 字段 | 用途 | +|---|---| +| `clientId` | OAuth client id。 | +| `clientSecretEnv` | 包含 client secret 的环境变量。 | +| `callbackPort` | 可选 loopback callback port。使用 `0` 或省略可自动选择空闲端口。 | +| `authServerMetadataUrl` | 可选的显式 authorization server metadata URL。 | + +Plaintext `oauth.clientSecret` 会被拒绝。请使用 `clientSecretEnv` 或安全 CLI prompt。 + +## 认证 + +运行: + +```bash +iac-code mcp auth secure-reviewer --scope user +``` + +IaC Code 会打开或打印 authorization URL,并在 `127.0.0.1` 上启动 loopback callback server。Provider 重定向回 authorization code 后,IaC Code 会交换 token 并安全存储。 + +如果 server 在普通会话中需要认证,IaC Code 会注册一个 authentication tool: + +```text +mcp____authenticate +``` + +模型可以调用该 tool,把 OAuth URL 提供给用户。认证流程完成后,IaC Code 会重连 MCP server 并刷新发现到的能力。 + +## Token 存储 + +IaC Code 通过 `MCPSecretStorage` 存储 OAuth tokens 和 MCP client secrets: + +1. 可用时优先使用操作系统 keyring。 +2. 如果 keyring 被禁用或不可用,则在 `/mcp/` 下保存加密 fallback 数据。 +3. fallback key 和加密 secret store 会限制文件权限。 + +设置 `IAC_CODE_MCP_DISABLE_KEYRING=1` 可以强制使用加密 fallback storage,适合隔离测试。 + +使用这个命令清除已存储的 auth state: + +```bash +iac-code mcp reset-auth secure-reviewer --scope user +``` + +## 项目信任 + +项目 `.mcp.json` 不会被自动信任,因为仓库可以添加会运行任意本地代码的 `stdio` server。交互式 approval 绑定到每个 server config signature。修改 command、args、env、URL、headers 或 OAuth config 都会使之前的 approval 失效。 + +Headless 和 protocol server 模式会跳过未批准的项目 servers,而不是提示用户。 + +## Secret 处理 + +IaC Code 通过多种方式保护 secrets: + +- `iac-code mcp get` 的 config 输出会对看起来像 token、secret、password、API key 和 authorization header 的字段脱敏。 +- 敏感 header 或 env 的 plaintext values 会被拒绝,除非使用环境变量引用。 +- MCP stdio servers 只会继承安全环境变量 allowlist 以及显式 server env。 +- 带 username 或 password 的 proxy 环境变量不会被 stdio MCP servers 继承。 +- MCP artifact files 会写入私有的 IaC Code runtime configuration directory。 + +## 权限 + +MCP tools 使用与内置 tools 相同的权限框架。远程 MCP server 不能仅通过声明一个 tool 就绕过 IaC Code 权限检查。请记住: + +- Read-only MCP tools 可能会根据当前权限策略自动允许。 +- Destructive MCP tools 应要求批准,除非被显式允许。 +- 在 headless automation 中,组合使用 `--permission-mode`、`--allowed-tools` 和 `--disallowed-tools` 限制 MCP tools 能做什么。 +- Remote MCP skills 不会授予自己的 `allowed_tools`。 + +## 暂不支持的安全敏感功能 + +IaC Code 当前有意拒绝或省略这些 MCP 功能: + +- `headersHelper` dynamic commands。 +- MCP elicitation UI。 +- WebSocket、IDE 和 SDK transports。 +- Enterprise managed MCP policy。 +- IaC Code 作为 MCP server。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/overview.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/overview.md new file mode 100644 index 00000000..49abf460 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: MCP 集成 +description: 使用 Model Context Protocol 服务器为 IaC Code 扩展外部工具、资源、提示和技能。 +--- + +# MCP 集成 + +IaC Code 可以作为 Model Context Protocol (MCP) host 运行。MCP 服务器可以为 agent 扩展外部工具、资源、提示和可复用技能,同时仍然经过 IaC Code 的权限、会话、日志和输出处理链路。 + +当你希望 IaC Code 调用产品内置能力之外的本地或远程能力时,可以使用 MCP,例如私有模板目录、内部部署审查器、资产查询服务或专用云操作工具。 + +## 支持的入口 + +| 入口 | MCP 支持 | +|---|---| +| 交互式 REPL | 加载用户、本地和已批准的项目服务器。信任新的项目 `.mcp.json` 服务器前会提示确认。 | +| 非交互模式 | 加载用户、本地和已批准的项目服务器。不会提示;待批准的项目服务器会被跳过并产生 warning。 | +| ACP server | 接收 ACP client 在会话中传入的 MCP server 配置,并在该会话中暴露发现到的 MCP 能力。 | +| A2A server | 通过普通 runtime 加载 MCP,并可在 A2A task metadata 中发布 MCP warning 和工具进度。 | +| Pipeline 模式 | 使用与 normal 模式相同的 runtime 集成,包括 MCP 工具进度和 warning 传播。 | + +## 支持的能力 + +| 能力 | 状态 | +|---|---| +| `stdio` transport | 支持本地 MCP server 进程。 | +| Streamable HTTP transport | 支持远程 MCP server。 | +| SSE transport | 支持远程 MCP server。 | +| MCP tools | 作为 agent 工具暴露,名称为 `mcp____`。 | +| MCP resources | 通过 `list_mcp_resources` 和 `read_mcp_resource` 暴露。 | +| MCP prompts | 作为 slash command 暴露,名称为 `mcp____`。 | +| MCP `skill://` resources | 作为 skill command 暴露,名称为 `mcp____`。 | +| OAuth loopback auth | 支持带 OAuth metadata 的远程服务器。 | +| `roots/list` | 支持。IaC Code 返回当前 workspace root 的 file URI。 | +| `list_changed` notifications | 支持 tools、resources 和 prompts。注册信息会动态刷新。 | +| MCP elicitation | 暂不支持。请求 elicitation 的服务器会收到明确的不支持错误。 | +| WebSocket、SDK、IDE transports | 不支持。 | +| 动态 `headersHelper` commands | 不支持。请使用静态 headers 或环境变量引用。 | +| IaC Code 作为 MCP server | 不支持。当前 IaC Code 只作为 MCP host。 | + +## 工作方式 + +运行时 IaC Code 会: + +1. 从用户、本地、项目和会话来源加载 MCP 配置。 +2. 展开 `${VAR}` 和 `${VAR:-default}` 引用。 +3. 通过用户可见 warning 跳过不安全或无效的 server。 +4. 以有界并发连接已批准的 server。 +5. 发现 tools、resources、prompts 和 `skill://` resources。 +6. 将这些能力注册到现有 tool registry 和 command registry。 +7. 将 MCP tool result 转换为普通 IaC Code tool result,并把二进制 artifact 存到运行时配置目录下。 +8. 在 REPL、headless run、ACP session 或 A2A runtime 关闭时断开 MCP client。 + +单个 MCP server 失败不会阻塞其他已配置 server。连接和发现失败会作为 MCP warning 保持可见。 + +## 命名 + +MCP tools 和 commands 会被规范化为公开名称: + +```text +mcp____ +mcp____ +mcp____ +``` + +字母、数字和下划线之外的字符会变成下划线。如果发现到的能力在规范化后重名,IaC Code 会追加短摘要以保持名称唯一。 + +## 相关页面 + +- [MCP 配置](./configuration.md) +- [工具、资源、提示和技能](./capabilities.md) +- [OAuth 和安全](./oauth-and-security.md) +- [排障](./troubleshooting.md) diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md new file mode 100644 index 00000000..e0d98ed0 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/troubleshooting.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 5 +title: MCP 排障 +description: 诊断 MCP 配置、连接、认证和能力发现问题。 +--- + +# MCP 排障 + +MCP warnings 通常不是致命问题,除非你需要的每项能力都不可用。一个 server 失败不应阻止其他 MCP servers 或 IaC Code 内置 tools 工作。 + +## 检查配置 + +列出已配置 servers: + +```bash +iac-code mcp list +``` + +检查脱敏后的 server config: + +```bash +iac-code mcp get my-server --scope local +``` + +移除错误 server: + +```bash +iac-code mcp remove my-server --scope local +``` + +清除项目 approval choices: + +```bash +iac-code mcp reset-project-choices +``` + +## 项目 Server 待批准 + +症状: + +```text +Project MCP server 'name' is pending approval. +``` + +修复: + +```bash +iac-code mcp approve name +``` + +或在该项目中启动交互式 REPL,并在提示时回答 `y`。直接回车表示 `N`,会拒绝 server。 + +如果之前 approval 可用但后来失效,请检查 `.mcp.json` 是否变化。Approval 绑定到 config signature。 + +## 缺少环境变量 + +症状: + +```text +Environment variable 'TOKEN' is not set for MCP config. +``` + +选择一种修复方式: + +```bash +export TOKEN=... +``` + +或使用默认值: + +```json +"Authorization": "${TOKEN:-}" +``` + +缺少必需环境变量的 servers 会被跳过。 + +## 连接失败 + +对于 stdio servers: + +- 确认 `command` 存在于 `PATH`。 +- 从不同目录启动时,脚本请使用绝对路径。 +- 在 Windows 上,通过 `cmd /c npx` 运行 Node-based servers。 +- 检查是否配置了所有必需环境变量。 + +对于 HTTP 或 SSE servers: + +- 确认 URL 和 transport type。 +- 检查 TLS 和 proxy settings。 +- 确认静态 headers 存在,且不包含 plaintext secrets。 +- 如果 server 要求 OAuth,运行 `iac-code mcp auth `。 + +## 需要认证 + +症状: + +```text +MCP server 'name' requires authentication. +``` + +修复: + +```bash +iac-code mcp auth name --scope user +``` + +如果 server 使用 OAuth refresh tokens 且需要重新认证,IaC Code 会清除过期 tokens 并要求新的认证流程。 + +## 能力发现失败 + +症状可能包括: + +```text +MCP server 'name' tools discovery failed: ... +MCP server 'name' resources discovery failed: ... +MCP server 'name' prompts discovery failed: ... +``` + +Server 已连接,但某个 capability list 失败。同一 server 的其他能力仍可能可用。修复 server 侧错误后,重启 IaC Code 或触发 reconnect/auth refresh。 + +## Resources 缺失 + +只有当至少一个已连接 server 暴露 resources 时,`list_mcp_resources` 才会注册。如果该 tool 缺失: + +- 确认 server 已连接。 +- 确认 server 支持 `resources/list`。 +- 检查启动 warnings 是否有 resource discovery errors。 + +## Prompt 或 Skill Command 缺失 + +Prompt 和 skill commands 只有成功发现后才会出现。请检查: + +- MCP server 上存在对应 prompt 或 `skill://` resource。 +- 规范化后的 command name 没有与 built-in command 冲突。 +- Remote skill resource 可以在启动超时时间内读取。 +- Skill description 和 body 没有超过 IaC Code 安全限制。 + +## Logs 和 Artifacts + +Runtime logs 默认位于: + +```text +/logs/ +``` + +设置 `IAC_CODE_LOG_DIR` 时使用该目录。 + +MCP tool results 产生的 binary artifacts 存储在: + +```text +/tool-results//mcp/ +``` + +共享 config、log 或 artifact directories 前,请先检查是否包含 secrets。 diff --git a/website/sidebars.ts b/website/sidebars.ts index a60ea687..ae44bbca 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -25,6 +25,17 @@ const sidebars: SidebarsConfig = { 'cli/commands', 'cli/skills', 'cli/sessions', + { + type: 'category', + label: 'MCP Integration', + items: [ + 'mcp/overview', + 'mcp/configuration', + 'mcp/capabilities', + 'mcp/oauth-and-security', + 'mcp/troubleshooting', + ], + }, ], }, {