diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f85019..f90da0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ called out explicitly under **Changed** or **Removed**. ## [Unreleased] +### Added + +- **`kizen mcp-servers`** lists, creates, updates and deletes MCP servers + from a spec, checks tool names against the live catalogue, and shows a + server's tools and change history. + ## [0.3.0] — 2026-10-21 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08ef939..af055cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,6 +107,7 @@ src/kizen_builder/ files.py # raw file bytes: the S3 upload/download legs (not JSON) forms.py # form/survey CRUD (base_path-shared with surveys) layouts.py # record-layout CRUD (field/block arrangement) + mcp_servers.py # MCP server CRUD, tool catalogue, events history messages.py # automation-scoped message resources (notify_* step content) permissions.py # roles, permission groups, permissions catalog pipelines.py # pipeline-stage CRUD + record stage-move @@ -125,6 +126,7 @@ src/kizen_builder/ form_ui.py # build a form/survey's visual, submittable page layout forms.py # list/get forms and surveys (base_path-shared) layouts.py # list/get, block helpers, inject_layout_ids + mcp_servers.py # resolve/list/get MCP servers, tool catalogue, history messages.py # automation message content (notify_* step templates) objects.py # list_objects(), get_object(api_name) permission_builder.py # enumerate/build/mutate the full permission-group structure @@ -159,6 +161,7 @@ src/kizen_builder/ fields.py # plan_create_field, plan_update_field forms.py # plan_create/update/delete forms/surveys + fields layouts.py # plan_update_layout + mcp_servers.py # plan_create/update/delete MCP servers messages.py # plan_create automation message content objects.py # plan_create/update_object, plan_create/update_category permissions.py # plan_create/update/delete roles + permission groups @@ -170,6 +173,7 @@ src/kizen_builder/ translate.py # Automation GET → PUT wire translation. models/spec/ # Pydantic shapes for desired state (spec inputs); # package, one module per cluster — see __init__.py. + mcp_servers.py # MCPServerDef + its sharing block output.py # Shared table/json/csv output layer for read commands. cli/ # Typer wrappers around tools/. The `kizen` command. __init__.py # exports `app`; imports every module below, IN ORDER @@ -203,6 +207,7 @@ src/kizen_builder/ forms_fields.py # …and its `fields` / `fields options` sub-apps automations_write.py # kizen automations — create/update/lifecycle folders.py # kizen automations folders + mcp_servers.py # kizen mcp-servers apply.py # kizen apply smart_connectors.py # kizen smart-connectors — app, shared helpers, build/configure smart_connectors_seeds.py # kizen smart-connectors seeds diff --git a/scripts/cli-tree-baseline.txt b/scripts/cli-tree-baseline.txt index 2b1c4a6..a640081 100644 --- a/scripts/cli-tree-baseline.txt +++ b/scripts/cli-tree-baseline.txt @@ -50,6 +50,7 @@ │ layout (form_ui). │ │ surveys Create, read, update, and delete surveys, their fields, and their visual page │ │ layout (form_ui). │ +│ mcp-servers Read, create, update, and delete MCP servers. │ │ smart-connectors Build, inspect, and iterate on smart connectors. Read verbs │ │ (list/get/executions/scripts/events/metadata) are safe. The local SQL loop is │ │ pull → run → push: `pull` builds a working directory, `run` executes │ @@ -2323,6 +2324,143 @@ Spec shape (a LayoutDef; PUT-replaces the layout): see `kizen docs show layout` + Usage: mcp-servers [OPTIONS] COMMAND [ARGS]... + + Read, create, update, and delete MCP servers. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────╮ +│ list List the MCP servers in the configured env. │ +│ get Show one MCP server, its endpoint URL and its tools. │ +│ tools List the tools a server can serve — the valid `tool_names`. │ +│ history Show a server's change history, newest first. │ +│ create Create an MCP server from a JSON MCPServerDef. │ +│ update Update an MCP server from a JSON MCPServerDef. │ +│ delete Delete an MCP server. Kizen deletes it outright, with its history. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Usage: mcp-servers create [OPTIONS] + + Create an MCP server from a JSON MCPServerDef. + + `name` and `api_name` are required. Tool names are checked against + `kizen mcp-servers tools`. With no `sharing_settings`, every team member + can view it. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --spec-file Path to a JSON MCPServerDef. Default: read from stdin. │ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers delete [OPTIONS] {server} + + Delete an MCP server. Kizen deletes it outright, with its history. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * server MCP server UUID or api_name. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers get [OPTIONS] {server} + + Show one MCP server, its endpoint URL and its tools. + + The endpoint URL is what an MCP client connects to; the CLI never calls it. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * server MCP server UUID or api_name. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --raw Emit the full API response — a valid `mcp-servers update` spec. Implies │ +│ JSON. │ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers history [OPTIONS] {server} + + Show a server's change history, newest first. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * server MCP server UUID or api_name. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers list [OPTIONS] + + List the MCP servers in the configured env. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers tools [OPTIONS] + + List the tools a server can serve — the valid `tool_names`. + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --output -o Output format: table (default), json, or csv. │ +│ --json Alias for --output json. │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + + Usage: mcp-servers update [OPTIONS] {server} + + Update an MCP server from a JSON MCPServerDef. + + Only the keys the spec sets are compared. `get --raw` output works as a + spec: its read-only keys are ignored. A Kizen-managed server takes only + `{"is_default_for_business": true}`, which makes it the default again. + +╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────╮ +│ * server MCP server UUID or api_name. [required] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ +│ --spec-file Path to a JSON MCPServerDef. Default: read from stdin. │ +│ --dry-run Show the plan without applying. │ +│ --yes -y Skip the y/N confirmation prompt. │ +│ --json Emit JSON (plan with --dry-run, results otherwise). │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + See `kizen docs show mcp-server` + + Usage: messages [OPTIONS] COMMAND [ARGS]... Email templates and automation-scoped messages — the content notify_member_via_email steps @@ -4346,7 +4484,8 @@ `--check` is the session-start form: it reports whether a newer version exists, caches the answer for a day, and always exits 0 — offline, behind a - proxy, or with no remote configured, it just says so quietly. + proxy, or with no remote configured, it just says so quietly. Under + app-builder it says the installation is managed there, and still exits 0. ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ │ --check Only report whether a newer version exists; change nothing. │ @@ -4733,6 +4872,30 @@ ===== layouts update ===== +===== mcp-servers ===== + + +===== mcp-servers create ===== + + +===== mcp-servers delete ===== + + +===== mcp-servers get ===== + + +===== mcp-servers history ===== + + +===== mcp-servers list ===== + + +===== mcp-servers tools ===== + + +===== mcp-servers update ===== + + ===== messages ===== diff --git a/src/kizen_builder/api/mcp_servers.py b/src/kizen_builder/api/mcp_servers.py new file mode 100644 index 0000000..1ea4ba3 --- /dev/null +++ b/src/kizen_builder/api/mcp_servers.py @@ -0,0 +1,59 @@ +"""MCP server configuration against the Kizen API. + +An MCP server is a named set of Kizen tools that AI steps and outside MCP +clients call. This module configures servers; it does not call one. A server's +``endpoint_url`` (``/api/mcp/{api_name}``) is the JSON-RPC endpoint MCP clients +use at run time, and nothing here touches it. + +Endpoints: + GET /api/mcp-servers list servers (DRF page) + POST /api/mcp-servers create + GET /api/mcp-servers/{id} one server, incl. its tools + PATCH /api/mcp-servers/{id} partial update + DELETE /api/mcp-servers/{id} hard delete (204) + GET /api/mcp-servers/tools the tool catalogue, by group + GET /api/mcp-servers/{id}/events-history change history (DRF page) +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api.client import KizenClient + + +def list_mcp_servers(client: KizenClient) -> list[dict[str, Any]]: + """GET /api/mcp-servers — every server, with ``tool_count`` and no ``tools``.""" + return client.paginate("/api/mcp-servers") + + +def get_mcp_server(client: KizenClient, server_id: str) -> dict[str, Any]: + """GET /api/mcp-servers/{id} — one server, with ``tools`` expanded.""" + return client.get(f"/api/mcp-servers/{server_id}") + + +def create_mcp_server(client: KizenClient, payload: dict[str, Any]) -> dict[str, Any]: + """POST /api/mcp-servers — returns the created server's read shape.""" + return client.post("/api/mcp-servers", json=payload) + + +def update_mcp_server( + client: KizenClient, server_id: str, payload: dict[str, Any] +) -> dict[str, Any]: + """PATCH /api/mcp-servers/{id} — only the supplied keys change.""" + return client.patch(f"/api/mcp-servers/{server_id}", json=payload) + + +def delete_mcp_server(client: KizenClient, server_id: str) -> None: + """DELETE /api/mcp-servers/{id}.""" + client.delete(f"/api/mcp-servers/{server_id}") + + +def get_tool_catalog(client: KizenClient) -> dict[str, Any]: + """GET /api/mcp-servers/tools — ``{groups: [{key, label, tools: [...]}]}``.""" + return client.get("/api/mcp-servers/tools") + + +def list_events_history(client: KizenClient, server_id: str) -> list[dict[str, Any]]: + """GET /api/mcp-servers/{id}/events-history — written a second or so after a change.""" + return client.paginate(f"/api/mcp-servers/{server_id}/events-history") diff --git a/src/kizen_builder/cli/__init__.py b/src/kizen_builder/cli/__init__.py index fdbff26..8bcb6f7 100644 --- a/src/kizen_builder/cli/__init__.py +++ b/src/kizen_builder/cli/__init__.py @@ -52,6 +52,7 @@ from kizen_builder.cli import forms # noqa: F401 from kizen_builder.cli import automations_write # noqa: F401 from kizen_builder.cli import folders # noqa: F401 +from kizen_builder.cli import mcp_servers # noqa: F401 from kizen_builder.cli import apply # noqa: F401 from kizen_builder.cli import smart_connectors # noqa: F401 from kizen_builder.cli import smart_connectors_seeds # noqa: F401 diff --git a/src/kizen_builder/cli/mcp_servers.py b/src/kizen_builder/cli/mcp_servers.py new file mode 100644 index 0000000..91efa96 --- /dev/null +++ b/src/kizen_builder/cli/mcp_servers.py @@ -0,0 +1,306 @@ +"""`kizen mcp-servers` — the tool sets AI steps and MCP clients use.""" + +from __future__ import annotations + +import typer +from rich.table import Table +from rich.text import Text + +from kizen_builder import output as out +from kizen_builder.cli._mutations import _read_spec, _run_mutation +from kizen_builder.cli._shared import ( + JSON_OPTION, + OUTPUT_OPTION, + app, + cli_errors, + console, +) +from kizen_builder.tools import mcp_servers as mcp_tools +from kizen_builder.tools.planners import mcp_servers as mcp_planners + +EPILOG = "See `kizen docs show mcp-server`" +SERVER_ARG = typer.Argument(..., help="MCP server UUID or api_name.") + +mcp_servers_app = typer.Typer( + help="Read, create, update, and delete MCP servers.", no_args_is_help=True +) +app.add_typer(mcp_servers_app, name="mcp-servers") + + +def _flag(value: object) -> str: + return "✓" if value else "" + + +@mcp_servers_app.command("list", epilog=EPILOG) +def mcp_servers_list( + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """List the MCP servers in the configured env.""" + fmt = out.resolve_format(output, json_out) + with cli_errors(): + items = mcp_tools.list_mcp_servers() + columns = [ + out.Column("api_name", "api_name"), + out.Column("name", "name"), + out.Column("tools", "tool_count"), + out.Column("enabled", "is_enabled"), + out.Column("default", "is_default_for_business"), + out.Column("system", "is_system_managed"), + out.Column("id", "id"), + ] + + def table() -> None: + t = Table(title="MCP servers") + t.add_column("api_name") + t.add_column("name") + t.add_column("tools", justify="right") + t.add_column("enabled", justify="center") + t.add_column("default", justify="center") + t.add_column("system", justify="center") + t.add_column("id") + for s in items: + t.add_row( + s.get("api_name") or "", + Text(s.get("name") or ""), + str(s.get("tool_count", "")), + _flag(s.get("is_enabled")), + _flag(s.get("is_default_for_business")), + _flag(s.get("is_system_managed")), + s.get("id") or "", + ) + console.print(t) + + out.render(fmt, json_data=items, table=table, csv_rows=items, csv_columns=columns) + + +@mcp_servers_app.command("get", epilog=EPILOG) +def mcp_servers_get( + server: str = SERVER_ARG, + raw: bool = typer.Option( + False, + "--raw", + help="Emit the full API response — a valid `mcp-servers update` spec. " + "Implies JSON.", + ), + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """Show one MCP server, its endpoint URL and its tools. + + The endpoint URL is what an MCP client connects to; the CLI never calls it. + """ + fmt = out.resolve_format(output, json_out) + with cli_errors(LookupError): + detail = mcp_tools.get_mcp_server_detail(server) + + if raw: + out.emit_json(detail["raw"]) + return + + def table() -> None: + t = Table(show_header=False, box=None) + t.add_column(style="bold") + t.add_column() + for label, value in ( + ("name", detail["name"]), + ("api_name", detail["api_name"]), + ("description", detail["description"]), + ("enabled", detail["is_enabled"]), + ("default", detail["is_default_for_business"]), + ("system", detail["is_system_managed"]), + ("endpoint_url", detail["endpoint_url"]), + ("owner", detail["owner"]), + ("id", detail["id"]), + ): + t.add_row(label, Text("" if value is None else str(value))) + console.print(t) + tools = Table(title="Tools") + tools.add_column("group") + tools.add_column("name") + for tool in detail["tools"]: + tools.add_row(tool["group"] or "", tool["name"] or "") + console.print(tools) + + out.render( + fmt, + json_data={k: v for k, v in detail.items() if k != "raw"}, + table=table, + csv_rows=detail["tools"], + csv_columns=[out.Column("group", "group"), out.Column("name", "name")], + ) + + +def _tool_rows(catalog: dict) -> list[dict]: + return [ + { + "group": g.get("key"), + "name": t.get("name"), + "read_only": (t.get("annotations") or {}).get("readOnlyHint"), + "description": (t.get("description") or "")[:80], + } + for g in catalog.get("groups") or [] + for t in g.get("tools") or [] + ] + + +@mcp_servers_app.command("tools", epilog=EPILOG) +def mcp_servers_tools( + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """List the tools a server can serve — the valid `tool_names`.""" + fmt = out.resolve_format(output, json_out) + with cli_errors(): + catalog = mcp_tools.tool_catalog() + rows = _tool_rows(catalog) + columns = [ + out.Column("group", "group"), + out.Column("name", "name"), + out.Column("read_only", "read_only"), + out.Column("description", "description"), + ] + + def table() -> None: + t = Table(title="MCP tools") + for c in columns: + t.add_column(c.header) + for r in rows: + t.add_row( + r["group"] or "", + r["name"] or "", + _flag(r["read_only"]), + Text(r["description"]), + ) + console.print(t) + + out.render( + fmt, + json_data=catalog.get("groups") or [], + table=table, + csv_rows=rows, + csv_columns=columns, + ) + + +def _history_rows(events: list[dict]) -> list[dict]: + return [ + { + "when": e.get("created"), + "event": e.get("type_display_name"), + "by": mcp_tools.initiator_label(e), + "changed": ", ".join(mcp_tools.changed_keys(e)) or "—", + } + for e in events + ] + + +@mcp_servers_app.command("history", epilog=EPILOG) +def mcp_servers_history( + server: str = SERVER_ARG, + output: str = OUTPUT_OPTION, + json_out: bool = JSON_OPTION, +) -> None: + """Show a server's change history, newest first.""" + fmt = out.resolve_format(output, json_out) + with cli_errors(LookupError): + events = mcp_tools.list_history(server) + rows = _history_rows(events) + columns = [ + out.Column("when", "when"), + out.Column("event", "event"), + out.Column("by", "by"), + out.Column("changed", "changed"), + ] + + def table() -> None: + if not rows: + console.print( + "no events yet (Kizen records them a second or so after a change)" + ) + return + t = Table(title="History") + for c in columns: + t.add_column(c.header) + for r in rows: + t.add_row(*(Text(str(r[c.header] or "")) for c in columns)) + console.print(t) + + out.render(fmt, json_data=events, table=table, csv_rows=rows, csv_columns=columns) + + +SPEC_FILE_OPTION = typer.Option( + "", "--spec-file", help="Path to a JSON MCPServerDef. Default: read from stdin." +) +DRY_RUN_OPTION = typer.Option( + False, "--dry-run", help="Show the plan without applying." +) +YES_OPTION = typer.Option( + False, "--yes", "-y", help="Skip the y/N confirmation prompt." +) +MUTATION_JSON_OPTION = typer.Option( + False, "--json", help="Emit JSON (plan with --dry-run, results otherwise)." +) + + +@mcp_servers_app.command("create", epilog=EPILOG) +def mcp_servers_create( + spec_file: str = SPEC_FILE_OPTION, + dry_run: bool = DRY_RUN_OPTION, + yes: bool = YES_OPTION, + json_out: bool = MUTATION_JSON_OPTION, +) -> None: + """Create an MCP server from a JSON MCPServerDef. + + `name` and `api_name` are required. Tool names are checked against + `kizen mcp-servers tools`. With no `sharing_settings`, every team member + can view it. + """ + spec, from_stdin = _read_spec(spec_file, what="MCP server") + _run_mutation( + lambda: mcp_planners.plan_create_mcp_server(spec), + dry_run=dry_run, + yes=yes, + json_out=json_out, + stdin_consumed=from_stdin, + ) + + +@mcp_servers_app.command("update", epilog=EPILOG) +def mcp_servers_update( + server: str = SERVER_ARG, + spec_file: str = SPEC_FILE_OPTION, + dry_run: bool = DRY_RUN_OPTION, + yes: bool = YES_OPTION, + json_out: bool = MUTATION_JSON_OPTION, +) -> None: + """Update an MCP server from a JSON MCPServerDef. + + Only the keys the spec sets are compared. `get --raw` output works as a + spec: its read-only keys are ignored. A Kizen-managed server takes only + `{"is_default_for_business": true}`, which makes it the default again. + """ + spec, from_stdin = _read_spec(spec_file, what="MCP server") + _run_mutation( + lambda: mcp_planners.plan_update_mcp_server(server, spec), + dry_run=dry_run, + yes=yes, + json_out=json_out, + stdin_consumed=from_stdin, + ) + + +@mcp_servers_app.command("delete", epilog=EPILOG) +def mcp_servers_delete( + server: str = SERVER_ARG, + dry_run: bool = DRY_RUN_OPTION, + yes: bool = YES_OPTION, + json_out: bool = MUTATION_JSON_OPTION, +) -> None: + """Delete an MCP server. Kizen deletes it outright, with its history.""" + _run_mutation( + lambda: mcp_planners.plan_delete_mcp_server(server), + dry_run=dry_run, + yes=yes, + json_out=json_out, + ) diff --git a/src/kizen_builder/docs/commands.md b/src/kizen_builder/docs/commands.md index f515c50..1aea3ac 100644 --- a/src/kizen_builder/docs/commands.md +++ b/src/kizen_builder/docs/commands.md @@ -32,6 +32,10 @@ kizen automations modification-history # who changed this automati kizen automations failures # recent step-failure history kizen dashboards list # list dashboards + homepages (mine) kizen dashboards get # one dashboard + dashlet summary (--raw for full config) +kizen mcp-servers list # MCP servers: api_name, tool count, enabled, default +kizen mcp-servers get # one server: endpoint_url + its tools (--raw is a valid update spec) +kizen mcp-servers tools # the tool catalogue — valid tool_names +kizen mcp-servers history # who changed a server, when, which keys kizen layouts list # record layouts on a custom object kizen layouts get [--name] # one layout's block/column structure kizen records get # one record with all field values @@ -173,6 +177,7 @@ kizen fields create --api-name X --type text --category Y [... kizen fields create [--spec-file path | < stdin] # many at once kizen automations create [--spec-file path | < stdin] kizen dashboards create [--spec-file path | < stdin] # dashboard/homepage + dashlets +kizen mcp-servers create [--spec-file path | < stdin] # MCP server: tools + sharing (kizen docs show mcp-server) kizen layouts update [--spec-file | < stdin] # PUT-replace a record layout kizen records create --field api_name=value [...] # one record (values resolved vs schema) kizen records create [--spec-file f.csv|f.json | < stdin] # bulk create from CSV/JSON @@ -184,6 +189,7 @@ kizen fields update [--name X] [--required] [ kizen automations update [--spec-file path | < stdin] kizen dashboards update [--spec-file | < stdin] # metadata + dashlets (diff by id) kizen dashboards duplicate [--name X] [--api-name Y] # unpublished copy, source's sharing +kizen mcp-servers update [--spec-file | < stdin] # tools compare as a set; `get --raw` output re-applies as a skip kizen records update --field api_name=value [...] # one record kizen records update [--spec-file f.csv|f.json | < stdin] # bulk update (each row needs an 'id') kizen records upsert --field api_name=value [...] # create-or-update by lookup_value @@ -211,6 +217,7 @@ kizen categories delete kizen filter-groups delete kizen quick-filters delete kizen columns delete +kizen mcp-servers delete # hard delete; Kizen-managed servers are refused # activities (activity types / loggable definitions) — logging/scheduling stays in the UI kizen activities create --name X [--api-name Y] [--association-mode ...] [--object ...] [--editable] [--spec-file f] kizen activities update [--name] [--description] [--object ...] \ diff --git a/src/kizen_builder/docs/reference.md b/src/kizen_builder/docs/reference.md index 714a3b5..94ead92 100644 --- a/src/kizen_builder/docs/reference.md +++ b/src/kizen_builder/docs/reference.md @@ -21,6 +21,7 @@ the wire formats, endpoints, and confirmed quirks together. | Activity types, logged & scheduled activities | `activity` | | Forms & surveys (incl. `form_ui` pages) | `form` | | Dashboards & homepages | `dashboard` | +| MCP servers (the tools AI steps and MCP clients use) | `mcp-server` | | Record layouts | `layout` | | Filter groups, quick filters, column templates | `saved-views` | | Roles & permission groups | `permission-group` | diff --git a/src/kizen_builder/docs/specs/README.md b/src/kizen_builder/docs/specs/README.md index e7b6537..2f3bef8 100644 --- a/src/kizen_builder/docs/specs/README.md +++ b/src/kizen_builder/docs/specs/README.md @@ -23,6 +23,7 @@ field Kizen rejected. | `activities create`, `activities fields create` | [activity.md](activity.md) | `ActivityDef` / `ActivityFieldDef` | | `forms create\|update\|set-ui`, `forms fields create` (and `surveys …`) | [form.md](form.md) | `FormDef` / `FormFieldDef` / `form_ui` | | `dashboards create\|update` | [dashboard.md](dashboard.md) | `DashboardDef` + dashlets | +| `mcp-servers create\|update` | [mcp-server.md](mcp-server.md) | `MCPServerDef` | | `layouts update` | [layout.md](layout.md) | `LayoutDef` (record layout) | | `filter-groups`, `quick-filters`, `columns` create\|update | [saved-views.md](saved-views.md) | filter DSL / `configuration_json` | | `permissions group-create`, `roles …` | [permission-group.md](permission-group.md) | shaping-op list | diff --git a/src/kizen_builder/docs/specs/mcp-server.md b/src/kizen_builder/docs/specs/mcp-server.md new file mode 100644 index 0000000..f65beb7 --- /dev/null +++ b/src/kizen_builder/docs/specs/mcp-server.md @@ -0,0 +1,190 @@ +# Spec shape: `MCPServerDef` — MCP servers + +**Consumed by:** `kizen mcp-servers create|update --spec-file ` +(also stdin). `delete ` takes no spec. + +An MCP server is a named set of Kizen tools. AI steps and outside MCP clients +call those tools through the server. A server is referenced by UUID or +`api_name`; display names aren't unique, so the CLI never matches them. + +Read first: `kizen mcp-servers list`, `get ` (add `--raw` for the wire +JSON), and `tools` for the valid `tool_names`. + +--- + +## Quick example + +A server with two tools, shared with one service account and nobody else: + +```json +{ + "name": "Support agent tools", + "api_name": "support_agent_tools", + "description": "What the support agent may read", + "tool_names": ["listCustomObjects", "searchCatalog"], + "is_enabled": true, + "sharing_settings": { + "private": false, + "all_team_members": 0, + "roles": {"view": [], "edit": [], "admin": []}, + "team_members": {"view": [""], "edit": [], "admin": []} + } +} +``` + +## `MCPServerDef` fields + +Every key is optional, so an update spec carries only what changes. `create` +needs `name` and `api_name`. Any other key is a spec error, and so is `null` +for any key but `description`. + +| Key | Type | Notes | +|---|---|---| +| `name` | string | Not unique. | +| `api_name` | string | `^[a-z_][a-z0-9_]*$`, 2–64 characters, unique. Fixed once created: `update` refuses a different one. | +| `description` | string or null | `null` clears it. | +| `tool_names` | list of strings | Names from `kizen mcp-servers tools`. Checked at plan time; duplicates are dropped (first one kept). `[]` is a server with no tools. | +| `is_enabled` | bool | | +| `is_default_for_business` | bool | One default per business. See below. | +| `sharing_settings` | object | The whole block: `private` (bool), `all_team_members` (0–3), `roles` and `team_members`, each `{view, edit, admin}` lists of UUIDs. Read-shape `{id, display_name}` entries are accepted too; an entry without `id` is a spec error. | + +**Service accounts are shared as team members.** Put the account's id in +`team_members`. There is no `service_accounts` bucket; the spec rejects one. + +**`get --raw` output is a valid spec.** These read-only keys are dropped +before validation, and the plan preview lists them under +`ignored_read_only_keys`: `id`, `tools`, `tool_count`, `is_system_managed`, +`endpoint_url`, `created`, `updated`, `owner`, `employee_access`, +`sharing_stats`. + +## What the plan does + +- **create** refuses an `api_name` that exists. With no `sharing_settings`, + it sends "every team member can view": `{"private": false, + "all_team_members": 1, "roles": {…empty…}, "team_members": {…empty…}}`. +- **update** compares only the keys the spec sets. `tool_names` compares as a + set, so reordering is a skip. `sharing_settings` compares after both sides + are normalised to bare UUIDs, so re-sending `get --raw` output unchanged is + a skip. A changed sharing block is sent whole; the preview lists the + changed scalars and each grant added (`+roles.view:`) or removed + (`-team_members.view:`). +- **delete** previews the name, api_name, id and tool count, plus + `is_business_default: true` when the server is the default. +- **`is_default_for_business: true`** previews `business_default`, naming the + server that stops being the default and how to restore it. +- **`is_default_for_business: false`** on the current default is refused: set + another server as the default instead. A business always has one. +- **Kizen-managed servers** (`is_system_managed`, e.g. `readonly`) can't be + updated or deleted from the CLI, with one exception: an update spec of + exactly `{"is_default_for_business": true}` makes one the default again: + + ```bash + echo '{"is_default_for_business": true}' | kizen mcp-servers update readonly --yes + ``` + +## Permission + +`mcp_servers_section.mcp_server_configuration` takes `{view, edit, remove}` +(labels None / View/Use Shared / Create/Edit All / Delete/All; default +`view`). Set it on a permission group with a `section` op (see +`kizen docs show permission-group`): + +```json +[{ "type": "section", "section_key": "mcp_servers_section", + "value": { "enabled": true, + "mcp_server_configuration": { "view": true, "edit": true, "remove": false } } }] +``` + +The CLI doesn't call a server's `endpoint_url` itself. That's the JSON-RPC +endpoint MCP clients use at run time; `get` prints it so you can wire one up. + +--- + +# Wire format & API behavior + +Confirmed live 2026-10-27 on a disposable test business. + +## Endpoints (no trailing slashes) + +``` +GET /api/mcp-servers # list (DRF page); rows carry tool_count, not tools +POST /api/mcp-servers # create → 201, the read shape +GET /api/mcp-servers/{id} # one server; tools[] adds group and group_label +PATCH /api/mcp-servers/{id} # partial update +DELETE /api/mcp-servers/{id} # hard delete → 204 +GET /api/mcp-servers/tools # {groups: [{key, label, tools: [{name, display_name, description, annotations}]}]} +GET /api/mcp-servers/{id}/events-history # DRF page, newest first +GET /api/mcp-servers/step-options # what a Call LLM step's mcp_server carries +POST /api/mcp/{api_name} # JSON-RPC, for MCP clients (endpoint_url) +``` + +## Create, update, delete + +- `sharing_settings` is required on create (400 without it). A partial block + (`{"all_team_members": 2}`) 400s with `roles` / `team_members` required. + `all_team_members: 5` 400s. An empty `roles.admin` is accepted (dashboards + need the Admin role there; MCP servers don't). +- Without `api_name`, Kizen derives one from `name`. A hyphen 400s: + `Must start with a letter or underscore and contain only lowercase letters, + numbers, or underscores.` A taken api_name, including `readonly`, 400s + `MCP Server 'api name' must be unique.` A name can repeat. +- One unknown tool 400s the whole request: `Unknown MCP tools: `, on + create and on PATCH. +- PATCH `tool_names` replaces the list in the order given. `[]` is valid. +- PATCH `api_name` 400s: `This field cannot be changed after creation.` +- PATCH ignores read-only keys (`is_system_managed`, `endpoint_url`, `tools`) + without an error. +- A `service_accounts` key inside `sharing_settings` returns **200 and is + dropped**. A service account's id in `team_members.view` is saved and reads + back as `{id, display_name: ""}`. +- DELETE is permanent. GET, events-history and a second DELETE then 404 + `No MCPServerConfig matches the given query.` The api_name is free again. + +## Reads + +- Writes take bare UUIDs in sharing; reads return `{id, display_name}`. +- `owner` is the creator (`{id, display_name}`; `{}` on the system server). + `employee_access` is `Owner` for the creator, `View` on the system server. + `sharing_stats` counts levels. +- `endpoint_url` is `https:///api/mcp/`. +- The tool catalogue on a business with knowledge and custom objects is + `custom_knowledge` (`listCatalogs`, `searchCatalog`, `getDocument`) and + `custom_objects` (`listCustomObjects`, `listObjectFields`, + `listObjectFieldOptions`, `getCustomObject`). All seven are + `readOnlyHint: true`. The system `readonly` server serves all seven. +- `step-options` is a bare list of `{id, name, api_name, is_enabled, deleted, + tool_count}`. It lists disabled servers and leaves out deleted ones. + +## Events history + +- **Written asynchronously.** Read right after a change, it can be empty; a + second later the events are there. +- Event keys: `id, group_id, business_id, type_name, type_display_name, + has_relationship, instance, initiator, data, comments, created`. Types: + `mcp_server_created`, `mcp_server_updated`. +- `data` is `{api_name, mcp_server_name, old_value, new_value}`, each value + carrying `name, is_enabled, tool_names, description, + is_default_for_business`. A create has no `old_value`. A sharing change logs + an update whose old and new values are equal. `history` shows `—` for both. +- `instance.custom_object_id` / `entity_record_name: "Contact"` are noise. +- The system server's history is empty. + +## The business default + +**Kizen keeps one default MCP server per business.** Creating a server with +`is_default_for_business: true` made it the default and cleared the flag on +the previous default (the system `readonly` server), without changing that +server's `updated`. PATCHing `readonly` back to `is_default_for_business: true` +then cleared it on the other server; `kizen mcp-servers update readonly` +with that one key does the same. Setting the flag to `false` on the current +default was not tried, so the CLI refuses it. + +## Permission key + +`GET /api/permissions/meta-data` has a section `mcp_servers_section` ("MCP +Servers") with one permission, `mcp_server_configuration` ("MCP Server +Configuration"), `allowed_access` `view/edit/remove`. An Admin group carries +`"mcp_servers_section": {"enabled": true, "mcp_server_configuration": +{"view": true, "edit": true, "remove": true}}`. `kizen permissions group` +renders it as "MCP Servers / MCP Server Configuration", and a `section` op +dry-runs on it, with no MCP-specific code. diff --git a/src/kizen_builder/models/spec/__init__.py b/src/kizen_builder/models/spec/__init__.py index 3762f40..c321b2a 100644 --- a/src/kizen_builder/models/spec/__init__.py +++ b/src/kizen_builder/models/spec/__init__.py @@ -131,6 +131,12 @@ ColumnTemplateDef, LayoutDef, ) +from kizen_builder.models.spec.mcp_servers import ( + MCPServerApiName, + SharingBuckets, + MCPServerSharing, + MCPServerDef, +) from kizen_builder.models.spec.email_templates import ( ColumnPreset, COLUMN_FRACTIONS, @@ -310,6 +316,10 @@ def build_payload_meta(model: BaseModel) -> dict[str, Any]: "QuickFilterDef", "ColumnTemplateDef", "LayoutDef", + "MCPServerApiName", + "SharingBuckets", + "MCPServerSharing", + "MCPServerDef", "ColumnPreset", "COLUMN_FRACTIONS", "TextBlockDef", diff --git a/src/kizen_builder/models/spec/mcp_servers.py b/src/kizen_builder/models/spec/mcp_servers.py new file mode 100644 index 0000000..335fb9b --- /dev/null +++ b/src/kizen_builder/models/spec/mcp_servers.py @@ -0,0 +1,106 @@ +"""MCP server spec: the tools a server serves and who can use it. + +Every key is optional so an update spec can carry only what changes. A create +needs ``name`` and ``api_name``; the planner checks that. ``kizen mcp-servers +get --raw`` output is a valid spec once the planner drops its read-only keys. +""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator +from pydantic_core import PydanticCustomError + +MCPServerApiName = Annotated[ + str, + StringConstraints(pattern=r"^[a-z_][a-z0-9_]*$", min_length=2, max_length=64), +] +"""Kizen's own rule: lowercase letters, digits and underscores, not starting +with a digit. Unique per business and fixed once the server exists.""" + +_SHARING_KEYS = ("private", "all_team_members", "roles", "team_members") +_SHARING_KEYS_MSG = ( + "sharing_settings takes private, all_team_members, roles and team_members. " + "Service accounts are shared as team members: put the account's id in " + "team_members." +) + + +class GrantRef(BaseModel): + """The read shape of a grant, `{id, display_name}`, as `get --raw` prints it. + + `id` is required: a grant without one would be dropped from the PATCH, + revoking it without a word. + """ + + model_config = ConfigDict(extra="allow") + + id: str = Field(min_length=1) + + +# A grant is a bare UUID (the write shape) or the read shape. +Grant = str | GrantRef + + +class SharingBuckets(BaseModel): + """Grants per access level. A level left out grants nobody.""" + + model_config = ConfigDict(extra="forbid") + + view: list[Grant] = Field(default_factory=list) + edit: list[Grant] = Field(default_factory=list) + admin: list[Grant] = Field(default_factory=list) + + +class MCPServerSharing(BaseModel): + """The whole sharing block. Kizen rejects a partial one, so all four keys + are required.""" + + model_config = ConfigDict(extra="forbid") + + private: bool + all_team_members: Literal[0, 1, 2, 3] + roles: SharingBuckets + team_members: SharingBuckets + + @model_validator(mode="before") + @classmethod + def _known_keys_only(cls, data: Any) -> Any: + unknown = ( + sorted(set(data) - set(_SHARING_KEYS)) if isinstance(data, dict) else [] + ) + if unknown: + raise PydanticCustomError( + "sharing_settings_keys", + "unknown key(s) {keys}. " + _SHARING_KEYS_MSG, + {"keys": ", ".join(unknown)}, + ) + return data + + +class MCPServerDef(BaseModel): + """One MCP server. ``tool_names`` come from ``kizen mcp-servers tools``.""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1) + api_name: MCPServerApiName | None = None + description: str | None = None + tool_names: list[str] | None = None + is_enabled: bool | None = None + is_default_for_business: bool | None = None + sharing_settings: MCPServerSharing | None = None + + @model_validator(mode="before") + @classmethod + def _null_only_clears_description(cls, data: Any) -> Any: + if isinstance(data, dict): + for key, value in data.items(): + if value is None and key != "description" and key in cls.model_fields: + raise PydanticCustomError( + "null_value", + "{key} can't be null; leave it out to keep the current value.", + {"key": key}, + ) + return data diff --git a/src/kizen_builder/tools/mcp_servers.py b/src/kizen_builder/tools/mcp_servers.py new file mode 100644 index 0000000..610e479 --- /dev/null +++ b/src/kizen_builder/tools/mcp_servers.py @@ -0,0 +1,110 @@ +"""Read tools for MCP servers: resolve, list, detail, tool catalogue, history. + +Servers are referenced by UUID or ``api_name``. Display names are not unique in +Kizen, so they are never matched. +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.api import mcp_servers as mcp_api +from kizen_builder.api.client import KizenAPIError, KizenClient +from kizen_builder.config import load_env_config +from kizen_builder.utils import is_uuid + + +def _client() -> KizenClient: + return KizenClient(load_env_config()) + + +def _resolve(client: KizenClient, ref: str) -> dict[str, Any]: + if is_uuid(ref): + try: + return mcp_api.get_mcp_server(client, ref) + except KizenAPIError as e: + if e.status_code != 404: + raise + servers = mcp_api.list_mcp_servers(client) + else: + servers = mcp_api.list_mcp_servers(client) + match = next((s for s in servers if s.get("api_name") == ref), None) + if match is not None: + return mcp_api.get_mcp_server(client, match["id"]) + names = ", ".join(sorted(s.get("api_name") or "" for s in servers)) or "none" + raise LookupError(f"no MCP server '{ref}'; available: {names}") + + +def resolve_mcp_server(ref: str) -> dict[str, Any]: + """The server's full GET, from its UUID or api_name.""" + with _client() as c: + return _resolve(c, ref) + + +def list_mcp_servers() -> list[dict[str, Any]]: + """Every server in the business, as the list endpoint returns them.""" + with _client() as c: + return mcp_api.list_mcp_servers(c) + + +def get_mcp_server_detail(ref: str) -> dict[str, Any]: + """One server's summary, its tools as ``{group, name}`` rows, and ``raw``.""" + raw = resolve_mcp_server(ref) + owner = raw.get("owner") or {} + return { + "name": raw.get("name"), + "api_name": raw.get("api_name"), + "description": raw.get("description"), + "is_enabled": raw.get("is_enabled"), + "is_default_for_business": raw.get("is_default_for_business"), + "is_system_managed": raw.get("is_system_managed"), + "endpoint_url": raw.get("endpoint_url"), + "owner": owner.get("display_name"), + "id": raw.get("id"), + "tools": [ + {"group": t.get("group"), "name": t.get("name")} + for t in raw.get("tools") or [] + ], + "raw": raw, + } + + +def tool_catalog() -> dict[str, Any]: + """The business's tool catalogue: ``{groups: [{key, label, tools}]}``.""" + with _client() as c: + return mcp_api.get_tool_catalog(c) + + +def catalog_tool_names(catalog: dict[str, Any]) -> set[str]: + return { + t["name"] + for g in catalog.get("groups") or [] + for t in g.get("tools") or [] + if t.get("name") + } + + +def list_history(ref: str) -> list[dict[str, Any]]: + """The server's change events, as Kizen returns them.""" + with _client() as c: + server = _resolve(c, ref) + return mcp_api.list_events_history(c, server["id"]) + + +def changed_keys(event: dict[str, Any]) -> list[str]: + """Keys of ``data.new_value`` whose value differs from ``data.old_value``. + + A create event has no ``old_value``, so it reports nothing changed. + """ + data = event.get("data") or {} + old, new = data.get("old_value"), data.get("new_value") + if not isinstance(old, dict) or not isinstance(new, dict): + return [] + return [k for k, v in new.items() if old.get(k) != v] + + +def initiator_label(event: dict[str, Any]) -> str: + who = event.get("initiator") or {} + if who.get("email"): + return str(who["email"]) + return " ".join(p for p in (who.get("first_name"), who.get("last_name")) if p) diff --git a/src/kizen_builder/tools/planners/mcp_servers.py b/src/kizen_builder/tools/planners/mcp_servers.py new file mode 100644 index 0000000..a05321c --- /dev/null +++ b/src/kizen_builder/tools/planners/mcp_servers.py @@ -0,0 +1,299 @@ +"""Plan creation, update and deletion of MCP servers. One op per server. + +A spec may be ``kizen mcp-servers get --raw`` output: its read-only keys are +dropped before validation and listed in the preview. Tool names are checked +against the live catalogue, because Kizen rejects the whole request for one +unknown tool. Kizen-managed servers (``is_system_managed``) are refused. +""" + +from __future__ import annotations + +from typing import Any + +from kizen_builder.config import load_env_config +from kizen_builder.models.spec import MCPServerDef, MCPServerSharing +from kizen_builder.tools.dashboards import normalize_sharing_settings +from kizen_builder.tools.mcp_servers import ( + catalog_tool_names, + list_mcp_servers, + resolve_mcp_server, + tool_catalog, +) +from kizen_builder.tools.plans import Plan, PlanError, PlanOperation, fetch_existing + +READ_ONLY_KEYS = ( + "id", + "tools", + "tool_count", + "is_system_managed", + "endpoint_url", + "created", + "updated", + "owner", + "employee_access", + "sharing_stats", +) + +DEFAULT_SHARING: dict[str, Any] = { + "private": False, + "all_team_members": 1, + "roles": {"view": [], "edit": [], "admin": []}, + "team_members": {"view": [], "edit": [], "admin": []}, +} + +_SCALAR_KEYS = ("name", "description", "is_enabled", "is_default_for_business") + + +def _parse(spec: dict[str, Any] | MCPServerDef) -> tuple[MCPServerDef, list[str]]: + if isinstance(spec, MCPServerDef): + return spec, [] + if not isinstance(spec, dict): + raise PlanError("an MCP server spec is a JSON object.") + dropped = [k for k in READ_ONLY_KEYS if k in spec] + kept = {k: v for k, v in spec.items() if k not in READ_ONLY_KEYS} + return MCPServerDef.model_validate(kept), dropped + + +def _check_tools(names: list[str]) -> list[str]: + """Drop duplicates (first seen wins) and refuse names the catalogue lacks.""" + deduped = list(dict.fromkeys(names)) + valid = catalog_tool_names(tool_catalog()) + unknown = [n for n in deduped if n not in valid] + if unknown: + raise PlanError( + f"unknown MCP tool(s): {', '.join(unknown)}. " + f"Valid tools: {', '.join(sorted(valid))}. " + "List them with `kizen mcp-servers tools`." + ) + return deduped + + +def _sharing(sharing: MCPServerSharing) -> dict[str, Any]: + return normalize_sharing_settings(sharing.model_dump()) + + +def _comparable(sharing: dict[str, Any]) -> dict[str, Any]: + """A normalised sharing block with each grant list as a set.""" + norm = normalize_sharing_settings(sharing) + for bucket in ("roles", "team_members"): + if isinstance(norm.get(bucket), dict): + norm[bucket] = {lvl: set(ids) for lvl, ids in norm[bucket].items()} + return norm + + +def _sharing_diff(old: dict[str, Any], new: dict[str, Any]) -> str: + """Which scalars changed and which grants are added (+) or removed (-).""" + names = { + g["id"]: g.get("display_name") or g["id"] + for block in (old, new) + for bucket in ("roles", "team_members") + for grants in (block.get(bucket) or {}).values() + for g in grants + if isinstance(g, dict) and g.get("id") + } + a, b = _comparable(old), _comparable(new) + parts = [ + f"{k} {a.get(k)} → {b.get(k)}" + for k in ("private", "all_team_members") + if a.get(k) != b.get(k) + ] + for bucket in ("roles", "team_members"): + for lvl in ("view", "edit", "admin"): + was = (a.get(bucket) or {}).get(lvl, set()) + now = (b.get(bucket) or {}).get(lvl, set()) + parts += [f"+{bucket}.{lvl}:{names.get(i, i)}" for i in sorted(now - was)] + parts += [f"-{bucket}.{lvl}:{names.get(i, i)}" for i in sorted(was - now)] + return ", ".join(parts) + + +def _default_takeover(servers: list[dict[str, Any]], api_name: str) -> str | None: + """Preview text for a server becoming the business default. + + Kizen keeps one default per business: setting the flag clears it on the + current default (confirmed live 2026-10-27). + """ + current = next((s for s in servers if s.get("is_default_for_business")), None) + if current is None or current.get("api_name") == api_name: + return None + old = current.get("api_name") + return ( + f"replaces '{old}', which stops being the default. To restore it, run " + f"`kizen mcp-servers update {old}` with " + '{"is_default_for_business": true}' + ) + + +def _system_message(live: dict[str, Any]) -> str: + return ( + f"'{live.get('api_name')}' is managed by Kizen and can't be changed or " + "deleted from the CLI. The one exception: an update that sets only " + '{"is_default_for_business": true}.' + ) + + +def _check_system_update(live: dict[str, Any], d: MCPServerDef) -> None: + """A Kizen-managed server takes only the default flag set to true.""" + if live.get("is_system_managed") and ( + d.model_fields_set != {"is_default_for_business"} + or d.is_default_for_business is not True + ): + raise PlanError(_system_message(live)) + + +def plan_create_mcp_server(spec: dict[str, Any] | MCPServerDef) -> Plan: + """Plan one new MCP server.""" + d, dropped = _parse(spec) + env = load_env_config().name + missing = [k for k in ("name", "api_name") if getattr(d, k) is None] + if missing: + raise PlanError(f"an MCP server create needs {' and '.join(missing)}.") + assert d.api_name is not None + + servers = list_mcp_servers() + existing = next((s for s in servers if s.get("api_name") == d.api_name), None) + if existing is not None: + raise PlanError( + f"MCP server '{d.api_name}' already exists (id {existing['id']}). " + "Use `kizen mcp-servers update` instead." + ) + + payload: dict[str, Any] = {"name": d.name, "api_name": d.api_name} + for key in ("description", "is_enabled", "is_default_for_business"): + if key in d.model_fields_set: + payload[key] = getattr(d, key) + if d.tool_names is not None: + payload["tool_names"] = _check_tools(d.tool_names) + payload["sharing_settings"] = ( + _sharing(d.sharing_settings) + if d.sharing_settings is not None + else DEFAULT_SHARING + ) + + preview: dict[str, Any] = { + "env": env, + "api_name": d.api_name, + "name": d.name, + "tools": len(payload.get("tool_names", [])), + } + if d.is_default_for_business: + takeover = _default_takeover(servers, d.api_name) + if takeover: + preview["business_default"] = takeover + if dropped: + preview["ignored_read_only_keys"] = dropped + op = PlanOperation( + action="create", + kind="mcp_server", + key=d.api_name, + preview=preview, + payload=payload, + ) + return Plan.build( + env=env, summary=f"Create MCP server '{d.api_name}'", operations=[op] + ) + + +def plan_update_mcp_server(server: str, spec: dict[str, Any] | MCPServerDef) -> Plan: + """Plan a PATCH of the keys the spec sets that differ from live. + + ``server`` is a UUID or api_name. Tools compare as a set and sharing + compares after normalising both sides, so re-sending ``get --raw`` output + unchanged plans a skip. + """ + d, dropped = _parse(spec) + env = load_env_config().name + live = fetch_existing("MCP server", server, lambda: resolve_mcp_server(server)) + _check_system_update(live, d) + api_name = live.get("api_name") or live["id"] + if d.is_default_for_business is False and live.get("is_default_for_business"): + raise PlanError( + f"'{api_name}' is the business default and Kizen keeps one. To change " + "it, set another server as the default instead " + '({"is_default_for_business": true} on that server).' + ) + if d.api_name is not None and d.api_name != live.get("api_name"): + raise PlanError( + "api_name can't change after creation; create a new server instead." + ) + + payload: dict[str, Any] = {} + diff: dict[str, str] = {} + for key in _SCALAR_KEYS: + if key in d.model_fields_set and getattr(d, key) != live.get(key): + payload[key] = getattr(d, key) + diff[key] = f"{live.get(key)} → {getattr(d, key)}" + if d.tool_names is not None: + wanted = _check_tools(d.tool_names) + current = live.get("tool_names") or [] + if set(wanted) != set(current): + payload["tool_names"] = wanted + added = [t for t in wanted if t not in current] + removed = [t for t in current if t not in wanted] + diff["tool_names"] = ", ".join( + [f"+{t}" for t in added] + [f"-{t}" for t in removed] + ) + if d.sharing_settings is not None: + new = _sharing(d.sharing_settings) + if _comparable(new) != _comparable(live.get("sharing_settings") or {}): + payload["sharing_settings"] = new + diff["sharing_settings"] = ( + _sharing_diff( + live.get("sharing_settings") or {}, + d.sharing_settings.model_dump(), + ) + + " (the whole block is sent)" + ) + + preview: dict[str, Any] = { + "env": env, + "mcp_server": api_name, + "diff": diff or "no changes", + } + if payload.get("is_default_for_business"): + takeover = _default_takeover(list_mcp_servers(), api_name) + if takeover: + preview["business_default"] = takeover + if dropped: + preview["ignored_read_only_keys"] = dropped + op = PlanOperation( + action="update" if payload else "skip", + kind="mcp_server", + key=api_name, + preview=preview, + payload=payload, + existing_uuid=live["id"], + ) + summary = ( + f"Update MCP server '{api_name}' ({len(diff)} change(s))" + if payload + else f"No changes to MCP server '{api_name}'" + ) + return Plan.build(env=env, summary=summary, operations=[op]) + + +def plan_delete_mcp_server(server: str) -> Plan: + """Plan a hard delete. ``server`` is a UUID or api_name.""" + env = load_env_config().name + live = fetch_existing("MCP server", server, lambda: resolve_mcp_server(server)) + if live.get("is_system_managed"): + raise PlanError(_system_message(live)) + api_name = live.get("api_name") or live["id"] + preview: dict[str, Any] = { + "env": env, + "name": live.get("name"), + "api_name": live.get("api_name"), + "id": live["id"], + "tool_count": len(live.get("tool_names") or []), + } + if live.get("is_default_for_business"): + preview["is_business_default"] = True + op = PlanOperation( + action="delete", + kind="mcp_server", + key=api_name, + preview=preview, + existing_uuid=live["id"], + ) + return Plan.build( + env=env, summary=f"Delete MCP server '{api_name}'", operations=[op] + ) diff --git a/src/kizen_builder/tools/plans.py b/src/kizen_builder/tools/plans.py index dbddba2..aae726e 100644 --- a/src/kizen_builder/tools/plans.py +++ b/src/kizen_builder/tools/plans.py @@ -34,6 +34,7 @@ from kizen_builder.api import files as files_api from kizen_builder.api import forms as forms_api from kizen_builder.api import layouts as layout_api +from kizen_builder.api import mcp_servers as mcp_api from kizen_builder.api import permissions as perm_api from kizen_builder.api import pipelines as pipelines_api from kizen_builder.api import records as records_api @@ -84,6 +85,7 @@ def copy_name_preview(source_name: str | None, name: str | None) -> str: "automation_folder", "dashboard", "dashlet", + "mcp_server", "layout", "record", "activity", @@ -791,6 +793,20 @@ def patch_and_read(copy_id: str) -> dict[str, Any]: client, op.parent_object_uuid, op.existing_uuid, op.payload ) + if op.kind == "mcp_server": + if op.action == "create": + return mcp_api.create_mcp_server(client, op.payload) + if op.existing_uuid is None: + raise PlanError( + f"{op.action} mcp_server op '{op.key}' has no existing_uuid — planning bug" + ) + if op.action == "update": + return mcp_api.update_mcp_server(client, op.existing_uuid, op.payload) + if op.action == "delete": + mcp_api.delete_mcp_server(client, op.existing_uuid) + return {} + raise PlanError(f"unsupported mcp_server action '{op.action}'") + if op.kind == "layout": # Layouts are PUT-replace only (Kizen auto-creates the Standard View). # parent_object_uuid = custom object id, existing_uuid = layout id. diff --git a/tests/drift/contracts.py b/tests/drift/contracts.py index 45a02d4..046f454 100644 --- a/tests/drift/contracts.py +++ b/tests/drift/contracts.py @@ -152,6 +152,10 @@ def key(self) -> str: "post", "/api/custom-objects/{object_identifier}/fields/{field_identifier}/options/{id}/replace", ), + # --- mcp_servers ------------------------------------------------------- + Contract("mcp_servers", "create MCP server", "post", "/api/mcp-servers"), + Contract("mcp_servers", "update MCP server", "patch", "/api/mcp-servers/{id}"), + Contract("mcp_servers", "delete MCP server", "delete", "/api/mcp-servers/{id}"), # --- permissions ------------------------------------------------------- Contract("permissions", "create permission group", "post", "/api/permission-group"), Contract( diff --git a/tests/drift/schema_snapshot.json b/tests/drift/schema_snapshot.json index df782a0..bf434d8 100644 --- a/tests/drift/schema_snapshot.json +++ b/tests/drift/schema_snapshot.json @@ -2,7 +2,7 @@ "_meta": { "base_url": "https://app.go.kizen.com", "captured": "2026-10-14", - "contracts_tracked": 69, + "contracts_tracked": 72, "how_to_refresh": "KIZEN_DRIFT_PROFILE= KIZEN_DRIFT_UPDATE_SNAPSHOT=1 uv run pytest -m drift", "info": { "title": "Kizen API", @@ -50,6 +50,19 @@ "schema_path": "/api/custom-objects/{object_identifier}/fields/{id}", "surface": "fields" }, + "DELETE /api/mcp-servers/{id}": { + "label": "delete MCP server", + "present": true, + "query_params": [ + "ordering" + ], + "request_schema": null, + "responses": [ + "204" + ], + "schema_path": "/api/mcp-servers/{id}", + "surface": "mcp_servers" + }, "DELETE /api/permission-group/{id}": { "label": "delete permission group", "present": true, @@ -178,6 +191,34 @@ "schema_path": "/api/custom-objects/{object_identifier}/fields/{id}", "surface": "fields" }, + "PATCH /api/mcp-servers/{id}": { + "label": "update MCP server", + "present": true, + "query_params": [ + "ordering" + ], + "request_body": { + "present": true, + "properties": { + "api_name": "string", + "description": "string?", + "is_default_for_business": "boolean", + "is_enabled": "boolean", + "name": "string", + "sharing_settings": "EntityPermissionRequest", + "tool_names": "array[string]" + }, + "required": [], + "type": "object" + }, + "request_required": false, + "request_schema": "PatchedMCPServerConfigRequest", + "responses": [ + "200" + ], + "schema_path": "/api/mcp-servers/{id}", + "surface": "mcp_servers" + }, "PATCH /api/permission-group/{id}": { "label": "update permission group sections", "present": true, @@ -611,6 +652,37 @@ "schema_path": "/api/custom-objects/{object_pk}/quick-filters", "surface": "saved_views" }, + "POST /api/mcp-servers": { + "label": "create MCP server", + "present": true, + "query_params": [ + "ordering" + ], + "request_body": { + "present": true, + "properties": { + "api_name": "string", + "description": "string?", + "is_default_for_business": "boolean", + "is_enabled": "boolean", + "name": "string", + "sharing_settings": "EntityPermissionRequest", + "tool_names": "array[string]" + }, + "required": [ + "name", + "sharing_settings" + ], + "type": "object" + }, + "request_required": true, + "request_schema": "MCPServerConfigRequest", + "responses": [ + "201" + ], + "schema_path": "/api/mcp-servers", + "surface": "mcp_servers" + }, "POST /api/permission-group": { "label": "create permission group", "present": true, diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 803c348..c2e7a27 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -43,6 +43,13 @@ automation steps). `role_detail.json` are still hand-authored and round out the read/resolve surface. UUIDs and the group name are replaced with `conftest.py`'s `00000000-0000-4000-8000-…` convention and `Sample Group`. +- `mcp_servers/` — live captures from 2026-10-27, sanitized (server, + person and service-account names, emails, host and business id replaced). + `list.json` is a `GET /api/mcp-servers` page: the system `readonly` server + plus `support_tools`, whose `detail.json` is shared with one service account + as a team member. `detail_readonly.json`, `tools.json`, `step_options.json` + and `events_history.json` (newest first; three updates and the create) are + the matching raw responses. ## Coverage diff --git a/tests/fixtures/mcp_servers/detail.json b/tests/fixtures/mcp_servers/detail.json new file mode 100644 index 0000000..a208b52 --- /dev/null +++ b/tests/fixtures/mcp_servers/detail.json @@ -0,0 +1,88 @@ +{ + "id": "e38f1367-267b-4344-823f-5126284b004d", + "name": "Support Tools", + "api_name": "support_tools", + "description": "Tools the support agent uses", + "tool_names": [ + "listCustomObjects", + "listObjectFields", + "searchCatalog" + ], + "tools": [ + { + "name": "listCustomObjects", + "display_name": "listCustomObjects", + "description": "List the objects configured in this business - Kizen's built-in objects (such as Contacts) plus any custom Standard or Pipeline objects. Use this to discover what kinds of records exist before querying them. Returns each object's api_name, display names, type, id, creation date, and record count. Supports search, sorting, and pagination. Only objects you have permission to see are returned. To fetch one object you already have an api_name or id for, use getCustomObject instead.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listObjectFields", + "display_name": "listObjectFields", + "description": "List the fields defined on one object, which is how you discover its schema before reading or writing records. Takes the object's api_name or id from listCustomObjects. Each row leads with `name` - the api_name to use in API payloads - alongside the display name, field type, category name, and the flags that govern writes (is_required, is_read_only, allows_nulls, allows_empty). Relation fields show their target as ' -> ', e.g. 'many_to_one -> receipts', with '(inverse)' appended on the mirror side of the relation. Dropdown-style fields list their valid `options`. Supports search on the display name or api_name, sorting, and filtering by category name. Not paginated - every field you have permission to see is returned. For a dropdown-style field, use listObjectFieldOptions (passing this row's `name`) to list its valid options.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "searchCatalog", + "display_name": "searchCatalog", + "description": "Semantic search across knowledge catalogs. Returns the most relevant content chunks with their source description, source_id, url, and a relevance score. Optionally narrow the search to specific catalogs by api_name or UUID.\n\n{catalog_list}", + "group": "custom_knowledge", + "group_label": "Custom Knowledge", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + } + ], + "is_system_managed": false, + "endpoint_url": "https://kizen.test/api/mcp/support_tools", + "is_enabled": true, + "is_default_for_business": false, + "created": "2026-10-27T08:18:35.333889-05:00", + "updated": "2026-10-27T08:18:36.775948-05:00", + "owner": { + "id": "00000000-0000-4000-8000-0000000005e7", + "display_name": "Sample User (user@example.com)" + }, + "employee_access": "Owner", + "sharing_settings": { + "private": false, + "all_team_members": 0, + "roles": { + "view": [], + "edit": [], + "admin": [] + }, + "team_members": { + "view": [ + { + "id": "00000000-0000-4000-8000-0000000005a1", + "display_name": "Sample Service Account" + } + ], + "edit": [], + "admin": [] + } + }, + "sharing_stats": { + "None": 2, + "View": 0, + "Edit": 0, + "Admin": 0 + } +} diff --git a/tests/fixtures/mcp_servers/detail_readonly.json b/tests/fixtures/mcp_servers/detail_readonly.json new file mode 100644 index 0000000..5d15c6a --- /dev/null +++ b/tests/fixtures/mcp_servers/detail_readonly.json @@ -0,0 +1,136 @@ +{ + "id": "bde29351-2a8b-4479-a0d3-c65d8292782e", + "name": "Read Only", + "api_name": "readonly", + "description": null, + "tool_names": [ + "listCustomObjects", + "listObjectFields", + "listObjectFieldOptions", + "getCustomObject", + "listCatalogs", + "searchCatalog", + "getDocument" + ], + "tools": [ + { + "name": "listCustomObjects", + "display_name": "listCustomObjects", + "description": "List the objects configured in this business - Kizen's built-in objects (such as Contacts) plus any custom Standard or Pipeline objects. Use this to discover what kinds of records exist before querying them. Returns each object's api_name, display names, type, id, creation date, and record count. Supports search, sorting, and pagination. Only objects you have permission to see are returned. To fetch one object you already have an api_name or id for, use getCustomObject instead.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listObjectFields", + "display_name": "listObjectFields", + "description": "List the fields defined on one object, which is how you discover its schema before reading or writing records. Takes the object's api_name or id from listCustomObjects. Each row leads with `name` - the api_name to use in API payloads - alongside the display name, field type, category name, and the flags that govern writes (is_required, is_read_only, allows_nulls, allows_empty). Relation fields show their target as ' -> ', e.g. 'many_to_one -> receipts', with '(inverse)' appended on the mirror side of the relation. Dropdown-style fields list their valid `options`. Supports search on the display name or api_name, sorting, and filtering by category name. Not paginated - every field you have permission to see is returned. For a dropdown-style field, use listObjectFieldOptions (passing this row's `name`) to list its valid options.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listObjectFieldOptions", + "display_name": "listObjectFieldOptions", + "description": "List the valid options for one dropdown-style field (checkboxes, choices, dropdown, dynamictags, radio, rating, status, yesnomaybe), as discovered via listObjectFields. Write back a row's `name` - or its `code` on rating fields, whose options are a fixed numeric scale that search and sort do not apply to. Not paginated. team_selector and timezone fields have no options here.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "getCustomObject", + "display_name": "getCustomObject", + "description": "Get one object's configuration by api_name or id - use this when you already have a reference to a specific object (from an automation, a dashboard, or another tool) and do not want to list everything. Returns its identity (api_name, display name, entity name, type), description, record count, ownership and configuration flags; for pipelines also its stage names and lost/disqualified reasons. For the object's fields use listObjectFields, for a field's options use listObjectFieldOptions, and to browse or search across many objects use listCustomObjects.", + "group": "custom_objects", + "group_label": "Custom Objects", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listCatalogs", + "display_name": "listCatalogs", + "description": "List the knowledge catalogs available to this business (owned and installed). Use this first to discover what knowledge exists before searching. Returns each catalog's id, api_name, and description.", + "group": "custom_knowledge", + "group_label": "Custom Knowledge", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "searchCatalog", + "display_name": "searchCatalog", + "description": "Semantic search across knowledge catalogs. Returns the most relevant content chunks with their source description, source_id, url, and a relevance score. Optionally narrow the search to specific catalogs by api_name or UUID.\n\n{catalog_list}", + "group": "custom_knowledge", + "group_label": "Custom Knowledge", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "getDocument", + "display_name": "getDocument", + "description": "Retrieve the full text of a knowledge source by its source_id (obtained from searchCatalog). Returns source metadata and the concatenated content of all its chunks in order.", + "group": "custom_knowledge", + "group_label": "Custom Knowledge", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + } + ], + "is_system_managed": true, + "endpoint_url": "https://kizen.test/api/mcp/readonly", + "is_enabled": true, + "is_default_for_business": true, + "created": "2026-09-14T18:52:55.847024-05:00", + "updated": "2026-09-14T18:52:55.847026-05:00", + "owner": {}, + "employee_access": "View", + "sharing_settings": { + "private": false, + "all_team_members": 1, + "roles": { + "view": [], + "edit": [], + "admin": [] + }, + "team_members": { + "view": [], + "edit": [], + "admin": [] + } + }, + "sharing_stats": { + "None": 0, + "View": 2, + "Edit": 0, + "Admin": 0 + } +} diff --git a/tests/fixtures/mcp_servers/events_history.json b/tests/fixtures/mcp_servers/events_history.json new file mode 100644 index 0000000..56e1547 --- /dev/null +++ b/tests/fixtures/mcp_servers/events_history.json @@ -0,0 +1,172 @@ +{ + "count": 4, + "next": null, + "previous": null, + "results": [ + { + "id": "81c3921d-b81c-481c-9e73-010dbb9378cf", + "group_id": null, + "business_id": "00000000-0000-4000-8000-00000000b1d0", + "type_name": "mcp_server_updated", + "type_display_name": "MCP Server Updated", + "has_relationship": false, + "instance": { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "custom_object_id": "9305ce04-f8c5-405c-a9fc-5eaa50dcb7ce", + "entity_record_name": "Contact" + }, + "initiator": { + "type": "employee_api_request", + "id": "00000000-0000-4000-8000-0000000005e7", + "first_name": "Sample", + "last_name": "User", + "email": "user@example.com", + "account_type": "user_standard" + }, + "data": { + "api_name": "support_tools", + "new_value": { + "name": "Support Tools", + "is_enabled": false, + "tool_names": [], + "description": null, + "is_default_for_business": false + }, + "old_value": { + "name": "Support Tools", + "is_enabled": false, + "tool_names": [], + "description": null, + "is_default_for_business": false + }, + "mcp_server_name": "Support Tools" + }, + "comments": [], + "created": "2026-10-27T13:18:37.057531Z" + }, + { + "id": "af19dc24-8d50-418b-9332-50d6459bb863", + "group_id": null, + "business_id": "00000000-0000-4000-8000-00000000b1d0", + "type_name": "mcp_server_updated", + "type_display_name": "MCP Server Updated", + "has_relationship": false, + "instance": { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "custom_object_id": "9305ce04-f8c5-405c-a9fc-5eaa50dcb7ce", + "entity_record_name": "Contact" + }, + "initiator": { + "type": "employee_api_request", + "id": "00000000-0000-4000-8000-0000000005e7", + "first_name": "Sample", + "last_name": "User", + "email": "user@example.com", + "account_type": "user_standard" + }, + "data": { + "api_name": "support_tools", + "new_value": { + "name": "Support Tools", + "is_enabled": false, + "tool_names": [], + "description": null, + "is_default_for_business": false + }, + "old_value": { + "name": "Support Tools", + "is_enabled": false, + "tool_names": [], + "description": "Tools the support agent uses", + "is_default_for_business": false + }, + "mcp_server_name": "Support Tools" + }, + "comments": [], + "created": "2026-10-27T13:18:36.117629Z" + }, + { + "id": "f20a3d0a-01d5-4c52-b1fd-ac7a23402aae", + "group_id": null, + "business_id": "00000000-0000-4000-8000-00000000b1d0", + "type_name": "mcp_server_updated", + "type_display_name": "MCP Server Updated", + "has_relationship": false, + "instance": { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "custom_object_id": "9305ce04-f8c5-405c-a9fc-5eaa50dcb7ce", + "entity_record_name": "Contact" + }, + "initiator": { + "type": "employee_api_request", + "id": "00000000-0000-4000-8000-0000000005e7", + "first_name": "Sample", + "last_name": "User", + "email": "user@example.com", + "account_type": "user_standard" + }, + "data": { + "api_name": "support_tools", + "new_value": { + "name": "Support Tools", + "is_enabled": true, + "tool_names": [], + "description": "Tools the support agent uses", + "is_default_for_business": false + }, + "old_value": { + "name": "Support Tools", + "is_enabled": true, + "tool_names": [ + "listCustomObjects", + "listObjectFields", + "searchCatalog" + ], + "description": "Tools the support agent uses", + "is_default_for_business": false + }, + "mcp_server_name": "Support Tools" + }, + "comments": [], + "created": "2026-10-27T13:18:35.888702Z" + }, + { + "id": "cc6ce8fd-8c1f-408f-b71e-926d081accf0", + "group_id": null, + "business_id": "00000000-0000-4000-8000-00000000b1d0", + "type_name": "mcp_server_created", + "type_display_name": "MCP Server Created", + "has_relationship": false, + "instance": { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "custom_object_id": "9305ce04-f8c5-405c-a9fc-5eaa50dcb7ce", + "entity_record_name": "Contact" + }, + "initiator": { + "type": "employee_api_request", + "id": "00000000-0000-4000-8000-0000000005e7", + "first_name": "Sample", + "last_name": "User", + "email": "user@example.com", + "account_type": "user_standard" + }, + "data": { + "api_name": "support_tools", + "new_value": { + "name": "Support Tools", + "is_enabled": true, + "tool_names": [ + "listCustomObjects", + "getCustomObject" + ], + "description": "Tools the support agent uses", + "is_default_for_business": false + }, + "mcp_server_name": "Support Tools" + }, + "comments": [], + "created": "2026-10-27T13:18:35.367536Z" + } + ], + "errors": null +} diff --git a/tests/fixtures/mcp_servers/list.json b/tests/fixtures/mcp_servers/list.json new file mode 100644 index 0000000..873f55b --- /dev/null +++ b/tests/fixtures/mcp_servers/list.json @@ -0,0 +1,100 @@ +{ + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": "bde29351-2a8b-4479-a0d3-c65d8292782e", + "name": "Read Only", + "api_name": "readonly", + "description": null, + "tool_names": [ + "listCustomObjects", + "listObjectFields", + "listObjectFieldOptions", + "getCustomObject", + "listCatalogs", + "searchCatalog", + "getDocument" + ], + "is_system_managed": true, + "endpoint_url": "https://kizen.test/api/mcp/readonly", + "is_enabled": true, + "is_default_for_business": true, + "created": "2026-09-14T18:52:55.847024-05:00", + "updated": "2026-09-14T18:52:55.847026-05:00", + "owner": {}, + "employee_access": "View", + "sharing_settings": { + "private": false, + "all_team_members": 1, + "roles": { + "view": [], + "edit": [], + "admin": [] + }, + "team_members": { + "view": [], + "edit": [], + "admin": [] + } + }, + "sharing_stats": { + "None": 0, + "View": 2, + "Edit": 0, + "Admin": 0 + }, + "tool_count": 7 + }, + { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "name": "Support Tools", + "api_name": "support_tools", + "description": "Tools the support agent uses", + "tool_names": [ + "listCustomObjects", + "listObjectFields", + "searchCatalog" + ], + "is_system_managed": false, + "endpoint_url": "https://kizen.test/api/mcp/support_tools", + "is_enabled": true, + "is_default_for_business": false, + "created": "2026-10-27T08:18:35.333889-05:00", + "updated": "2026-10-27T08:18:36.775948-05:00", + "owner": { + "id": "00000000-0000-4000-8000-0000000005e7", + "display_name": "Sample User (user@example.com)" + }, + "employee_access": "Owner", + "sharing_settings": { + "private": false, + "all_team_members": 0, + "roles": { + "view": [], + "edit": [], + "admin": [] + }, + "team_members": { + "view": [ + { + "id": "00000000-0000-4000-8000-0000000005a1", + "display_name": "Sample Service Account" + } + ], + "edit": [], + "admin": [] + } + }, + "sharing_stats": { + "None": 2, + "View": 0, + "Edit": 0, + "Admin": 0 + }, + "tool_count": 3 + } + ], + "errors": null +} diff --git a/tests/fixtures/mcp_servers/step_options.json b/tests/fixtures/mcp_servers/step_options.json new file mode 100644 index 0000000..a680bd9 --- /dev/null +++ b/tests/fixtures/mcp_servers/step_options.json @@ -0,0 +1,18 @@ +[ + { + "id": "bde29351-2a8b-4479-a0d3-c65d8292782e", + "name": "Read Only", + "api_name": "readonly", + "is_enabled": true, + "deleted": false, + "tool_count": 7 + }, + { + "id": "e38f1367-267b-4344-823f-5126284b004d", + "name": "Support Tools", + "api_name": "support_tools", + "is_enabled": false, + "deleted": false, + "tool_count": 0 + } +] diff --git a/tests/fixtures/mcp_servers/tools.json b/tests/fixtures/mcp_servers/tools.json new file mode 100644 index 0000000..330911b --- /dev/null +++ b/tests/fixtures/mcp_servers/tools.json @@ -0,0 +1,93 @@ +{ + "groups": [ + { + "key": "custom_knowledge", + "label": "Custom Knowledge", + "tools": [ + { + "name": "listCatalogs", + "display_name": "listCatalogs", + "description": "List the knowledge catalogs available to this business (owned and installed). Use this first to discover what knowledge exists before searching. Returns each catalog's id, api_name, and description.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "searchCatalog", + "display_name": "searchCatalog", + "description": "Semantic search across knowledge catalogs. Returns the most relevant content chunks with their source description, source_id, url, and a relevance score. Optionally narrow the search to specific catalogs by api_name or UUID.\n\n{catalog_list}", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "getDocument", + "display_name": "getDocument", + "description": "Retrieve the full text of a knowledge source by its source_id (obtained from searchCatalog). Returns source metadata and the concatenated content of all its chunks in order.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + } + ] + }, + { + "key": "custom_objects", + "label": "Custom Objects", + "tools": [ + { + "name": "listCustomObjects", + "display_name": "listCustomObjects", + "description": "List the objects configured in this business - Kizen's built-in objects (such as Contacts) plus any custom Standard or Pipeline objects. Use this to discover what kinds of records exist before querying them. Returns each object's api_name, display names, type, id, creation date, and record count. Supports search, sorting, and pagination. Only objects you have permission to see are returned. To fetch one object you already have an api_name or id for, use getCustomObject instead.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listObjectFields", + "display_name": "listObjectFields", + "description": "List the fields defined on one object, which is how you discover its schema before reading or writing records. Takes the object's api_name or id from listCustomObjects. Each row leads with `name` - the api_name to use in API payloads - alongside the display name, field type, category name, and the flags that govern writes (is_required, is_read_only, allows_nulls, allows_empty). Relation fields show their target as ' -> ', e.g. 'many_to_one -> receipts', with '(inverse)' appended on the mirror side of the relation. Dropdown-style fields list their valid `options`. Supports search on the display name or api_name, sorting, and filtering by category name. Not paginated - every field you have permission to see is returned. For a dropdown-style field, use listObjectFieldOptions (passing this row's `name`) to list its valid options.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "listObjectFieldOptions", + "display_name": "listObjectFieldOptions", + "description": "List the valid options for one dropdown-style field (checkboxes, choices, dropdown, dynamictags, radio, rating, status, yesnomaybe), as discovered via listObjectFields. Write back a row's `name` - or its `code` on rating fields, whose options are a fixed numeric scale that search and sort do not apply to. Not paginated. team_selector and timezone fields have no options here.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "getCustomObject", + "display_name": "getCustomObject", + "description": "Get one object's configuration by api_name or id - use this when you already have a reference to a specific object (from an automation, a dashboard, or another tool) and do not want to list everything. Returns its identity (api_name, display name, entity name, type), description, record count, ownership and configuration flags; for pipelines also its stage names and lost/disqualified reasons. For the object's fields use listObjectFields, for a field's options use listObjectFieldOptions, and to browse or search across many objects use listCustomObjects.", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + } + ] + } + ] +} diff --git a/tests/test_cli.py b/tests/test_cli.py index 1bdd38a..3a35025 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3243,3 +3243,80 @@ def no_apply(plan): "remove": [], } assert json.loads(result.stdout)["operations"][0]["kind"] == "team_member" + + +# --- mcp-servers ------------------------------------------------------------- + + +@respx.mock +@pytest.mark.parametrize( + ("args", "check"), + [ + ( + ["list"], + lambda d: [s["api_name"] for s in d] == ["readonly", "support_tools"], + ), + ( + ["get", "support_tools"], + lambda d: ( + d["tools"][0] + == {"group": "custom_objects", "name": "listCustomObjects"} + and "raw" not in d + ), + ), + (["get", "support_tools", "--raw"], lambda d: "sharing_stats" in d), + ( + ["tools"], + lambda d: [g["key"] for g in d] == ["custom_knowledge", "custom_objects"], + ), + ( + ["history", "support_tools"], + lambda d: d[-1]["type_name"] == "mcp_server_created", + ), + ], + ids=["list", "get", "get-raw", "tools", "history"], +) +def test_mcp_servers_reads_emit_json(args, check): + base = f"{FAKE_BASE_URL}/api/mcp-servers" + detail = load_fixture("mcp_servers/detail.json") + respx.get(base).respond(json=load_fixture("mcp_servers/list.json")) + respx.get(f"{base}/tools").respond(json=load_fixture("mcp_servers/tools.json")) + respx.get(f"{base}/{detail['id']}").respond(json=detail) + respx.get(f"{base}/{detail['id']}/events-history").respond( + json=load_fixture("mcp_servers/events_history.json") + ) + flags = [] if "--raw" in args else ["--json"] + result = runner.invoke(cli.app, ["mcp-servers", *args, *flags]) + assert result.exit_code == 0, result.output + assert check(json.loads(result.stdout)) + + +def test_mcp_servers_history_changed_column(monkeypatch): + from kizen_builder.tools import mcp_servers as mcp_tools + + events = load_fixture("mcp_servers/events_history.json")["results"] + monkeypatch.setattr(mcp_tools, "list_history", lambda ref: events) + result = runner.invoke( + cli.app, ["mcp-servers", "history", "support_tools", "-o", "csv"] + ) + assert result.exit_code == 0, result.output + rows = result.stdout.strip().splitlines() + assert rows[0] == "when,event,by,changed" + assert [r.split(",", 3)[3] for r in rows[1:]] == [ + "—", + "description", + "tool_names", + "—", + ] + assert rows[1].split(",")[2] == "user@example.com" + + +def test_mcp_servers_history_with_no_events(monkeypatch): + from kizen_builder.tools import mcp_servers as mcp_tools + + monkeypatch.setattr(mcp_tools, "list_history", lambda ref: []) + result = runner.invoke(cli.app, ["mcp-servers", "history", "support_tools"]) + assert result.exit_code == 0, result.output + assert "no events yet (Kizen records them a second or so after a change)" in plain( + result.stdout + ) diff --git a/tests/test_execute_dispatch.py b/tests/test_execute_dispatch.py index 6f3210b..7a132d2 100644 --- a/tests/test_execute_dispatch.py +++ b/tests/test_execute_dispatch.py @@ -355,6 +355,7 @@ def _saved_view(kind: str, base: str, served: set[str]) -> list[Any]: f"/api/dashboards/{P}/dashlet/{E}", parent=True, )[:2], + *_crud("mcp_server", "/api/mcp-servers", f"/api/mcp-servers/{E}", parent=False), _row( "layout", "update", diff --git a/tests/test_mcp_server_plans.py b/tests/test_mcp_server_plans.py new file mode 100644 index 0000000..a84551d --- /dev/null +++ b/tests/test_mcp_server_plans.py @@ -0,0 +1,454 @@ +"""MCP server planners: create/update/delete, the tool check and sharing diffs. + +Planners build their own client from config, so these go through respx +against `FAKE_BASE_URL`, served from the sanitized live captures in +`tests/fixtures/mcp_servers/`. +""" + +from __future__ import annotations + +import copy +import json + +import httpx +import pytest +import respx +from pydantic import ValidationError +from typer.testing import CliRunner + +import kizen_builder.cli as cli +from kizen_builder.api.client import KizenClient +from kizen_builder.config import load_env_config +from kizen_builder.tools import plans as plan_tools +from kizen_builder.tools.planners.mcp_servers import ( + DEFAULT_SHARING, + plan_create_mcp_server, + plan_delete_mcp_server, + plan_update_mcp_server, +) +from kizen_builder.tools.plans import PlanError, PlanOperation +from tests.conftest import FAKE_BASE_URL, load_fixture + +BASE = f"{FAKE_BASE_URL}/api/mcp-servers" +LIST = load_fixture("mcp_servers/list.json") +DETAIL = load_fixture("mcp_servers/detail.json") +READONLY = load_fixture("mcp_servers/detail_readonly.json") +TOOLS = load_fixture("mcp_servers/tools.json") +SID = DETAIL["id"] +SA_ID = DETAIL["sharing_settings"]["team_members"]["view"][0]["id"] + + +def _serve( + detail: dict | None = None, + servers: list | None = None, + readonly: dict | None = None, +) -> None: + """Serve the list, the catalogue, and each server's detail by id.""" + rows = servers if servers is not None else LIST["results"] + respx.get(BASE).respond(json={**LIST, "results": rows}) + respx.get(f"{BASE}/tools").respond(json=TOOLS) + respx.get(f"{BASE}/{SID}").respond(json=detail or DETAIL) + respx.get(f"{BASE}/{READONLY['id']}").respond(json=readonly or READONLY) + + +def _op(plan) -> PlanOperation: + assert len(plan.operations) == 1 + return plan.operations[0] + + +# --- create --------------------------------------------------------------- + + +@respx.mock +def test_create_payload_carries_only_write_keys_and_default_sharing(): + _serve() + op = _op( + plan_create_mcp_server( + { + "name": "Intake", + "api_name": "intake", + "tool_names": ["listCatalogs", "getDocument", "listCatalogs"], + } + ) + ) + assert op.action == "create" + assert op.kind == "mcp_server" + assert op.payload == { + "name": "Intake", + "api_name": "intake", + "tool_names": ["listCatalogs", "getDocument"], + "sharing_settings": DEFAULT_SHARING, + } + + +@respx.mock +def test_create_normalises_read_shape_sharing(): + _serve() + op = _op( + plan_create_mcp_server( + { + "name": "Intake", + "api_name": "intake", + "sharing_settings": DETAIL["sharing_settings"], + } + ) + ) + assert op.payload["sharing_settings"]["team_members"]["view"] == [SA_ID] + + +@respx.mock +def test_create_refuses_an_existing_api_name(): + _serve() + with pytest.raises(PlanError) as e: + plan_create_mcp_server({"name": "Again", "api_name": "support_tools"}) + assert str(e.value) == ( + f"MCP server 'support_tools' already exists (id {SID}). " + "Use `kizen mcp-servers update` instead." + ) + + +def test_create_needs_name_and_api_name(): + with pytest.raises(PlanError, match="needs name and api_name"): + plan_create_mcp_server({"description": "no names"}) + + +@respx.mock +def test_create_refuses_unknown_tools_naming_the_valid_ones(): + _serve() + with pytest.raises(PlanError) as e: + plan_create_mcp_server( + { + "name": "X", + "api_name": "intake", + "tool_names": ["nope", "getDocument", "nah"], + } + ) + assert str(e.value) == ( + "unknown MCP tool(s): nope, nah. Valid tools: getCustomObject, " + "getDocument, listCatalogs, listCustomObjects, listObjectFieldOptions, " + "listObjectFields, searchCatalog. List them with `kizen mcp-servers tools`." + ) + + +@respx.mock +def test_unknown_tool_sends_no_request(tmp_path): + _serve() + post = respx.post(BASE).respond(201, json={}) + spec = tmp_path / "spec.json" + spec.write_text( + json.dumps({"name": "X", "api_name": "intake", "tool_names": ["nope"]}) + ) + result = CliRunner().invoke( + cli.app, ["mcp-servers", "create", "--spec-file", str(spec), "-y"] + ) + assert result.exit_code == 1 + assert "unknown MCP tool(s): nope" in result.stderr + assert post.call_count == 0 + + +@respx.mock +def test_create_as_default_names_the_current_default(): + _serve() + op = _op( + plan_create_mcp_server( + {"name": "X", "api_name": "intake", "is_default_for_business": True} + ) + ) + assert op.payload["is_default_for_business"] is True + assert op.preview["business_default"] == ( + "replaces 'readonly', which stops being the default. To restore it, run " + '`kizen mcp-servers update readonly` with {"is_default_for_business": true}' + ) + + +# --- update --------------------------------------------------------------- + + +@respx.mock +def test_get_raw_output_resent_unchanged_plans_skip(): + _serve() + op = _op(plan_update_mcp_server("support_tools", copy.deepcopy(DETAIL))) + assert op.action == "skip" + assert op.payload == {} + assert op.preview["ignored_read_only_keys"] == [ + "id", + "tools", + "is_system_managed", + "endpoint_url", + "created", + "updated", + "owner", + "employee_access", + "sharing_stats", + ] + + +@respx.mock +def test_reordered_tools_plan_skip(): + _serve() + op = _op( + plan_update_mcp_server( + SID, {"tool_names": list(reversed(DETAIL["tool_names"]))} + ) + ) + assert op.action == "skip" + + +@respx.mock +def test_read_shape_sharing_with_reordered_grants_plans_skip(): + detail = copy.deepcopy(DETAIL) + detail["sharing_settings"]["roles"]["view"] = [ + {"id": "00000000-0000-4000-8000-0000000000a1", "display_name": "A"}, + {"id": "00000000-0000-4000-8000-0000000000a2", "display_name": "B"}, + ] + _serve(detail) + sharing = copy.deepcopy(detail["sharing_settings"]) + sharing["roles"]["view"] = [ + "00000000-0000-4000-8000-0000000000a2", + "00000000-0000-4000-8000-0000000000a1", + ] + op = _op(plan_update_mcp_server(SID, {"sharing_settings": sharing})) + assert op.action == "skip" + + +@respx.mock +def test_changed_sharing_is_sent_whole(): + _serve() + sharing = copy.deepcopy(DETAIL["sharing_settings"]) + sharing["all_team_members"] = 1 + sharing["roles"]["view"] = ["00000000-0000-4000-8000-0000000000a1"] + sharing["team_members"]["view"] = [] + op = _op(plan_update_mcp_server(SID, {"sharing_settings": sharing})) + assert op.action == "update" + assert op.preview["diff"]["sharing_settings"] == ( + "all_team_members 0 → 1, +roles.view:00000000-0000-4000-8000-0000000000a1, " + "-team_members.view:Sample Service Account (the whole block is sent)" + ) + assert op.payload == { + "sharing_settings": { + "private": False, + "all_team_members": 1, + "roles": { + "view": ["00000000-0000-4000-8000-0000000000a1"], + "edit": [], + "admin": [], + }, + "team_members": {"view": [], "edit": [], "admin": []}, + } + } + + +@respx.mock +def test_update_diffs_only_the_keys_the_spec_sets(): + _serve() + op = _op( + plan_update_mcp_server( + SID, + {"description": None, "tool_names": ["listCustomObjects", "getDocument"]}, + ) + ) + assert op.existing_uuid == SID + assert op.payload == { + "description": None, + "tool_names": ["listCustomObjects", "getDocument"], + } + assert op.preview["diff"]["tool_names"] == ( + "+getDocument, -listObjectFields, -searchCatalog" + ) + + +@respx.mock +def test_update_checks_tools_against_the_catalogue(): + _serve() + with pytest.raises(PlanError, match=r"unknown MCP tool\(s\): bogus\."): + plan_update_mcp_server(SID, {"tool_names": ["bogus"]}) + + +@respx.mock +def test_update_refuses_an_api_name_change(): + _serve() + with pytest.raises(PlanError) as e: + plan_update_mcp_server(SID, {"api_name": "renamed"}) + assert str(e.value) == ( + "api_name can't change after creation; create a new server instead." + ) + + +@respx.mock +@pytest.mark.parametrize( + "plan", + [ + lambda: plan_update_mcp_server("readonly", {"is_enabled": False}), + lambda: plan_update_mcp_server( + "readonly", {"is_default_for_business": True, "name": "Read Only"} + ), + lambda: plan_update_mcp_server("readonly", copy.deepcopy(READONLY)), + lambda: plan_delete_mcp_server("readonly"), + ], + ids=["update", "default-plus-extra-key", "get-raw", "delete"], +) +def test_system_server_is_refused(plan): + _serve(readonly={**READONLY, "is_default_for_business": False}) + with pytest.raises(PlanError) as e: + plan() + assert str(e.value) == ( + "'readonly' is managed by Kizen and can't be changed or deleted from the " + 'CLI. The one exception: an update that sets only {"is_default_for_business": ' + "true}." + ) + + +@respx.mock +def test_system_server_can_be_made_the_default_again(): + readonly = {**READONLY, "is_default_for_business": False} + servers = [ + {**LIST["results"][0], "is_default_for_business": False}, + {**LIST["results"][1], "is_default_for_business": True}, + ] + _serve(servers=servers, readonly=readonly) + op = _op(plan_update_mcp_server("readonly", {"is_default_for_business": True})) + assert op.action == "update" + assert op.existing_uuid == READONLY["id"] + assert op.payload == {"is_default_for_business": True} + assert op.preview["business_default"].startswith( + "replaces 'support_tools', which stops being the default." + ) + + +@respx.mock +def test_system_server_already_default_plans_skip(): + _serve() + op = _op(plan_update_mcp_server("readonly", {"is_default_for_business": True})) + assert op.action == "skip" + + +@respx.mock +def test_unsetting_the_current_default_is_refused(): + _serve({**DETAIL, "is_default_for_business": True}) + with pytest.raises(PlanError) as e: + plan_update_mcp_server(SID, {"is_default_for_business": False}) + assert str(e.value) == ( + "'support_tools' is the business default and Kizen keeps one. To change " + "it, set another server as the default instead " + '({"is_default_for_business": true} on that server).' + ) + + +@respx.mock +def test_unsetting_a_non_default_is_a_skip(): + _serve() + op = _op(plan_update_mcp_server(SID, {"is_default_for_business": False})) + assert op.action == "skip" + + +@respx.mock +def test_unknown_server_lists_api_names(): + _serve() + with pytest.raises(PlanError, match="available: readonly, support_tools"): + plan_update_mcp_server("Support Tools", {"is_enabled": False}) + + +# --- delete --------------------------------------------------------------- + + +@respx.mock +def test_delete_preview(): + _serve() + op = _op(plan_delete_mcp_server("support_tools")) + assert op.action == "delete" + assert op.existing_uuid == SID + assert {k: op.preview[k] for k in ("name", "api_name", "id", "tool_count")} == { + "name": "Support Tools", + "api_name": "support_tools", + "id": SID, + "tool_count": 3, + } + assert "is_business_default" not in op.preview + + +@respx.mock +def test_delete_preview_flags_the_business_default(): + _serve({**DETAIL, "is_default_for_business": True}) + op = _op(plan_delete_mcp_server(SID)) + assert op.preview["is_business_default"] is True + + +# --- spec validation ------------------------------------------------------ + + +def test_unknown_sharing_key_names_the_team_members_route(): + sharing = {**DEFAULT_SHARING, "service_accounts": {"view": [SA_ID]}} + with pytest.raises(ValidationError) as e: + plan_create_mcp_server( + {"name": "X", "api_name": "intake", "sharing_settings": sharing} + ) + assert e.value.errors()[0]["msg"] == ( + "unknown key(s) service_accounts. sharing_settings takes private, all_team_members, roles and team_members. " + "Service accounts are shared as team members: put the account's id in " + "team_members." + ) + + +@pytest.mark.parametrize( + ("spec", "loc"), + [ + ({"name": "X", "api_name": "Has-Caps"}, "api_name"), + ({"name": "X", "api_name": "9lives"}, "api_name"), + ({"name": "X", "api_name": "intake", "colour": "red"}, "colour"), + ( + {"name": "X", "api_name": "intake", "sharing_settings": {"private": True}}, + "sharing_settings.all_team_members", + ), + ( + { + "name": "X", + "api_name": "intake", + "sharing_settings": { + **DEFAULT_SHARING, + "roles": {"view": [{"display_name": "No id"}]}, + }, + }, + "sharing_settings.roles.view.0.GrantRef.id", + ), + ({"name": None}, "spec"), + ({"is_enabled": None}, "spec"), + ], + ids=[ + "caps", + "leading-digit", + "unknown-key", + "partial-sharing", + "grant-without-id", + "null-name", + "null-is-enabled", + ], +) +def test_bad_specs_fail_validation(spec, loc): + with pytest.raises(ValidationError) as e: + plan_create_mcp_server(spec) + errors = e.value.errors() + locs = {".".join(str(p) for p in err["loc"]) or "spec" for err in errors} + assert loc in locs + + +# --- execute -------------------------------------------------------------- + + +def test_execute_refuses_an_unsupported_action(): + op = PlanOperation( + action="duplicate", kind="mcp_server", key="x", existing_uuid=SID + ) + with KizenClient(load_env_config()) as c, pytest.raises(PlanError) as e: + plan_tools._execute(c, op) + assert str(e.value) == "unsupported mcp_server action 'duplicate'" + + +@respx.mock +def test_execute_create_reaches_the_api(): + route = respx.post(BASE).mock(return_value=httpx.Response(201, json={"id": SID})) + op = PlanOperation( + action="create", kind="mcp_server", key="x", payload={"name": "X"} + ) + with KizenClient(load_env_config()) as c: + assert plan_tools._execute(c, op) == {"id": SID} + assert route.call_count == 1 diff --git a/tests/test_mcp_servers_api.py b/tests/test_mcp_servers_api.py new file mode 100644 index 0000000..162178f --- /dev/null +++ b/tests/test_mcp_servers_api.py @@ -0,0 +1,91 @@ +"""api/mcp_servers.py: each wrapper calls exactly one method and path.""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from kizen_builder.api import mcp_servers as mcp_api +from kizen_builder.api.client import KizenClient +from tests.conftest import FAKE_BASE_URL, load_fixture + +BASE = f"{FAKE_BASE_URL}/api/mcp-servers" +SID = "00000000-0000-4000-8000-0000000000e1" + + +@pytest.fixture +def client(env_config): + with KizenClient(env_config) as c: + yield c + + +@respx.mock +def test_list_follows_pages(client): + page = load_fixture("mcp_servers/list.json") + route = respx.get(BASE).mock( + side_effect=[ + httpx.Response(200, json={**page, "next": f"{BASE}?page=2"}), + httpx.Response(200, json={"results": [], "next": None}), + ] + ) + rows = mcp_api.list_mcp_servers(client) + assert route.call_count == 2 + assert [r["api_name"] for r in rows] == ["readonly", "support_tools"] + + +@respx.mock +def test_get(client): + route = respx.get(f"{BASE}/{SID}").respond(json={"id": SID}) + assert mcp_api.get_mcp_server(client, SID) == {"id": SID} + assert route.call_count == 1 + + +@respx.mock +def test_create_posts_without_trailing_slash(client): + route = respx.post(BASE).respond(201, json={"id": SID}) + mcp_api.create_mcp_server(client, {"name": "X", "api_name": "x"}) + assert json.loads(route.calls.last.request.content) == { + "name": "X", + "api_name": "x", + } + + +@respx.mock +def test_update_patches(client): + route = respx.patch(f"{BASE}/{SID}").respond(json={"id": SID}) + mcp_api.update_mcp_server(client, SID, {"is_enabled": False}) + assert json.loads(route.calls.last.request.content) == {"is_enabled": False} + + +@respx.mock +def test_delete(client): + route = respx.delete(f"{BASE}/{SID}").respond(204) + assert mcp_api.delete_mcp_server(client, SID) is None + assert route.call_count == 1 + + +@respx.mock +def test_tool_catalog_returns_the_dict(client): + tools = load_fixture("mcp_servers/tools.json") + respx.get(f"{BASE}/tools").respond(json=tools) + assert mcp_api.get_tool_catalog(client) == tools + + +@respx.mock +def test_events_history_is_paginated(client): + history = load_fixture("mcp_servers/events_history.json") + route = respx.get(f"{BASE}/{SID}/events-history").respond(json=history) + assert mcp_api.list_events_history(client, SID) == history["results"] + assert route.call_count == 1 + + +def test_step_options_rows_carry_server_keys(): + """step-options (wrapped with the automations API) returns a subset of the + server read shape plus `deleted`, so a step's `mcp_server` can be matched + to a server by id or api_name.""" + server_keys = set(load_fixture("mcp_servers/list.json")["results"][0]) + for row in load_fixture("mcp_servers/step_options.json"): + assert set(row) - {"deleted"} <= server_keys