Skip to content

2026 11 12 plugin agents

kubed-io[bot] edited this page Nov 13, 2026 · 1 revision

One plugin/ folder, and the agents extension: Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use skill://superpowers/subagent-driven-development/SKILL.md (recommended) or skill://superpowers/executing-plans/SKILL.md to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: skills/ and prompts/ move into one plugin/ folder that ships whole in the wheel, .claude-plugin/plugin.json included. The server declares io.modelcontextprotocol/agents and answers agents/list and agents/get from plugin/agents/, which is missing for now, so the list is empty.

Architecture: One package-dir mapping, kubed.selenium_flow.mcp.plugin = "plugin", replaces the two. A new module, mcp/frontmatter.py, holds the header parse and the JSON check that skill_extension.py uses today, and both extensions import it. mcp/agents.py loads agent files into cards and details, and serves them through a ServerExtension and an AdvertiseAgents middleware. server.py registers it whenever the skill is served.

Tech Stack: Python 3.11+, FastMCP 4 (ServerExtension, MethodBinding, Middleware), mcp 2 (MCPError), mcp_types, PyYAML, setuptools package-data. No ui/ change.

Spec: docs/superpowers/specs/2026-11-12-plugin-agents-design.md. Dr K approved the draft on #119 (Rulings 1–9). It was finished this round, not replaced. Checked against the code on 11-13, it gained Checked against the code:

  • the dot-folder question is settled (setuptools expands with the stdlib glob, which skips dot-names);
  • the declared value comes from ServerExtension.settings();
  • agents/get carries no capabilities;
  • the README is not touched;
  • both package-data tests go;
  • the shared parse is its own module;
  • there are two middlewares, not one.

Design

The spec's The design is what this builds. The decisions it leaves to the plan:

  • agents.load returns list[Agent], in name order, where

    @dataclass(frozen=True)
    class Agent:
        name: str
        card: dict    # {"name", "description", "capabilities"}: what agents/list sends
        detail: dict  # {"agent", ["instructions"], ["modelRoutingHint"], "tools", "_meta"}: what agents/get sends

    A key in brackets is absent when it does not apply. agents/list answers _result(agents=[deepcopy(a.card) for a in agents]). agents/get answers _result(**deepcopy(a.detail)), so the detail's keys sit at the top level of the result beside the three freshness fields.

  • agents.py imports nothing from skill_extension.py, so either can be deleted alone. It has its own three-line _result, with a comment saying it is the same as the Skills extension's.

  • A file's reason for being skipped is one of five sentences, logged as log.warning("agent file %s is skipped: %s", path.name, reason):

    • no YAML frontmatter
    • its frontmatter is not a non-empty mapping
    • its frontmatter is not representable as JSON
    • its name must be a non-empty string with no whitespace, control or invisible characters, "/" or "@"
    • its description must be a non-empty string

    A duplicate logs agent file %s is skipped: %s is already served by %s, with the name and the first file's name. A read error, OSError or UnicodeDecodeError, logs agent file %s is skipped: %s with the exception.

  • The name rule. The name must be a str and non-empty. It is refused if any character ch has ch.isspace(), or a unicodedata.category(ch) in {"Cc", "Cf", "Co", "Cs", "Cn"}, or is in "/@".

  • Where the declaration's value lives. AGENTS_SETTINGS = {"listChanged": False} is a module constant. AgentsExtension.settings() and AdvertiseAgents both return a copy of it.

