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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
`LECODE_CONFIG_DIR`, `LECODE_SKILLS_DIR`, and project cwd under `tmp_path`.
The shared `tool_ctx` fixture is not autouse and isolates only the config directory.
- Reuse `tests/fakes.py` for providers/catalogs. MCP tests launch mock subprocesses
and localhost servers; disable auto servers as in `tests/test_mcp.py:mcp_config`
to avoid ambient credentials activating Exa. Keep the SSE shutdown reset in
and localhost servers, configured explicitly via `tests/test_mcp.py:mcp_config`.
Keep the SSE shutdown reset in
`tests/conftest.py`; it prevents order-dependent failures across server tests.
- `docs/build-plan.md` is historical, not the current spec: permissions now have
two modes, config is TOML-only, and the catalog is live with an empty fallback.
Expand Down
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ just your terminal, an OpenAI-compatible model, and a sharp set of tools.
│ – hooks none configured │
│ – pierre off │
│ ✓ lsp enabled │
│ ! mcp exa (no EXA_API_KEY) │
│ – mcp no servers │
╰─────────────────── ~/github.com/you/your-project ─────────────────────╯
```

Expand Down Expand Up @@ -170,8 +170,7 @@ non-tty `--setup`) · `3` max turns / max loop iterations / context overflow.
`[notifications]`.
- **MCP** — stdio, streamable-HTTP, and SSE servers, with optional OAuth 2.1
(`auth = "oauth"`, browser flow, tokens under `<config_dir>/mcp-auth/`;
`/mcp auth` to authorize, `/mcp login|logout` to manage). Exa web search is
preconfigured (needs `EXA_API_KEY`); context7 is one flag away.
`/mcp auth` to authorize, `/mcp login|logout` to manage).
- **LSP** — diagnostics from real language servers appended to `write`/`edit`
results; fail-open, never blocks.
- **Worktrees** — `--worktree <name>` or `/worktree` for isolated branches,
Expand Down Expand Up @@ -219,7 +218,7 @@ Full reference: [docs/configuration.md](docs/configuration.md).
- [docs/hooks.md](docs/hooks.md) — hook events, envelope, verdict protocol
- [docs/memory.md](docs/memory.md) — the memory store
- [docs/agents-and-skills.md](docs/agents-and-skills.md) — custom agents and skills
- [docs/mcp.md](docs/mcp.md) — MCP servers, Exa, context7
- [docs/mcp.md](docs/mcp.md) — MCP servers
- [docs/build-plan.md](docs/build-plan.md) — the full product definition

## Development
Expand Down
5 changes: 0 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,6 @@ Payload: `{"event", "session", "ts", …}`; failures are dropped silently.

## `[mcp]`

| field | default | meaning |
|---|---|---|
| `enable_exa` | `true` | auto-configure Exa web search when `EXA_API_KEY` is set |
| `enable_context7` | `false` | auto-configure the context7 docs server |

`[mcp.servers.<name>]` entries:

| field | default | meaning |
Expand Down
28 changes: 14 additions & 14 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@ legacy HTTP transport). Their tools show up as first-class lecode tools named
`mcp:<server>:<tool>` and go through the permission system like everything
else.

## Auto-configured servers
There are no built-in servers; everything comes from `[mcp.servers]`. To add
a hosted server like Exa or context7, configure it yourself:

| server | when | transport |
|---|---|---|
| **Exa** (web search) | on by default; needs `EXA_API_KEY` in the environment | http `https://mcp.exa.ai/mcp` |
| **context7** (docs lookup) | off by default; set `enable_context7 = true` | http `https://mcp.context7.com/mcp` |
```toml
[mcp.servers.exa]
transport = "http"
url = "https://mcp.exa.ai/mcp?exaApiKey=<key>"
headers = { Authorization = "Bearer <key>" }

Exa authentication: the key is sent as the `?exaApiKey=` query parameter and
as an `Authorization: Bearer` header. Without `EXA_API_KEY`, Exa is skipped
silently.
[mcp.servers.context7]
transport = "http"
url = "https://mcp.context7.com/mcp"
```

## Configured servers

Expand All @@ -39,9 +42,6 @@ url = "https://mcp.remote.example/sse"
auth = "oauth" # interactive OAuth 2.1 authorization-code flow
```

A `[mcp.servers]` entry named `exa` or `context7` replaces the auto-configured
definition.

## OAuth 2.1

Remote servers (`http` / `sse`) that advertise OAuth (like GlitchTip)
Expand Down Expand Up @@ -81,9 +81,9 @@ callback. `auth = "oauth"` conflicts with a static `Authorization` header

## Permissions

Tools from the read-only-ish servers **exa**, **context7**, and **grep-app**
are read-equivalent: allowed in both `yolo` and `readonly` modes. Every
other MCP tool follows the mode fallback (allowed in `yolo`, denied in
Tools from servers **named** `exa`, `context7`, or `grep-app` (read-only-ish
services) are read-equivalent: allowed in both `yolo` and `readonly` modes.
Every other MCP tool follows the mode fallback (allowed in `yolo`, denied in
`readonly`) and can be matched by rules:

```toml
Expand Down
6 changes: 1 addition & 5 deletions src/lecode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,11 +831,7 @@ def run_interactive(
# into the feed when they land. Headless/loop paths still block on the
# fetch, as they print no feed.
progress.pending("models", f"fetching live from {spec.name} in the background…")
if (
config.mcp.enable_exa
or config.mcp.enable_context7
or any(s.enabled for s in config.mcp.servers.values())
):
if any(s.enabled for s in config.mcp.servers.values()):
progress.pending("mcp", "connecting in the background…")
console.print()

Expand Down
16 changes: 14 additions & 2 deletions src/lecode/config/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,20 @@

from lecode.config.models import CURRENT_SCHEMA_VERSION

#: from_version -> migrator producing from_version + 1. Empty at v1.
MIGRATIONS: dict[int, Callable[[dict], dict]] = {}

def _v1_to_v2(raw: dict) -> dict:
"""v2: drop the removed ``[mcp] enable_exa`` / ``enable_context7`` flags."""
mcp = raw.get("mcp")
if isinstance(mcp, dict):
raw = {
**raw,
"mcp": {k: v for k, v in mcp.items() if k not in ("enable_exa", "enable_context7")},
}
return raw


#: from_version -> migrator producing from_version + 1.
MIGRATIONS: dict[int, Callable[[dict], dict]] = {1: _v1_to_v2}


def migrate_config(raw: dict, current: int | None = None) -> tuple[dict, list[str], bool]:
Expand Down
4 changes: 1 addition & 3 deletions src/lecode/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator

#: Current on-disk schema version. Bump when adding a migration.
CURRENT_SCHEMA_VERSION = 1
CURRENT_SCHEMA_VERSION = 2

ThinkingLevel = Literal["none", "low", "medium", "high"]
AuthPolicy = Literal["auto", "required", "none"]
Expand Down Expand Up @@ -189,8 +189,6 @@ class McpConfig(BaseModel):

model_config = ConfigDict(extra="ignore")

enable_exa: bool = True
enable_context7: bool = False
servers: dict[str, McpServerConfig] = Field(default_factory=dict)


Expand Down
50 changes: 6 additions & 44 deletions src/lecode/extras/mcp_client.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
"""The MCP client: connects configured + auto servers and exposes their tools.
"""The MCP client: connects configured servers and exposes their tools.

Uses the official ``mcp`` SDK over stdio, streamable-HTTP, and SSE. Each
server is connected lazily at session start with a ~10s budget; failures are
isolated per server (one bad server never blocks the others or startup).
Discovered tools are registered as lecode tools named ``mcp:<server>:<tool>``
— the permission system already treats exa/context7/grep-app as
— the permission system treats servers named exa/context7/grep-app as
read-equivalent, every other MCP tool falls back to the mode default (Allow
in yolo, Deny in readonly).

Auto-configured servers (``[mcp] enable_exa`` / ``enable_context7``):

- **Exa** (default on, needs ``EXA_API_KEY``): the hosted streamable-HTTP
endpoint ``https://mcp.exa.ai/mcp``. Auth assumption: the key goes as the
documented ``?exaApiKey=`` query param *and* an ``Authorization: Bearer``
header — Exa's exact header scheme is not pinned down in their docs.
- **context7** (default off): ``https://mcp.context7.com/mcp``, no auth.

OAuth 2.1 for remote servers (``transport = "http"`` or ``"sse"``;
Servers come from ``[mcp.servers]`` in the config; there are no built-in
servers. OAuth 2.1 for remote servers (``transport = "http"`` or ``"sse"``;
``[mcp.servers.<name>] auth = "oauth"``) rides on the SDK's
``OAuthClientProvider`` (see ``lecode.extras.mcp_auth``): automatic
connections reuse cached credentials and refresh them silently; interactive
Expand Down Expand Up @@ -54,9 +47,6 @@
#: ``ctx.extras`` key under which the connected manager is installed.
MCP_EXTRA = "mcp"

EXA_URL = "https://mcp.exa.ai/mcp"
CONTEXT7_URL = "https://mcp.context7.com/mcp"

#: Per-server startup budget.
CONNECT_TIMEOUT_S = 10.0

Expand All @@ -65,34 +55,6 @@
INTERACTIVE_AUTH_BUDGET_S = CALLBACK_TIMEOUT_S + 30.0


def auto_servers(config: Config, env: dict[str, str] | None = None) -> dict[str, McpServerConfig]:
"""The auto-configured servers implied by the ``[mcp]`` flags."""
env = os.environ if env is None else env
servers: dict[str, McpServerConfig] = {}
if config.mcp.enable_exa:
key = env.get("EXA_API_KEY", "").strip()
if key:
servers["exa"] = McpServerConfig(
transport="http",
url=f"{EXA_URL}?exaApiKey={key}",
headers={"Authorization": f"Bearer {key}"},
)
else:
log.debug("mcp: exa enabled but EXA_API_KEY is not set — skipping")
if config.mcp.enable_context7:
servers["context7"] = McpServerConfig(transport="http", url=CONTEXT7_URL)
return servers


def all_server_configs(
config: Config, env: dict[str, str] | None = None
) -> dict[str, McpServerConfig]:
"""Auto servers merged with ``[mcp.servers]`` (user config wins)."""
merged = auto_servers(config, env)
merged.update(config.mcp.servers)
return merged


@dataclass(frozen=True)
class ServerStatus:
"""One server's state for ``/mcp``."""
Expand Down Expand Up @@ -220,8 +182,8 @@ def __init__(
# -- connection ------------------------------------------------------------

async def connect(self) -> None:
"""Connect every configured + auto server; failures stay per-server."""
for name, server_config in all_server_configs(self._config).items():
"""Connect every configured server; failures stay per-server."""
for name, server_config in self._config.mcp.servers.items():
server = _Server(name, server_config)
self._servers[name] = server
if server_config.enabled:
Expand Down
5 changes: 2 additions & 3 deletions src/lecode/slash/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1259,9 +1259,8 @@ async def cmd_init(app: TuiApp, args: list[str]) -> None:
),
"chain": "/chain <topic> runs brainstorm → plan → code → review as one turn.",
"mcp": (
"MCP servers are configured under [mcp.servers] (stdio, http, or sse); "
"Exa web search is auto-configured when EXA_API_KEY is set, context7 "
"with enable_context7 = true. /mcp shows state; /mcp "
"MCP servers are configured under [mcp.servers] (stdio, http, or sse). "
"/mcp shows state; /mcp "
"tools|reconnect|auth|login|logout <name>. Remote servers with "
'auth = "oauth" log in via /mcp auth (opens your browser once; '
"credentials are reused afterwards; /mcp login forces a fresh login). "
Expand Down
26 changes: 3 additions & 23 deletions src/lecode/tui/loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from __future__ import annotations

import logging
import os
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -131,34 +130,15 @@ def mcp_step(config: Config, mcp_servers: list[ServerStatus] | None = None) -> L
lines.append(s.auth_hint)
else:
lines.append(f"{s.name}: disabled")
exa_missing = (
config.mcp.enable_exa
and not os.environ.get("EXA_API_KEY")
and not any(s.name == "exa" for s in mcp_servers)
)
if exa_missing:
lines.append("exa: no EXA_API_KEY")
if not lines:
return LoadStep("mcp", "no servers", SKIP)
degraded = exa_missing or any(s.state in ("failed", "auth_required") for s in mcp_servers)
degraded = any(s.state in ("failed", "auth_required") for s in mcp_servers)
return LoadStep("mcp", "\n".join(lines), WARN if degraded else OK)

# no live statuses (tests, headless): report the configuration only
mcp_bits: list[str] = []
mcp_status = OK
if config.mcp.enable_exa:
if os.environ.get("EXA_API_KEY"):
mcp_bits.append("exa")
else:
mcp_bits.append("exa (no EXA_API_KEY)")
mcp_status = WARN
if config.mcp.enable_context7:
mcp_bits.append("context7")
configured = [n for n, s in config.mcp.servers.items() if s.enabled]
configured = sorted(n for n, s in config.mcp.servers.items() if s.enabled)
if configured:
mcp_bits.append(_join_names(sorted(configured), cap=3))
if mcp_bits:
return LoadStep("mcp", " · ".join(mcp_bits), mcp_status)
return LoadStep("mcp", _join_names(configured, cap=3), OK)
return LoadStep("mcp", "no servers", SKIP)


Expand Down
23 changes: 18 additions & 5 deletions tests/test_config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def project(tmp_path):

def test_defaults_validate_from_empty():
config = Config.model_validate({})
assert config.schema_version == 1
assert config.schema_version == 2
assert config.llm.provider == "openrouter"
assert config.llm.thinking == "medium"
assert config.llm.auth_policy == "auto"
Expand All @@ -45,8 +45,6 @@ def test_defaults_validate_from_empty():
assert config.worktree.validation == []
assert config.permissions.mode == "yolo"
assert config.notifications.volume == 0.5
assert config.mcp.enable_exa is True
assert config.mcp.enable_context7 is False
assert config.memory.max_bytes == 32768
assert config.telemetry.enabled is False
assert config.telemetry.sentry_dsn is None
Expand Down Expand Up @@ -164,11 +162,26 @@ def test_missing_schema_version_migrated_and_rewritten(global_dir, tmp_path):
path = global_dir / "config.toml"
path.write_text('[llm]\nmodel = "x"\n')
result = load_config(cwd=tmp_path)
assert result.config.schema_version == 1
assert "schema_version = 1" in path.read_text()
assert result.config.schema_version == 2
assert "schema_version = 2" in path.read_text()
assert 'model = "x"' in path.read_text()


def test_v1_migration_drops_removed_mcp_flags(global_dir, tmp_path):
path = global_dir / "config.toml"
path.write_text(
"schema_version = 1\n[mcp]\nenable_exa = true\nenable_context7 = true\n"
'[mcp.servers.mine]\ntransport = "stdio"\ncommand = "x"\n'
)
result = load_config(cwd=tmp_path)
assert result.config.schema_version == 2
assert list(result.config.mcp.servers) == ["mine"]
assert not any("unknown config key" in w for w in result.warnings)
rewritten = path.read_text()
assert "enable_exa" not in rewritten and "enable_context7" not in rewritten
assert "schema_version = 2" in rewritten


def test_migration_applied_from_registry(global_dir, tmp_path, monkeypatch):
monkeypatch.setattr("lecode.config.migrations.CURRENT_SCHEMA_VERSION", 2)

Expand Down
Loading
Loading