2026 10 05 skills extension - kubed-io/selenium-flow GitHub Wiki
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A Skills-aware MCP client discovers selenium-flow's embedded skill with skills/list/skills/get and verifies every file it reads against the entry's digests. And (Task 3, added mid-run) show draws the secrets catalogue, one secret opening in place.
Architecture: A new module, kubed/selenium_flow/mcp/skill_extension.py, builds the skill's one SEP-2640 entry when the server starts, from FastMCP's SkillProvider. It registers a FastMCP ServerExtension binding the two JSON-RPC methods, plus a middleware that adds the declaration to a legacy initialize reply. skill.register() calls it, so --mcp-skill false turns it off with the skill.
Tech Stack: Python ≥3.10, FastMCP 4.0.x (fastmcp.server.extensions), mcp_types, PyYAML, pytest (asyncio auto mode).
Spec: docs/superpowers/specs/2026-10-05-skills-extension-design.md
- Extension identifier:
io.modelcontextprotocol/skills, exactly. - Result fields on both methods:
resultType: "complete",ttlMs: 0,cacheScope: "private". - Errors: unknown skill →
INVALID_PARAMS"Skill not found"; any cursor →INVALID_PARAMS"Invalid skills cursor". - Digests:
sha256:<hex>of the bytesresources/readreturns, never the disk bytes. - No change to
resources/list,_manifest, the mirror tools or the instructions. - ruff: target py310, 88 columns outside
tests/. CI runsruff check. - Comments earn their lines: keep the non-obvious why, nothing else.
- The user is "Dr K" in anything committed; no real names.
- One PR, on branch
skills-extension. The wiki is a submodule: commit insidewiki/. The controller pushes wikimasterbefore the branch.
There is no venv. Use the scratch libs and -S (the kubed-krm package shadows the kubed namespace otherwise):
PY=/tmp/claude-1000/-projects-cluster/c609cf8e-e357-4d7e-8988-ef046fbf64f9/scratchpad/pylibs
PYTHONPATH=$PWD:$PY python3 -S -m pytest tests/test_skills_extension.py -q
$PY/bin/ruff check . && $PY/bin/ruff format --check kubed testsSix full-suite failures are known pod-environment failures and are not yours: test_boundaries ×4, the test_shutdown SIGTERM test, test_wiki::test_the_generated_pages_are_current.
Files:
- Create:
kubed/selenium_flow/mcp/skill_extension.py - Modify:
kubed/selenium_flow/mcp/skill.py(import, andregister()at the end of the file) - Test:
tests/test_skills_extension.py(create)
Interfaces:
-
Consumes:
skill.load() -> SkillProvider | Noneandskill.register(mcp, provider)(existing); theserverfixture intests/conftest.py(an authenticatedSeleniumMCP). -
Produces:
skill_extension.EXTENSION_ID: str,entry_for(provider: SkillProvider) -> dict | None,SkillsExtension(entry: dict)withasync list_skills(ctx, params: PaginatedRequestParams) -> dictandasync get_skill(ctx, params: GetSkillParams) -> dict,GetSkillParams,AdvertiseSkills(Middleware),register(mcp, provider: SkillProvider) -> bool. -
Step 1: Write the failing tests
Create tests/test_skills_extension.py:
"""The MCP Skills extension (SEP-2640), through real MCP requests in both eras.
A Skills-aware host trusts nothing it cannot verify: it reads each file the entry
names and checks it against the digest. So the strongest tests here do exactly
that over the wire, rather than comparing the entry with the disk.
"""
import hashlib
import logging
import pytest
import yaml
from fastmcp import Client, FastMCP
from fastmcp.server.providers.skills.skill_provider import SkillProvider
from mcp.shared.exceptions import MCPError
from mcp_types import (
INVALID_PARAMS,
METHOD_NOT_FOUND,
PaginatedRequestParams,
Request,
RequestParams,
)
from pydantic import ConfigDict, TypeAdapter
from kubed.selenium_flow.config import Settings
from kubed.selenium_flow.mcp import skill_extension
from kubed.selenium_flow.mcp.skill_extension import EXTENSION_ID, SkillsExtension
from kubed.selenium_flow.server import SeleniumMCP
pytestmark = pytest.mark.unit
SKILL_URI = "skill://selenium-flow/SKILL.md"
MANIFEST_URI = "skill://selenium-flow/_manifest"
class ExtensionParams(RequestParams):
model_config = ConfigDict(extra="allow")
async def request(client, method, **params):
return await client.session.send_request(
Request(method=method, params=ExtensionParams(**params)), TypeAdapter(dict)
)
def header(text: str) -> object:
return yaml.safe_load(text.split("---", 2)[1])
@pytest.mark.parametrize("mode", ["auto", "legacy"])
async def test_the_extension_is_declared_beside_resources(server, mode):
async with Client(server.mcp, mode=mode) as client:
caps = client.session.server_capabilities
assert caps.extensions[EXTENSION_ID] == {}
assert caps.resources is not None
async def test_the_listing_is_the_one_skill(server):
async with Client(server.mcp) as client:
listing = await request(client, "skills/list")
assert listing["resultType"] == "complete"
assert listing["ttlMs"] == 0
assert listing["cacheScope"] == "private"
assert [entry["uri"] for entry in listing["skills"]] == [SKILL_URI]
async def test_get_returns_the_listed_entry(server):
async with Client(server.mcp) as client:
listed = (await request(client, "skills/list"))["skills"][0]
got = await request(client, "skills/get", uri=SKILL_URI)
assert got["skill"] == listed
assert got["resultType"] == "complete"
@pytest.mark.parametrize("mode", ["auto", "legacy"])
async def test_every_file_verifies_against_its_digest(server, mode):
async with Client(server.mcp, mode=mode) as client:
entry = (await request(client, "skills/list"))["skills"][0]
for resource in entry["resources"]:
data = (await client.read_resource(resource["uri"]))[0].text.encode()
assert resource["size"] == len(data), resource["uri"]
digest = f"sha256:{hashlib.sha256(data).hexdigest()}"
assert resource["digest"] == digest, resource["uri"]
async def test_the_entry_names_every_file_the_server_serves(server):
"""A reference added later is in the entry without anyone touching it."""
async with Client(server.mcp) as client:
entry = (await request(client, "skills/list"))["skills"][0]
served = {
str(r.uri)
for r in await client.list_resources()
if str(r.uri).startswith("skill://selenium-flow/")
}
named = [resource["uri"] for resource in entry["resources"]]
assert len(named) == len(set(named))
assert set(named) == served - {MANIFEST_URI}
assert SKILL_URI in named
async def test_the_frontmatter_is_the_served_header(server):
async with Client(server.mcp) as client:
entry = (await request(client, "skills/list"))["skills"][0]
text = (await client.read_resource(SKILL_URI))[0].text
assert entry["frontmatter"] == header(text)
assert entry["frontmatter"]["name"] == "selenium-flow"
@pytest.mark.parametrize(
("method", "params", "message"),
[
("skills/get", {"uri": "skill://nope/SKILL.md"}, "Skill not found"),
("skills/get", {"uri": MANIFEST_URI}, "Skill not found"),
("skills/list", {"cursor": "2"}, "Invalid skills cursor"),
],
)
async def test_refusals_are_invalid_params(server, method, params, message):
async with Client(server.mcp) as client:
with pytest.raises(MCPError) as caught:
await request(client, method, **params)
assert caught.value.error.code == INVALID_PARAMS
assert message in str(caught.value)
@pytest.mark.parametrize("mode", ["auto", "legacy"])
async def test_switching_the_skill_off_removes_the_extension(mode):
off = SeleniumMCP(
Settings(grid={"url": "http://grid.invalid:4444"}, auth={"token": "t"},
mcp={"skill": False})
)
async with Client(off.mcp, mode=mode) as client:
extensions = client.session.server_capabilities.extensions or {}
assert EXTENSION_ID not in extensions
with pytest.raises(MCPError) as caught:
await request(client, "skills/list")
assert caught.value.error.code == METHOD_NOT_FOUND
@pytest.mark.parametrize(
"front",
[
"name: some-other-name\ndescription: Does a thing.",
"name: odd-skill\ndescription: ''",
"name: odd-skill\ndescription: " + "x" * 1025,
"- a list\n- not a mapping",
"name: odd-skill\ndescription: Dated.\nwhen: 2026-10-05",
],
ids=["name", "empty-description", "long-description", "not-a-mapping", "date"],
)
async def test_a_nonconforming_skill_is_served_but_not_offered(tmp_path, caplog, front):
folder = tmp_path / "odd-skill"
folder.mkdir()
(folder / "SKILL.md").write_text(f"---\n{front}\n---\n\n# Body\n", encoding="utf-8")
provider = SkillProvider(folder, supporting_files="resources")
mcp = FastMCP("t")
mcp.add_provider(provider)
with caplog.at_level(logging.WARNING):
assert skill_extension.register(mcp, provider) is False
assert "not offered through the Skills extension" in caplog.text
async with Client(mcp) as client:
assert EXTENSION_ID not in (client.session.server_capabilities.extensions or {})
assert (await client.read_resource("skill://odd-skill/SKILL.md"))[0].text
async def test_a_caller_cannot_change_what_the_next_caller_is_served(server):
entry = skill_extension.entry_for(server.skill)
extension = SkillsExtension(entry)
first = await extension.list_skills(None, PaginatedRequestParams())
first["skills"][0]["resources"].clear()
first["skills"][0]["frontmatter"]["name"] = "changed"
second = await extension.list_skills(None, PaginatedRequestParams())
assert second["skills"][0]["resources"]
assert second["skills"][0]["frontmatter"]["name"] == "selenium-flow"Note on the date case: yaml.safe_load turns 2026-10-05 into a datetime.date, which json.dumps rejects. That is the "not representable as JSON" refusal.
- Step 2: Run the tests to verify they fail
Run: PYTHONPATH=$PWD:$PY python3 -S -m pytest tests/test_skills_extension.py -q
Expected: collection error, ModuleNotFoundError: No module named 'kubed.selenium_flow.mcp.skill_extension'.
- Step 3: Create the module
Create kubed/selenium_flow/mcp/skill_extension.py with exactly this content. It was prototyped against the real server in this pod and passes ruff check and ruff format --check.
"""The MCP Skills extension (SEP-2640) for the embedded skill.
``skill.py`` serves the skill as resources, which is FastMCP's job. A client that
implements the Skills extension looks for more: the extension declared in the
server's capabilities, then ``skills/list`` and ``skills/get``. They return an
entry naming every file of the skill with the digest and size of what
``resources/read`` hands back, so the client can verify each file it loads. They
are JSON-RPC methods beside ``resources/list``, not tools.
FastMCP has no built-in support yet (its #5016), so this part is ours, and kept
apart from ``skill.py`` so it is easy to delete once FastMCP ships its own.
The entry is built once, with the server: package data cannot change under a
running process.
"""
from __future__ import annotations
import copy
import hashlib
import json
import logging
import mimetypes
from collections.abc import Sequence
from pathlib import Path
import yaml
from fastmcp.server.extensions import MethodBinding, ServerExtension
from fastmcp.server.middleware import Middleware
from fastmcp.server.providers.skills.skill_provider import SkillProvider
from mcp.shared.exceptions import MCPError
from mcp_types import INVALID_PARAMS, PaginatedRequestParams, RequestParams
from pydantic import Field
log = logging.getLogger(__name__)
EXTENSION_ID = "io.modelcontextprotocol/skills"
MAX_DESCRIPTION = 1024
class GetSkillParams(RequestParams):
uri: str = Field(min_length=1)
def _served_bytes(path: Path) -> bytes:
"""What ``resources/read`` returns for this file, as bytes.
SkillProvider reads a ``text/*`` file with ``read_text``, which normalises
newlines, and anything else raw. A host verifies the bytes it receives, so
the digest follows the read, not the disk (which is what ``_manifest``
hashes). Importing SkillProvider registers ``.md`` as ``text/markdown``.
"""
mime_type, _ = mimetypes.guess_type(str(path))
if mime_type and mime_type.startswith("text/"):
return path.read_text(encoding="utf-8").encode("utf-8")
return path.read_bytes()
def _frontmatter(text: str) -> object:
"""The YAML header of a SKILL.md, as parsed, or None without one."""
if not text.startswith("---"):
return None
parts = text.split("---", 2)
if len(parts) < 3:
return None
try:
return yaml.safe_load(parts[1])
except yaml.YAMLError:
return None
def _problem(metadata: object, name: str) -> str | None:
"""Why this frontmatter cannot be offered as a conforming entry, if it cannot."""
if not isinstance(metadata, dict):
return "SKILL.md has no YAML frontmatter mapping"
try:
# Frontmatter travels as JSON; a YAML-only value would not survive it.
json.dumps(metadata, allow_nan=False)
except (TypeError, ValueError):
return "its frontmatter is not representable as JSON"
if metadata.get("name") != name:
return f"its frontmatter name is not the folder name {name!r}"
description = metadata.get("description")
if not isinstance(description, str) or not (
1 <= len(description) <= MAX_DESCRIPTION
):
return f"its description is not a string of 1-{MAX_DESCRIPTION} characters"
return None
def entry_for(provider: SkillProvider) -> dict | None:
"""The skill's SEP-2640 entry, or None (logged) if it does not conform."""
info = provider.skill_info
metadata = _frontmatter((info.path / info.main_file).read_text(encoding="utf-8"))
problem = _problem(metadata, info.name)
if problem:
log.warning(
"skill %s is not offered through the Skills extension: %s",
info.name,
problem,
)
return None
resources = []
for file in info.files:
data = _served_bytes(info.path / file.path)
resources.append(
{
"uri": f"skill://{info.name}/{file.path}",
"digest": f"sha256:{hashlib.sha256(data).hexdigest()}",
"size": len(data),
}
)
resources.sort(key=lambda resource: resource["uri"])
return {
"uri": f"skill://{info.name}/{info.main_file}",
"frontmatter": metadata,
"resources": resources,
}
def _result(**fields: object) -> dict:
# The same freshness fields as mcp-kb: a hint, not an integrity property.
return {"resultType": "complete", "ttlMs": 0, "cacheScope": "private", **fields}
class SkillsExtension(ServerExtension):
"""``skills/list`` and ``skills/get`` over the one embedded skill."""
identifier = EXTENSION_ID
def __init__(self, entry: dict):
self._entry = entry
def methods(self) -> Sequence[MethodBinding]:
return (
MethodBinding("skills/list", PaginatedRequestParams, self.list_skills),
MethodBinding("skills/get", GetSkillParams, self.get_skill),
)
async def list_skills(self, ctx, params: PaginatedRequestParams) -> dict:
if params.cursor is not None:
# One skill, so one page; any cursor is one this server never issued.
raise MCPError(code=INVALID_PARAMS, message="Invalid skills cursor")
return _result(skills=[copy.deepcopy(self._entry)])
async def get_skill(self, ctx, params: GetSkillParams) -> dict:
if params.uri != self._entry["uri"]:
raise MCPError(code=INVALID_PARAMS, message="Skill not found")
return _result(skill=copy.deepcopy(self._entry))
class AdvertiseSkills(Middleware):
"""Declare the extension in a legacy ``initialize`` reply too.
FastMCP declares registered extensions in ``server/discover`` but drops
``capabilities.extensions`` from ``initialize``, and still answers the
methods; without this, a handshake-era client never learns they exist.
"""
async def on_initialize(self, context, call_next):
result = await call_next(context)
if result is not None:
result.capabilities.extensions = {
**(result.capabilities.extensions or {}),
EXTENSION_ID: {},
}
return result
def register(mcp, provider: SkillProvider) -> bool:
"""Offer the skill through the extension.
False, with nothing added, when the skill does not conform: its resources
are still served, so a bad SKILL.md costs the extension, not the boot.
"""
entry = entry_for(provider)
if entry is None:
return False
mcp.add_extension(SkillsExtension(entry))
mcp.add_middleware(AdvertiseSkills())
return True- Step 4: Wire it into
skill.register
In kubed/selenium_flow/mcp/skill.py, add the import after the SkillProvider import:
from fastmcp.server.providers.skills.skill_provider import SkillProvider
from . import skill_extensionReplace register at the end of the file:
def register(mcp, provider: SkillProvider) -> None:
"""Serve the skill's resources, and offer it through the Skills extension."""
mcp.add_provider(provider)
skill_extension.register(mcp, provider)In the module docstring, after the paragraph ending "…invisible to every one of them.", add:
A client that implements the MCP Skills extension also finds the skill through
``skills/list`` and ``skills/get``; that part is ours, in ``skill_extension``.
- Step 5: Run the new tests
Run: PYTHONPATH=$PWD:$PY python3 -S -m pytest tests/test_skills_extension.py -q
Expected: 19 passed. (The plan's tests were run against this exact module in the pod: 19 passed, plus the 123 neighbouring tests in Step 6.)
- Step 6: Run the skill and neighbouring tests, then lint
Run: PYTHONPATH=$PWD:$PY python3 -S -m pytest tests/test_skill.py tests/test_resources.py tests/test_discoverability.py tests/test_show.py -q
Expected: all pass.
Run: $PY/bin/ruff check . && $PY/bin/ruff format --check kubed/selenium_flow/mcp/skill_extension.py kubed/selenium_flow/mcp/skill.py
Expected: All checks passed! and both files already formatted.
- Step 7: Commit
git add kubed/selenium_flow/mcp/skill_extension.py kubed/selenium_flow/mcp/skill.py tests/test_skills_extension.py
git commit -m "The embedded skill on the MCP Skills extension: skills/list and skills/get"Files:
- Modify:
AGENTS.md(section## The embedded skill, after the paragraph abouttest_every_skill_file_is_covered_by_package_data) - Modify:
README.md(section### 📖 It teaches you how to use it, after the paragraph ending "list_skillsanddownload_skillwork here with no special casing.") - Modify:
kubed/selenium_flow/config.py:251 - Modify:
wiki/Configuration.md(themcp.skillrow), in thewikisubmodule - Modify:
CHANGELOG.md(## [Unreleased], as its first entry)
Interfaces:
-
Consumes: Task 1's module name
skill_extensionand the behaviour it ships. -
Produces: nothing code depends on.
-
Step 1: AGENTS.md
After the package-data paragraph in The embedded skill (it ends "…that no pattern matches."), insert:
The **Skills extension** (SEP-2640, `io.modelcontextprotocol/skills`) is a second
discovery surface over the same files, in `mcp/skill_extension.py`: `skills/list`
and `skills/get` return one entry naming every file with the sha256 and size of
the bytes `resources/read` serves. Those are not `_manifest`'s hashes, which are of
the disk, and `SkillProvider` reads text with newline normalisation. FastMCP
declares the extension in `server/discover` only, so `AdvertiseSkills` puts it back
into a legacy `initialize`. A SKILL.md that fails the spec (name ≠ folder,
description outside 1–1024 characters, frontmatter that is not JSON) is still
served as resources but not offered. agentgateway 1.6 refuses both methods
(`unsupported method`, agentgateway#3579), so through the gateway a client sees the
declaration and gets an error; in-cluster they work. Delete the module when
FastMCP ships its own (#5016).- Step 2: README.md
After "Those URIs are FastMCP's convention, served by its own SkillProvider, so list_skills and download_skill work here with no special casing.", add a new paragraph:
A client that implements the MCP Skills extension also finds it through
`skills/list` and `skills/get`, and can verify every file it reads.- Step 3: The flag's description, in both places
kubed/selenium_flow/config.py:251 becomes (the longer wording would pass 88 columns):
skill: bool = Field(
True, description="Serve the agent skill: resources and the Skills extension."
)In wiki/Configuration.md, the mcp.skill row's last cell becomes the same text:
| `mcp.skill` | `MCP_SKILL` | `--mcp-skill` | `true` | Serve the agent skill: resources and the Skills extension. |Then try PYTHONPATH=$PWD:$PY python3 -S scripts/generate_wiki.py --check. If it reports Configuration.md stale, run it without --check and keep its output. Its known pod failure (see Running tests) is not a blocker; the hand edit is.
Also check the description is the same everywhere it is copied:
Run: grep -rn "Serve the agent skill" kubed wiki README.md AGENTS.md
Expected: exactly the two lines above, with identical text.
- Step 4: CHANGELOG.md
As the first entry under ## [Unreleased]:
- Skills-aware clients discover the embedded skill through the MCP Skills extension (`skills/list`, `skills/get`).- Step 5: Verify
Run: PYTHONPATH=$PWD:$PY python3 -S -m pytest tests/test_readme.py tests/test_wiki.py tests/test_config_schema.py tests/test_config_load.py -q
Expected: all pass except the known test_the_generated_pages_are_current.
Run: $PY/bin/ruff check .
Expected: All checks passed!
- Step 6: Commit (wiki first, then the repo)
git -C wiki add Configuration.md
git -C wiki commit -m "mcp.skill: the Skills extension too"
git add AGENTS.md README.md CHANGELOG.md kubed/selenium_flow/config.py wiki
git commit -m "Docs: the Skills extension"Added mid-run by Dr K, 2026-10-05: "give the mcp-apps a view for the secrets list or one secret. the value would never be passed over the call, this would be purely read only like the admin UI." Ruling (Dr K): one secret opens inside the app from the list data already sent. There is no new resource: secrets.py's "one read" rule (§F1.31, no get-one) stands, so secret://secrets/{name} stays refused.
Files:
- Modify:
kubed/selenium_flow/mcp/show.py(theVIEWStable,NOUNS) - Create:
ui/src/lib/secrets.ts(the admin pane's secret helpers, moved verbatim) - Create:
ui/src/lib/SecretCard.svelte(the admin pane's card snippet, as a component) - Modify:
ui/src/admin/SecretsPane.svelte(use the two above) - Modify:
ui/src/lib/types.ts(addSecretsData) - Create:
ui/src/lib/views/SecretsView.svelte - Modify:
ui/src/App.svelte(register the view, its noun) - Test:
tests/test_show.py,ui/src/lib/views/SecretsView.test.ts(create),ui/src/App.test.ts - Docs:
skills/selenium-flow/SKILL.md(theshowrow),AGENTS.md(showis the one app tool),CHANGELOG.md([Unreleased])
Interfaces:
- Consumes:
secret://secrets(secrets.py), which returns{count, session, secrets: [entry]}; entries carry names, keys,restricted,allowed_urls, the warnings,key_sourcesandorigins, but never a value. With no catalogue the resource raisesValueError(OFF)("secrets are not enabled on this server: ..."), whichshowsurfaces as a tool error. - Produces: component name
secrets;SecretCard(propsecret: Secret);lib/secrets.tsexportskeyLabel,warnsOf,reasonOf,fromOf.
The code below was drafted and run in this pod:
-
pytest:
test_show.pyandtest_admin_secrets.pygave 44 passed. -
vitest: 263 passed.
npm run check: 0 errors.npm run lint: clean. -
bundle size: app 84.2 KB of 110, admin 39.4 KB of 45 (gzipped).
-
Step 0: UI toolchain
ui/node_modules is installed in this worktree (npm ci already ran). UI commands run from ui/: npx vitest run <file>, npm run -s check, npm run -s lint, npm run -s build && npm run -s size.
- Step 1: Write the failing tests
Apply this diff to tests/test_show.py:
@@ -30,6 +30,20 @@ def flow_server(tmp_path, named_caller):
return server
+@pytest.fixture
+def secrets_server(tmp_path, named_caller):
+ """A server with one secret whose values are recognisable."""
+ demo = tmp_path / "secrets" / "demo"
+ demo.mkdir(parents=True)
+ (demo / "username").write_text("user-7f3a")
+ (demo / "password").write_text("pass-9c1e")
+ return SeleniumMCP(Settings(
+ grid={"url": "http://grid.invalid:4444"},
+ auth={"token": TOKEN},
+ secrets={"dirs": str(tmp_path / "secrets")},
+ ))
+
+
@pytest.mark.parametrize(
("uri", "component"),
[
@@ -39,6 +53,7 @@ def flow_server(tmp_path, named_caller):
("session://files/downloads", "folder"),
("flow://flows", "flows"),
("flow://flows/login", "flow"),
+ ("secret://secrets", "secrets"),
],
)
def test_every_showable_uri_has_one_view(uri, component):
@@ -55,7 +70,7 @@ def test_every_row_matches_its_own_display_form():
@pytest.mark.parametrize(
"uri",
["skill://selenium-flow/SKILL.md", "flow://schema", "session://files/a.png",
- "flow://flows/", "session://current/x", "secret://secrets"],
+ "flow://flows/", "session://current/x", "secret://secrets/demo"],
)
def test_anything_else_is_refused_naming_what_can_be_shown(uri):
with pytest.raises(ValueError) as refused:
@@ -237,3 +252,31 @@ async def test_content_that_is_not_json_is_refused_cleanly(flow_server, content)
):
await c.call_tool("show", {"uri": "session://current"})
assert "not a resource show can draw" in str(refused.value)
+
+
+async def test_the_secrets_show_as_the_resource_lists_them(secrets_server):
+ async with Client(secrets_server.mcp) as c:
+ result = await c.call_tool("show", {"uri": "secret://secrets"})
+ read = json.loads((await c.read_resource("secret://secrets"))[0].text)
+ assert result.structured_content == {
+ "component": "secrets", "uri": "secret://secrets", "data": read,
+ }
+ assert [s["name"] for s in read["secrets"]] == ["demo"]
+ assert result.content[0].text == "Showing secret://secrets to the person: 1 secret."
+
+
+async def test_showing_the_secrets_never_sends_a_value(secrets_server):
+ async with Client(secrets_server.mcp) as c:
+ result = await c.call_tool("show", {"uri": "secret://secrets"})
+ sent = json.dumps(result.structured_content) + "".join(
+ block.text for block in result.content
+ )
+ assert "demo" in sent
+ assert "user-7f3a" not in sent and "pass-9c1e" not in sent
+
+
+async def test_showing_secrets_on_a_server_without_them_says_why(flow_server):
+ async with Client(flow_server.mcp) as c:
+ with pytest.raises(ToolError) as refused:
+ await c.call_tool("show", {"uri": "secret://secrets"})
+ assert "secrets are not enabled" in str(refused.value)Add this row to the test.each table of views in ui/src/App.test.ts, after the ['flow', FLOW.data, 'navigate'], row:
['secrets', { session: 's', count: 1, secrets: [{ name: 'demo', keys: ['password'], restricted: false }] }, 'demo'],Create ui/src/lib/views/SecretsView.test.ts:
import { fireEvent, render, screen } from '@testing-library/svelte'
import { expect, test } from 'vitest'
import SecretsView from './SecretsView.svelte'
const data = {
session: 's', count: 2,
secrets: [
{
name: 'admin', description: 'The admin login', keys: ['username', 'password'],
restricted: true, allowed_urls: ['https://example.test/'],
key_sources: { password: { from: 'env' as const, name: 'ADMIN_PW' } },
origins: [{ source: 'config' as const, location: '/etc/c.yaml' }],
},
{ name: 'open', keys: ['token'], restricted: false },
],
}
test('one card per secret in a list scroller', () => {
render(SecretsView, { props: { data } })
expect(screen.getByRole('list')).toBeInTheDocument()
expect(screen.getAllByRole('listitem')).toHaveLength(2)
expect(screen.getByText('The admin login')).toBeInTheDocument()
expect(screen.getByText('2 keys · 1 site')).toBeInTheDocument()
expect(screen.getByText('1 key · any site')).toBeInTheDocument()
})
test('a card warns as the admin pane does', () => {
render(SecretsView, { props: { data } })
const open = screen.getByRole('button', { name: 'open' })
expect(open.querySelector('.pill.warn')).toHaveTextContent('any site')
expect(screen.getByRole('button', { name: 'admin' }).querySelector('.pill.warn')).toBeNull()
})
test('a card opens that one secret in place, and back returns to the list', async () => {
const { container } = render(SecretsView, { props: { data } })
await fireEvent.click(screen.getByRole('button', { name: 'admin' }))
expect(screen.queryByRole('list')).toBeNull()
const card = container.querySelector('.card.secret')!
expect(card).toHaveTextContent('admin')
expect(card).toHaveTextContent('password · env ADMIN_PW')
expect(card).toHaveTextContent('allowedhttps://example.test/')
expect(card).toHaveTextContent('fromconfig · /etc/c.yaml')
await fireEvent.click(screen.getByRole('button', { name: '← All secrets' }))
expect(screen.getAllByRole('listitem')).toHaveLength(2)
})
test('the cards open without a server call: they work with no onshow', async () => {
const { container } = render(SecretsView, { props: { data } })
await fireEvent.click(screen.getByRole('button', { name: 'open' }))
expect(container.querySelector('.card.secret')).toHaveTextContent('open')
})
test('no secrets: one line, no scroller', () => {
render(SecretsView, { props: { data: { session: 's', count: 0, secrets: [] } } })
expect(screen.getByText('No secrets are configured.')).toBeInTheDocument()
expect(screen.queryByRole('list')).toBeNull()
})- Step 2: Run them to verify they fail
Run: PYTHONPATH=$PWD:$PY python3 -S -m pytest tests/test_show.py -q
Expected: failures on the new secret://secrets cases (has no view).
Run (in ui/): npx vitest run src/lib/views/SecretsView.test.ts src/App.test.ts
Expected: SecretsView fails to import; the App secrets row fails with Nothing to show for "secrets".
- Step 3: The server's table
Apply to kubed/selenium_flow/mcp/show.py:
@@ -34,6 +34,9 @@ VIEWS: tuple[tuple[str, re.Pattern[str], str], ...] = (
("session://files/downloads", re.compile(r"session://files/downloads"), "folder"),
("flow://flows", re.compile(r"flow://flows"), "flows"),
("flow://flows/{name}", re.compile(r"flow://flows/[^/]+"), "flow"),
+ # One secret is a closer look inside the app, not a URI: there is no
+ # single-secret resource to read (secrets.py, "one read").
+ ("secret://secrets", re.compile(r"secret://secrets"), "secrets"),
)
SHOWABLE = tuple(form for form, _, _ in VIEWS)
@@ -41,7 +44,7 @@ SHOWABLE = tuple(form for form, _, _ in VIEWS)
# its data: a flow may be 1 MiB of YAML and a listing is unbounded.
MAX_SHOWN = 100_000
# What a listing's `count` counts, for the model's line.
-NOUNS = {"files": "kept file", "folder": "file", "flows": "flow"}
+NOUNS = {"files": "kept file", "folder": "file", "flows": "flow", "secrets": "secret"}
def view_for(uri: str) -> str:- Step 4: Share the admin pane's secret card
Create ui/src/lib/secrets.ts. Its function bodies and comments are moved verbatim from SecretsPane.svelte:
import type { Secret } from './types'
// What a secret's card says about it, for the admin pane and the app alike:
// both draw the same catalogue entry, so they must never read it differently.
export const keyLabel = (s: Secret, k: string) => {
const from = s.key_sources?.[k]
if (!from || from.from === 'filesystem') return k
if (from.from === 'env') return `${k} · env ${from.name}`
return `${k} · ${from.from}`
}
// A list, not one pill: an unrestricted secret with an inline or unresolved
// key needs BOTH warnings shown, not whichever one happened to be checked
// first. Order matches severity. `any site` is withheld when the leash
// itself is broken — that secret is restricted, just unusably so.
export const warnsOf = (s: Secret) => [
s.allowed_urls_rejected ? 'unusable until fixed' : null,
s.keys_unresolved?.length ? 'key unresolved' : null,
s.inline_keys?.length ? 'inline value' : null,
!s.restricted && !s.allowed_urls_rejected ? 'any site' : null,
].filter((w): w is string => w !== null)
// Both a broken leash AND unresolved keys can be true at once — show both,
// leash first, so the operator sees every reason the secret will fail.
export const reasonOf = (s: Secret) => [
s.allowed_urls_rejected ? 'allowed_urls: ' + ([] as string[]).concat(s.allowed_urls_rejected).join(', ') : null,
...(s.keys_unresolved?.map((u) => `${u.key}: ${u.reason}`) ?? []),
].filter((r): r is string => r !== null).join('; ')
export const fromOf = (s: Secret) => (s.origins ?? []).map((o) => `${o.source} · ${o.location}`).join(' + ')Create ui/src/lib/SecretCard.svelte. Its markup is the pane's {#snippet card} body, verbatim:
<script lang="ts">
import { fromOf, keyLabel, reasonOf, warnsOf } from './secrets'
import type { Secret } from './types'
let { secret: s }: { secret: Secret } = $props()
const warns = $derived(warnsOf(s))
const reason = $derived(reasonOf(s))
</script>
<div class="card secret">
<div class="row"><span>🔑</span><strong class="grow">{s.name}</strong>{#each warns as warn (warn)}<span class="pill warn">{warn}</span>{/each}</div>
{#if s.description}<div>{s.description}</div>{/if}
{#if reason}<div class="small error">{reason}</div>{/if}
{#if s.keys}
<div class="fact"><span class="k">keys</span>{#each s.keys as k (k)}<span class="pill">{keyLabel(s, k)}</span>{/each}</div>
<div class="fact"><span class="k">allowed</span>{s.restricted ? (s.allowed_urls || []).join(', ') || '—' : 'any site'}</div>
{/if}
{#if s.origins?.length}<div class="fact"><span class="k">from</span><span class="small muted">{fromOf(s)}</span></div>{/if}
</div>ui/src/admin/SecretsPane.svelte becomes exactly:
<script lang="ts">
import { onMount } from 'svelte'
import SecretCard from '../lib/SecretCard.svelte'
import type { SecretsPayload } from '../lib/types'
import type { Api } from './api'
import { Latest } from './latest'
let { api }: { api: Api } = $props()
// Raw: an API response, replaced wholesale, never mutated.
let data = $state.raw<SecretsPayload | null>(null)
let error = $state<string | null>(null)
const loads = new Latest()
onMount(() => {
void loads.run((signal) => api<SecretsPayload>('/admin/secrets', 'GET', undefined, signal),
(d) => { data = d; error = null }, (e) => { error = e.message })
return () => loads.abort()
})
</script>
<section id="paneSecrets">
<div id="secrets">
{#if error}
<div class="empty error">{error}</div>
{:else if !data}
<div class="empty">Loading…</div>
{:else if !data.enabled}
<div class="empty">No secrets are configured on this server.</div>
{:else}
<h2>Secrets</h2>
<p class="small muted">What flows can type without anyone seeing it. Read-only here: names, keys and where each may be used — never a value.</p>
{#each data.secrets as s (s.name)}<SecretCard secret={s} />{/each}
{/if}
</div>
</section>In ui/src/lib/types.ts, insert immediately before export interface SecretsPayload {:
// `secret://secrets`, as `show` hands it to the app.
export interface SecretsData {
session: string
count: number
secrets: Secret[]
}- Step 5: The view, and the shell's table
Create ui/src/lib/views/SecretsView.svelte:
<script lang="ts">
import { plural } from '../format'
import SecretCard from '../SecretCard.svelte'
import { warnsOf } from '../secrets'
import type { Secret, SecretsData } from '../types'
let { data }: { data: SecretsData } = $props()
// One secret opens from the card already here rather than another `show`:
// there is no single-secret resource to read (secrets.py, "one read").
let open = $state<string | null>(null)
const chosen = $derived(data.secrets.find((s) => s.name === open))
const reach = (s: Secret) => (s.restricted ? plural((s.allowed_urls ?? []).length, 'site') : 'any site')
</script>
{#if chosen}
<div class="back"><button type="button" onclick={() => (open = null)}>← All secrets</button></div>
<SecretCard secret={chosen} />
{:else if !data.secrets.length}
<div class="empty">No secrets are configured.</div>
{:else}
<div class="scroller" role="list">
{#each data.secrets as s (s.name)}
<div class="slot" role="listitem">
<button type="button" class="card pick" aria-label={s.name} onclick={() => (open = s.name)}>
<div class="row"><span>🔑</span><strong class="grow">{s.name}</strong></div>
{#if s.description}<div class="desc small muted">{s.description}</div>{/if}
<div class="small muted">{plural((s.keys ?? []).length, 'key')} · {reach(s)}</div>
{#if warnsOf(s).length}<div class="warns">{#each warnsOf(s) as warn (warn)}<span class="pill warn">{warn}</span>{/each}</div>{/if}
</button>
</div>
{/each}
</div>
{/if}
<style>
.back { margin-bottom: 8px; }
.back button { padding: 4px 10px; min-height: 32px; }
.scroller {
display: flex; gap: var(--gap); overflow-x: auto; overflow-y: hidden;
scroll-snap-type: x mandatory; scroll-padding-inline: var(--safe-left, 0);
}
.slot { flex: 0 0 min(240px, 80%); scroll-snap-align: start; display: flex; }
.card.pick {
flex: 1; margin: 0; min-height: 44px; text-align: left; font: inherit; color: var(--ink);
display: flex; flex-direction: column; gap: 6px; cursor: pointer;
}
.card.pick:hover { border-color: var(--accent); }
.card.pick:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.desc {
display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.warns { display: flex; flex-wrap: wrap; gap: 4px; }
</style>Apply to ui/src/App.svelte:
@@ -10,6 +10,7 @@
import FlowsView from './lib/views/FlowsView.svelte'
import FlowView from './lib/views/FlowView.svelte'
import FolderView from './lib/views/FolderView.svelte'
+ import SecretsView from './lib/views/SecretsView.svelte'
type Props = { data: never; onshow?: (uri: string) => void; expanded?: boolean }
// By the name a `show` result carries in `component`.
@@ -19,9 +20,10 @@
folder: FolderView as Component<Props>,
flows: FlowsView as Component<Props>,
flow: FlowView as Component<Props>,
+ secrets: SecretsView as Component<Props>,
}
const EXPANDS = new Set(['flow'])
- const NOUNS: Record<string, string> = { files: 'kept file', folder: 'file', flows: 'flow' }
+ const NOUNS: Record<string, string> = { files: 'kept file', folder: 'file', flows: 'flow', secrets: 'secret' }
type Shown = { component?: string; uri?: string; data?: unknown }
type Result = { structuredContent?: unknown; isError?: boolean; content?: { type: string; text?: string }[] }- Step 6: Run everything that covers it
Run: PYTHONPATH=$PWD:$PY python3 -S -m pytest tests/test_show.py tests/test_admin_secrets.py tests/test_skill.py -q
Expected: all pass.
Run (in ui/): npx vitest run, npm run -s check, npm run -s lint, npm run -s build && npm run -s size
Expected: all vitest files pass (the admin SecretsPane.test.ts proves the moved card is unchanged); 0 check errors; lint clean; both bundles under budget.
Run: $PY/bin/ruff check .
Expected: All checks passed! (ruff format --check reports an older line in show.py this task does not touch; leave it.)
- Step 7: Docs
skills/selenium-flow/SKILL.md, the show row of the tool table: change context, files, a folder, flows, one flow to context, files, a folder, flows, one flow, the secrets.
AGENTS.md, section show is the one app tool: after the bullet ending "…the app would never get its data.", add:
- **One secret opens inside the app**, from the list `show` already sent: there
is no single-secret resource (secrets.py, "one read"), so a drill-down has
nothing to call. `SecretCard` and `lib/secrets.ts` are shared with the admin
pane, so both read a catalogue entry the same way, and neither ever has a value.CHANGELOG.md, under ## [Unreleased], immediately after the existing show(uri) line:
- `show` draws `secret://secrets` too: a card per secret, one opening in place with its keys and allowed sites, never a value.- Step 8: Commit
git add kubed/selenium_flow/mcp/show.py tests/test_show.py ui/src skills/selenium-flow/SKILL.md AGENTS.md CHANGELOG.md
git commit -m "show draws the secrets: a card each, one opening in place"- Full suite:
PYTHONPATH=$PWD:$PY python3 -S -m pytest -q. Expected: only the six known pod failures. - Push wiki
master, then the branch, then open the PR (one PR). Dr K merges. - After merge and the
:mainimage:kubectl rollout restart -n flow deploy/selenium-flow. Then the spec's Live check after deploy:- in-cluster, legacy
initializeshows the declaration; -
skills/listreturns the entry; - every digest verifies;
- through
mcp./mcp-pub., the declaration arrives and the call is refused.
- in-cluster, legacy