Repository navigation
2026 11 12 plugin agents
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/getcarries nocapabilities; - the README is not touched;
- both package-data tests go;
- the shared parse is its own module;
- there are two middlewares, not one.
The spec's The design is what this builds. The decisions it leaves to the plan:
-
agents.loadreturnslist[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/listanswers_result(agents=[deepcopy(a.card) for a in agents]).agents/getanswers_result(**deepcopy(a.detail)), so the detail's keys sit at the top level of the result beside the three freshness fields. -
agents.pyimports nothing fromskill_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 frontmatterits frontmatter is not a non-empty mappingits frontmatter is not representable as JSONits 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,OSErrororUnicodeDecodeError, logsagent file %s is skipped: %swith the exception. -
The name rule. The name must be a
strand non-empty. It is refused if any characterchhasch.isspace(), or aunicodedata.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()andAdvertiseAgentsboth return a copy of it.
- Python 3.11 is the floor: nothing 3.12+ (no
Path.walk, noPath.full_match). - The skill's URIs, the prompts,
skills/list,skills/get,_manifestand the mirror tools answer exactly as before.tests/test_skill.py,tests/test_prompts.pyandtests/test_skills_extension.pypass 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, andAgent not foundwithdata={"reason": "agent_not_found"}, bothINVALID_PARAMS. - The
_metakey is"io.kubed/agent", and there is noio.kubed.selenium-flow/agent. - The routing hints are opus →
{"role": "large", "vision": False}, sonnet →medium, haiku →small.
-
-
plugin.jsonhas exactlynameanddescription: noversion, nocommands(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 pushesimage.yml'splugin/**,package.yml's comments andcopilot-instructions.mdhimself (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.mdchanges as a commit inside it, then the pointer here. - New and changed tests use
drkanddeskwhen they need a person's or a workspace's name. - The checks, as CI runs them:
- Python (
test.yml):ruff check .andpytest. The integration tests need a Grid and run in CI. - OpenAPI (
lint.yml):python scripts/generate_openapi.pysucceeds. - Changelog (
pr.yml): a new line under## [Unreleased].
- Python (
-
A SKILL.md that no longer conforms to the Skills extension.
skill_extension.registerreturns False and adds noAdvertiseSkills, but the skill is still served. A person expects the agents extension to be declared on its own in a legacyinitialize. Task 3 tests it on a server with only the agents registered. - 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.
-
agents/getwith noname, orname: "". A person expectsINVALID_PARAMS, not an internal error. Task 3 tests both. -
skills: selenium-flowwritten as a bare string, not a list. A person expects{}, not a map of its letters and not a crash. Task 2 tests it. -
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.
Files:
- Move (with
git mv):skills/selenium-flow/→plugin/skills/selenium-flow/, andprompts/→plugin/prompts/ - Create:
plugin/.claude-plugin/plugin.json - Modify:
pyproject.toml:127-175(packages,package-dir,package-dataand 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_DIRS347–351, and new tests after 380);tests/test_skill.py(delete 86–111, and thefnmatchandtomllibimports);tests/test_prompts.py(delete 91–105)
Interfaces:
-
Produces:
-
skill.PLUGIN_DIR == "plugin"andprompts.PLUGIN_DIR == "plugin". - In a checkout,
skill.skill_path() == REPO / "plugin" / "skills" / "selenium-flow"andprompts.prompts_path() == REPO / "plugin" / "prompts". Installed, each is the same path underPath(__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_DIRSbecomes:
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.jsonas{"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:-
packagesreplaces…mcp.promptsand…mcp.skillswith"kubed.selenium_flow.mcp.plugin". -
package-dirreplaces the two entries with"kubed.selenium_flow.mcp.plugin" = "plugin". -
package-datareplaces 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.
- over the mapping:
-
skill.py: addPLUGIN_DIR = "plugin"aboveSKILLS_DIR = "skills". Both lookups go throughPLUGIN_DIR / SKILLS_DIR / SKILL_NAME. The docstrings nameplugin/skills/selenium-flow/andplugin/. -
prompts.py: the same, withPLUGIN_DIR / PROMPTS_DIR. Theprompts_pathdocstring saysplugin/is mapped, and does not mentionskills/. -
js.py:11: "the same way it mapsplugin/".
- Move the two folders with
-
Step 4: Fix the words
-
AGENTS.md:1043: "plugin/skills/selenium-flow/sits inplugin/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]coversplugin/**/*, 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 stdlibglob, whose**skips a dot-folder.test_every_plugin_file_is_matched_by_package_dataexpands 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 isplugin/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.mdand its eightreferences/*.md; -
…/plugin/prompts/build_flow.mdandrepair_flow.md; - nothing under
mcp/skills/ormcp/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.
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) andentry_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 asyaml.safe_loadparses 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---afterrstrip). -
frontmatter.survives_json(value: object) -> bool:json.loads(json.dumps(value, allow_nan=False)) == value, False onTypeError/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], wherebase=Nonemeansagents_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 helperagent_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 assertsrouting_hint(value) == ({"role": role, "vision": False} if role else None). A file with nomodelhas nomodelRoutingHintkey. -
test_capabilities_default_to_none: with nocapabilities, and withcapabilities: [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 noskillsat all. Each gives_meta[META_KEY]["skills"] == {}withskills={"selenium-flow"}. A list[selenium-flow]loaded with the defaultskills=()gives{}too. -
test_the_cards_are_in_name_order:a.mdnameszetaandb.mdnamesalpha→[a.name for a in load(...)] == ["alpha", "zeta"]. -
test_a_nonconforming_file_is_skipped_and_logged, parametrised over whole file texts, beside a goodexplorer.md:-
"# no header\n"; -
"---\n- a\n- list\n---\n"; -
"---\n{}\n---\n"; -
"---\nname: [unclosed\n---\n"; - names
"ex plorer","a/b","a@b","explorer"(written as the real character) and123; - descriptions
'',' ', and none; -
when: 2026-11-12; -
metadata: {1: one, '1': also}.
Each gives
[a.name for a in load(...)] == ["explorer"], andcaplog.textcontains"is skipped"and the bad file's name. -
-
test_a_file_that_is_not_utf8_is_skipped(Review Focus 2):bad.mdis 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 withnewline="\r\n". It loads, withinstructions == "# Explore\n\nRead the skill first.". -
test_the_first_of_two_files_with_one_name_wins:a.mdandb.mdboth nameexplorer, with descriptionsFirst.andSecond.→ one agent,card["description"] == "First.", and the log namesb.mdanda.md. -
test_only_markdown_at_the_top_level_is_read:drafts/x.mdandnotes.txtgive[]. -
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")andsplit("---\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 pointskill_extension.pyat it:-
_frontmattergoes; -
entry_forreadsparsed = frontmatter.split(text)andmetadata = parsed[0] if parsed else None; -
_problem's JSON check becomesif 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/agentis the shape both servers share (spec 2026-11-12-plugin-agents, Ruling 2).
agents_path()has the same shape and comment asskill_path(). The description is collapsed with" ".join(text.split()), and the routing hint's words come fromre.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, holdingfrontmatter.pyandskill_extension.py. Then commitfeat: agent files load into agents/list cards and agents/get details.
Files:
- Modify:
kubed/selenium_flow/mcp/agents.py:GetAgentParams,AgentsExtension,AdvertiseAgents,register - Modify:
kubed/selenium_flow/server.py: the.mcpimport list (37–49), and:303-304 - Modify:
kubed/selenium_flow/config.py:388; regeneratedocs/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)withname: str = Field(min_length=1); -
AgentsExtension(ServerExtension), built asAgentsExtension(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'sExtensionParams,requestandheadershapes, written out here, not imported. - Add a helper
serving(monkeypatch, folder, **settings) -> SeleniumMCP. It monkeypatchesagents.agents_pathto returnfolder, then buildsSeleniumMCP(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 isFRONTwithname: zetain place ofname: explorer, and Task 2's body; -
alpha.md, withname: alpha,description: First., a blank body and no model.
-
-
FRESH = {"resultType": "complete", "ttlMs": 0, "cacheScope": "private"}.
- Reuse
@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 alphaThe 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. Thenopecase also assertscaught.value.error.data == {"reason": "agent_not_found"}. -
-
test_a_broken_file_costs_only_itself:folderplusbroken.md("# no header\n"). The server boots, and the listing's names are["alpha", "zeta"]. -
test_switching_the_skill_off_removes_the_extension, overautoandlegacy, withmcp={"skill": False}:EXTENSION_IDis not in the extensions, andagents/listisMETHOD_NOT_FOUND. -
test_no_agents_folder_is_an_empty_listing, with the foldertmp_path / "missing", which is the shipped state:- declared
{"listChanged": False}inlegacy; -
agents/list == {**FRESH, "agents": []}; -
agents/getexplorerisAgent not found.
- declared
-
test_the_declaration_stands_without_the_skills_one(Review Focus 1): onmcp = FastMCP("t"), callagents.register(mcp, []). Alegacyclient then seesextensions == {EXTENSION_ID: {"listChanged": False}}. -
test_a_caller_cannot_change_what_the_next_caller_is_served: on anAgentsExtension(load(folder, skills={"selenium-flow"})), change a returned card'scapabilitiesand a returned detail's_metafrontmatter, 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 asskill_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_initializemerges{EXTENSION_ID: dict(AGENTS_SETTINGS)}into what is already there. -
registeralways callsmcp.add_extension(AgentsExtension(agents))andmcp.add_middleware(AdvertiseAgents()).
In
server.py, addagentsto the.mcpimport list. Inside the existingif self.skill is not None:block, afterskill.register, addagents.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
_metafrom an extension's result, so thattest_get_returns_the_detailcannot pass as written, stop there and say so in the PR, because the shape is mcp-kb's and Dr K's to change. - Unknown name:
-
Step 4: Words
-
config.py:388:description="Serve the agent skill: resources, the Skills extension, and the agents that use it.", then runpython scripts/generate_wiki.py. Onlydocs/Configuration.md'smcp.skillrow 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) ismcp/agents.py:agents/listandagents/getover the*.mdfiles inplugin/agents/, each YAML frontmatter and a Markdown body.agents/get's_metaisio.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 underio.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 whileplugin/agents/is missing, answering an empty list: a declaration that came and went with the agents could not be checked live.AdvertiseAgentsputs it into a legacyinitialize, asAdvertiseSkillsdoes, 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
-
feat: agents/list and agents/get, declared whenever the skill is served, with the code, the tests andAGENTS.md. - Inside
docs/, the regeneratedConfiguration.md, then the pointer here:docs: mcp.skill serves the agents too. -
chore: changelog for the agents extension.
-
- 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-controlkb-agents' secondurl. These go in the PR body, and a person makes them.
The action pages are generated from openapi.yaml, which is itself generated from the live MCP tool schemas — so they describe the server that shipped, not the one someone remembered. Prose belongs in wiki-notes/<tool>.md in the repo.
selenium-flow · MIT
Start here
Guides
Lifecycle
Going places
Doing things
Getting things out
Site data
Console and network