From ed5b7192e6526640071e0b737530e9177d9f6e35 Mon Sep 17 00:00:00 2001 From: Mathias Gesbert Date: Mon, 29 Jun 2026 17:14:25 +0200 Subject: [PATCH] v2.18.1 (#856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Charles-Edouard Cady Co-authored-by: Guillaume LE GOFF Co-authored-by: Pierre Rossinès Co-authored-by: Sylvain Afchain Co-authored-by: Mistral Vibe --- CHANGELOG.md | 16 + README.md | 28 ++ distribution/zed/extension.toml | 12 +- pyproject.toml | 3 +- tests/acp/test_initialize.py | 4 +- tests/cli/test_input_kinds.py | 26 ++ tests/cli/test_mcp_add_command.py | 212 ++++++++++++++ tests/cli/test_ui_exit_command.py | 38 +++ .../cli/textual_ui/test_quit_confirmation.py | 35 +++ tests/core/test_config_orchestrator.py | 250 +++++++++++++--- tests/core/test_config_toml_end_to_end.py | 4 +- tests/core/test_keyring.py | 29 ++ tests/core/test_mcp_oauth.py | 12 + tests/core/test_mcp_settings.py | 195 +++++++++++++ tests/core/test_project_config_layer.py | 18 ++ tests/core/test_user_config_layer.py | 33 ++- tests/core/test_vibe_config_schema.py | 2 +- uv.lock | 4 +- vibe/__init__.py | 2 +- vibe/cli/commands.py | 5 +- vibe/cli/textual_ui/app.py | 88 +++++- vibe/cli/textual_ui/mcp_commands.py | 123 ++++++++ .../textual_ui/widgets/banner/petit_chat.py | 46 ++- .../widgets/chat_input/input_kinds.py | 2 +- vibe/core/auth/mcp_oauth.py | 5 +- vibe/core/config/layer.py | 7 +- vibe/core/config/layers/project.py | 4 + vibe/core/config/layers/user.py | 10 +- vibe/core/config/orchestrator.py | 51 +++- vibe/core/skills/builtins/vibe.py | 18 ++ vibe/core/tools/mcp_settings.py | 274 +++++++++++++++++- vibe/core/utils/keyring.py | 15 +- vibe/whats_new.md | 10 +- 33 files changed, 1456 insertions(+), 125 deletions(-) create mode 100644 tests/cli/test_input_kinds.py create mode 100644 tests/cli/test_mcp_add_command.py create mode 100644 tests/cli/test_ui_exit_command.py create mode 100644 tests/core/test_mcp_settings.py create mode 100644 vibe/cli/textual_ui/mcp_commands.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3502683..8ce489e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.18.1] - 2026-06-26 + +### Added + +- `/mcp add` slash command for adding OAuth MCP servers +- Petit chat animation idle pauses + +### Changed + +- Bare exit synonyms (exit, quit, :q, :quit) now treated as slash commands instead of prompts + +### Fixed + +- MCP OAuth login crash when keyring backend is un-loadable + + ## [2.18.0] - 2026-06-25 ### Added diff --git a/README.md b/README.md index 789ee44..6b227d2 100644 --- a/README.md +++ b/README.md @@ -542,6 +542,20 @@ Notes: You can configure MCP (Model Context Protocol) servers to extend Vibe's capabilities. Add MCP server configurations under the `mcp_servers` section: +For hosted OAuth MCP servers, you can add the server from inside Vibe: + +```text +/mcp add https://mcp.linear.app/mcp +/mcp add https://mcp.example.com/mcp --name docs --scope read --transport http --no-login +``` + +`/mcp add` is OAuth-only. It writes `auth.type = "oauth"` with optional +scopes and starts login by default. It uses `transport = "streamable-http"` +unless you pass `--transport http`. Pass `--no-login` to add the server without +starting OAuth login. The shortcut supports `streamable-http` and `http` +transports. For API-key/static auth, edit `config.toml` using the static auth +example below. + ```toml # Example MCP server configurations [[mcp_servers]] @@ -586,6 +600,20 @@ Key fields: - `tool_timeout_sec`: Timeout in seconds for tool execution (default 60s) - `env`: Environment variables to set for the MCP server of transport type stdio +HTTP MCP servers can use either static auth or OAuth. Static auth uses +`api_key_env` / `headers` in `config.toml`; OAuth uses an `auth` block: + +```toml +[[mcp_servers]] +name = "linear" +transport = "streamable-http" +url = "https://mcp.linear.app/mcp" + +[mcp_servers.auth] +type = "oauth" +scopes = [] +``` + MCP tools are named using the pattern `{server_name}_{tool_name}` and can be configured with permissions like built-in tools: ```toml diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 3d832c3..3f3f620 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "2.18.0" +version = "2.18.1" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,21 +11,21 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-darwin-aarch64-2.18.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-darwin-aarch64-2.18.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-darwin-x86_64-2.18.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-darwin-x86_64-2.18.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-linux-aarch64-2.18.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-linux-aarch64-2.18.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-linux-x86_64-2.18.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-linux-x86_64-2.18.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.0/vibe-acp-windows-x86_64-2.18.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.18.1/vibe-acp-windows-x86_64-2.18.1.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index 5bcad57..ac6d46d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.18.0" +version = "2.18.1" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -215,7 +215,6 @@ select = [ "PLR", "B0", "B905", - "DOC102", "RUF022", "RUF010", "RUF012", diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index fe53d58..2c2362f 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -72,7 +72,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.1" ) assert response.auth_methods is not None @@ -172,7 +172,7 @@ class TestACPInitialize: ), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.18.1" ) assert response.auth_methods is not None diff --git a/tests/cli/test_input_kinds.py b/tests/cli/test_input_kinds.py new file mode 100644 index 0000000..ee27c45 --- /dev/null +++ b/tests/cli/test_input_kinds.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import pytest + +from vibe.cli.commands import CommandRegistry +from vibe.cli.textual_ui.widgets.chat_input.input_kinds import ( + Prompt, + SlashCommand, + classify, +) + + +def _classify(value: str) -> object: + return classify(value, commands=CommandRegistry(), expand_skill=lambda _value: None) + + +@pytest.mark.parametrize("alias", ["/exit", "exit", "quit", ":q", ":quit"]) +def test_classify_treats_bare_exit_synonyms_as_slash_command(alias: str) -> None: + assert isinstance(_classify(alias), SlashCommand) + + +@pytest.mark.parametrize("value", ["exit the function early", "quit your job"]) +def test_classify_keeps_bare_synonym_with_trailing_text_as_prompt(value: str) -> None: + result = _classify(value) + assert isinstance(result, Prompt) + assert result.text == value diff --git a/tests/cli/test_mcp_add_command.py b/tests/cli/test_mcp_add_command.py new file mode 100644 index 0000000..070ed67 --- /dev/null +++ b/tests/cli/test_mcp_add_command.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from tests.conftest import build_test_vibe_app, build_test_vibe_config +from vibe.cli.textual_ui.app import VibeApp +from vibe.cli.textual_ui.mcp_commands import parse_mcp_add_args, parse_mcp_subcommand +from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage +from vibe.core.config import MCPHttp, MCPOAuth, MCPStreamableHttp, VibeConfig + + +def _capture_mounted_widgets( + app: VibeApp, monkeypatch: pytest.MonkeyPatch +) -> list[object]: + mounted_widgets: list[object] = [] + + async def mount_and_scroll(widget: object, after: object | None = None) -> None: + mounted_widgets.append(widget) + + monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll) + return mounted_widgets + + +def test_parse_mcp_add_args_accepts_url_and_options() -> None: + args = parse_mcp_add_args( + "https://mcp.example.com/mcp --name docs --scope read --scope write " + "--transport http --no-login" + ) + + assert args.url == "https://mcp.example.com/mcp" + assert args.name == "docs" + assert args.scopes == ["read", "write"] + assert args.transport == "http" + assert args.login is False + + +def test_parse_mcp_add_args_defaults_to_login() -> None: + args = parse_mcp_add_args("https://mcp.example.com/mcp") + + assert args.transport == "streamable-http" + assert args.login is True + + +@pytest.mark.parametrize( + "raw_args", + [ + "", + "https://mcp.example.com/mcp extra", + "https://mcp.example.com/mcp --unknown", + "https://mcp.example.com/mcp --name", + "https://mcp.example.com/mcp --name a --name b", + "https://mcp.example.com/mcp --scope", + "https://mcp.example.com/mcp --login", + "https://mcp.example.com/mcp --transport", + "https://mcp.example.com/mcp --transport sse", + "https://mcp.example.com/mcp --transport http --transport streamable-http", + "'unterminated", + ], +) +def test_parse_mcp_add_args_rejects_invalid_args(raw_args: str) -> None: + with pytest.raises(ValueError): + parse_mcp_add_args(raw_args) + + +def test_parse_mcp_subcommand_recognizes_supported_subcommands() -> None: + parsed = parse_mcp_subcommand("add https://mcp.linear.app/mcp --no-login") + + assert parsed is not None + assert parsed.name == "add" + assert parsed.args == "https://mcp.linear.app/mcp --no-login" + + +def test_parse_mcp_subcommand_ignores_unknown_subcommands() -> None: + assert parse_mcp_subcommand("tools linear") is None + + +@pytest.mark.asyncio +async def test_mcp_add_saves_oauth_server_and_prints_next_steps( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = build_test_vibe_app(config=build_test_vibe_config()) + mounted_widgets = _capture_mounted_widgets(app, monkeypatch) + + await app._mcp_add("https://mcp.linear.app/mcp --no-login") + + server = VibeConfig.load().mcp_servers[0] + assert isinstance(server, MCPStreamableHttp) + assert server.name == "linear" + assert isinstance(server.auth, MCPOAuth) + assert server.auth.scopes == [] + assert any( + isinstance(widget, UserCommandMessage) + and "Added OAuth MCP server `linear`." in widget._content + and "/mcp login linear" in widget._content + for widget in mounted_widgets + ) + + +@pytest.mark.asyncio +async def test_mcp_add_saves_name_and_scopes(monkeypatch: pytest.MonkeyPatch) -> None: + app = build_test_vibe_app(config=build_test_vibe_config()) + mounted_widgets = _capture_mounted_widgets(app, monkeypatch) + + await app._mcp_add( + "https://mcp.example.com/mcp --name docs --scope read --scope write --no-login" + ) + + server = VibeConfig.load().mcp_servers[0] + assert isinstance(server, MCPStreamableHttp) + assert server.name == "docs" + assert isinstance(server.auth, MCPOAuth) + assert server.auth.scopes == ["read", "write"] + assert any( + isinstance(widget, UserCommandMessage) + and "Added OAuth MCP server `docs`." in widget._content + for widget in mounted_widgets + ) + + +@pytest.mark.asyncio +async def test_mcp_add_saves_http_transport(monkeypatch: pytest.MonkeyPatch) -> None: + app = build_test_vibe_app(config=build_test_vibe_config()) + _capture_mounted_widgets(app, monkeypatch) + + await app._mcp_add("https://mcp.example.com/mcp --transport http --no-login") + + server = VibeConfig.load().mcp_servers[0] + assert isinstance(server, MCPHttp) + assert server.transport == "http" + assert isinstance(server.auth, MCPOAuth) + + +@pytest.mark.asyncio +async def test_mcp_add_delegates_to_login_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = build_test_vibe_app(config=build_test_vibe_config()) + mounted_widgets = _capture_mounted_widgets(app, monkeypatch) + login = AsyncMock() + monkeypatch.setattr(app, "_mcp_login", login) + + await app._mcp_add("https://mcp.linear.app/mcp") + + login.assert_awaited_once_with("linear") + assert any( + isinstance(widget, UserCommandMessage) + and "Starting OAuth login..." in widget._content + for widget in mounted_widgets + ) + + +@pytest.mark.asyncio +async def test_mcp_add_no_login_skips_login(monkeypatch: pytest.MonkeyPatch) -> None: + app = build_test_vibe_app(config=build_test_vibe_config()) + _capture_mounted_widgets(app, monkeypatch) + login = AsyncMock() + monkeypatch.setattr(app, "_mcp_login", login) + + await app._mcp_add("https://mcp.linear.app/mcp --no-login") + + login.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_mcp_add_mounts_parser_errors(monkeypatch: pytest.MonkeyPatch) -> None: + app = build_test_vibe_app(config=build_test_vibe_config()) + mounted_widgets = _capture_mounted_widgets(app, monkeypatch) + + await app._mcp_add("https://mcp.example.com/mcp --unknown") + + assert any( + isinstance(widget, ErrorMessage) + and widget._error == "Unknown /mcp add option: --unknown" + for widget in mounted_widgets + ) + + +@pytest.mark.asyncio +async def test_mcp_add_help_states_oauth_only(monkeypatch: pytest.MonkeyPatch) -> None: + app = build_test_vibe_app(config=build_test_vibe_config()) + mounted_widgets = _capture_mounted_widgets(app, monkeypatch) + + await app._mcp_add("--help") + + assert any( + isinstance(widget, UserCommandMessage) + and "Usage: /mcp add " in widget._content + and "--transport " in widget._content + and "OAuth-only shortcut" in widget._content + for widget in mounted_widgets + ) + + +@pytest.mark.asyncio +async def test_mcp_subcommand_handler_recognizes_add( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = build_test_vibe_app(config=build_test_vibe_config()) + mounted_widgets = _capture_mounted_widgets(app, monkeypatch) + + handled = await app._maybe_handle_mcp_subcommand( + "add https://mcp.linear.app/mcp --no-login" + ) + + assert handled is True + assert any( + isinstance(widget, UserCommandMessage) + and "Added OAuth MCP server `linear`." in widget._content + for widget in mounted_widgets + ) diff --git a/tests/cli/test_ui_exit_command.py b/tests/cli/test_ui_exit_command.py new file mode 100644 index 0000000..6c8c50e --- /dev/null +++ b/tests/cli/test_ui_exit_command.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from tests.conftest import build_test_vibe_app +from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer +from vibe.cli.textual_ui.widgets.messages import SlashCommandMessage, UserMessage + + +@pytest.mark.parametrize("alias", ["/exit", "exit", "quit", ":q", ":quit"]) +@pytest.mark.asyncio +async def test_exit_synonym_runs_exit_handler_and_is_not_sent_as_prompt( + alias: str, monkeypatch: pytest.MonkeyPatch +) -> None: + vibe_app = build_test_vibe_app() + async with vibe_app.run_test() as pilot: + await pilot.pause(0.1) + + calls: list[str] = [] + + async def _record_exit(**_kwargs: Any) -> None: + calls.append(alias) + + monkeypatch.setattr(vibe_app, "_exit_app", _record_exit) + + chat_input = vibe_app.query_one(ChatInputContainer) + chat_input.post_message(ChatInputContainer.Submitted(alias)) + await pilot.pause(0.2) + + assert calls == [alias] + prompts = [ + m + for m in vibe_app.query(UserMessage) + if not isinstance(m, SlashCommandMessage) + ] + assert prompts == [] diff --git a/tests/cli/textual_ui/test_quit_confirmation.py b/tests/cli/textual_ui/test_quit_confirmation.py index 46b0a10..34cb242 100644 --- a/tests/cli/textual_ui/test_quit_confirmation.py +++ b/tests/cli/textual_ui/test_quit_confirmation.py @@ -1,10 +1,13 @@ from __future__ import annotations import asyncio +from collections.abc import Callable +import signal import time from unittest.mock import AsyncMock, MagicMock, patch import pytest +from textual.app import WINDOWS from tests.conftest import build_test_vibe_app from vibe.cli.textual_ui.app import VibeApp, _run_app_with_cleanup @@ -249,3 +252,35 @@ async def test_run_app_with_cleanup_runs_cleanup_when_run_async_raises( await _run_app_with_cleanup(app) cleanup.assert_awaited_once() + + +def test_force_quit_delegates_to_private(app: VibeApp) -> None: + with patch.object(app, "_force_quit") as private: + app._force_quit() + + private.assert_called_once_with() + + +@pytest.mark.skipif(WINDOWS, reason="SIGTERM handlers are not installed on Windows") +@pytest.mark.asyncio +async def test_run_app_with_cleanup_sigterm_triggers_force_quit(app: VibeApp) -> None: + captured: list[Callable[[], None]] = [] + loop = asyncio.get_running_loop() + + def capture_add(sig: int, callback: Callable[[], None], *args: object) -> None: + captured.append(callback) + + async def fake_run_async() -> None: + captured[0]() + + with ( + patch.object(loop, "add_signal_handler", side_effect=capture_add), + patch.object(loop, "remove_signal_handler") as remove_handler, + patch.object(app, "run_async", side_effect=fake_run_async), + patch.object(app, "shutdown_cleanup", new_callable=AsyncMock), + patch.object(app, "_force_quit") as force_quit, + ): + await _run_app_with_cleanup(app) + + force_quit.assert_called_once_with() + remove_handler.assert_any_call(signal.SIGTERM) diff --git a/tests/core/test_config_orchestrator.py b/tests/core/test_config_orchestrator.py index 901b445..3983faa 100644 --- a/tests/core/test_config_orchestrator.py +++ b/tests/core/test_config_orchestrator.py @@ -18,8 +18,14 @@ from vibe.core.config.layer import ( RawConfig, ) from vibe.core.config.layers.environment import EnvironmentLayer +from vibe.core.config.layers.overrides import OverridesLayer +from vibe.core.config.layers.project import ProjectConfigLayer from vibe.core.config.layers.user import UserConfigLayer -from vibe.core.config.orchestrator import ConfigOrchestrator, ConfigPatchValidationError +from vibe.core.config.orchestrator import ( + ConfigOrchestrator, + ConfigPatchValidationError, + DefaultLayerResolutionError, +) from vibe.core.config.patch import ( AddOperationPatch, RemoveOperationPatch, @@ -36,6 +42,7 @@ from vibe.core.config.types import ( ConfigChangeEvent, LayerConfigSnapshot, ) +from vibe.core.trusted_folders import trusted_folders_manager class FakeLayer(ConfigLayer[RawConfig]): @@ -143,6 +150,12 @@ class RoutingSchema(ConfigSchema): default_agent: Annotated[str, WithReplaceMerge()] = "default-agent" +class CliRoutingSchema(ConfigSchema): + active_model: Annotated[str, WithReplaceMerge()] = "default-model" + default_agent: Annotated[str, WithReplaceMerge()] = "default-agent" + enabled_tools: Annotated[list[str], WithConcatMerge()] = Field(default_factory=list) + + class RequiredPairSchema(ConfigSchema): first: Annotated[str, WithReplaceMerge()] second: Annotated[str, WithReplaceMerge()] @@ -157,23 +170,33 @@ def assert_single_failure[E: BaseException]( return failure +def unused_default_layer() -> ConfigLayer[RawConfig]: + return FakeLayer(name="unused-default", data={}) + + @pytest.mark.asyncio async def test_create_builds_config() -> None: layer = FakeLayer(name="test", data={"value": "hello"}) - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer + ) assert orch.config.model_dump() == {"value": "hello"} @pytest.mark.asyncio async def test_get_layer_returns_named_layer() -> None: layer = FakeLayer(name="my-layer", data={}) - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer + ) assert orch.get_layer("my-layer") is layer @pytest.mark.asyncio async def test_get_layer_unknown_raises() -> None: - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[]) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[], default_layer_resolver=unused_default_layer + ) with pytest.raises(KeyError, match="No layer named 'unknown'"): orch.get_layer("unknown") @@ -181,7 +204,9 @@ async def test_get_layer_unknown_raises() -> None: @pytest.mark.asyncio async def test_reload_picks_up_changes() -> None: layer = FakeLayer(name="mutable", data={"value": "original"}) - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer + ) assert orch.config.value == "original" layer._data = {"value": "updated"} @@ -192,21 +217,27 @@ async def test_reload_picks_up_changes() -> None: @pytest.mark.asyncio async def test_config_is_immutable() -> None: layer = FakeLayer(name="test", data={"value": "hello"}) - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer + ) with pytest.raises(ValidationError, match="frozen"): orch.config.value = "changed" # type: ignore[misc] @pytest.mark.asyncio async def test_origin_of_missing_key_returns_none() -> None: - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[]) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[], default_layer_resolver=unused_default_layer + ) assert orch.config.origin_of("nonexistent") is None @pytest.mark.asyncio async def test_apply_patch_empty_operations_is_noop() -> None: layer = FakeLayer(name="test", data={"value": "hello"}) - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer + ) result = await orch.apply_patch([], reason="no-op") @@ -216,7 +247,9 @@ async def test_apply_patch_empty_operations_is_noop() -> None: @pytest.mark.asyncio async def test_apply_patch_rejects_invalid_schema_result_before_routing() -> None: - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[]) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[], default_layer_resolver=unused_default_layer + ) with pytest.raises(ConfigPatchValidationError) as exc_info: await orch.apply_patch( [ReplaceOperationPatch(path="/value", value={"invalid": "shape"})], @@ -229,25 +262,12 @@ async def test_apply_patch_rejects_invalid_schema_result_before_routing() -> Non assert isinstance(exc_info.value.__cause__, ValidationError) -@pytest.mark.asyncio -async def test_apply_patch_returns_failure_when_default_fallback_layer_is_missing() -> ( - None -): - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[]) - - result = await orch.apply_patch( - [ReplaceOperationPatch(path="/value", value="updated")], reason="test update" - ) - - failure = assert_single_failure(result, KeyError) - assert str(failure) == f'"No layer named {UserConfigLayer.LAYER_NAME!r}"' - assert orch.config.value == "default" - - @pytest.mark.asyncio async def test_apply_patch_unknown_explicit_target_returns_failure() -> None: - layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={}) - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) + layer = NormalizingWritableLayer(name="user-toml", data={}) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer + ) result = await orch.apply_patch( [ @@ -265,11 +285,11 @@ async def test_apply_patch_unknown_explicit_target_returns_failure() -> None: @pytest.mark.asyncio -async def test_apply_patch_falls_back_to_default_user_layer_and_reloads_config() -> ( - None -): - layer = NormalizingWritableLayer(name=UserConfigLayer.LAYER_NAME, data={}) - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) +async def test_apply_patch_uses_default_layer_resolver_and_reloads_config() -> None: + layer = NormalizingWritableLayer(name="user-toml", data={}) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer + ) result = await orch.apply_patch( [AddOperationPatch(path="/value", value="updated")], reason="test update" @@ -280,6 +300,19 @@ async def test_apply_patch_falls_back_to_default_user_layer_and_reloads_config() assert layer._data == {"value": "updated-normalized"} +@pytest.mark.asyncio +async def test_set_field_uses_default_layer_resolver() -> None: + layer = NormalizingWritableLayer(name="user-toml", data={}) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer + ) + + result = await orch.set_field("/value", "updated", reason="set field") + + assert result == [] + assert orch.config.value == "updated-normalized" + + @pytest.mark.asyncio async def test_apply_patch_returns_layer_save_error_after_other_layer_commits() -> None: first_layer = FieldWritableLayer( @@ -287,7 +320,9 @@ async def test_apply_patch_returns_layer_save_error_after_other_layer_commits() ) second_layer = FailingSaveLayer(name="second-layer", data={"second": "two"}) orch = await ConfigOrchestrator.create( - schema=MultiValueSchema, layers=[first_layer, second_layer] + schema=MultiValueSchema, + layers=[first_layer, second_layer], + default_layer_resolver=lambda: first_layer, ) result = await orch.apply_patch( @@ -325,7 +360,9 @@ async def test_apply_patch_returns_layer_apply_error_in_failures( error: Exception, ) -> None: layer = ApplyErrorLayer(name="test", data={"value": "hello"}, error=error) - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[layer]) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, layers=[layer], default_layer_resolver=lambda: layer + ) result = await orch.apply_patch( [ @@ -344,7 +381,9 @@ async def test_apply_patch_returns_unloaded_layer_error_in_failures() -> None: loaded_layer = FakeLayer(name="loaded", data={"value": "hello"}) target_layer = FakeLayer(name="target", data={"value": "hello"}) orch = await ConfigOrchestrator.create( - schema=SimpleSchema, layers=[loaded_layer, target_layer] + schema=SimpleSchema, + layers=[loaded_layer, target_layer], + default_layer_resolver=lambda: loaded_layer, ) await target_layer.invalidate_cache() @@ -374,7 +413,9 @@ async def test_apply_patch_applies_layers_in_parallel() -> None: barrier=barrier, ) orch = await ConfigOrchestrator.create( - schema=MultiValueSchema, layers=[first_layer, second_layer] + schema=MultiValueSchema, + layers=[first_layer, second_layer], + default_layer_resolver=lambda: first_layer, ) result = await orch.apply_patch( @@ -412,7 +453,11 @@ deprecated_setting = true ) user_layer = UserConfigLayer(path=toml_path) - orch = await ConfigOrchestrator.create(schema=ToolSchema, layers=[user_layer]) + orch = await ConfigOrchestrator.create( + schema=ToolSchema, + layers=[user_layer], + default_layer_resolver=lambda: user_layer, + ) result = await orch.apply_patch( [ @@ -437,22 +482,26 @@ deprecated_setting = true @pytest.mark.asyncio -async def test_apply_patch_fallback_to_user_layer_fails_when_user_file_is_missing( +async def test_apply_patch_creates_user_file_when_it_is_missing( tmp_working_directory: Path, ) -> None: toml_path = tmp_working_directory / "config.toml" user_layer = UserConfigLayer(path=toml_path) - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[user_layer]) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, + layers=[user_layer], + default_layer_resolver=lambda: user_layer, + ) result = await orch.apply_patch( [AddOperationPatch(path="/value", value="created-later")], reason="fallback write without user file", ) - failure = assert_single_failure(result, LayerNotLoadedError) - assert str(failure) == "Layer 'user-toml' must be loaded before applying patches" - assert not toml_path.exists() - assert orch.config.value == "default" + assert result == [] + with toml_path.open("rb") as file: + assert tomllib.load(file) == {"value": "created-later"} + assert orch.config.value == "created-later" @pytest.mark.asyncio @@ -466,7 +515,9 @@ async def test_apply_patch_end_to_end_falls_back_to_user_layer_when_no_target_is user_layer = UserConfigLayer(path=toml_path) environment_layer = EnvironmentLayer(schema=RoutingSchema) orch = await ConfigOrchestrator.create( - schema=RoutingSchema, layers=[user_layer, environment_layer] + schema=RoutingSchema, + layers=[user_layer, environment_layer], + default_layer_resolver=lambda: user_layer, ) result = await orch.apply_patch( @@ -498,7 +549,9 @@ async def test_apply_patch_end_to_end_respects_explicit_target_layer( user_layer = UserConfigLayer(path=toml_path) environment_layer = EnvironmentLayer(schema=RoutingSchema) orch = await ConfigOrchestrator.create( - schema=RoutingSchema, layers=[user_layer, environment_layer] + schema=RoutingSchema, + layers=[user_layer, environment_layer], + default_layer_resolver=lambda: user_layer, ) result = await orch.apply_patch( @@ -521,10 +574,117 @@ async def test_apply_patch_end_to_end_respects_explicit_target_layer( assert orch.config.default_agent == "accept-edits" +@pytest.mark.asyncio +async def test_apply_patch_returns_failure_when_resolver_returns_unknown_layer() -> ( + None +): + loaded_layer = RawWritableLayer(name="loaded", data={}) + unknown_layer = RawWritableLayer(name="unknown", data={}) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, + layers=[loaded_layer], + default_layer_resolver=lambda: unknown_layer, + ) + + with pytest.raises(DefaultLayerResolutionError, match="unknown layer 'unknown'"): + await orch.apply_patch( + [AddOperationPatch(path="/value", value="updated")], + reason="unknown resolver target", + ) + + assert orch.config.value == "default" + + +@pytest.mark.asyncio +async def test_apply_patch_explicit_target_does_not_resolve_default_layer() -> None: + layer = RawWritableLayer(name="target", data={"value": "original"}) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, + layers=[layer], + default_layer_resolver=lambda: (_ for _ in ()).throw( + AssertionError("resolver should not be called") + ), + ) + + result = await orch.apply_patch( + [ + ReplaceOperationPatch( + path="/value", value="updated", target_layer_name="target" + ) + ], + reason="explicit target only", + ) + + assert result == [] + assert orch.config.value == "updated" + + +@pytest.mark.asyncio +async def test_apply_patch_end_to_end_routes_default_writes_to_project_layer( + monkeypatch: pytest.MonkeyPatch, tmp_working_directory: Path +) -> None: + workspace = tmp_working_directory / "workspace" + workspace.mkdir() + project_config_path = workspace / ".vibe" / "config.toml" + project_config_path.parent.mkdir(parents=True, exist_ok=True) + project_config_path.write_text('default_agent = "plan"\n', encoding="utf-8") + + user_config_path = tmp_working_directory / "user.toml" + user_config_path.write_text('default_agent = "accept-edits"\n', encoding="utf-8") + + trusted_folders_manager.add_trusted(project_config_path.parent) + monkeypatch.setenv("VIBE_ACTIVE_MODEL", "env-model") + + user_layer = UserConfigLayer(path=user_config_path) + project_layer = ProjectConfigLayer(path=workspace) + environment_layer = EnvironmentLayer(schema=CliRoutingSchema) + overrides_layer = OverridesLayer(data={"enabled_tools": ["read"]}) + + def resolve_default_layer() -> ConfigLayer[RawConfig]: + if project_layer.is_file_discovered: + return project_layer + + return user_layer + + orch = await ConfigOrchestrator.create( + schema=CliRoutingSchema, + layers=[user_layer, project_layer, environment_layer, overrides_layer], + default_layer_resolver=resolve_default_layer, + ) + + assert project_layer.is_file_discovered is True + assert orch.config.active_model == "env-model" + assert orch.config.default_agent == "plan" + assert orch.config.enabled_tools == ["read"] + + result = await orch.apply_patch( + [ + AddOperationPatch(path="/active_model", value="persisted-in-project-file"), + ReplaceOperationPatch(path="/default_agent", value="auto-approve"), + ], + reason="update runtime defaults", + ) + + failure = assert_single_failure(result, NotImplementedError) + assert str(failure) == "ProjectConfigLayer patch persistence is not implemented yet" + with project_config_path.open("rb") as file: + assert tomllib.load(file) == {"default_agent": "plan"} + with user_config_path.open("rb") as file: + assert tomllib.load(file) == {"default_agent": "accept-edits"} + assert orch.config.active_model == "env-model" + assert orch.config.default_agent == "plan" + assert orch.config.enabled_tools == ["read"] + + @pytest.mark.asyncio async def test_subscribe_registers_on_the_bus() -> None: bus = EventBus() - orch = await ConfigOrchestrator.create(schema=SimpleSchema, layers=[], bus=bus) + orch = await ConfigOrchestrator.create( + schema=SimpleSchema, + layers=[], + default_layer_resolver=unused_default_layer, + bus=bus, + ) received: list[ConfigChangeEvent] = [] orch.subscribe(received.append) diff --git a/tests/core/test_config_toml_end_to_end.py b/tests/core/test_config_toml_end_to_end.py index 3b77266..8c2b61b 100644 --- a/tests/core/test_config_toml_end_to_end.py +++ b/tests/core/test_config_toml_end_to_end.py @@ -50,7 +50,9 @@ api_base = "https://api.mistral.ai/v1" from vibe.core.config.orchestrator import ConfigOrchestrator layer = UserConfigLayer(path=toml_path) - orchestrator = await ConfigOrchestrator.create(schema=MinimalSchema, layers=[layer]) + orchestrator = await ConfigOrchestrator.create( + schema=MinimalSchema, layers=[layer], default_layer_resolver=lambda: layer + ) assert orchestrator.config.models.active_model == "mistral-large" assert orchestrator.config.models.models == [ diff --git a/tests/core/test_keyring.py b/tests/core/test_keyring.py index 9102fdc..d9e6678 100644 --- a/tests/core/test_keyring.py +++ b/tests/core/test_keyring.py @@ -357,6 +357,35 @@ def test_delete_attempts_legacy_service_when_current_service_raises( assert deleted == [(service, "CUSTOM_API_KEY") for service in _ALL_SERVICES] +def _raise_unloadable_backend(*_args: str) -> None: + # Mirrors keyring.get_keyring() failing because PYTHON_KEYRING_BACKEND points + # at a backend module absent from Vibe's venv (e.g. flyte._keyring.file). + raise ModuleNotFoundError("No module named 'flyte'") + + +def test_get_returns_none_when_backend_module_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(keyring, "get_password", _raise_unloadable_backend) + assert get_api_key_from_keyring("CUSTOM_API_KEY") is None + + +def test_set_converts_backend_import_error_to_keyring_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(keyring, "set_password", _raise_unloadable_backend) + with pytest.raises(KeyringError): + set_api_key_in_keyring("CUSTOM_API_KEY", "new-key") + + +def test_delete_converts_backend_import_error_to_keyring_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(keyring, "delete_password", _raise_unloadable_backend) + with pytest.raises(KeyringError): + delete_api_key_from_keyring("CUSTOM_API_KEY") + + def test_delete_prefers_operation_error_over_missing_entry( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/core/test_mcp_oauth.py b/tests/core/test_mcp_oauth.py index a733afa..229230f 100644 --- a/tests/core/test_mcp_oauth.py +++ b/tests/core/test_mcp_oauth.py @@ -190,6 +190,18 @@ class TestKeyringTokenStorage: assert "api_key_env" in msg assert exc_info.value.server_alias == "linear" + def test_unloadable_backend_raises_headless(self) -> None: + # PYTHON_KEYRING_BACKEND can point at a backend module that is absent from + # Vibe's isolated venv (e.g. flyte._keyring.file in Flyte/Slurm envs), so + # get_keyring() raises ModuleNotFoundError instead of returning a backend. + with patch( + "vibe.core.auth.mcp_oauth.keyring.get_keyring", + side_effect=ModuleNotFoundError("No module named 'flyte'"), + ): + with pytest.raises(MCPOAuthHeadlessError) as exc_info: + KeyringTokenStorage(alias="notion") + assert exc_info.value.server_alias == "notion" + class TestFingerprint: def test_compute_stable_across_scope_order( diff --git a/tests/core/test_mcp_settings.py b/tests/core/test_mcp_settings.py new file mode 100644 index 0000000..9bd45dd --- /dev/null +++ b/tests/core/test_mcp_settings.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from pathlib import Path +import tomllib + +import pytest + +from tests.conftest import build_test_vibe_config +from vibe.core.config import MCPHttp, MCPOAuth, MCPStreamableHttp, VibeConfig +from vibe.core.tools.mcp_settings import ( + MCPServerAddError, + parse_mcp_add_transport, + persist_oauth_mcp_server, +) + + +def _persisted_servers(config_dir: Path) -> list[dict]: + with (config_dir / "config.toml").open("rb") as file: + return tomllib.load(file).get("mcp_servers", []) + + +@pytest.mark.parametrize( + ("url", "expected_url"), + [ + ("https://mcp.example.com/mcp", "https://mcp.example.com/mcp"), + ("http://localhost:8000/mcp", "http://localhost:8000/mcp"), + ("http://127.0.0.1:8000/mcp", "http://127.0.0.1:8000/mcp"), + ("https://example.com:443/mcp?tenant=a", "https://example.com/mcp?tenant=a"), + ], +) +def test_add_oauth_mcp_server_accepts_supported_urls( + url: str, expected_url: str, config_dir: Path +) -> None: + result = persist_oauth_mcp_server(build_test_vibe_config(), url=url) + + assert result.created is True + assert result.url == expected_url + assert _persisted_servers(config_dir)[0]["url"] == expected_url + + +@pytest.mark.parametrize( + "url", + [ + "mcp.example.com/mcp", + "ftp://mcp.example.com/mcp", + "https:///mcp", + "http://mcp.example.com/mcp", + "https://mcp.example.com/mcp#section", + ], +) +def test_add_oauth_mcp_server_rejects_invalid_urls(url: str) -> None: + with pytest.raises(MCPServerAddError): + persist_oauth_mcp_server(build_test_vibe_config(), url=url) + + +@pytest.mark.parametrize( + ("url", "expected_name"), + [ + ("https://mcp.linear.app/mcp", "linear"), + ("https://example.com/mcp", "example"), + ("https://mcp.example.com/notion/mcp", "example"), + ("https://mcp.localhost/notion/mcp", "notion"), + ], +) +def test_add_oauth_mcp_server_generates_alias(url: str, expected_name: str) -> None: + result = persist_oauth_mcp_server(build_test_vibe_config(), url=url) + + assert result.name == expected_name + + +def test_add_oauth_mcp_server_suffixes_generated_alias_collision( + config_dir: Path, +) -> None: + config = build_test_vibe_config( + mcp_servers=[ + MCPStreamableHttp( + name="linear", + transport="streamable-http", + url="https://other.example.com/mcp", + ) + ] + ) + + result = persist_oauth_mcp_server(config, url="https://mcp.linear.app/mcp") + + assert result.name == "linear_2" + assert _persisted_servers(config_dir)[0]["name"] == "linear_2" + + +def test_add_oauth_mcp_server_is_idempotent_for_existing_url(config_dir: Path) -> None: + first = persist_oauth_mcp_server( + build_test_vibe_config(), url="https://mcp.linear.app/mcp" + ) + second = persist_oauth_mcp_server( + VibeConfig.load(), url="https://mcp.linear.app:443/mcp/" + ) + + assert first.created is True + assert second.created is False + assert second.name == "linear" + assert len(_persisted_servers(config_dir)) == 1 + + +def test_add_oauth_mcp_server_rejects_active_static_url_match() -> None: + config = build_test_vibe_config( + mcp_servers=[ + MCPStreamableHttp( + name="docs", + transport="streamable-http", + url="https://mcp.example.com/mcp", + ) + ] + ) + + with pytest.raises(MCPServerAddError, match="`docs` with static auth"): + persist_oauth_mcp_server(config, url="https://mcp.example.com/mcp") + + +def test_add_oauth_mcp_server_rejects_persisted_static_url_match() -> None: + VibeConfig.save_updates({ + "mcp_servers": [ + { + "name": "docs", + "transport": "streamable-http", + "url": "https://mcp.example.com/mcp", + } + ] + }) + + with pytest.raises(MCPServerAddError, match="`docs` with static auth"): + persist_oauth_mcp_server( + build_test_vibe_config(), url="https://mcp.example.com/mcp" + ) + + +def test_add_oauth_mcp_server_rejects_existing_url_with_different_name() -> None: + persist_oauth_mcp_server(build_test_vibe_config(), url="https://mcp.linear.app/mcp") + + with pytest.raises(MCPServerAddError, match="already configured as `linear`"): + persist_oauth_mcp_server( + VibeConfig.load(), url="https://mcp.linear.app/mcp", name="other" + ) + + +def test_add_oauth_mcp_server_rejects_explicit_alias_collision() -> None: + config = build_test_vibe_config( + mcp_servers=[ + MCPStreamableHttp( + name="linear", + transport="streamable-http", + url="https://other.example.com/mcp", + ) + ] + ) + + with pytest.raises(MCPServerAddError, match="name `linear` is already configured"): + persist_oauth_mcp_server( + config, url="https://mcp.example.com/mcp", name="linear" + ) + + +def test_add_oauth_mcp_server_persists_loadable_oauth_config() -> None: + persist_oauth_mcp_server( + build_test_vibe_config(), + url="https://mcp.example.com/mcp", + name="docs", + scopes=["read", "write"], + ) + + server = VibeConfig.load().mcp_servers[0] + + assert isinstance(server, MCPStreamableHttp) + assert server.name == "docs" + assert isinstance(server.auth, MCPOAuth) + assert server.auth.scopes == ["read", "write"] + + +def test_add_oauth_mcp_server_persists_http_transport() -> None: + persist_oauth_mcp_server( + build_test_vibe_config(), + url="https://mcp.example.com/mcp", + name="docs", + transport="http", + ) + + server = VibeConfig.load().mcp_servers[0] + + assert isinstance(server, MCPHttp) + assert server.transport == "http" + assert isinstance(server.auth, MCPOAuth) + + +def test_parse_mcp_add_transport_rejects_unsupported_transport() -> None: + with pytest.raises(MCPServerAddError, match="http, streamable-http"): + parse_mcp_add_transport("sse") diff --git a/tests/core/test_project_config_layer.py b/tests/core/test_project_config_layer.py index 553862a..caa2c7a 100644 --- a/tests/core/test_project_config_layer.py +++ b/tests/core/test_project_config_layer.py @@ -188,6 +188,24 @@ async def test_find_file_result_is_cached(tmp_working_directory: Path) -> None: assert layer._config_file_path is cached_path +@pytest.mark.asyncio +async def test_is_file_discovered_reflects_cached_discovery_state( + tmp_working_directory: Path, +) -> None: + trusted_folders_manager.add_trusted(tmp_working_directory) + config_path = tmp_working_directory / ".vibe" / "config.toml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text('active_model = "project-model"\n') + + layer = ProjectConfigLayer(path=tmp_working_directory) + + assert layer.is_file_discovered is False + + await layer.load() + + assert layer.is_file_discovered is True + + @pytest.mark.asyncio async def test_config_file_added_after_first_search_is_not_detected( tmp_working_directory: Path, diff --git a/tests/core/test_user_config_layer.py b/tests/core/test_user_config_layer.py index 44cad6a..691163e 100644 --- a/tests/core/test_user_config_layer.py +++ b/tests/core/test_user_config_layer.py @@ -58,7 +58,7 @@ async def test_missing_file_returns_empty(tmp_working_directory: Path) -> None: @pytest.mark.asyncio -async def test_apply_raises_when_file_does_not_exist( +async def test_apply_creates_file_when_it_does_not_exist( tmp_working_directory: Path, ) -> None: path = tmp_working_directory / random_config_file_name() @@ -67,15 +67,16 @@ async def test_apply_raises_when_file_does_not_exist( await layer.load() assert layer.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT - with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"): - await layer.apply( - ConfigPatch( - AddOperationPatch(path="/active_model", value="mistral-large"), - fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT, - ) + await layer.apply( + ConfigPatch( + AddOperationPatch(path="/active_model", value="mistral-large"), + fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT, ) + ) - assert not path.exists() + with path.open("rb") as file: + assert tomllib.load(file) == {"active_model": "mistral-large"} + assert layer.fingerprint == create_file_fingerprint(file) @pytest.mark.asyncio @@ -224,7 +225,7 @@ async def test_apply_raises_when_cache_is_invalidated( @pytest.mark.asyncio -async def test_apply_raises_when_parent_directory_does_not_exist( +async def test_apply_creates_parent_directory_when_it_does_not_exist( tmp_working_directory: Path, ) -> None: path = tmp_working_directory / "nested" / random_config_file_name() @@ -232,15 +233,15 @@ async def test_apply_raises_when_parent_directory_does_not_exist( await layer.load() - with pytest.raises(LayerNotLoadedError, match="loaded before applying patches"): - await layer.apply( - ConfigPatch( - AddOperationPatch(path="/active_model", value="mistral-large"), - fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT, - ) + await layer.apply( + ConfigPatch( + AddOperationPatch(path="/active_model", value="mistral-large"), + fingerprint=MISSING_BACKING_STORE_DATA_FINGERPRINT, ) + ) - assert not path.exists() + with path.open("rb") as file: + assert tomllib.load(file) == {"active_model": "mistral-large"} @pytest.mark.asyncio diff --git a/tests/core/test_vibe_config_schema.py b/tests/core/test_vibe_config_schema.py index 50987e9..da3ff2d 100644 --- a/tests/core/test_vibe_config_schema.py +++ b/tests/core/test_vibe_config_schema.py @@ -56,7 +56,7 @@ provider = "mistral" layer = UserConfigLayer(path=toml_path) orchestrator = await ConfigOrchestrator[VibeConfig].create( - schema=VibeConfig, layers=[layer] + schema=VibeConfig, layers=[layer], default_layer_resolver=lambda: layer ) config = orchestrator.config diff --git a/uv.lock b/uv.lock index 4ad1204..af1a766 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-23T12:49:20.1698Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [[package]] @@ -831,7 +831,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.18.0" +version = "2.18.1" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index 4c078d1..1cda36b 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "2.18.0" +__version__ = "2.18.1" diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py index b28a913..7edd30a 100644 --- a/vibe/cli/commands.py +++ b/vibe/cli/commands.py @@ -130,8 +130,9 @@ class CommandRegistry: aliases=frozenset(["/mcp", "/connectors"]), description=( "Display available MCP servers and connectors. " - "Pass a name to list tools; subcommands: status, " - "login , logout " + "Pass a name to list tools; subcommands: add " + "[--transport http|streamable-http], status, login , " + "logout " ), handler="_show_mcp", ), diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 8a303b7..9bbc531 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -52,6 +52,12 @@ from vibe.cli.textual_ui.lazy_audio_managers import ( create_default_narrator_manager, create_default_voice_manager, ) +from vibe.cli.textual_ui.mcp_commands import ( + MCP_ADD_HELP, + is_mcp_add_help_request, + parse_mcp_add_args, + parse_mcp_subcommand, +) from vibe.cli.textual_ui.message_queue import MessageQueue, QueueController, QueuePorts from vibe.cli.textual_ui.notifications import ( NotificationContext, @@ -202,7 +208,11 @@ from vibe.core.tools.builtins.ask_user_question import ( Question, ) from vibe.core.tools.connectors.counts import compute_connector_counts -from vibe.core.tools.mcp_settings import persist_mcp_toggle +from vibe.core.tools.mcp_settings import ( + MCPServerAddError, + persist_mcp_toggle, + persist_oauth_mcp_server, +) from vibe.core.tools.permissions import RequiredPermission from vibe.core.types import ( MAX_IMAGE_BYTES, @@ -2154,24 +2164,24 @@ class VibeApp(App): # noqa: PLR0904 return "Refreshed." async def _maybe_handle_mcp_subcommand(self, cmd_args: str) -> bool: - parts = cmd_args.strip().split(None, 1) - if not parts or parts[0] not in {"login", "logout", "status"}: + parsed = parse_mcp_subcommand(cmd_args) + if parsed is None: return False - subcommand = parts[0] - arg = parts[1].strip() if len(parts) > 1 else "" - match subcommand: + match parsed.name: + case "add": + await self._mcp_add(parsed.args) case "status": - if arg: + if parsed.args: await self._mount_and_scroll( ErrorMessage("Usage: /mcp status", collapsed=True) ) return True await self._show_mcp_status() case "login": - await self._mcp_login(arg) + await self._mcp_login(parsed.args) case "logout": - await self._mcp_logout(arg) + await self._mcp_logout(parsed.args) return True async def _show_mcp_status(self) -> None: @@ -2253,6 +2263,49 @@ class VibeApp(App): # noqa: PLR0904 UserCommandMessage(f"MCP server `{alias}` logged out.") ) + async def _mcp_add(self, raw_args: str) -> None: + if is_mcp_add_help_request(raw_args): + await self._mount_and_scroll(UserCommandMessage(MCP_ADD_HELP)) + return + + try: + args = parse_mcp_add_args(raw_args) + except ValueError as exc: + await self._mount_and_scroll(ErrorMessage(str(exc), collapsed=True)) + return + + try: + result = persist_oauth_mcp_server( + self.agent_loop.config, + url=args.url, + name=args.name, + scopes=args.scopes, + transport=args.transport, + ) + except MCPServerAddError as exc: + await self._mount_and_scroll(ErrorMessage(str(exc), collapsed=True)) + return + + self.agent_loop.refresh_config() + await self._refresh_mcp_browser() + head = ( + f"Added OAuth MCP server `{result.name}`." + if result.created + else f"OAuth MCP server `{result.name}` is already configured." + ) + tail = ( + "Starting OAuth login..." + if args.login + else ( + f"Run `/mcp login {result.name}` to authenticate, " + "or `/mcp status` to inspect it." + ) + ) + await self._mount_and_scroll(UserCommandMessage(f"{head}\n{tail}")) + + if args.login: + await self._mcp_login(result.name) + async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None: if await self._maybe_handle_mcp_subcommand(cmd_args): return @@ -3727,10 +3780,27 @@ class VibeApp(App): # noqa: PLR0904 async def _run_app_with_cleanup(app: VibeApp) -> str | None: from vibe.cli.stderr_guard import stderr_guard + loop = asyncio.get_running_loop() + if not WINDOWS: + try: + + def _sigterm_handler() -> None: + loop.remove_signal_handler(signal.SIGTERM) + app._force_quit() + + loop.add_signal_handler(signal.SIGTERM, _sigterm_handler) + except (NotImplementedError, OSError): + pass + try: with stderr_guard(): return await app.run_async() finally: + if not WINDOWS: + try: + loop.remove_signal_handler(signal.SIGTERM) + except (NotImplementedError, OSError): + pass sys.stderr.write("Closing\u2026\r") sys.stderr.flush() try: diff --git a/vibe/cli/textual_ui/mcp_commands.py b/vibe/cli/textual_ui/mcp_commands.py new file mode 100644 index 0000000..bbc02e5 --- /dev/null +++ b/vibe/cli/textual_ui/mcp_commands.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from dataclasses import dataclass +import shlex +from typing import Literal + +from vibe.core.tools.mcp_settings import MCPAddTransport, parse_mcp_add_transport + +MCPSubcommandName = Literal["add", "login", "logout", "status"] + +MCP_ADD_USAGE = ( + "Usage: /mcp add [--name ] [--scope ...] " + "[--transport ] [--no-login]" +) +MCP_ADD_HELP = f"""{MCP_ADD_USAGE} + +OAuth-only shortcut for hosted MCP servers. +Defaults to streamable-http; pass --transport http for servers documented with +HTTP transport. +For API-key/static auth, edit config.toml.""" + + +@dataclass(frozen=True, slots=True) +class MCPSubcommand: + name: MCPSubcommandName + args: str + + +@dataclass(frozen=True, slots=True) +class MCPAddArgs: + url: str + name: str | None + scopes: list[str] + transport: MCPAddTransport + login: bool + + +def parse_mcp_subcommand(raw_args: str) -> MCPSubcommand | None: + parts = raw_args.strip().split(None, 1) + if not parts: + return None + + name = _parse_mcp_subcommand_name(parts[0]) + if name is None: + return None + + args = parts[1].strip() if len(parts) > 1 else "" + return MCPSubcommand(name=name, args=args) + + +def is_mcp_add_help_request(raw_args: str) -> bool: + return raw_args.strip() in {"--help", "-h"} + + +def parse_mcp_add_args(raw_args: str) -> MCPAddArgs: + try: + tokens = shlex.split(raw_args) + except ValueError as exc: + raise ValueError(f"Invalid /mcp add arguments: {exc}") from exc + + url: str | None = None + name: str | None = None + scopes: list[str] = [] + transport: MCPAddTransport = "streamable-http" + transport_seen = False + login = True + index = 0 + while index < len(tokens): + token = tokens[index] + match token: + case "--no-login": + login = False + index += 1 + case "--name": + if name is not None: + raise ValueError("Usage: /mcp add accepts --name only once.") + name = _mcp_add_option_value(tokens, index, "--name", "") + index += 2 + case "--transport": + if transport_seen: + raise ValueError("Usage: /mcp add accepts --transport only once.") + transport = parse_mcp_add_transport( + _mcp_add_option_value( + tokens, index, "--transport", "" + ) + ) + transport_seen = True + index += 2 + case "--scope": + scopes.append( + _mcp_add_option_value(tokens, index, "--scope", "") + ) + index += 2 + case _ if token.startswith("--"): + raise ValueError(f"Unknown /mcp add option: {token}") + case _: + if url is not None: + raise ValueError(MCP_ADD_USAGE) + url = token + index += 1 + + if url is None: + raise ValueError(MCP_ADD_USAGE) + + return MCPAddArgs( + url=url, name=name, scopes=scopes, transport=transport, login=login + ) + + +def _parse_mcp_subcommand_name(value: str) -> MCPSubcommandName | None: + match value: + case "add" | "login" | "logout" | "status": + return value + case _: + return None + + +def _mcp_add_option_value( + tokens: list[str], index: int, option: str, placeholder: str +) -> str: + if index + 1 >= len(tokens) or tokens[index + 1].startswith("--"): + raise ValueError(f"Usage: /mcp add {option} {placeholder}") + return tokens[index + 1] diff --git a/vibe/cli/textual_ui/widgets/banner/petit_chat.py b/vibe/cli/textual_ui/widgets/banner/petit_chat.py index 0b6c462..183991a 100644 --- a/vibe/cli/textual_ui/widgets/banner/petit_chat.py +++ b/vibe/cli/textual_ui/widgets/banner/petit_chat.py @@ -1,5 +1,6 @@ from __future__ import annotations +import random from typing import Any from textual.app import ComposeResult @@ -10,6 +11,9 @@ from vibe.cli.textual_ui.widgets.braille_renderer import render_braille WIDTH = 22 HEIGHT = 12 +FRAME_INTERVAL_S = 0.16 +CYCLE_DELAY_MIN_S = 5.0 +CYCLE_DELAY_MAX_S = 20.0 STARTING_DOTS = [ set[int](), {6, 7, 15, 19}, @@ -160,6 +164,11 @@ TRANSITIONS = [ ] # cf render_braille() docstring for coordinates convention +# Transition indices reached right after a head settles into a direction, with +# the eyes open. The cat may rest at any of these, on top of the end of cycle. +EYES_OPEN_PAUSE_FRAMES = frozenset({5, 11, 21, 24}) +MID_CYCLE_PAUSE_CHANCE = 0.25 + class PetitChat(Static): def __init__(self, animate: bool = True, **kwargs: Any) -> None: @@ -171,6 +180,7 @@ class PetitChat(Static): self._do_animate = animate self._freeze_requested = False self._timer: Timer | None = None + self._resume_frame: int | None = None def compose(self) -> ComposeResult: yield Static(render_braille(self._dots, WIDTH, HEIGHT), classes="petit-chat") @@ -178,7 +188,9 @@ class PetitChat(Static): def on_mount(self) -> None: self._inner = self.query_one(".petit-chat", Static) if self._do_animate: - self._timer = self.set_interval(0.16, self._apply_next_transition) + self._timer = self.set_interval( + FRAME_INTERVAL_S, self._apply_next_transition + ) def freeze_animation(self) -> None: self._freeze_requested = True @@ -195,3 +207,35 @@ class PetitChat(Static): self._dots |= transition["add"] self._transition_index = (self._transition_index + 1) % len(TRANSITIONS) self._inner.update(render_braille(self._dots, WIDTH, HEIGHT)) + + if not self._may_stop(): + return + + if self._transition_index == 0 or ( + self._transition_index in EYES_OPEN_PAUSE_FRAMES + and random.random() < MID_CYCLE_PAUSE_CHANCE + ): + self._pause_between_cycles() + + def _may_stop(self) -> bool: + # After a stop, play one full cycle back to the frame we stopped at + # before considering any new stop. + if self._resume_frame is None: + return True + if self._transition_index == self._resume_frame: + self._resume_frame = None + return True + return False + + def _pause_between_cycles(self) -> None: + if self._timer: + self._timer.stop() + self._resume_frame = self._transition_index + delay = random.uniform(CYCLE_DELAY_MIN_S, CYCLE_DELAY_MAX_S) + self._timer = self.set_timer(delay, self._resume_animation) + + def _resume_animation(self) -> None: + if self._freeze_requested: + self._timer = None + return + self._timer = self.set_interval(FRAME_INTERVAL_S, self._apply_next_transition) diff --git a/vibe/cli/textual_ui/widgets/chat_input/input_kinds.py b/vibe/cli/textual_ui/widgets/chat_input/input_kinds.py index 6411ddb..9d2a992 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/input_kinds.py +++ b/vibe/cli/textual_ui/widgets/chat_input/input_kinds.py @@ -48,7 +48,7 @@ def classify( ) -> ClassifiedInput: if value.startswith("&") and commands.has_command("teleport"): return Teleport(target=value[1:]) - if value.startswith("/") and commands.parse_command(value) is not None: + if commands.parse_command(value) is not None: return SlashCommand() if value.startswith("/"): if (expanded := expand_skill(value)) is not None: diff --git a/vibe/core/auth/mcp_oauth.py b/vibe/core/auth/mcp_oauth.py index 1af6768..a7f21a8 100644 --- a/vibe/core/auth/mcp_oauth.py +++ b/vibe/core/auth/mcp_oauth.py @@ -167,7 +167,10 @@ class KeyringTokenStorage(TokenStorage): *, fallback_client_info: OAuthClientInformationFull | None = None, ) -> None: - backend = keyring.get_keyring() + try: + backend = keyring.get_keyring() + except (ImportError, keyring.errors.KeyringError) as exc: + raise MCPOAuthHeadlessError(server_alias=alias) from exc if isinstance(backend, keyring.backends.fail.Keyring): raise MCPOAuthHeadlessError(server_alias=alias) self._alias = alias diff --git a/vibe/core/config/layer.py b/vibe/core/config/layer.py index 9674ded..5a25f96 100644 --- a/vibe/core/config/layer.py +++ b/vibe/core/config/layer.py @@ -11,7 +11,6 @@ from pydantic import BaseModel, ConfigDict, ValidationError from vibe.core.config.patch import ConfigPatch from vibe.core.config.types import ( - MISSING_BACKING_STORE_DATA_FINGERPRINT, ConcurrencyConflictError, ConflictStrategy, LayerConfigSnapshot, @@ -300,11 +299,7 @@ class ConfigLayer[S: BaseModel](ABC): self, state: _LayerState[S], patch: ConfigPatch, on_conflict: ConflictStrategy ) -> _LayerState[S]: - if ( - state.data is None - or state.fingerprint is None - or state.fingerprint == MISSING_BACKING_STORE_DATA_FINGERPRINT - ): + if state.data is None or state.fingerprint is None: raise LayerNotLoadedError(self.name) match on_conflict: diff --git a/vibe/core/config/layers/project.py b/vibe/core/config/layers/project.py index ce11dc9..807ee44 100644 --- a/vibe/core/config/layers/project.py +++ b/vibe/core/config/layers/project.py @@ -28,6 +28,10 @@ class ProjectConfigLayer(ConfigLayer[RawConfig]): def config_file_path(self) -> Path | None: return self._config_file_path + @property + def is_file_discovered(self) -> bool: + return self._is_set and self._config_file_path is not None + async def _check_trust(self) -> bool: await self._find_config_file() diff --git a/vibe/core/config/layers/user.py b/vibe/core/config/layers/user.py index aea1b7e..2b7c26b 100644 --- a/vibe/core/config/layers/user.py +++ b/vibe/core/config/layers/user.py @@ -4,7 +4,6 @@ import os from pathlib import Path import tempfile import tomllib -from typing import ClassVar import tomli_w @@ -21,10 +20,8 @@ class UserConfigLayer(ConfigLayer[RawConfig]): Pass an explicit ``path`` for testing. """ - LAYER_NAME: ClassVar[str] = "user-toml" - - def __init__(self, *, path: Path | None = None) -> None: - super().__init__(name=self.LAYER_NAME) + def __init__(self, *, path: Path | None = None, name: str = "user-toml") -> None: + super().__init__(name=name) self._path = path or (VIBE_HOME.path / "config.toml") async def _check_trust(self) -> bool: @@ -40,8 +37,7 @@ class UserConfigLayer(ConfigLayer[RawConfig]): return LayerConfigSnapshot(data=data, fingerprint=fingerprint) async def _save_to_store(self, next_config: RawConfig) -> str: - if not self._path.exists(): - raise FileNotFoundError(self._path) + self._path.parent.mkdir(parents=True, exist_ok=True) tmp_path: Path | None = None try: diff --git a/vibe/core/config/orchestrator.py b/vibe/core/config/orchestrator.py index 1a4d654..18a2a38 100644 --- a/vibe/core/config/orchestrator.py +++ b/vibe/core/config/orchestrator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections import defaultdict from collections.abc import Callable +from typing import Any from jsonpatch import JsonPatchException, apply_patch from jsonpointer import JsonPointerException @@ -11,13 +12,10 @@ from pydantic import ValidationError from vibe.core.config.builder import ConfigBuilder from vibe.core.config.event_bus import EventBus from vibe.core.config.layer import ConfigLayer, LayerNotLoadedError, RawConfig -from vibe.core.config.layers.user import UserConfigLayer -from vibe.core.config.patch import ConfigPatch, PatchOp +from vibe.core.config.patch import AddOperationPatch, ConfigPatch, PatchOp from vibe.core.config.schema import ConfigSchema from vibe.core.config.types import ConfigChangeCallback, ConflictStrategy -DEFAULT_PATCH_LAYER_NAME = UserConfigLayer.LAYER_NAME - class ConfigPatchValidationError(Exception): """Raised when the merged-config preflight rejects a patch.""" @@ -29,14 +27,26 @@ class ConfigPatchValidationError(Exception): ) +class DefaultLayerResolutionError(Exception): + """Raised when a patch needs implicit routing but no valid default is available.""" + + +type DefaultLayerResolver = Callable[[], ConfigLayer[RawConfig]] + + class ConfigOrchestrator[S: ConfigSchema]: """Single entry point for config management.""" def __init__( - self, builder: ConfigBuilder[S], config: S, bus: EventBus | None = None + self, + builder: ConfigBuilder[S], + config: S, + default_layer_resolver: DefaultLayerResolver, + bus: EventBus | None = None, ) -> None: self._builder = builder self._config = config + self._default_layer_resolver = default_layer_resolver self._bus = bus if bus is not None else EventBus() @classmethod @@ -45,13 +55,14 @@ class ConfigOrchestrator[S: ConfigSchema]: *, schema: type[S], layers: list[ConfigLayer[RawConfig]], + default_layer_resolver: DefaultLayerResolver, bus: EventBus | None = None, ) -> ConfigOrchestrator[S]: """Build an orchestrator from a schema and an ordered list of layers.""" builder = ConfigBuilder[S](schema) builder.add_layers(layers) config = await builder.build() - instance = cls(builder, config, bus) + instance = cls(builder, config, default_layer_resolver, bus) return instance @property @@ -68,6 +79,13 @@ class ConfigOrchestrator[S: ConfigSchema]: """Force-reload all layers and atomically replace the config snapshot.""" self._config = await self._builder.build(force_load=True) + async def set_field( + self, path: str, value: Any, reason: str = "No reason" + ) -> list[BaseException]: + return await self.apply_patch( + [AddOperationPatch(path=path, value=value)], reason=reason + ) + async def apply_patch( self, operations: list[PatchOp], @@ -97,12 +115,14 @@ class ConfigOrchestrator[S: ConfigSchema]: raise ConfigPatchValidationError() from exc operations_by_layer: dict[str, list[PatchOp]] = defaultdict(list) + default_layer_name: str | None = None for op in operations: - layer_name = ( - op.target_layer_name - if op.target_layer_name is not None - else DEFAULT_PATCH_LAYER_NAME - ) + layer_name = op.target_layer_name + if layer_name is None: + if default_layer_name is None: + default_layer_name = self._resolve_default_layer_name() + layer_name = default_layer_name + operations_by_layer[layer_name].append(op) tasks = [] @@ -142,6 +162,15 @@ class ConfigOrchestrator[S: ConfigSchema]: on_conflict=on_conflict, ) + def _resolve_default_layer_name(self) -> str: + layer = self._default_layer_resolver() + if layer not in self._builder.layers: + raise DefaultLayerResolutionError( + f"Default layer resolver returned unknown layer {layer.name!r}" + ) + + return layer.name + def subscribe( self, callback: ConfigChangeCallback, *, keys: set[str] | None = None ) -> Callable[[], None]: diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py index bb33ecf..a2ec8dd 100644 --- a/vibe/core/skills/builtins/vibe.py +++ b/vibe/core/skills/builtins/vibe.py @@ -260,6 +260,20 @@ default_agent = "plan" ### MCP Servers +Hosted OAuth MCP servers can be added from inside Vibe: + +```text +/mcp add https://mcp.linear.app/mcp +/mcp add https://mcp.example.com/mcp --name docs --scope read --transport http --no-login +``` + +`/mcp add` is OAuth-only. It writes `auth.type = "oauth"` with optional +scopes and starts login by default. It uses `transport = "streamable-http"` +unless you pass `--transport http`. Pass `--no-login` to add the server without +starting OAuth login. The shortcut supports `streamable-http` and `http` +transports. For API-key/static auth, edit `config.toml` using the static auth +example below. + ```toml [[mcp_servers]] name = "my-server" @@ -581,6 +595,10 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`. - `/voice` - Configure voice settings - `/mcp` - Display MCP servers and connector status; pass a server or connector name to list its tools or open its auth panel when authentication is required +- `/mcp add ` - Add a hosted OAuth MCP server. Supports `--name `, + repeatable `--scope `, `--transport `, and + `--no-login`. Starts OAuth login by default. OAuth-only; use `config.toml` + for API-key/static auth. - `/mcp status` - Display MCP auth state (`ok`, `needs_auth`, `static`, `stdio`) - `/mcp login ` - Start OAuth login for an MCP server - `/mcp logout ` - Log out from an MCP server and delete stored OAuth diff --git a/vibe/core/tools/mcp_settings.py b/vibe/core/tools/mcp_settings.py index 59f6b57..e40028b 100644 --- a/vibe/core/tools/mcp_settings.py +++ b/vibe/core/tools/mcp_settings.py @@ -6,9 +6,34 @@ tied to a particular UI layer. from __future__ import annotations -from typing import Any +from dataclasses import dataclass +from ipaddress import ip_address +import re +from typing import Any, Literal, cast +from urllib.parse import SplitResult, urlsplit, urlunsplit -from vibe.core.config import VibeConfig +from pydantic import ValidationError + +from vibe.core.config import MCPHttp, MCPOAuth, MCPServer, MCPStreamableHttp, VibeConfig + +MCPAddTransport = Literal["http", "streamable-http"] + + +class MCPServerAddError(ValueError): + pass + + +@dataclass(frozen=True) +class MCPServerAddResult: + name: str + url: str + created: bool + + +_DEFAULT_PORTS = {"http": 80, "https": 443} +_HOST_PREFIXES_TO_DROP = {"mcp", "www"} +_GENERIC_ALIAS_SEGMENTS = {"api", "mcp", "server"} +_LEADING_HOST_PREFIX_LABEL_MIN_COUNT = 3 def updated_tool_list(tools: list[str], name: str, disabled: bool) -> list[str]: @@ -73,3 +98,248 @@ def _persist_connector_toggle( entry["disabled"] = disabled connectors.append(entry) VibeConfig.save_updates({"connectors": connectors}) + + +def persist_oauth_mcp_server( + config: VibeConfig, + *, + url: str, + name: str | None = None, + scopes: list[str] | None = None, + transport: MCPAddTransport = "streamable-http", +) -> MCPServerAddResult: + normalized_url = _normalize_mcp_oauth_url(url) + normalized_url_key = _url_key(normalized_url) + requested_name = _normalize_server_name(name) if name is not None else None + if name is not None and not requested_name: + raise MCPServerAddError("MCP server name must contain letters or numbers.") + + active_servers = list(config.mcp_servers) + if result := _find_active_url_match( + active_servers, normalized_url_key, requested_name + ): + return result + + raw_servers = _load_persisted_mcp_servers() + if result := _find_persisted_url_match( + raw_servers, normalized_url_key, requested_name + ): + return result + + raw_names = _persisted_server_names(raw_servers) + active_names = {server.name for server in active_servers} | raw_names + server_name = _resolve_new_server_name(requested_name, normalized_url, active_names) + + entry: dict[str, Any] = { + "name": server_name, + "transport": transport, + "url": normalized_url, + "auth": {"type": "oauth", "scopes": scopes or []}, + } + try: + model = MCPHttp if transport == "http" else MCPStreamableHttp + model.model_validate(entry) + except ValidationError as exc: + raise MCPServerAddError(f"Invalid MCP server configuration: {exc}") from exc + + VibeConfig.save_updates({"mcp_servers": [*raw_servers, entry]}) + return MCPServerAddResult(name=server_name, url=normalized_url, created=True) + + +def parse_mcp_add_transport(value: str) -> MCPAddTransport: + match value: + case "http" | "streamable-http": + return value + case _: + raise MCPServerAddError( + "MCP server transport must be one of: http, streamable-http." + ) + + +def _find_active_url_match( + servers: list[MCPServer], normalized_url_key: str, requested_name: str | None +) -> MCPServerAddResult | None: + for server in servers: + if server.transport not in {"http", "streamable-http"}: + continue + http_server = cast("MCPHttp | MCPStreamableHttp", server) + if _url_key(http_server.url) != normalized_url_key: + continue + if not isinstance(http_server.auth, MCPOAuth): + raise MCPServerAddError( + f"MCP server URL is already configured as `{http_server.name}` " + "with static auth. `/mcp add` only supports OAuth MCP servers." + ) + if requested_name is not None and requested_name != http_server.name: + raise MCPServerAddError( + f"MCP server URL is already configured as `{http_server.name}`." + ) + return MCPServerAddResult( + name=http_server.name, url=http_server.url, created=False + ) + return None + + +def _load_persisted_mcp_servers() -> list[dict[str, Any]]: + raw_servers = VibeConfig.get_persisted_config().get("mcp_servers", []) + if not isinstance(raw_servers, list): + raise MCPServerAddError("Cannot add MCP server: mcp_servers is not a list.") + return [server for server in raw_servers if isinstance(server, dict)] + + +def _find_persisted_url_match( + raw_servers: list[dict[str, Any]], + normalized_url_key: str, + requested_name: str | None, +) -> MCPServerAddResult | None: + for raw_server in raw_servers: + if raw_server.get("transport") not in {"http", "streamable-http"}: + continue + raw_url = raw_server.get("url") + if not isinstance(raw_url, str) or _url_key(raw_url) != normalized_url_key: + continue + raw_name = raw_server.get("name") + if not isinstance(raw_name, str): + raw_name = _suggest_server_name(raw_url) + if not _is_persisted_oauth_server(raw_server): + raise MCPServerAddError( + f"MCP server URL is already configured as `{raw_name}` " + "with static auth. `/mcp add` only supports OAuth MCP servers." + ) + if requested_name is not None and requested_name != raw_name: + raise MCPServerAddError( + f"MCP server URL is already configured as `{raw_name}`." + ) + return MCPServerAddResult(name=raw_name, url=raw_url, created=False) + return None + + +def _is_persisted_oauth_server(raw_server: dict[str, Any]) -> bool: + auth = raw_server.get("auth") + return isinstance(auth, dict) and auth.get("type") == "oauth" + + +def _persisted_server_names(raw_servers: list[dict[str, Any]]) -> set[str]: + names: set[str] = set() + for raw_server in raw_servers: + name = raw_server.get("name") + if isinstance(name, str): + names.add(name) + return names + + +def _resolve_new_server_name( + requested_name: str | None, normalized_url: str, active_names: set[str] +) -> str: + if requested_name is None: + return _dedupe_server_name(_suggest_server_name(normalized_url), active_names) + + if requested_name in active_names: + raise MCPServerAddError( + f"MCP server name `{requested_name}` is already configured." + ) + return requested_name + + +def _normalize_mcp_oauth_url(value: str) -> str: + raw_url = value.strip() + if not raw_url: + raise MCPServerAddError("MCP server URL is required.") + + parsed = urlsplit(raw_url) + scheme = parsed.scheme.lower() + host = parsed.hostname + if not scheme: + raise MCPServerAddError("MCP server URL must include a scheme.") + if scheme not in {"http", "https"}: + raise MCPServerAddError("MCP server URL must use https.") + if not host: + raise MCPServerAddError("MCP server URL must include a host.") + if parsed.fragment: + raise MCPServerAddError("MCP server URL must not include a fragment.") + if scheme == "http" and not _is_loopback_host(host): + raise MCPServerAddError( + "MCP server URL must use https unless it points to localhost." + ) + + return _url_with_normalized_host(parsed, trim_trailing_slash=False) + + +def _url_key(value: str) -> str: + parsed = urlsplit(value.strip()) + return _url_with_normalized_host(parsed, trim_trailing_slash=True) + + +def _url_with_normalized_host(parsed: SplitResult, *, trim_trailing_slash: bool) -> str: + scheme = parsed.scheme.lower() + host = parsed.hostname + if host is None: + return parsed.geturl() + + hostname = host.lower() + if ":" in hostname and not hostname.startswith("["): + hostname = f"[{hostname}]" + + netloc = hostname + if parsed.port is not None and parsed.port != _DEFAULT_PORTS.get(scheme): + netloc = f"{netloc}:{parsed.port}" + + path = parsed.path + if trim_trailing_slash: + path = path.rstrip("/") + + return urlunsplit((scheme, netloc, path, parsed.query, "")) + + +def _is_loopback_host(host: str) -> bool: + if host.lower() == "localhost": + return True + try: + return ip_address(host).is_loopback + except ValueError: + return False + + +def _suggest_server_name(url: str) -> str: + parsed = urlsplit(url) + host = parsed.hostname or "mcp" + labels = [label for label in host.lower().split(".") if label] + if ( + len(labels) >= _LEADING_HOST_PREFIX_LABEL_MIN_COUNT + and labels[0] in _HOST_PREFIXES_TO_DROP + ): + labels = labels[1:] + + candidate = labels[0] if labels else "" + if candidate in _GENERIC_ALIAS_SEGMENTS: + candidate = _path_alias_candidate(parsed.path) + + return _normalize_server_name(candidate) or "mcp" + + +def _path_alias_candidate(path: str) -> str: + for segment in path.split("/"): + normalized = _normalize_server_name(segment.lower()) + if normalized and normalized not in _GENERIC_ALIAS_SEGMENTS: + return normalized + return "" + + +def _normalize_server_name(value: str | None) -> str: + if not value: + return "" + normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", value) + normalized = normalized.strip("_-") + return normalized[:256] + + +def _dedupe_server_name(base: str, existing_names: set[str]) -> str: + if base not in existing_names: + return base + + index = 2 + while True: + candidate = f"{base}_{index}" + if candidate not in existing_names: + return candidate + index += 1 diff --git a/vibe/core/utils/keyring.py b/vibe/core/utils/keyring.py index 835228f..99cbd86 100644 --- a/vibe/core/utils/keyring.py +++ b/vibe/core/utils/keyring.py @@ -60,7 +60,10 @@ def _delete_macos_password(service: str, username: str) -> None: def _set_password(service: str, username: str, password: str) -> None: if not _should_use_macos_security(): - keyring.set_password(service, username, password) + try: + keyring.set_password(service, username, password) + except ImportError as exc: + raise KeyringError("Can't load keyring backend") from exc return try: @@ -100,7 +103,10 @@ def _get_password(service: str, username: str) -> str | None: raise KeyringError("Can't get password from macOS Keychain") from exc return result.stdout.removesuffix("\n") - return keyring.get_password(service, username) + try: + return keyring.get_password(service, username) + except ImportError as exc: + raise KeyringError("Can't load keyring backend") from exc def _delete_password(service: str, username: str) -> None: @@ -108,7 +114,10 @@ def _delete_password(service: str, username: str) -> None: _delete_macos_password(service, username) return - keyring.delete_password(service, username) + try: + keyring.delete_password(service, username) + except ImportError as exc: + raise KeyringError("Can't load keyring backend") from exc def _migrate_legacy_password(username: str, password: str, legacy_service: str) -> None: diff --git a/vibe/whats_new.md b/vibe/whats_new.md index e1b361f..6d6e5e5 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,9 +1,7 @@ +# What's new in v2.18.1 + +- **OAuth MCP server setup**: Added `/mcp add` command to add and authenticate OAuth MCP servers directly from the TUI + # What's new in v2.18.0 - **Clipboard image paste**: Paste images directly from clipboard in the TUI on macOS - -# What's new in v2.17.0 - -- **MCP OAuth login**: Authenticate OAuth-backed MCP servers from the TUI with the new `/mcp login`, `/mcp logout`, and `/mcp status` commands -- **`--check-upgrade`**: New flag to check for a Vibe update on demand, prompt to install it, and exit -- **`--yolo`**: New shorthand alias for `--auto-approve`