2026 10 05 skills extension design - kubed-io/selenium-flow GitHub Wiki
Status: SPEC, 2026-10-05, branch skills-extension. Plan:
docs/superpowers/plans/2026-10-05-skills-extension.md. Dr K reads this as it
grows and drops rulings in chat; every ruling is recorded under Rulings.
Dr K, 2026-10-05, after reading how mcp-kb serves the Skills extension (#23, from flujoapp): "For this skills extension to be registered do we need a get_skills and list_skills on selenium flow … or can it work through the get list resources? I want to fully support the skills extension in selenium flow as well." And: "I want to try with Claude web … maybe Claude web supports the skills extension. Either way let's do it."
The extension is protocol methods, not tools. SEP-2640
(io.modelcontextprotocol/skills, Final since 2026-09-13, spec in
modelcontextprotocol/ext-skills, specification/stable/skills.mdx) adds two
JSON-RPC methods beside resources/list: skills/list and skills/get. A client
calls them only after it sees the extension declared in the server's capabilities;
to any other client skill:// URIs are ordinary resources. Content still travels by
resources/read. So serving resources alone, as selenium-flow does today, is not
the extension, and no tool is involved.
What the spec requires of a server:
- Declaring the extension requires declaring the
resourcescapability too. - An entry is
{uri, frontmatter, resources}.uriis the skill'sSKILL.md;frontmatteris its YAML header as JSON, withnameanddescription(1–1024 characters) at minimum. -
resourcesis either"dynamic"or a complete array: every file of the skill, each exactly once,SKILL.mdincluded, each{uri, digest, size}withdigest=sha256:<hex>. A host verifies what it reads against these, and may read only these URIs for the skill. -
skills/listresults extendCacheableResult:ttlMsandcacheScopeare required. Pagination is the base protocol's: an optional cursor,nextCursorwhen there is more.
How mcp-kb does it (kubed/mcp_kb/mcp/skills.py, about 100 lines): a FastMCP
ServerExtension whose methods() binds the two methods with MethodBinding,
added by mcp.add_extension(...); and an on_initialize middleware that puts the
declaration back into a legacy initialize reply. Digests are of the bytes
resources/read returns, not the bytes on disk. Results carry
resultType: "complete", ttlMs: 0, cacheScope: "private". An unknown skill is
INVALID_PARAMS "Skill not found"; a cursor is INVALID_PARAMS "Invalid skills
cursor".
FastMCP 4.0.11, probed in the pod: add_extension declares the extension in
server/discover (modern mode), but legacy initialize returns
capabilities.extensions = None while still answering skills/list. The legacy
middleware is needed. An unregistered method is Method not found. FastMCP has no
built-in SEP-2640 support yet; its issue #5016 proposes one for resource-backed
skills.
FastMCP's SkillProvider (what serves the skill today) reads a text file with
read_text(encoding="utf-8"), which normalises newlines, and any other file as
bytes. Its _manifest hashes the file on disk. Its skill_info exposes the
folder, the main file, and the relative path of every file it serves.
Who can use it. The official client matrix
(modelcontextprotocol.io/extensions/client-matrix) marks ChatGPT, fast-agent and
MCP Inspector Partial; Claude (web), Claude Desktop and VS Code Copilot have no
entry.
The gateway blocks it. Probed 2026-10-05 against mcp-kb with a real Keycloak
token: through https://mcp.kellyferrone.com/kb/mcp the declaration arrives in
initialize, but skills/list fails with HTTP 500, unsupported method: skills/list (agentgateway's InvalidMethod). Directly in-cluster mcp-kb answers
it. agentgateway 1.6.0 is the latest release; upstream issue
agentgateway#3579, "MCP
Skills Support", is open, and a maintainer agreed to a single-server passthrough.
-
Server side only; wait for the gateway (Dr K, 2026-10-05). Build the
extension as designed, test it in-cluster and with MCP Inspector, and let
agentgateway#3579 make it reachable through
mcp./mcp-pub.later. No plain-HTTP gateway route, no conditional declaration. mcp-kb is already in the same state. -
Secrets in the app, added to this PR (Dr K, 2026-10-05, mid-build): "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."
One secret opens inside the app from the list data
showalready sent; nosecret://secrets/{name}resource (Dr K chose this over adding one), sosecrets.py's "one read" rule (§F1.31) stands.
A Skills-aware client connected to selenium-flow discovers the embedded skill with
skills/list, fetches it with skills/get, and verifies every file it reads
against the entry's digests.
- Anything in the cluster repo or the gateway (Ruling 1).
- Changing
resources/list,_manifest, theread_resource/list_resourcesmirror tools, or the instructions. A client that ignores the extension sees no difference. -
"dynamic"resources, pagination, or more than one skill: there is one skill and it is static. - Declaring the extension conditionally per client.
A new module, kubed/selenium_flow/mcp/skill_extension.py, beside skill.py.
skill.py's docstring says serving the skill is FastMCP's job; this part is ours
until FastMCP ships #5016, so it lives apart and is easy to delete then.
-
EXTENSION_ID = "io.modelcontextprotocol/skills". -
entry_for(provider) -> dict | Nonebuilds the one entry from theSkillProvider, or returnsNone(with a warning logged) if the skill does not conform. -
SkillsExtension(ServerExtension)holds that entry and bindsskills/list(PaginatedRequestParams) andskills/get(GetSkillParams, aRequestParamswith a required, non-emptyuri). -
AdvertiseSkills(Middleware):on_initializeadds{EXTENSION_ID: {}}toresult.capabilities.extensions, keeping any already there. -
register(mcp, provider) -> boolbuilds the entry, and if it has one adds the extension and the middleware; returns whether it did.
skill.register(mcp, provider) calls skill_extension.register after
mcp.add_provider(provider). With --mcp-skill false there is no provider, so
skill.register is never called and nothing is declared.
Built once, when the server is constructed. Package data cannot change under a running process, so a per-request rebuild buys nothing.
-
uri:skill://selenium-flow/SKILL.md, formed the waySkillProviderforms it:skill://<folder>/<main file>. -
frontmatter: the YAML between the first two---lines ofSKILL.md, parsed withyaml.safe_load, kept exactly as parsed. Notskill_info.frontmatter, which may come from FastMCP's line-based fallback. -
resources: one item per file inskill_info.files(which includesSKILL.md), sorted by URI,{uri: skill://<folder>/<path>, digest: sha256:<hex>, size}. Digest and size are of the bytesresources/readreturns: for a file whose guessed MIME type istext/*,read_text(encoding="utf-8").encode("utf-8"); otherwiseread_bytes(). That mirrorsSkillProvider's read exactly, and the tests prove it over the wire.
Conformance check. entry_for returns None and logs a warning when:
- the frontmatter is not a mapping, or does not survive
json.dumps(..., allow_nan=False); - its
nameis not the folder name; - its
descriptionis not a string of 1–1024 characters.
The resources keep being served. Bad package data must not stop the boot; that is
skill.load()'s rule already.
Both methods return resultType: "complete", ttlMs: 0,
cacheScope: "private", the same as mcp-kb, plus:
-
skills/list→skills: [entry]. A cursor getsINVALID_PARAMS"Invalid skills cursor": there is only ever one page. -
skills/get→skill: entrywhenuriis the entry's URI; anything else getsINVALID_PARAMS"Skill not found".
The entry dict is shared, so each result carries a deep copy: a caller's mutation cannot change what the next caller is served.
Nothing new. The methods arrive on /mcp like any request, behind the same token
or JWT check.
-
show.py: one moreVIEWSrow,secret://secrets→secrets;NOUNSgainssecrets: secret, so the model's line reads "…: 1 secret." With no catalogue the resource's own refusal ("secrets are not enabled…") comes back as the tool error.secret://secrets/{name}stays unshowable. - The app:
SecretsViewdraws the catalogue as a horizontal card scroller, like the flows. Each card shows the name, description, key count, reach and the warning pills. A click opens that one secret in place as the admin pane's full card (keys with their sources, allowed sites, the reasons it may fail, origins), with "← All secrets" to go back. It needs no server call, so it works where the host offers nocallServerTool. - One card for both surfaces: the admin pane's card snippet becomes
lib/SecretCard.svelte, and its helpers move tolib/secrets.ts. The admin and the app then read an entry the same way. The admin's DOM is unchanged, which its tests prove. - Never a value: the catalogue has none. A test configures a secret with known
values and checks that neither appears anywhere in
show's result.
tests/test_skills_extension.py, unit-marked, through FastMCP's in-memory
Client (the server fixture, which is how the server is deployed), sending the
methods with client.session.send_request as mcp-kb's test does. Where it says
both modes, it is parametrised over mode="auto" and mode="legacy".
-
Declared, in both modes:
server_capabilities.extensions[EXTENSION_ID] == {}andserver_capabilities.resourcesis set. -
Listing:
skills/listreturns exactly one entry, with URIskill://selenium-flow/SKILL.md, and the three result fields. -
skills/getagrees: it returns the same entry as the listing. -
Every digest verifies, in both modes: each listed file is read with
client.read_resource, and its size and sha256 match the entry. -
The list is complete: its URIs equal the
skill://selenium-flow/...resources the server lists, minus_manifest. So a reference added later can never be left out. -
Frontmatter: it equals the YAML header of the
SKILL.mdthatresources/readreturns. -
Refusals: an unknown URI and a cursor are each
MCPErrorwithINVALID_PARAMS. -
Skill off (
mcp={"skill": False}): no declaration, andskills/listisMethod not found. -
Non-conforming skill:
entry_foron a provider over a temporary skill whosenamediffers from its folder returnsNone, andregisteradds nothing. - Isolation: mutating a returned entry does not change the next result.
-
AGENTS.md, The embedded skill: a paragraph on the extension. It covers:- the module;
- the digests are of the served bytes, not the
_manifest's disk bytes; - both eras must declare it;
- agentgateway 1.6 refuses the methods (#3579).
-
README.md, It teaches you how to use it: one sentence. Skills-aware clients also find the skill through the Skills extension (skills/list,skills/get). -
config.py: themcp.skilldescription becomes "Serve the agent skill: resources and the Skills extension.";wiki/Configuration.mdis regenerated withscripts/generate_wiki.py. -
CHANGELOG.md[Unreleased]: one line, "Skills-aware clients discover the embedded skill through the MCP Skills extension (skills/list,skills/get)."
Through the gateway the methods are refused (Research), so:
- In-cluster, from the pod:
initialize(legacy) shows the declaration;skills/listreturns the entry; every file read back matches its digest. - Optionally, MCP Inspector through a port-forward, for Dr K to see it in a real client.
- Through
mcp./mcp-pub.: confirm the declaration arrives and the call is refused, which records where agentgateway stands.
- When agentgateway ships #3579: re-run the gateway probe; nothing should change here.
- When FastMCP ships #5016: replace
skill_extension.pywith FastMCP's own.