v2.18.1 (#856)
Co-authored-by: Charles-Edouard Cady <charles.edouard.cady@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Sylvain Afchain <safchain@gmail.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
e607ccbb00
commit
ed5b7192e6
33 changed files with 1456 additions and 125 deletions
16
CHANGELOG.md
16
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
|
||||
|
|
|
|||
28
README.md
28
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
26
tests/cli/test_input_kinds.py
Normal file
26
tests/cli/test_input_kinds.py
Normal file
|
|
@ -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
|
||||
212
tests/cli/test_mcp_add_command.py
Normal file
212
tests/cli/test_mcp_add_command.py
Normal file
|
|
@ -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 <url>" in widget._content
|
||||
and "--transport <http|streamable-http>" 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
|
||||
)
|
||||
38
tests/cli/test_ui_exit_command.py
Normal file
38
tests/cli/test_ui_exit_command.py
Normal file
|
|
@ -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 == []
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 == [
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
195
tests/core/test_mcp_settings.py
Normal file
195
tests/core/test_mcp_settings.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 <alias>, logout <alias>"
|
||||
"Pass a name to list tools; subcommands: add <url> "
|
||||
"[--transport http|streamable-http], status, login <alias>, "
|
||||
"logout <alias>"
|
||||
),
|
||||
handler="_show_mcp",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
123
vibe/cli/textual_ui/mcp_commands.py
Normal file
123
vibe/cli/textual_ui/mcp_commands.py
Normal file
|
|
@ -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 <url> [--name <alias>] [--scope <scope> ...] "
|
||||
"[--transport <http|streamable-http>] [--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", "<alias>")
|
||||
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", "<http|streamable-http>"
|
||||
)
|
||||
)
|
||||
transport_seen = True
|
||||
index += 2
|
||||
case "--scope":
|
||||
scopes.append(
|
||||
_mcp_add_option_value(tokens, index, "--scope", "<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]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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 <url>` - Add a hosted OAuth MCP server. Supports `--name <alias>`,
|
||||
repeatable `--scope <scope>`, `--transport <http|streamable-http>`, 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 <alias>` - Start OAuth login for an MCP server
|
||||
- `/mcp logout <alias>` - Log out from an MCP server and delete stored OAuth
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue