From f94d76a337622dd12bf27f8ced6ff06538610f4e Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:51:52 +0100 Subject: [PATCH 1/8] chore: start work on #78 From cb6f5654a2240a13f80750e94028de029ddadaf3 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:21:48 +0100 Subject: [PATCH 2/8] docs(specs): add tool schema translation design (#78) --- ...26-09-22-tool-schema-translation-design.md | 227 +++++ .../2026-09-22-tool-schema-translation.md | 809 ++++++++++++++++++ 2 files changed, 1036 insertions(+) create mode 100644 .agents/specs/2026-09-22-tool-schema-translation-design.md create mode 100644 .agents/specs/2026-09-22-tool-schema-translation.md diff --git a/.agents/specs/2026-09-22-tool-schema-translation-design.md b/.agents/specs/2026-09-22-tool-schema-translation-design.md new file mode 100644 index 0000000..a4c0dff --- /dev/null +++ b/.agents/specs/2026-09-22-tool-schema-translation-design.md @@ -0,0 +1,227 @@ +# Design: Tool Schema Translation Layer — MCP inputSchema → Provider-Native Tools + +- **Issue:** #78 — feat: tool schema translation layer +- **Branch:** `feature/tool-schema-translation-layer` +- **Draft PR:** [#100](https://github.com/Svagtlys/Octave/pull/100) +- **Date:** 2026-09-22 +- **Status:** Approved + +## Problem + +Issue #78 asks for the bridge between the two vocabularies shipped by #77 and +the inference adapter: MCP servers advertise tools with JSON Schema +`inputSchema` (`ToolInfo`, [mcp/types.py](../../backend/src/octave/mcp/types.py)), +while OpenAI-dialect engines expect a `tools` array of +`{"type": "function", "function": {name, description, parameters}}` objects on +`/chat/completions`. `CompletionRequest` ([inference/types.py](../../backend/src/octave/inference/types.py)) +has no `tools` field, and nothing converts between the shapes. The #77 spec +recorded this work item as the home of **fleet-wide name resolution / +namespacing** (its decision 3); the inventory surface it needs +(`ToolRegistry.inventory()` / `tools_for()`) already exists. + +| Capability | Status | Evidence | +|---|---|---| +| Per-server `ToolInfo` inventory with cache | Done | `ToolRegistry` ([mcp/registry.py](../../backend/src/octave/mcp/registry.py)), #77 | +| `CompletionRequest.tools` field | **Gap** | Not present in `inference/types.py` | +| MCP `inputSchema` → `function.parameters` | **Gap** | No translator anywhere | +| Envelope wrapping in the adapter | **Gap** | `_chat_kwargs` never emits `tools` | +| Fleet-wide name dedupe (prefixing) | **Gap** | Deferred to #78 by #77 decision 3 | +| Reverse routing (exposed name → server + tool) | **Gap** | Agent loop cannot yet resolve model tool calls | + +## Decisions from brainstorming + +| # | Topic | Decision | +|---|-------|----------| +| 1 | Name dedupe | **`mcp____` prefix (standard MCP-proxy convention).** Components sanitized to OpenAI's function-name charset `^[a-zA-Z0-9_-]{1,64}$`; whole name truncated to 64 chars. Prefix uses `server_name` (LLM-readable; unique-constrained by `uq_mcp_servers_name` in [models/mcp.py](../../backend/src/octave/db/models/mcp.py)); routing keys off `server_id` (stable PK). | +| 2 | Rename / re-describe | **Deferred to TODO MCP Connector #9–10** (user rename/description overrides). Issue exists; #78 ships dedupe + translation only. Overrides will compose over the translator input later without reshaping it. | +| 3 | Reverse mapping | **Emit now.** `translate_tools()` returns both provider-facing `tools` and a `routes` lookup (exposed name → `server_id` + original `tool_name`) so the agent loop never re-implements the prefixing scheme. Pure derivation from the same input. | +| 4 | Collisions | **Raise `ToolNameCollisionError`.** After sanitization/truncation, two tools landing on one exposed name is pathological (server names are unique; MCP forbids per-server duplicate tool names). Loud and deterministic beats silent renaming; auto-disambiguation returns only if a real fleet hits it. | +| 5 | Module placement | **New `octave/tools/` package** — pure translator importing both vocabularies; `octave.mcp` and `octave.inference` never import each other or the translator. Precedent: subsystem independence ADRs (`octave.db` never imports `octave.inference`). Future tool-plane concerns (tagging #8, overrides #9–10, routing) land in this package. | +| 6 | Schema body | **Verbatim passthrough minus `$schema`.** Octave never interprets JSON Schema (`ToolInfo` docstring invariant). Only the `$schema` key is stripped — local engines (Ollama/vLLM/llama.cpp) are strict about it. Deep copy; never mutate cached `ToolInfo`. | +| 7 | Envelope location | **Adapter wraps.** `ToolDefinition` stays Octave-neutral in `inference/types.py`; the `{"type": "function", ...}` envelope is built in `OpenAIAdapter._chat_kwargs` — same boundary rule as exception translation (SDK dialect stays inside the adapter). | + +### Rejected alternatives + +- **Pass-through names verbatim** — violates OpenAI's `^[a-zA-Z0-9_-]{1,64}$` + charset for many real servers (dots, colons, CJK) and leaves cross-server + duplicates unresolved; #77 explicitly assigned namespacing to #78. +- **Full namespacing + call-time routing in the registry** — registry stays a + pure cache/call surface; routing is a translation-output concern (decision 3). +- **Translator inside `octave.mcp` or `octave.inference`** — forces a + subsystem→subsystem dependency in one direction (MCP knowing provider + dialect, or inference knowing MCP vocabulary). Rejected in brainstorming. +- **`tool_choice` / `parallel_tool_calls` first-class fields** — YAGNI; they + flow through `CompletionRequest.extra` → `extra_body` today. +- **Silent numeric-suffix disambiguation on collision** — unstable names across + inventory refreshes (suffix order depends on iteration order), which would + silently break any persisted agent-loop decisions. + +## Architecture + +```mermaid +graph LR + A[ToolRegistry
inventory reads] -->|list ServerToolInventory| B[octave.tools.translate_tools
NEW pure function] + B -->|ProviderToolset| C[Future agent loop
Integration & Testing 1] + C -->|CompletionRequest.tools| D[OpenAIAdapter
_chat_kwargs wraps envelope] + E[octave.inference.types
ToolDefinition] --> B + F[octave.mcp facade
ToolInfo / ServerToolInventory] --> B +``` + +New package `backend/src/octave/tools/`: + +- **`types.py`** — `ToolRoute`, `ProviderToolset` (translation-output vocabulary). +- **`errors.py`** — `ToolTranslationError(Exception)` base; `ToolNameCollisionError` subclass. +- **`translate.py`** — `translate_tools()` pure function + name sanitization helpers. +- **`__init__.py`** — re-exports `translate_tools`, `ProviderToolset`, `ToolRoute`, errors. + +Modified (additive only): + +- `inference/types.py` — `ToolDefinition` model; `CompletionRequest.tools: list[ToolDefinition] | None = None`. +- `inference/openai_adapter.py` — `_chat_kwargs` emits the `tools` envelope when `request.tools` is non-empty. + +`octave/mcp/*`: **no changes.** No new settings knobs. No runtime wiring — the +consumer (agent loop) is a later work item; this issue ships the complete seam. + +## Data model & API + +```python +# octave/inference/types.py (additive) +class ToolDefinition(BaseModel): + """One tool in Octave's provider-neutral shape. The adapter wraps it + into the provider envelope; SDK types never appear here.""" + + name: str + description: str | None = None + parameters: dict[str, Any] = Field(default_factory=dict) + +class CompletionRequest(BaseModel): + ... + tools: list[ToolDefinition] | None = None + +# octave/tools/types.py +class ToolRoute(BaseModel): + """Reverse-resolution target for one exposed tool name.""" + + server_id: str + tool_name: str + """Original (unprefixed) MCP tool name.""" + +class ProviderToolset(BaseModel): + """Translator output: what the engine sees + how to route calls back.""" + + tools: list[ToolDefinition] + routes: dict[str, ToolRoute] + """Exposed name -> route. Keys match ``tools[*].name`` exactly.""" + +# octave/tools/errors.py +class ToolTranslationError(Exception): + """Base for translation failures.""" + +class ToolNameCollisionError(ToolTranslationError): + """Two tools landed on the same exposed name after sanitization.""" + +# octave/tools/translate.py +def translate_tools( + inventories: Sequence[ServerToolInventory], +) -> ProviderToolset: + """Pure: fleet inventories -> provider tool defs + reverse routes. + + Raises ToolNameCollisionError when two tools expose the same name. + Never mutates inputs; no I/O, no logging. + """ +``` + +## Translation mechanics + +**Exposed name.** For each `(inventory, tool)`: + +1. `component = re.sub(r"[^a-zA-Z0-9_-]", "_", s)` applied independently to + `inventory.server_name` and `tool.name`. +2. Join: `mcp__{sanitized_server}__{sanitized_tool}`. +3. Truncate the whole name to 64 chars (`[:64]`). + +**Collision.** After step 3, if the exposed name is already claimed within +this call, raise `ToolNameCollisionError` naming both sources +(`server_id/tool_name` pairs). Detection is per-call; stateless translator. + +**Route.** `routes[exposed_name] = ToolRoute(server_id=inv.server_id, tool_name=tool.name)` +— `server_id` for addressing (matches `ToolRegistry.call_tool(id, name)`), +original `tool_name` for the wire call. + +**Schema body.** `parameters = copy.deepcopy(tool.input_schema)` with the +`$schema` key removed at the top level. Everything else verbatim — `$defs`, +`definitions`, `anyOf`, `enum`, nesting all pass through untouched. Octave +never validates or rewrites JSON Schema internals. + +**Description.** `tool.description` forwarded; `None` omitted from the +provider payload (`model_dump(exclude_none=True)` at envelope time). + +**Ordering.** Inventories in input order, tools in per-server order — +deterministic output for a given input snapshot. + +**Empty inputs.** No inventories, or inventories with `tools=[]` (never +fetched / failed discovery), contribute nothing. All-empty input returns an +empty `ProviderToolset` — callers decide whether to send `tools` at all. + +## Adapter forwarding + +In `_chat_kwargs` (shared by `complete` and `stream`): + +```python +if request.tools: + kwargs["tools"] = [ + {"type": "function", "function": tool.model_dump(exclude_none=True)} + for tool in request.tools + ] +``` + +Omitted entirely when `tools` is `None` or empty (some local engines behave +differently with an explicit empty array). `tool_choice`, +`parallel_tool_calls`, and friends flow through `extra` → `extra_body` +unchanged. No changes to `complete`/`stream` bodies, error translation, or +the SDK quarantine rule. + +## Scope reconciliation & deferrals + +| Deferred | Trigger / home | +|---|---| +| Response-side `tool_calls` parsing (assistant messages, streaming deltas) | Agent-loop work item (Integration & Testing #1) — #78 ships request-side only, per issue scope | +| Tool rename / re-describe overrides | TODO MCP Connector #9–10 (existing issue; user decision 2026-09-22) | +| Tool tagging (`kb_read` etc.) | TODO MCP Connector #8 | +| Collision auto-disambiguation | Only if a real fleet triggers `ToolNameCollisionError` | +| `tool_choice` / `parallel_tool_calls` first-class fields | When a caller needs them; `extra` carries them meanwhile | +| Registry→translator glue service | Agent loop composes `inventory() → translate_tools()` itself; a convenience wrapper waits for a second consumer | +| `strict` mode / structured outputs | Engine capability matrix work item | + +## Testing + +**Unit — `tests/tools/test_translate.py`** (pure functions, hand-built +`ServerToolInventory` fixtures; no manager, no SDK): + +1. Happy path: one server, one tool → correct exposed name, envelope-ready + `parameters`, route entry. +2. Multi-server / multi-tool: ordering deterministic; all routes present. +3. Sanitization: dots/colons/spaces/CJK in server or tool name → `_`. +4. Truncation: >64-char composed name cut to exactly 64. +5. Collision: two servers whose sanitized names collide → `ToolNameCollisionError` + naming both sources. +6. Routes map to `server_id` + **original** (unsanitized) tool name. +7. `$schema` stripped; sibling keys (`type`, `properties`, `required`, + `$defs`) verbatim and deep-equal to input. +8. Input not mutated (deep-copy check); cached `ToolInfo` safe. +9. `description=None` → field absent in dump; present when set. +10. Empty inventories / empty `tools=[]` / empty input → empty `ProviderToolset`. + +**Unit — `tests/inference/test_types.py`:** `CompletionRequest` defaults +`tools=None`; round-trips a list of `ToolDefinition`. + +**Adapter — `tests/inference/test_openai_adapter.py`** (`httpx.MockTransport`): + +11. `complete()` with tools → request body carries + `{"type": "function", "function": {...}}` envelope, `exclude_none` applied. +12. `stream()` with tools → same envelope on the stream request body. +13. No tools / empty list → body has no `tools` key. + +**Package hygiene — `tests/tools/test_package.py`:** `octave.tools` imports +neither `openai` nor `mcp` SDK (guards the quarantine posture, mirrors +`tests/inference/test_package.py`). diff --git a/.agents/specs/2026-09-22-tool-schema-translation.md b/.agents/specs/2026-09-22-tool-schema-translation.md new file mode 100644 index 0000000..2f6faa1 --- /dev/null +++ b/.agents/specs/2026-09-22-tool-schema-translation.md @@ -0,0 +1,809 @@ +# Tool Schema Translation Layer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Translate MCP tool inventories (`ToolInfo.input_schema`) into OpenAI-compatible function tool definitions carried on `CompletionRequest.tools`, with `mcp____` dedupe and a reverse-routing map. + +**Architecture:** A new pure `octave/tools/` package converts `octave.mcp` inventory objects into `octave.inference` `ToolDefinition`s — neither subsystem imports the other. `OpenAIAdapter._chat_kwargs` wraps definitions into the `{"type": "function", ...}` wire envelope. No runtime wiring: the agent-loop consumer is a later work item. + +**Tech Stack:** Python 3.12, pydantic v2, pytest (asyncio auto-mode), `httpx.MockTransport` wire tests, uv, ruff, mypy strict. + +**Design spec:** [`.agents/specs/2026-09-22-tool-schema-translation-design.md`](2026-09-22-tool-schema-translation-design.md) +**Issue:** [#78](https://github.com/Svagtlys/Octave/issues/78) · **Draft PR:** [#100](https://github.com/Svagtlys/Octave/pull/100) · **Branch:** `feature/tool-schema-translation-layer` + +--- + +## Conventions for this plan + +- All commands run from the repo root (`Octave/`). Python commands are `cd backend && uv run …`. +- Work on branch `feature/tool-schema-translation-layer` (already checked out by `start-work-item`). +- Test files import from `octave.*` (pytest `pythonpath = ["src"]` is configured in [`backend/pyproject.toml`](../backend/pyproject.toml)). +- mypy is strict (`backend/pyproject.toml`); annotate everything. Ruff line-length 88. +- Commit format: `type(scope): description` (per [`.agents/rules/coding.md`](../.agents/rules/coding.md)). + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `backend/src/octave/inference/types.py` | Modify | Add `ToolDefinition` model; `CompletionRequest.tools` field | +| `backend/src/octave/inference/__init__.py` | Modify | Re-export `ToolDefinition` | +| `backend/src/octave/inference/openai_adapter.py` | Modify | `_chat_kwargs` emits the `tools` envelope | +| `backend/src/octave/tools/__init__.py` | Create | Public façade of the tool plane | +| `backend/src/octave/tools/errors.py` | Create | `ToolTranslationError`, `ToolNameCollisionError` | +| `backend/src/octave/tools/types.py` | Create | `ToolRoute`, `ProviderToolset` (translation output vocabulary) | +| `backend/src/octave/tools/translate.py` | Create | `translate_tools()` — pure MCP → provider conversion | +| `backend/tests/tools/__init__.py` | Create | Package marker | +| `backend/tests/tools/test_package.py` | Create | Public-surface + no-SDK-import guard | +| `backend/tests/tools/test_translate.py` | Create | Translator unit tests | +| `backend/tests/inference/test_types.py` | Modify | `tools` field defaults/round-trip | +| `backend/tests/inference/test_package.py` | Modify | `ToolDefinition` in public names | +| `backend/tests/inference/test_openai_adapter.py` | Modify | Wire envelope tests | +| `docs/ARCHITECTURE.md` | Modify | Record the tool plane | + +--- + +### Task 1: Commit the approved design doc + +**Files:** +- Commit: `.agents/specs/2026-09-22-tool-schema-translation-design.md` (already written) + +- [ ] **Step 1: Verify branch and file presence** + +```bash +git branch --show-current +git status --short .agents/specs/ +``` +Expected: `feature/tool-schema-translation-layer`; `?? ../.agents/specs/2026-09-22-tool-schema-translation-design.md` (or `A`). + +- [ ] **Step 2: Commit** + +```bash +git add .agents/specs/2026-09-22-tool-schema-translation-design.md +git commit -m "docs(specs): add tool schema translation design (#78)" +``` + +--- + +### Task 2: `ToolDefinition` + `CompletionRequest.tools` + +**Files:** +- Modify: `backend/src/octave/inference/types.py` +- Modify: `backend/src/octave/inference/__init__.py` +- Modify: `backend/tests/inference/test_types.py` +- Modify: `backend/tests/inference/test_package.py` + +- [ ] **Step 1: Write the failing tests** — append to `backend/tests/inference/test_types.py`: + +```python +def test_completion_request_tools_defaults_none() -> None: + request = CompletionRequest(model=None, messages=[]) + assert request.tools is None + + +def test_completion_request_round_trips_tools() -> None: + tool = ToolDefinition( + name="mcp__fs__read", + description="Read a file", + parameters={"type": "object", "properties": {"path": {"type": "string"}}}, + ) + request = CompletionRequest(model="m", messages=[], tools=[tool]) + assert request.tools == [tool] + + +def test_tool_definition_defaults() -> None: + tool = ToolDefinition(name="x") + assert tool.description is None + assert tool.parameters == {} + assert ToolDefinition(name="y").parameters is not tool.parameters +``` + +Add `ToolDefinition` to the existing import block at the top of the file: + +```python +from octave.inference.types import ( + CompletionChunk, + CompletionRequest, + EmbeddingRequest, + Message, + ModelInfo, + ToolDefinition, +) +``` + +Append to the tuple in `backend/tests/inference/test_package.py::test_public_names_are_exported` (inside the existing `for name in (...)`): + +```python + "ToolDefinition", +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd backend && uv run pytest tests/inference/test_types.py tests/inference/test_package.py -v +``` +Expected: FAIL / collection error — `ImportError: cannot import name 'ToolDefinition'`. + +- [ ] **Step 3: Implement** — in `backend/src/octave/inference/types.py`, add `"ToolDefinition",` to `__all__` (alphabetical position after `"Usage"` is not required; keep the list sorted as-is by inserting after `"ModelInfo",`), and add the model after `Message` (before `Usage`): + +```python +class ToolDefinition(BaseModel): + """One tool in Octave's provider-neutral shape. + + ``parameters`` is a JSON Schema object; the adapter wraps this model + into the provider envelope. SDK types never appear here. + """ + + name: str + description: str | None = None + parameters: dict[str, Any] = Field(default_factory=dict) +``` + +Add the field to `CompletionRequest` (after `stop`, before `extra`): + +```python + tools: list[ToolDefinition] | None = None +``` + +In `backend/src/octave/inference/__init__.py`, add `ToolDefinition` to the `from octave.inference.types import (...)` block (after `ModelInfo`) and `"ToolDefinition",` to `__all__` (after `"OpenAIAdapter",`). + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && uv run pytest tests/inference/test_types.py tests/inference/test_package.py -v +``` +Expected: PASS (all, including pre-existing tests). + +- [ ] **Step 5: Lint/type gate + commit** + +```bash +cd backend && uv run ruff check src tests && uv run mypy src +git add backend/src/octave/inference/types.py backend/src/octave/inference/__init__.py backend/tests/inference/test_types.py backend/tests/inference/test_package.py +git commit -m "feat(inference): add ToolDefinition and tools field to CompletionRequest" +``` +Expected: ruff and mypy exit 0. + +--- + +### Task 3: Adapter forwards the `tools` envelope + +**Files:** +- Modify: `backend/src/octave/inference/openai_adapter.py` (`_chat_kwargs`, ~line 158) +- Modify: `backend/tests/inference/test_openai_adapter.py` + +- [ ] **Step 1: Write the failing tests** — append to `backend/tests/inference/test_openai_adapter.py`: + +```python +def _tools_request() -> CompletionRequest: + return CompletionRequest( + model=None, + messages=[Message(role="user", content="hi")], + tools=[ + ToolDefinition( + name="mcp__fs__read", + description="Read a file", + parameters={ + "type": "object", + "properties": {"path": {"type": "string"}}, + }, + ) + ], + ) + + +async def test_tools_wrap_in_function_envelope() -> None: + captured: list[httpx.Request] = [] + + def capture(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json=CHAT_RESPONSE) + + client = _mock_client(capture) + adapter = _adapter(client) + await adapter.complete(_tools_request()) + assert json.loads(captured[0].content)["tools"] == [ + { + "type": "function", + "function": { + "name": "mcp__fs__read", + "description": "Read a file", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + }, + }, + } + ] + await adapter.aclose() + await client.aclose() + + +async def test_stream_sends_tools_envelope() -> None: + captured: list[httpx.Request] = [] + + def capture(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + 200, + text=_sse(STREAM_CHUNKS), + headers={"content-type": "text/event-stream"}, + ) + + client = _mock_client(capture) + adapter = _adapter(client) + async for _ in adapter.stream(_tools_request()): + pass + assert json.loads(captured[0].content)["tools"][0]["type"] == "function" + await adapter.aclose() + await client.aclose() + + +async def test_tools_key_absent_when_none_or_empty() -> None: + captured: list[httpx.Request] = [] + + def capture(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json=CHAT_RESPONSE) + + client = _mock_client(capture) + adapter = _adapter(client) + for tools in (None, []): + await adapter.complete( + CompletionRequest( + model=None, messages=[Message(role="user", content="hi")], tools=tools + ) + ) + assert all("tools" not in json.loads(r.content) for r in captured) + await adapter.aclose() + await client.aclose() +``` + +Extend the existing import: `from octave.inference.types import CompletionRequest, Message, ToolDefinition`. + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd backend && uv run pytest tests/inference/test_openai_adapter.py -k "tools" -v +``` +Expected: the two envelope tests FAIL (`KeyError: 'tools'`); the absent test passes already (it asserts absence). + +- [ ] **Step 3: Implement** — in `backend/src/octave/inference/openai_adapter.py::_chat_kwargs`, insert after the `stop` block (before the `extra` block): + +```python + if request.tools: + kwargs["tools"] = [ + {"type": "function", "function": tool.model_dump(exclude_none=True)} + for tool in request.tools + ] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && uv run pytest tests/inference/test_openai_adapter.py -v +``` +Expected: PASS (new + all pre-existing adapter tests, including the conformance class). + +- [ ] **Step 5: Lint/type gate + commit** + +```bash +cd backend && uv run ruff check src tests && uv run mypy src +git add backend/src/octave/inference/openai_adapter.py backend/tests/inference/test_openai_adapter.py +git commit -m "feat(inference): forward tools envelope in OpenAI adapter chat kwargs" +``` + +--- + +### Task 4: `octave.tools` package scaffold (errors + output types + hygiene test) + +**Files:** +- Create: `backend/src/octave/tools/__init__.py` +- Create: `backend/src/octave/tools/errors.py` +- Create: `backend/src/octave/tools/types.py` +- Create: `backend/tests/tools/__init__.py` (empty) +- Create: `backend/tests/tools/test_package.py` + +- [ ] **Step 1: Write the failing test** — create `backend/tests/tools/test_package.py`: + +```python +"""Public API surface and SDK-quarantine posture of the tool plane.""" + +import ast +from pathlib import Path + +import octave.tools as tools_pkg + +SDK_MODULES = {"openai", "mcp"} + + +def _imported_top_level_modules() -> set[str]: + names: set[str] = set() + for path in Path(tools_pkg.__file__).parent.rglob("*.py"): + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + names.add(node.module.split(".")[0]) + return names + + +def test_no_sdk_imports() -> None: + """octave.tools touches Octave façades only, never the openai/mcp SDKs.""" + assert not _imported_top_level_modules() & SDK_MODULES + + +def test_public_names_are_exported() -> None: + for name in ( + "ProviderToolset", + "ToolRoute", + "ToolTranslationError", + "ToolNameCollisionError", + ): + assert hasattr(tools_pkg, name), name +``` + +Create empty `backend/tests/tools/__init__.py`. + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd backend && uv run pytest tests/tools -v +``` +Expected: collection error — `ModuleNotFoundError: No module named 'octave.tools'`. + +- [ ] **Step 3: Implement** — create `backend/src/octave/tools/errors.py`: + +```python +"""Translation-layer errors (design spec #78).""" + +__all__ = ["ToolNameCollisionError", "ToolTranslationError"] + + +class ToolTranslationError(Exception): + """Base for tool translation failures.""" + + +class ToolNameCollisionError(ToolTranslationError): + """Two tools landed on the same exposed name after sanitization.""" +``` + +Create `backend/src/octave/tools/types.py`: + +```python +"""Translator output vocabulary (design spec #78).""" + +from pydantic import BaseModel + +from octave.inference.types import ToolDefinition + +__all__ = ["ProviderToolset", "ToolRoute"] + + +class ToolRoute(BaseModel): + """Reverse-resolution target for one exposed tool name.""" + + server_id: str + """Registry addressing key: ``ToolRegistry.call_tool(server_id, ...)``.""" + + tool_name: str + """Original (unprefixed, unsanitized) MCP tool name.""" + + +class ProviderToolset(BaseModel): + """Translator output: what the engine sees + how to route calls back.""" + + tools: list[ToolDefinition] + routes: dict[str, ToolRoute] + """Exposed name -> route. Keys match ``tools[*].name`` exactly.""" +``` + +Create `backend/src/octave/tools/__init__.py`: + +```python +"""Tool plane: MCP -> provider schema translation (issue #78). + +Pure functions over the Octave vocabularies of octave.mcp and +octave.inference. Neither subsystem imports the other; no SDK crosses +this boundary (guarded by tests/tools/test_package.py). +""" + +from octave.tools.errors import ToolNameCollisionError, ToolTranslationError +from octave.tools.types import ProviderToolset, ToolRoute + +__all__ = [ + "ProviderToolset", + "ToolNameCollisionError", + "ToolRoute", + "ToolTranslationError", +] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && uv run pytest tests/tools -v +``` +Expected: 2 passed. + +- [ ] **Step 5: Lint/type gate + commit** + +```bash +cd backend && uv run ruff check src tests && uv run mypy src +git add backend/src/octave/tools backend/tests/tools +git commit -m "feat(tools): scaffold octave.tools package with output types and errors" +``` + +--- + +### Task 5: `translate_tools` — exposed names, routes, collisions, ordering + +**Files:** +- Create: `backend/src/octave/tools/translate.py` +- Modify: `backend/src/octave/tools/__init__.py` +- Modify: `backend/tests/tools/test_package.py` +- Create: `backend/tests/tools/test_translate.py` + +- [ ] **Step 1: Write the failing tests** — create `backend/tests/tools/test_translate.py`: + +```python +"""translate_tools: naming, dedupe, routes, ordering (design spec #78).""" + +import pytest +from octave.mcp import ServerToolInventory, ToolInfo +from octave.tools import ( + ProviderToolset, + ToolNameCollisionError, + ToolRoute, + translate_tools, +) + + +def _tool( + name: str = "read_file", + description: str | None = None, + schema: dict | None = None, +) -> ToolInfo: + return ToolInfo( + name=name, + description=description, + input_schema=schema or {"type": "object", "properties": {}}, + ) + + +def _inventory( + server_id: str = "srv-1", + server_name: str = "fs", + tools: list[ToolInfo] | None = None, +) -> ServerToolInventory: + return ServerToolInventory( + server_id=server_id, + server_name=server_name, + state="connected", + tools=tools if tools is not None else [_tool()], + ) + + +def test_translates_single_tool() -> None: + result = translate_tools([_inventory()]) + assert isinstance(result, ProviderToolset) + assert [t.name for t in result.tools] == ["mcp__fs__read_file"] + assert result.tools[0].parameters == {"type": "object", "properties": {}} + assert result.routes == { + "mcp__fs__read_file": ToolRoute(server_id="srv-1", tool_name="read_file") + } + + +def test_multi_server_ordering_and_routes() -> None: + result = translate_tools( + [ + _inventory("srv-a", "alpha", [_tool("one"), _tool("two")]), + _inventory("srv-b", "beta", [_tool("three")]), + ] + ) + assert [t.name for t in result.tools] == [ + "mcp__alpha__one", + "mcp__alpha__two", + "mcp__beta__three", + ] + assert result.routes["mcp__beta__three"].server_id == "srv-b" + + +def test_sanitizes_invalid_characters() -> None: + result = translate_tools( + [_inventory(server_name="my.fs:srv", tools=[_tool("get.file!")])] + ) + assert result.tools[0].name == "mcp__my_fs_srv__get_file_" + + +def test_truncates_to_64_chars() -> None: + result = translate_tools( + [_inventory(server_name="s" * 40, tools=[_tool("t" * 40)])] + ) + name = result.tools[0].name + assert len(name) == 64 + assert name == f"mcp__{'s' * 40}__{'t' * 40}"[:64] + assert list(result.routes) == [name] + + +def test_collision_raises_naming_both_sources() -> None: + with pytest.raises(ToolNameCollisionError) as excinfo: + translate_tools( + [ + _inventory("srv-a", "fs!", [_tool("read")]), + _inventory("srv-b", "fs:", [_tool("read")]), + ] + ) + message = str(excinfo.value) + assert "srv-a/read" in message + assert "srv-b/read" in message + + +def test_routes_keep_original_unsanitized_tool_name() -> None: + result = translate_tools( + [_inventory("srv-9", "srv.name", tools=[_tool("read.file")])] + ) + route = result.routes["mcp__srv_name__read_file"] + assert route.server_id == "srv-9" + assert route.tool_name == "read.file" + + +def test_empty_inputs_produce_empty_toolset() -> None: + assert translate_tools([]).tools == [] + assert translate_tools([]).routes == {} + assert translate_tools([_inventory(tools=[])]).tools == [] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd backend && uv run pytest tests/tools/test_translate.py -v +``` +Expected: collection error — `ImportError: cannot import name 'translate_tools'`. + +- [ ] **Step 3: Implement** — create `backend/src/octave/tools/translate.py`: + +```python +"""MCP tool schemas -> provider-native tool definitions (issue #78). + +Pure translation: fleet inventories in, ProviderToolset out. No I/O, no +state, no SDK imports — octave.mcp and octave.inference never import +each other (design spec decision 5). +""" + +from collections.abc import Sequence +import re + +from octave.inference.types import ToolDefinition +from octave.mcp import ServerToolInventory +from octave.tools.errors import ToolNameCollisionError +from octave.tools.types import ProviderToolset, ToolRoute + +__all__ = ["translate_tools"] + +_NAME_PREFIX = "mcp" +_MAX_NAME_LENGTH = 64 +"""OpenAI function-name charset: ^[a-zA-Z0-9_-]{1,64}$.""" + +_INVALID_CHARS = re.compile(r"[^a-zA-Z0-9_-]") + + +def _sanitize(component: str) -> str: + """Replace characters outside the provider charset with ``_``.""" + return _INVALID_CHARS.sub("_", component) + + +def _exposed_name(server_name: str, tool_name: str) -> str: + """``mcp____``, sanitized and truncated to 64 chars.""" + name = f"{_NAME_PREFIX}__{_sanitize(server_name)}__{_sanitize(tool_name)}" + return name[:_MAX_NAME_LENGTH] + + +def translate_tools( + inventories: Sequence[ServerToolInventory], +) -> ProviderToolset: + """Translate fleet inventories into provider definitions + reverse routes. + + Deterministic: inventories in input order, tools in per-server order. + Raises ``ToolNameCollisionError`` when two tools expose the same name. + """ + tools: list[ToolDefinition] = [] + routes: dict[str, ToolRoute] = {} + for inventory in inventories: + for tool in inventory.tools: + name = _exposed_name(inventory.server_name, tool.name) + if name in routes: + existing = routes[name] + raise ToolNameCollisionError( + f"Tool name collision on exposed name {name!r}: " + f"{existing.server_id}/{existing.tool_name} and " + f"{inventory.server_id}/{tool.name}" + ) + routes[name] = ToolRoute( + server_id=inventory.server_id, tool_name=tool.name + ) + tools.append( + ToolDefinition( + name=name, + description=tool.description, + parameters=dict(tool.input_schema), + ) + ) + return ProviderToolset(tools=tools, routes=routes) +``` + +Update `backend/src/octave/tools/__init__.py` — add the import and `__all__` entries: + +```python +from octave.tools.errors import ToolNameCollisionError, ToolTranslationError +from octave.tools.translate import translate_tools +from octave.tools.types import ProviderToolset, ToolRoute + +__all__ = [ + "ProviderToolset", + "ToolNameCollisionError", + "ToolRoute", + "ToolTranslationError", + "translate_tools", +] +``` + +Add `"translate_tools",` to the tuple in `backend/tests/tools/test_package.py::test_public_names_are_exported`. + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && uv run pytest tests/tools -v +``` +Expected: all PASS (7 translate + 2 package). + +- [ ] **Step 5: Lint/type gate + commit** + +```bash +cd backend && uv run ruff check src tests && uv run mypy src +git add backend/src/octave/tools backend/tests/tools +git commit -m "feat(tools): translate MCP inventories to provider defs with routes" +``` + +--- + +### Task 6: Schema body — verbatim passthrough minus `$schema`, deep copy + +**Files:** +- Modify: `backend/src/octave/tools/translate.py` +- Modify: `backend/tests/tools/test_translate.py` + +- [ ] **Step 1: Write the failing tests** — append to `backend/tests/tools/test_translate.py`: + +```python +def test_strips_dollar_schema_keeps_rest_verbatim() -> None: + schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"q": {"type": "string", "enum": ["a", "b"]}}, + "required": ["q"], + "$defs": {"nested": {"type": "object"}}, + "additionalProperties": False, + } + result = translate_tools([_inventory(tools=[_tool(schema=schema)])]) + parameters = result.tools[0].parameters + assert "$schema" not in parameters + expected = {k: v for k, v in schema.items() if k != "$schema"} + assert parameters == expected + + +def test_does_not_mutate_input_schemas() -> None: + schema = {"$schema": "draft-07", "type": "object"} + inventory = _inventory(tools=[_tool(schema=schema)]) + result = translate_tools([inventory]) + assert inventory.tools[0].input_schema == schema # $schema untouched + result.tools[0].parameters["mutated"] = True + assert "mutated" not in inventory.tools[0].input_schema + + +def test_description_none_omitted_from_dump() -> None: + result = translate_tools( + [_inventory(tools=[_tool(description=None)])] + ) + assert "description" not in result.tools[0].model_dump(exclude_none=True) + result = translate_tools( + [_inventory(tools=[_tool(description="Reads files")])] + ) + assert ( + result.tools[0].model_dump(exclude_none=True)["description"] == "Reads files" + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd backend && uv run pytest tests/tools/test_translate.py -v +``` +Expected: `test_strips_dollar_schema_keeps_rest_verbatim` and `test_does_not_mutate_input_schemas` FAIL (`$schema` present / shared reference mutation); `test_description_none_omitted_from_dump` passes already. + +- [ ] **Step 3: Implement** — in `backend/src/octave/tools/translate.py`, add imports at the top (stdlib first per ruff/isort ordering): + +```python +import copy +from typing import Any +``` + +Add the helper above `translate_tools`: + +```python +def _parameters(schema: dict[str, Any]) -> dict[str, Any]: + """JSON Schema passthrough minus ``$schema``; deep copy, input untouched. + + Octave never interprets JSON Schema internals (ToolInfo invariant); + local engines (Ollama/vLLM/llama.cpp) are strict about ``$schema``. + """ + parameters = copy.deepcopy(schema) + parameters.pop("$schema", None) + return parameters +``` + +Replace `parameters=dict(tool.input_schema),` inside `translate_tools` with: + +```python + parameters=_parameters(tool.input_schema), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && uv run pytest tests/tools -v +``` +Expected: all PASS (10 translate + 2 package). + +- [ ] **Step 5: Lint/type gate + commit** + +```bash +cd backend && uv run ruff check src tests && uv run mypy src +git add backend/src/octave/tools/translate.py backend/tests/tools/test_translate.py +git commit -m "feat(tools): strip \$schema and deep-copy parameters in translation" +``` + +(Note the escaped `$` in the commit message, or use single quotes.) + +--- + +### Task 7: Docs + full green gate + +**Files:** +- Modify: `docs/ARCHITECTURE.md` (MCP Connector section, after the Tool Execution Engine bullet ~line 72) + +- [ ] **Step 1: Update `docs/ARCHITECTURE.md`** — insert a new bullet in the **MCP Connector → Responsibilities** list, immediately after the `**Tool Execution Engine**` bullet: + +```markdown +- **Schema Translation** — Converts MCP `inputSchema` into the provider-native `tools` array format with `mcp____` dedupe and reverse routing *(shipped: `octave.tools.translate_tools` — pure translation into `octave.inference` `ToolDefinition`s carried on `CompletionRequest.tools`; request-side only, response-side tool-call parsing lands with the agent loop, PR #100)* +``` + +- [ ] **Step 2: Full test suite** + +```bash +cd backend && uv run pytest -v +``` +Expected: all tests pass (pre-existing suites + 14 new in `tests/tools` + 3 new adapter + 3 new types tests). + +- [ ] **Step 3: Full lint/type gate** + +```bash +cd backend && uv run ruff check src tests && uv run mypy src +``` +Expected: exit 0. + +- [ ] **Step 4: Commit + push** + +```bash +git add docs/ARCHITECTURE.md +git commit -m "docs: record tool schema translation layer in architecture" +git push +``` + +--- + +## Out of scope (recorded in the spec's deferral table) + +- Response-side `tool_calls` parsing (assistant messages, stream deltas) → agent loop. +- Rename/re-describe overrides → TODO MCP Connector #9–10. +- Tool tagging → TODO #8. `tool_choice`/`parallel_tool_calls` → `extra` passthrough. +- Registry→translator glue service; collision auto-disambiguation. From ec45e65135c35aa8a0cc67aa399e32df35da0cfe Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:25:38 +0100 Subject: [PATCH 3/8] feat(inference): add ToolDefinition and tools field to CompletionRequest --- backend/src/octave/inference/__init__.py | 2 ++ backend/src/octave/inference/types.py | 14 ++++++++++++++ backend/tests/inference/test_package.py | 1 + backend/tests/inference/test_types.py | 23 +++++++++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/backend/src/octave/inference/__init__.py b/backend/src/octave/inference/__init__.py index 49d1653..69b33b7 100644 --- a/backend/src/octave/inference/__init__.py +++ b/backend/src/octave/inference/__init__.py @@ -27,6 +27,7 @@ EmbeddingResult, Message, ModelInfo, + ToolDefinition, Usage, ) @@ -51,6 +52,7 @@ "ModelInfo", "ModelNotFoundError", "OpenAIAdapter", + "ToolDefinition", "UnknownAdapterError", "Usage", "default_registry", diff --git a/backend/src/octave/inference/types.py b/backend/src/octave/inference/types.py index a66e4a0..1a93685 100644 --- a/backend/src/octave/inference/types.py +++ b/backend/src/octave/inference/types.py @@ -17,6 +17,7 @@ "EmbeddingResult", "Message", "ModelInfo", + "ToolDefinition", "Usage", ] @@ -30,6 +31,18 @@ class Message(BaseModel): content: str +class ToolDefinition(BaseModel): + """One tool in Octave's provider-neutral shape. + + ``parameters`` is a JSON Schema object; the adapter wraps this model + into the provider envelope. SDK types never appear here. + """ + + name: str + description: str | None = None + parameters: dict[str, Any] = Field(default_factory=dict) + + class Usage(BaseModel): """Token accounting for a completion or embedding call.""" @@ -50,6 +63,7 @@ class CompletionRequest(BaseModel): max_tokens: int | None = None top_p: float | None = None stop: list[str] | None = None + tools: list[ToolDefinition] | None = None extra: dict[str, Any] = Field(default_factory=dict) diff --git a/backend/tests/inference/test_package.py b/backend/tests/inference/test_package.py index 9ab51fd..0a5bfc7 100644 --- a/backend/tests/inference/test_package.py +++ b/backend/tests/inference/test_package.py @@ -20,5 +20,6 @@ def test_public_names_are_exported() -> None: "CompletionRequest", "EmbeddingRequest", "ModelInfo", + "ToolDefinition", ): assert hasattr(inference, name), name diff --git a/backend/tests/inference/test_types.py b/backend/tests/inference/test_types.py index 7bd6c10..226ca39 100644 --- a/backend/tests/inference/test_types.py +++ b/backend/tests/inference/test_types.py @@ -9,6 +9,7 @@ EmbeddingRequest, Message, ModelInfo, + ToolDefinition, ) @@ -51,3 +52,25 @@ def test_model_info_minimal() -> None: info = ModelInfo(id="qwen2.5-coder:32b") assert info.created is None assert info.owned_by is None + + +def test_completion_request_tools_defaults_none() -> None: + request = CompletionRequest(model=None, messages=[]) + assert request.tools is None + + +def test_completion_request_round_trips_tools() -> None: + tool = ToolDefinition( + name="mcp__fs__read", + description="Read a file", + parameters={"type": "object", "properties": {"path": {"type": "string"}}}, + ) + request = CompletionRequest(model="m", messages=[], tools=[tool]) + assert request.tools == [tool] + + +def test_tool_definition_defaults() -> None: + tool = ToolDefinition(name="x") + assert tool.description is None + assert tool.parameters == {} + assert ToolDefinition(name="y").parameters is not tool.parameters From 5089752959041d3d8814ca82f3289208535ef179 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:29:43 +0100 Subject: [PATCH 4/8] feat(inference): forward tools envelope in OpenAI adapter chat kwargs --- .../src/octave/inference/openai_adapter.py | 5 ++ .../tests/inference/test_openai_adapter.py | 86 ++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/backend/src/octave/inference/openai_adapter.py b/backend/src/octave/inference/openai_adapter.py index 82b7e07..73e7160 100644 --- a/backend/src/octave/inference/openai_adapter.py +++ b/backend/src/octave/inference/openai_adapter.py @@ -168,6 +168,11 @@ def _chat_kwargs(self, request: CompletionRequest) -> dict[str, Any]: kwargs["top_p"] = request.top_p if request.stop is not None: kwargs["stop"] = request.stop + if request.tools: + kwargs["tools"] = [ + {"type": "function", "function": tool.model_dump(exclude_none=True)} + for tool in request.tools + ] if request.extra: kwargs["extra_body"] = dict(request.extra) return kwargs diff --git a/backend/tests/inference/test_openai_adapter.py b/backend/tests/inference/test_openai_adapter.py index ac10056..fff37bc 100644 --- a/backend/tests/inference/test_openai_adapter.py +++ b/backend/tests/inference/test_openai_adapter.py @@ -16,7 +16,7 @@ ModelNotFoundError, ) from octave.inference.openai_adapter import OpenAIAdapter -from octave.inference.types import CompletionRequest, Message +from octave.inference.types import CompletionRequest, Message, ToolDefinition from tests.inference.conformance import InferenceAdapterConformanceSuite BASE_URL = "http://engine.test/v1" @@ -264,3 +264,87 @@ def raiser(request: httpx.Request) -> httpx.Response: ) await adapter.aclose() await client.aclose() + + +def _tools_request() -> CompletionRequest: + return CompletionRequest( + model=None, + messages=[Message(role="user", content="hi")], + tools=[ + ToolDefinition( + name="mcp__fs__read", + description="Read a file", + parameters={ + "type": "object", + "properties": {"path": {"type": "string"}}, + }, + ) + ], + ) + + +async def test_tools_wrap_in_function_envelope() -> None: + captured: list[httpx.Request] = [] + + def capture(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json=CHAT_RESPONSE) + + client = _mock_client(capture) + adapter = _adapter(client) + await adapter.complete(_tools_request()) + assert json.loads(captured[0].content)["tools"] == [ + { + "type": "function", + "function": { + "name": "mcp__fs__read", + "description": "Read a file", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + }, + }, + } + ] + await adapter.aclose() + await client.aclose() + + +async def test_stream_sends_tools_envelope() -> None: + captured: list[httpx.Request] = [] + + def capture(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + 200, + text=_sse(STREAM_CHUNKS), + headers={"content-type": "text/event-stream"}, + ) + + client = _mock_client(capture) + adapter = _adapter(client) + async for _ in adapter.stream(_tools_request()): + pass + assert json.loads(captured[0].content)["tools"][0]["type"] == "function" + await adapter.aclose() + await client.aclose() + + +async def test_tools_key_absent_when_none_or_empty() -> None: + captured: list[httpx.Request] = [] + + def capture(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json=CHAT_RESPONSE) + + client = _mock_client(capture) + adapter = _adapter(client) + for tools in (None, []): + await adapter.complete( + CompletionRequest( + model=None, messages=[Message(role="user", content="hi")], tools=tools + ) + ) + assert all("tools" not in json.loads(r.content) for r in captured) + await adapter.aclose() + await client.aclose() From 6cf9b7833e7cd7251cc8487c3e9428ba9812a029 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:32:25 +0100 Subject: [PATCH 5/8] feat(tools): scaffold octave.tools package with output types and errors --- backend/src/octave/tools/__init__.py | 16 +++++++++++++ backend/src/octave/tools/errors.py | 11 +++++++++ backend/src/octave/tools/types.py | 25 ++++++++++++++++++++ backend/tests/tools/__init__.py | 0 backend/tests/tools/test_package.py | 35 ++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+) create mode 100644 backend/src/octave/tools/__init__.py create mode 100644 backend/src/octave/tools/errors.py create mode 100644 backend/src/octave/tools/types.py create mode 100644 backend/tests/tools/__init__.py create mode 100644 backend/tests/tools/test_package.py diff --git a/backend/src/octave/tools/__init__.py b/backend/src/octave/tools/__init__.py new file mode 100644 index 0000000..39ee346 --- /dev/null +++ b/backend/src/octave/tools/__init__.py @@ -0,0 +1,16 @@ +"""Tool plane: MCP -> provider schema translation (issue #78). + +Pure functions over the Octave vocabularies of octave.mcp and +octave.inference. Neither subsystem imports the other; no SDK crosses +this boundary (guarded by tests/tools/test_package.py). +""" + +from octave.tools.errors import ToolNameCollisionError, ToolTranslationError +from octave.tools.types import ProviderToolset, ToolRoute + +__all__ = [ + "ProviderToolset", + "ToolNameCollisionError", + "ToolRoute", + "ToolTranslationError", +] diff --git a/backend/src/octave/tools/errors.py b/backend/src/octave/tools/errors.py new file mode 100644 index 0000000..726f67e --- /dev/null +++ b/backend/src/octave/tools/errors.py @@ -0,0 +1,11 @@ +"""Translation-layer errors (design spec #78).""" + +__all__ = ["ToolNameCollisionError", "ToolTranslationError"] + + +class ToolTranslationError(Exception): + """Base for tool translation failures.""" + + +class ToolNameCollisionError(ToolTranslationError): + """Two tools landed on the same exposed name after sanitization.""" diff --git a/backend/src/octave/tools/types.py b/backend/src/octave/tools/types.py new file mode 100644 index 0000000..7c7c133 --- /dev/null +++ b/backend/src/octave/tools/types.py @@ -0,0 +1,25 @@ +"""Translator output vocabulary (design spec #78).""" + +from pydantic import BaseModel + +from octave.inference.types import ToolDefinition + +__all__ = ["ProviderToolset", "ToolRoute"] + + +class ToolRoute(BaseModel): + """Reverse-resolution target for one exposed tool name.""" + + server_id: str + """Registry addressing key: ``ToolRegistry.call_tool(server_id, ...)``.""" + + tool_name: str + """Original (unprefixed, unsanitized) MCP tool name.""" + + +class ProviderToolset(BaseModel): + """Translator output: what the engine sees + how to route calls back.""" + + tools: list[ToolDefinition] + routes: dict[str, ToolRoute] + """Exposed name -> route. Keys match ``tools[*].name`` exactly.""" diff --git a/backend/tests/tools/__init__.py b/backend/tests/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/tools/test_package.py b/backend/tests/tools/test_package.py new file mode 100644 index 0000000..0203aca --- /dev/null +++ b/backend/tests/tools/test_package.py @@ -0,0 +1,35 @@ +"""Public API surface and SDK-quarantine posture of the tool plane.""" + +import ast +from pathlib import Path + +import octave.tools as tools_pkg + +SDK_MODULES = {"openai", "mcp"} + + +def _imported_top_level_modules() -> set[str]: + names: set[str] = set() + for path in Path(tools_pkg.__file__).parent.rglob("*.py"): + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + names.add(node.module.split(".")[0]) + return names + + +def test_no_sdk_imports() -> None: + """octave.tools touches Octave façades only, never the openai/mcp SDKs.""" + assert not _imported_top_level_modules() & SDK_MODULES + + +def test_public_names_are_exported() -> None: + for name in ( + "ProviderToolset", + "ToolRoute", + "ToolTranslationError", + "ToolNameCollisionError", + ): + assert hasattr(tools_pkg, name), name From eaf83420904ff4910c950fc5df9ba5d22ed540f1 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:38:40 +0100 Subject: [PATCH 6/8] feat(tools): translate MCP inventories to provider defs with routes --- backend/src/octave/tools/__init__.py | 2 + backend/src/octave/tools/translate.py | 66 ++++++++++++++++ backend/tests/tools/test_package.py | 1 + backend/tests/tools/test_translate.py | 106 ++++++++++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 backend/src/octave/tools/translate.py create mode 100644 backend/tests/tools/test_translate.py diff --git a/backend/src/octave/tools/__init__.py b/backend/src/octave/tools/__init__.py index 39ee346..8fc9653 100644 --- a/backend/src/octave/tools/__init__.py +++ b/backend/src/octave/tools/__init__.py @@ -6,6 +6,7 @@ """ from octave.tools.errors import ToolNameCollisionError, ToolTranslationError +from octave.tools.translate import translate_tools from octave.tools.types import ProviderToolset, ToolRoute __all__ = [ @@ -13,4 +14,5 @@ "ToolNameCollisionError", "ToolRoute", "ToolTranslationError", + "translate_tools", ] diff --git a/backend/src/octave/tools/translate.py b/backend/src/octave/tools/translate.py new file mode 100644 index 0000000..fa4c840 --- /dev/null +++ b/backend/src/octave/tools/translate.py @@ -0,0 +1,66 @@ +"""MCP tool schemas -> provider-native tool definitions (issue #78). + +Pure translation: fleet inventories in, ProviderToolset out. No I/O, no +state, no SDK imports — octave.mcp and octave.inference never import +each other (design spec decision 5). +""" + +import re +from collections.abc import Sequence + +from octave.inference.types import ToolDefinition +from octave.mcp import ServerToolInventory +from octave.tools.errors import ToolNameCollisionError +from octave.tools.types import ProviderToolset, ToolRoute + +__all__ = ["translate_tools"] + +_NAME_PREFIX = "mcp" +_MAX_NAME_LENGTH = 64 +"""OpenAI function-name charset: ^[a-zA-Z0-9_-]{1,64}$.""" + +_INVALID_CHARS = re.compile(r"[^a-zA-Z0-9_-]") + + +def _sanitize(component: str) -> str: + """Replace characters outside the provider charset with ``_``.""" + return _INVALID_CHARS.sub("_", component) + + +def _exposed_name(server_name: str, tool_name: str) -> str: + """``mcp____``, sanitized and truncated to 64 chars.""" + name = f"{_NAME_PREFIX}__{_sanitize(server_name)}__{_sanitize(tool_name)}" + return name[:_MAX_NAME_LENGTH] + + +def translate_tools( + inventories: Sequence[ServerToolInventory], +) -> ProviderToolset: + """Translate fleet inventories into provider definitions + reverse routes. + + Deterministic: inventories in input order, tools in per-server order. + Raises ``ToolNameCollisionError`` when two tools expose the same name. + """ + tools: list[ToolDefinition] = [] + routes: dict[str, ToolRoute] = {} + for inventory in inventories: + for tool in inventory.tools: + name = _exposed_name(inventory.server_name, tool.name) + if name in routes: + existing = routes[name] + raise ToolNameCollisionError( + f"Tool name collision on exposed name {name!r}: " + f"{existing.server_id}/{existing.tool_name} and " + f"{inventory.server_id}/{tool.name}" + ) + routes[name] = ToolRoute( + server_id=inventory.server_id, tool_name=tool.name + ) + tools.append( + ToolDefinition( + name=name, + description=tool.description, + parameters=dict(tool.input_schema), + ) + ) + return ProviderToolset(tools=tools, routes=routes) diff --git a/backend/tests/tools/test_package.py b/backend/tests/tools/test_package.py index 0203aca..a5fd768 100644 --- a/backend/tests/tools/test_package.py +++ b/backend/tests/tools/test_package.py @@ -31,5 +31,6 @@ def test_public_names_are_exported() -> None: "ToolRoute", "ToolTranslationError", "ToolNameCollisionError", + "translate_tools", ): assert hasattr(tools_pkg, name), name diff --git a/backend/tests/tools/test_translate.py b/backend/tests/tools/test_translate.py new file mode 100644 index 0000000..5ba1a80 --- /dev/null +++ b/backend/tests/tools/test_translate.py @@ -0,0 +1,106 @@ +"""translate_tools: naming, dedupe, routes, ordering (design spec #78).""" + +import pytest + +from octave.mcp import ServerToolInventory, ToolInfo +from octave.tools import ( + ProviderToolset, + ToolNameCollisionError, + ToolRoute, + translate_tools, +) + + +def _tool( + name: str = "read_file", + description: str | None = None, + schema: dict | None = None, +) -> ToolInfo: + return ToolInfo( + name=name, + description=description, + input_schema=schema or {"type": "object", "properties": {}}, + ) + + +def _inventory( + server_id: str = "srv-1", + server_name: str = "fs", + tools: list[ToolInfo] | None = None, +) -> ServerToolInventory: + return ServerToolInventory( + server_id=server_id, + server_name=server_name, + state="connected", + tools=tools if tools is not None else [_tool()], + ) + + +def test_translates_single_tool() -> None: + result = translate_tools([_inventory()]) + assert isinstance(result, ProviderToolset) + assert [t.name for t in result.tools] == ["mcp__fs__read_file"] + assert result.tools[0].parameters == {"type": "object", "properties": {}} + assert result.routes == { + "mcp__fs__read_file": ToolRoute(server_id="srv-1", tool_name="read_file") + } + + +def test_multi_server_ordering_and_routes() -> None: + result = translate_tools( + [ + _inventory("srv-a", "alpha", [_tool("one"), _tool("two")]), + _inventory("srv-b", "beta", [_tool("three")]), + ] + ) + assert [t.name for t in result.tools] == [ + "mcp__alpha__one", + "mcp__alpha__two", + "mcp__beta__three", + ] + assert result.routes["mcp__beta__three"].server_id == "srv-b" + + +def test_sanitizes_invalid_characters() -> None: + result = translate_tools( + [_inventory(server_name="my.fs:srv", tools=[_tool("get.file!")])] + ) + assert result.tools[0].name == "mcp__my_fs_srv__get_file_" + + +def test_truncates_to_64_chars() -> None: + result = translate_tools( + [_inventory(server_name="s" * 40, tools=[_tool("t" * 40)])] + ) + name = result.tools[0].name + assert len(name) == 64 + assert name == f"mcp__{'s' * 40}__{'t' * 40}"[:64] + assert list(result.routes) == [name] + + +def test_collision_raises_naming_both_sources() -> None: + with pytest.raises(ToolNameCollisionError) as excinfo: + translate_tools( + [ + _inventory("srv-a", "fs!", [_tool("read")]), + _inventory("srv-b", "fs:", [_tool("read")]), + ] + ) + message = str(excinfo.value) + assert "srv-a/read" in message + assert "srv-b/read" in message + + +def test_routes_keep_original_unsanitized_tool_name() -> None: + result = translate_tools( + [_inventory("srv-9", "srv.name", tools=[_tool("read.file")])] + ) + route = result.routes["mcp__srv_name__read_file"] + assert route.server_id == "srv-9" + assert route.tool_name == "read.file" + + +def test_empty_inputs_produce_empty_toolset() -> None: + assert translate_tools([]).tools == [] + assert translate_tools([]).routes == {} + assert translate_tools([_inventory(tools=[])]).tools == [] From db1acb4320ee709e3fb2f064db0d33060911f643 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:42:39 +0100 Subject: [PATCH 7/8] feat(tools): strip $schema and deep-copy parameters in translation --- backend/src/octave/tools/translate.py | 15 ++++++++++- backend/tests/tools/test_translate.py | 38 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/backend/src/octave/tools/translate.py b/backend/src/octave/tools/translate.py index fa4c840..4fe6045 100644 --- a/backend/src/octave/tools/translate.py +++ b/backend/src/octave/tools/translate.py @@ -5,8 +5,10 @@ each other (design spec decision 5). """ +import copy import re from collections.abc import Sequence +from typing import Any from octave.inference.types import ToolDefinition from octave.mcp import ServerToolInventory @@ -33,6 +35,17 @@ def _exposed_name(server_name: str, tool_name: str) -> str: return name[:_MAX_NAME_LENGTH] +def _parameters(schema: dict[str, Any]) -> dict[str, Any]: + """JSON Schema passthrough minus ``$schema``; deep copy, input untouched. + + Octave never interprets JSON Schema internals (ToolInfo invariant); + local engines (Ollama/vLLM/llama.cpp) are strict about ``$schema``. + """ + parameters = copy.deepcopy(schema) + parameters.pop("$schema", None) + return parameters + + def translate_tools( inventories: Sequence[ServerToolInventory], ) -> ProviderToolset: @@ -60,7 +73,7 @@ def translate_tools( ToolDefinition( name=name, description=tool.description, - parameters=dict(tool.input_schema), + parameters=_parameters(tool.input_schema), ) ) return ProviderToolset(tools=tools, routes=routes) diff --git a/backend/tests/tools/test_translate.py b/backend/tests/tools/test_translate.py index 5ba1a80..8fbc03b 100644 --- a/backend/tests/tools/test_translate.py +++ b/backend/tests/tools/test_translate.py @@ -104,3 +104,41 @@ def test_empty_inputs_produce_empty_toolset() -> None: assert translate_tools([]).tools == [] assert translate_tools([]).routes == {} assert translate_tools([_inventory(tools=[])]).tools == [] + + +def test_strips_dollar_schema_keeps_rest_verbatim() -> None: + schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {"q": {"type": "string", "enum": ["a", "b"]}}, + "required": ["q"], + "$defs": {"nested": {"type": "object"}}, + "additionalProperties": False, + } + result = translate_tools([_inventory(tools=[_tool(schema=schema)])]) + parameters = result.tools[0].parameters + assert "$schema" not in parameters + expected = {k: v for k, v in schema.items() if k != "$schema"} + assert parameters == expected + + +def test_does_not_mutate_input_schemas() -> None: + schema = {"$schema": "draft-07", "type": "object"} + inventory = _inventory(tools=[_tool(schema=schema)]) + result = translate_tools([inventory]) + assert inventory.tools[0].input_schema == schema # $schema untouched + result.tools[0].parameters["mutated"] = True + assert "mutated" not in inventory.tools[0].input_schema + + +def test_description_none_omitted_from_dump() -> None: + result = translate_tools( + [_inventory(tools=[_tool(description=None)])] + ) + assert "description" not in result.tools[0].model_dump(exclude_none=True) + result = translate_tools( + [_inventory(tools=[_tool(description="Reads files")])] + ) + assert ( + result.tools[0].model_dump(exclude_none=True)["description"] == "Reads files" + ) From e4c2928dca17aa8410e0cc77e4f065b48c0f92f9 Mon Sep 17 00:00:00 2001 From: Momo the Bestest <45446348+Svagtlys@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:23:25 +0100 Subject: [PATCH 8/8] docs: record tool schema translation layer in architecture --- docs/ARCHITECTURE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0b1f3ff..482e031 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -70,6 +70,7 @@ Implements the Model Context Protocol client, enabling Octave to discover, manag - **Connection Lifecycle** — Start and manual restart (`McpClient.restart()`) with `is_connected` liveness; subprocess exit detected via transport-stream monitoring (fail-fast `McpConnectionError`). Auto-restart policy and health monitoring land with the server lifecycle manager (roadmap #4) *(shipped: `octave.mcp.manager` — per-server supervisors, auto-restart with backoff + crash-loop detection, probe-on-timeout health, PR #93)* - **Tool Discovery & Caching** — Fetches tool schemas and descriptions from servers; caches for fast lookup *(shipped: `octave.mcp.registry.ToolRegistry` — fleet-wide inventory cache with event-driven invalidation (restart drift, `tools/list_changed` notifications, warm-up + lazy refresh), PR #98)* - **Tool Execution Engine** — Invokes tools with arguments, handles responses and errors, enforces timeouts *(shipped: `ToolRegistry.call_tool` — `(server_id, tool_name)` surface forwarding to `McpClient.call_tool`; timeout/RPC/connection semantics stay the client's, PR #98)* +- **Schema Translation** — Converts MCP `inputSchema` into the provider-native `tools` array format with `mcp____` dedupe and reverse routing *(shipped: `octave.tools.translate_tools` — pure translation into `octave.inference` `ToolDefinition`s carried on `CompletionRequest.tools`; request-side only, response-side tool-call parsing lands with the agent loop, PR #100)* - **Configuration Persistence** — Stores server connection configs in the unified database - **Tool Tagging System** — Labels tools with internal Octave tags (e.g., `context_retrieval`, `file_operations`) used by the Context Manager for vault population - **Tool Re-naming / Re-describing** — Maps custom agent-facing names and descriptions to underlying MCP tool identifiers, improving clarity for the agent without modifying the MCP server