Global Constraints

  • Python 3.11 is the floor: nothing 3.12+ (no Path.walk, no Path.full_match).
  • The skill's URIs, the prompts, skills/list, skills/get, _manifest and the mirror tools answer exactly as before. tests/test_skill.py, tests/test_prompts.py and tests/test_skills_extension.py pass unchanged, except for the two package-data tests Task 1 deletes.
  • Wire values, verbatim from the spec:
    • EXTENSION_ID = "io.modelcontextprotocol/agents", declared as {"listChanged": False}.
    • The errors are Invalid agents cursor, and Agent not found with data={"reason": "agent_not_found"}, both INVALID_PARAMS.
    • The _meta key is "io.kubed/agent", and there is no io.kubed.selenium-flow/agent.
    • The routing hints are opus → {"role": "large", "vision": False}, sonnet → medium, haiku → small.
  • plugin.json has exactly name and description: no version, no commands (Rulings 3, 6).
  • No README change (Ruling 9). No integration flow: tests/integration/ does not change (AGENTS.md, less is more).
  • Earlier specs and plans, docs/saga/ and the shipped CHANGELOG sections are history and are not edited.
  • Never edit .github/. Dr K pushes image.yml's plugin/**, package.yml's comments and copilot-instructions.md himself (spec, .github/: Dr K's edits). Until he does, tests/test_packaging.py::test_everything_in_the_wheel_rebuilds_the_image[plugin] fails, and it is the only test that may.
  • The wiki is the docs/ submodule: docs/Configuration.md changes as a commit inside it, then the pointer here.
  • New and changed tests use drk and desk when they need a person's or a workspace's name.
  • The checks, as CI runs them:
    • Python (test.yml): ruff check . and pytest. The integration tests need a Grid and run in CI.
    • OpenAPI (lint.yml): python scripts/generate_openapi.py succeeds.
    • Changelog (pr.yml): a new line under ## [Unreleased].

Review Focus

  1. A SKILL.md that no longer conforms to the Skills extension. skill_extension.register returns False and adds no AdvertiseSkills, but the skill is still served. A person expects the agents extension to be declared on its own in a legacy initialize. Task 3 tests it on a server with only the agents registered.
  2. An agent file that is not UTF-8, such as one saved from a Windows editor in cp1252. A person expects that file to be skipped with a log line, and the server to boot and list the rest. Task 2 tests it.
  3. agents/get with no name, or name: "". A person expects INVALID_PARAMS, not an internal error. Task 3 tests both.
  4. skills: selenium-flow written as a bare string, not a list. A person expects {}, not a map of its letters and not a crash. Task 2 tests it.
  5. An agent file with CRLF line endings. A person expects it to load, with its body's lines joined by \n. Task 2 tests it.

Task 1: One plugin/ folder, and a test that proves it ships

Files:

  • Move (with git mv): skills/selenium-flow/ → plugin/skills/selenium-flow/, and prompts/ → plugin/prompts/
  • Create: plugin/.claude-plugin/plugin.json
  • Modify: pyproject.toml:127-175 (packages, package-dir, package-data and their comments)
  • Modify: kubed/selenium_flow/mcp/skill.py: docstring lines 9 and 21, SKILLS_DIR (42), skill_path() (47–59)
  • Modify: kubed/selenium_flow/mcp/prompts.py: PROMPTS_DIR (37), prompts_path() (68–80)
  • Modify: kubed/selenium_flow/core/js.py:11
  • Modify: AGENTS.md, The embedded skill (1043–1047, 1067–1071); CONTRIBUTING.md:63, :127-130
  • Test: tests/test_packaging.py (DATA_DIRS 347–351, and new tests after 380); tests/test_skill.py (delete 86–111, and the fnmatch and tomllib imports); tests/test_prompts.py (delete 91–105)

Interfaces:

  • Produces:

    • skill.PLUGIN_DIR == "plugin" and prompts.PLUGIN_DIR == "plugin".
    • In a checkout, skill.skill_path() == REPO / "plugin" / "skills" / "selenium-flow" and prompts.prompts_path() == REPO / "plugin" / "prompts". Installed, each is the same path under Path(__file__).parent.
    • The package-data key is "kubed.selenium_flow.mcp.plugin", with patterns ["*", "**/*", ".claude-plugin/*"].
  • Step 1: Write the failing tests in tests/test_packaging.py. DATA_DIRS becomes:

DATA_DIRS = {
    "kubed/selenium_flow/core/js.py": "js",
    "kubed/selenium_flow/mcp/skill.py": "plugin",
    "kubed/selenium_flow/mcp/prompts.py": "plugin",
}

Add after test_a_data_directory_is_packaged_beside_the_module_that_reads_it, importing glob, json and os:

PLUGIN = REPO / "plugin"
PLUGIN_PACKAGE = "kubed.selenium_flow.mcp.plugin"
MANIFEST = ".claude-plugin/plugin.json"


def plugin_patterns() -> list[str]:
    return tomllib.loads(PYPROJECT.read_text())["tool"]["setuptools"]["package-data"][PLUGIN_PACKAGE]


def collected(patterns) -> set[str]:
    """What setuptools' build_py collects: the stdlib glob, recursive, from the
    mapped root, files only. Its * and ** skip a name that starts with a dot."""
    ...  # glob.glob(p, root_dir=PLUGIN, recursive=True) per pattern, kept when a file, as posix


def plugin_files() -> set[str]:
    """Every file under plugin/, dot-folders included (os.walk hides nothing)."""
    ...


def test_every_plugin_file_is_matched_by_package_data():  # Ruling 4
    files = plugin_files()
    assert MANIFEST in files
    assert "skills/selenium-flow/SKILL.md" in files
    assert "prompts/build_flow.md" in files
    missing = files - collected(plugin_patterns())
    assert not missing, f"no package-data pattern ships {sorted(missing)}"


def test_the_plugin_manifest_ships_by_its_own_pattern():
    """Proves the test above can fail: **/* alone leaves the dot-folder out."""
    without = [p for p in plugin_patterns() if p != ".claude-plugin/*"]
    assert len(without) == len(plugin_patterns()) - 1
    assert MANIFEST not in collected(without)


def test_the_plugin_manifest_names_the_skill_and_declares_nothing_else():  # Rulings 3, 6
    manifest = json.loads((PLUGIN / MANIFEST).read_text())
    assert set(manifest) == {"name", "description"}
    assert manifest["name"] == "selenium-flow"
    assert manifest["description"].strip()

glob.glob takes # noqa: PTH207, and the comment says why: pathlib's glob matches dot-names, while setuptools' glob does not.

In tests/test_skill.py, delete test_every_skill_file_is_covered_by_package_data and the imports only it used. In tests/test_prompts.py, delete test_every_prompt_file_is_covered_by_package_data.

  • Step 2: Run them to see them fail

Run: pytest tests/test_packaging.py -v Expected: FAIL. The three new tests error (there is no plugin/, and no package-data key), and test_a_data_directory_is_packaged_beside_the_module_that_reads_it fails for skill.py and prompts.py.

  • Step 3: Move and map

    • Move the two folders with git mv, so history follows them.
    • Write plugin.json as {"name": "selenium-flow", "description": "Drive a real browser on Selenium Grid: the skill, prompts and agents that ship inside the selenium-flow MCP server."}, with a trailing newline.
    • In pyproject.toml:
      • packages replaces …mcp.prompts and …mcp.skills with "kubed.selenium_flow.mcp.plugin".
      • package-dir replaces the two entries with "kubed.selenium_flow.mcp.plugin" = "plugin".
      • package-data replaces them with "kubed.selenium_flow.mcp.plugin" = ["*", "**/*", ".claude-plugin/*"].
    • The three old comments become two:
      • over the mapping: plugin/ is the skill, the prompts and the agents this server serves, laid out as a Claude-format plugin (spec 2026-11-12-plugin-agents). It reads as documentation at the repo root and has to install inside the package, or a wheel serves none of it and says nothing: built from ./plugin, installed as kubed/selenium_flow/mcp/plugin/.
      • over the patterns: Everything under it, not just *.md, and .claude-plugin/* spelt out: setuptools expands these with the stdlib glob, whose * and ** skip a name starting with a dot, so **/* alone would leave plugin.json out. A skill's directory name IS its name: SkillProvider publishes skill://<folder>/SKILL.md.
    • skill.py: add PLUGIN_DIR = "plugin" above SKILLS_DIR = "skills". Both lookups go through PLUGIN_DIR / SKILLS_DIR / SKILL_NAME. The docstrings name plugin/skills/selenium-flow/ and plugin/.
    • prompts.py: the same, with PLUGIN_DIR / PROMPTS_DIR. The prompts_path docstring says plugin/ is mapped, and does not mention skills/.
    • js.py:11: "the same way it maps plugin/".
  • Step 4: Fix the words

    • AGENTS.md:1043: "plugin/skills/selenium-flow/ sits in plugin/ at the repo root, which is mapped into the package by…". The rest of the paragraph stays.
    • AGENTS.md:1067-1071: Package data is easy to lose: [tool.setuptools.package-data] covers plugin/**/*, not just *.md, because a file of another type would otherwise be absent from the wheel with no error at build or import time, and it spells out .claude-plugin/*, because setuptools expands the patterns with the stdlib glob, whose ** skips a dot-folder. test_every_plugin_file_is_matched_by_package_data expands them the same way and fails if a file is ever added that no pattern ships.
    • CONTRIBUTING.md:63: | `plugin/` | the embedded Agent Skill (`skills/selenium-flow/`), the prompts and the agents, mapped into the package at build time |.
    • CONTRIBUTING.md:127: "plugin/ sits at the repo root and is mapped into the package by [tool.setuptools.package-dir], so it reads as documentation but installs inside the wheel. The skill is plugin/skills/selenium-flow/. The directory name is the skill name…"
  • Step 5: Run the suite

Run: pytest && ruff check . Expected: PASS, except tests/test_packaging.py::test_everything_in_the_wheel_rebuilds_the_image[plugin], which fails until Dr K's image.yml edit is on the branch. If his push has landed, it passes too.

Then run: grep -rnE "(^|[^a-z_./])(skills|prompts)/" --exclude-dir=.git --exclude-dir=docs --exclude-dir=node_modules --exclude=CHANGELOG.md . Expected: only the method names skills/list and skills/get, and the .github/ lines that are Dr K's.

  • Step 6: Check one real wheel, by hand. This is not a test.

Run: rm -rf /tmp/wheel && pip wheel --no-deps -w /tmp/wheel . && python -m zipfile -l /tmp/wheel/kubed_selenium_flow-*.whl | grep -E 'mcp/(plugin|skills|prompts)/' Expected:

  • kubed/selenium_flow/mcp/plugin/.claude-plugin/plugin.json;
  • …/plugin/skills/selenium-flow/SKILL.md and its eight references/*.md;
  • …/plugin/prompts/build_flow.md and repair_flow.md;
  • nothing under mcp/skills/ or mcp/prompts/.

If the runner has no network for build isolation, say so in the PR and leave it to package.yml.

  • Step 7: Commit refactor: the skill and the prompts move into one plugin/ folder.

Task 2: Agent files load into cards and details

Files:

  • Create: kubed/selenium_flow/mcp/frontmatter.py
  • Create: kubed/selenium_flow/mcp/agents.py (loading only; Task 3 adds the extension)
  • Modify: kubed/selenium_flow/mcp/skill_extension.py: _frontmatter (59–70) goes, and _problem (73–93) and entry_for (96–107) use the new module
  • Test: tests/test_agents_extension.py (new), tests/test_packaging.py (DATA_DIRS)

Interfaces:

  • Produces:

    • frontmatter.split(text: str) -> tuple[object, str] | None: the header as yaml.safe_load parses it, and the body, which is every line after the closing --- joined by "\n". It is None when the first line is not ---, the header is unterminated, or the YAML fails. A --- inside a value is not the end (today's rule: a line that is --- after rstrip).
    • frontmatter.survives_json(value: object) -> bool: json.loads(json.dumps(value, allow_nan=False)) == value, False on TypeError/ValueError.
    • In agents:
      • EXTENSION_ID, PLUGIN_DIR = "plugin", AGENTS_DIR = "agents", META_KEY = "io.kubed/agent";
      • Agent (see Design);
      • agents_path() -> Path;
      • routing_hint(model: object) -> dict | None;
      • load(base: Path | None = None, skills: Collection[str] = ()) -> list[Agent], where base=None means agents_path(), looked up when called so that a test can monkeypatch it.
  • Step 1: Write the failing tests in tests/test_agents_extension.py (pytestmark = pytest.mark.unit). Use a helper agent_file(folder, file, front, body="# Explore\n\nRead the skill first.\n") -> Path, which writes ---\n{front}\n---\n{body}.

FRONT = ("name: explorer\ndescription: |\n  Explores\n  a site.\nmodel: sonnet\n"
         "tools: [navigate, extract]\ncapabilities: [browse]\nskills: [selenium-flow, nope]")

def test_a_file_becomes_a_card_and_a_detail(tmp_path):
    agent_file(tmp_path, "explorer.md", FRONT)
    [agent] = agents.load(tmp_path, skills={"selenium-flow"})
    assert agent.name == "explorer"
    assert agent.card == {"name": "explorer", "description": "Explores a site.", "capabilities": ["browse"]}
    assert agent.detail == {
        "agent": "explorer",
        "instructions": "# Explore\n\nRead the skill first.",
        "modelRoutingHint": {"role": "medium", "vision": False},
        "tools": [],
        "_meta": {"io.kubed/agent": {
            "frontmatter": yaml.safe_load(FRONT),
            "skills": {"selenium-flow": "skill://selenium-flow/SKILL.md"},
        }},
    }

Then, each against files written into tmp_path:

  • test_the_routing_hint, parametrised over ("opus", "large"), ("claude-opus-4-1", "large"), ("Sonnet", "medium"), ("claude-sonnet-4-5", "medium"), ("haiku", "small"), ("inherit", None), ("gpt-5", None), (None, None), (4, None). It asserts routing_hint(value) == ({"role": role, "vision": False} if role else None). A file with no model has no modelRoutingHint key.

  • test_capabilities_default_to_none: with no capabilities, and with capabilities: [1, x], card["capabilities"] == [].

  • test_a_blank_body_has_no_instructions: body "\n \n" → "instructions" not in detail.

  • test_skills_resolve_only_to_served_skills, parametrised over "skills: selenium-flow" (Review Focus 4), "skills: [1, selenium-flow]", "skills: {selenium-flow: x}" and no skills at all. Each gives _meta[META_KEY]["skills"] == {} with skills={"selenium-flow"}. A list [selenium-flow] loaded with the default skills=() gives {} too.

  • test_the_cards_are_in_name_order: a.md names zeta and b.md names alpha → [a.name for a in load(...)] == ["alpha", "zeta"].

  • test_a_nonconforming_file_is_skipped_and_logged, parametrised over whole file texts, beside a good explorer.md:

    • "# no header\n";
    • "---\n- a\n- list\n---\n";
    • "---\n{}\n---\n";
    • "---\nname: [unclosed\n---\n";
    • names "ex plorer", "a/b", "a@b", "ex​plorer" (written as the real character) and 123;
    • descriptions '', ' ', and none;
    • when: 2026-11-12;
    • metadata: {1: one, '1': also}.

    Each gives [a.name for a in load(...)] == ["explorer"], and caplog.text contains "is skipped" and the bad file's name.

  • test_a_file_that_is_not_utf8_is_skipped (Review Focus 2): bad.md is written as bytes, b"---\nname: caf\xe9\ndescription: x\n---\n", beside a good file. It is skipped, logged, and the good one loads.

  • test_crlf_line_endings_load (Review Focus 5): the text is written with newline="\r\n". It loads, with instructions == "# Explore\n\nRead the skill first.".

  • test_the_first_of_two_files_with_one_name_wins: a.md and b.md both name explorer, with descriptions First. and Second. → one agent, card["description"] == "First.", and the log names b.md and a.md.

  • test_only_markdown_at_the_top_level_is_read: drafts/x.md and notes.txt give [].

  • test_a_missing_folder_is_no_agents: load(tmp_path / "missing") == [].

  • test_split_returns_the_header_and_the_body: split("---\na: 1\n---\nbody\n") == ({"a": 1}, "body"). split("no header"), split("---\na: 1\n") and split("---\na: [\n---\n") are each None.

In tests/test_packaging.py, DATA_DIRS gains "kubed/selenium_flow/mcp/agents.py": "plugin".

  • Step 2: Run them to see them fail

Run: pytest tests/test_agents_extension.py tests/test_packaging.py -v Expected: FAIL. kubed.selenium_flow.mcp.agents cannot be imported, and the DATA_DIRS row names a missing module.

  • Step 3: Implement frontmatter.py, with a docstring saying both extensions import it so that either can be deleted alone. Then point skill_extension.py at it:

    • _frontmatter goes;
    • entry_for reads parsed = frontmatter.split(text) and metadata = parsed[0] if parsed else None;
    • _problem's JSON check becomes if not frontmatter.survives_json(metadata), keeping its comment.
  • Step 4: Run the Skills extension's tests unchanged

Run: pytest tests/test_skills_extension.py tests/test_skill.py -v Expected: PASS, test_a_dash_run_inside_a_value_is_not_the_header_end included.

  • Step 5: Implement the loading in agents.py, as Design and the Interfaces say. The module docstring says:

    • what the extension is: mcp-kb's draft of agents-wg proposal 0000;
    • that the server serves definitions and the host runs them;
    • that agents are package data, built once with the server;
    • that io.kubed/agent is the shape both servers share (spec 2026-11-12-plugin-agents, Ruling 2).

    agents_path() has the same shape and comment as skill_path(). The description is collapsed with " ".join(text.split()), and the routing hint's words come from re.split(r"[^a-z0-9]+", model.lower()).

  • Step 6: Run the suite

Run: pytest && ruff check . Expected: PASS, except Task 1's [plugin] image test if Dr K's edit is not on the branch yet.

  • Step 7: Commit refactor: one frontmatter parse for skills and agents, holding frontmatter.py and skill_extension.py. Then commit feat: agent files load into agents/list cards and agents/get details.

Task 3: agents/list and agents/get, declared whenever the skill is served

Files:

  • Modify: kubed/selenium_flow/mcp/agents.py: GetAgentParams, AgentsExtension, AdvertiseAgents, register
  • Modify: kubed/selenium_flow/server.py: the .mcp import list (37–49), and :303-304
  • Modify: kubed/selenium_flow/config.py:388; regenerate docs/Configuration.md
  • Modify: AGENTS.md: a new paragraph after the Skills extension's (after 1084); CHANGELOG.md ## [Unreleased]
  • Test: tests/test_agents_extension.py

Interfaces:

  • Consumes: Agent, load, EXTENSION_ID, META_KEY (Task 2).

  • Produces:

    • GetAgentParams(RequestParams) with name: str = Field(min_length=1);
    • AgentsExtension(ServerExtension), built as AgentsExtension(agents: Sequence[Agent]), with:
      • identifier = EXTENSION_ID;
      • settings() -> dict, which returns {"listChanged": False};
      • list_agents(ctx, params: PaginatedRequestParams) -> dict;
      • get_agent(ctx, params: GetAgentParams) -> dict;
    • AdvertiseAgents(Middleware);
    • register(mcp, agents: Sequence[Agent]) -> None.
  • Step 1: Write the failing tests, appended to tests/test_agents_extension.py.

    • Reuse test_skills_extension.py's ExtensionParams, request and header shapes, written out here, not imported.
    • Add a helper serving(monkeypatch, folder, **settings) -> SeleniumMCP. It monkeypatches agents.agents_path to return folder, then builds SeleniumMCP(Settings(grid={"url": "http://grid.invalid:4444"}, auth={"token": "t"}, **settings)).
    • A pytest fixture folder(tmp_path) writes and returns a folder holding two files:
      • zeta.md, which is FRONT with name: zeta in place of name: explorer, and Task 2's body;
      • alpha.md, with name: alpha, description: First., a blank body and no model.
    • FRESH = {"resultType": "complete", "ttlMs": 0, "cacheScope": "private"}.
@pytest.mark.parametrize("mode", ["auto", "legacy"])
async def test_the_extension_is_declared_beside_skills(monkeypatch, folder, mode):
    async with Client(serving(monkeypatch, folder).mcp, mode=mode) as client:
        extensions = client.session.server_capabilities.extensions
    assert extensions[EXTENSION_ID] == {"listChanged": False}
    assert extensions[skill_extension.EXTENSION_ID] == {}

async def test_the_listing_is_the_cards_in_name_order(monkeypatch, folder):
    async with Client(serving(monkeypatch, folder).mcp) as client:
        listing = await request(client, "agents/list")
    assert listing == {**FRESH, "agents": [
        {"name": "alpha", "description": "First.", "capabilities": []},
        {"name": "zeta", "description": "Explores a site.", "capabilities": ["browse"]},
    ]}

async def test_get_returns_the_detail(monkeypatch, folder):
    async with Client(serving(monkeypatch, folder).mcp) as client:
        got = await request(client, "agents/get", name="zeta")
        alpha = await request(client, "agents/get", name="alpha")
        skill = got["_meta"]["io.kubed/agent"]["skills"]["selenium-flow"]
        assert (await client.read_resource(skill))[0].text.startswith("---")
    assert got["agent"] == "zeta" and got["tools"] == [] and "capabilities" not in got
    assert got["modelRoutingHint"] == {"role": "medium", "vision": False}
    assert got["_meta"]["io.kubed/agent"]["frontmatter"] == header((folder / "zeta.md").read_text())
    assert {k: got[k] for k in FRESH} == FRESH
    assert "instructions" not in alpha and "modelRoutingHint" not in alpha

The rest of the step's tests:

  • test_refusals_are_invalid_params, parametrised:

    • ("agents/get", {"name": "nope"}, "Agent not found");
    • ("agents/get", {"name": ""}, None) and ("agents/get", {}, None) (Review Focus 3);
    • ("agents/list", {"cursor": "2"}, "Invalid agents cursor").

    Each asserts caught.value.error.code == INVALID_PARAMS, and the message when one is given. The nope case also asserts caught.value.error.data == {"reason": "agent_not_found"}.

  • test_a_broken_file_costs_only_itself: folder plus broken.md ("# no header\n"). The server boots, and the listing's names are ["alpha", "zeta"].

  • test_switching_the_skill_off_removes_the_extension, over auto and legacy, with mcp={"skill": False}: EXTENSION_ID is not in the extensions, and agents/list is METHOD_NOT_FOUND.

  • test_no_agents_folder_is_an_empty_listing, with the folder tmp_path / "missing", which is the shipped state:

    • declared {"listChanged": False} in legacy;
    • agents/list == {**FRESH, "agents": []};
    • agents/get explorer is Agent not found.
  • test_the_declaration_stands_without_the_skills_one (Review Focus 1): on mcp = FastMCP("t"), call agents.register(mcp, []). A legacy client then sees extensions == {EXTENSION_ID: {"listChanged": False}}.

  • test_a_caller_cannot_change_what_the_next_caller_is_served: on an AgentsExtension(load(folder, skills={"selenium-flow"})), change a returned card's capabilities and a returned detail's _meta frontmatter, then check that the next call returns the originals.

  • Step 2: Run them to see them fail

Run: pytest tests/test_agents_extension.py -v Expected: FAIL. Nothing declares the extension, and agents/list is Method not found.

  • Step 3: Implement the extension, its middleware and register, as the Interfaces say and as skill_extension.py's twins do:

    • Unknown name: raise MCPError(code=INVALID_PARAMS, message="Agent not found", data={"reason": "agent_not_found"}).
    • Any cursor: Invalid agents cursor, because there is one page.
    • AdvertiseAgents.on_initialize merges {EXTENSION_ID: dict(AGENTS_SETTINGS)} into what is already there.
    • register always calls mcp.add_extension(AgentsExtension(agents)) and mcp.add_middleware(AdvertiseAgents()).

    In server.py, add agents to the .mcp import list. Inside the existing if self.skill is not None: block, after skill.register, add agents.register(self.mcp, agents.load(skills={self.skill.skill_info.name})), with a comment: An agent's first step is to read the skill, so they are served together and only together (spec 2026-11-12-plugin-agents, Rulings 6 and 7): declared even when there are none.

    If FastMCP strips or moves a top-level _meta from an extension's result, so that test_get_returns_the_detail cannot pass as written, stop there and say so in the PR, because the shape is mcp-kb's and Dr K's to change.

  • Step 4: Words

    • config.py:388: description="Serve the agent skill: resources, the Skills extension, and the agents that use it.", then run python scripts/generate_wiki.py. Only docs/Configuration.md's mcp.skill row changes.

    • AGENTS.md, a paragraph after the Skills extension's:

      The agents extension (io.modelcontextprotocol/agents, mcp-kb's draft of agents-wg proposal 0000) is mcp/agents.py: agents/list and agents/get over the *.md files in plugin/agents/, each YAML frontmatter and a Markdown body. agents/get's _meta is io.kubed/agent, the one shape both our servers serve — the frontmatter as written, and each skill it names as a URI on the server that answered; anything only selenium-flow needs goes under io.kubed.selenium-flow/agent, never into the shared key. It is served with the skill and only then, since an agent's first step is to read it, and it is declared even while plugin/agents/ is missing, answering an empty list: a declaration that came and went with the agents could not be checked live. AdvertiseAgents puts it into a legacy initialize, as AdvertiseSkills does, and neither depends on the other. A file that does not conform is logged and skipped. The gateway refuses these methods too; in-cluster clients call the Service directly, with the same token (spec 2026-11-12-plugin-agents).

    • CHANGELOG.md, a new line under ## [Unreleased]: - selenium-flow declares the agents extension (`agents/list`, `agents/get`); no agents yet.

  • Step 5: Run every check

Run: pytest tests/test_agents_extension.py tests/test_skills_extension.py tests/test_skill.py tests/test_prompts.py tests/test_wiki.py -v && pytest && ruff check . && python scripts/generate_openapi.py Expected: PASS, except Task 1's [plugin] image test until Dr K's edit is on the branch.

  • Step 6: Commit
    1. feat: agents/list and agents/get, declared whenever the skill is served, with the code, the tests and AGENTS.md.
    2. Inside docs/, the regenerated Configuration.md, then the pointer here: docs: mcp.skill serves the agents too.
    3. chore: changelog for the agents extension.

After merge

  • The live check in the spec (Live check after deploy), run in-cluster from the pod.
  • Outside this repo: kubed-io/selenium, mcp-kb's io.kubed/agent, and claude-control kb-agents' second url. These go in the PR body, and a person makes them.

Clone this wiki locally