mainsequence 9.0.22.dev65__py3-none-any.whl → 9.0.23.dev66__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agent_scaffold/AGENTS.md CHANGED
@@ -77,7 +77,8 @@ bundle. Platform skills are installed separately under
77
77
  `mainsequence.client.metatables`, or calls the retired `mainsequence` table
78
78
  and migration commands:
79
79
  `.agents/skills/mainsequence/maintenance/metatables_transition/SKILL.md`
80
- - FastAPI APIs serving the Command Center frontend:
80
+ - FastAPI APIs serving the Command Center frontend, and MCP tools and skills
81
+ served from a FastAPI release:
81
82
  `.agents/skills/mainsequence/application_surfaces/api_surfaces/SKILL.md`
82
83
  - Jobs, schedules, images, resources, releases, and Artifacts:
83
84
  `.agents/skills/mainsequence/platform_operations/orchestration_and_releases/SKILL.md`
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: mainsequence-command-center-fastapi
3
- description: Build, contract-test, release, and verify a FastAPI code repository resource serving the Command Center frontend, including applications with public OAuth callback or webhook routes. Use when implementing a Command Center wire contract, declaring exact public ingress, or validating the deployed API.
3
+ description: Build, contract-test, release, and verify a FastAPI code repository resource serving the Command Center frontend, including applications with public OAuth callback or webhook routes. Use when implementing a Command Center wire contract, declaring exact public ingress, serving MCP tools or publishing MCP skills from the app (including setting the folder of skills it publishes), or validating the deployed API.
4
4
  ---
5
5
 
6
6
  # Command Center FastAPI Release Lifecycle
@@ -236,10 +236,11 @@ See also
236
236
 
237
237
  ### MCP tools
238
238
 
239
- To serve MCP tools from the same application, add the `mcp` extra and call
240
- `install_mcp(app, mcp.streamable_http_app(), lifespan=mcp.session_manager.run)`
241
- after `install_request_identity(app)`, with a stateless FastMCP server that
242
- returns JSON. Tools authorize with `User.get_logged_user()` and
239
+ To serve MCP tools from the same application, add the `mcp` extra (the
240
+ official MCP Python SDK 2.x) and call
241
+ `install_mcp(app, mcp.streamable_http_app(stateless_http=True, json_response=True, transport_security=...), lifespan=mcp.session_manager.run)`
242
+ after `install_request_identity(app)`, with an `MCPServer`; 1.x's `FastMCP`
243
+ is not supported by the extra. Tools authorize with `User.get_logged_user()` and
243
244
  `User.get_requester()` exactly like REST handlers; admission to the release
244
245
  does not authorize every tool. Each tool decides whether it needs a person: a
245
246
  tool that needs one fails with its own error when the getter returns `None`,
@@ -251,6 +252,59 @@ threads it uses through `asyncio.to_thread` or anyio's `to_thread.run_sync`,
251
252
  and ends with the call. See
252
253
  [Serving MCP](https://mainsequence-sdk.github.io/mainsequence-sdk/knowledge/fastapi/#serving-mcp).
253
254
 
255
+ ### MCP skills
256
+
257
+ The MCP server publishes skills to its clients only from the folder set with
258
+ `skills_extension`. Setting it is required: without it, the server exposes no
259
+ skills and declares no Skills extension, whatever the repository holds.
260
+
261
+ 1. Ask the user which folder holds the skills to publish. Its name and place
262
+ are the user's choice; the SDK neither requires nor suggests one. The
263
+ examples below use `.agents/skills/mcp_only`. Only that folder is published;
264
+ the coding agents' other skill folders are not.
265
+ 2. In that folder, a folder with a `SKILL.md` is a skill, and a folder without
266
+ one groups skills; the group's path prefixes the URIs of the skills in it.
267
+ Each `SKILL.md` has frontmatter with `name`, equal to its folder's name, and
268
+ `description`. Supporting files sit beside it, in any subfolders:
269
+
270
+ ```text
271
+ .agents/skills/mcp_only/ # the folder set below (an example)
272
+ billing/ # a group: no SKILL.md
273
+ refund-review/
274
+ SKILL.md # skill://billing/refund-review/SKILL.md
275
+ references/policy.md
276
+ chargebacks/
277
+ SKILL.md # skill://billing/chargebacks/SKILL.md
278
+ daily-close/
279
+ SKILL.md # skill://daily-close/SKILL.md
280
+ ```
281
+
282
+ Skill and group folder names are 1 to 64 characters of `a-z`, `0-9` and
283
+ single hyphens. A skill inside another skill is refused.
284
+ 3. Set the folder when constructing the server. A relative path is
285
+ repository-relative: it is looked up from the module that calls
286
+ `skills_extension` upwards, so it works from any module of the repository
287
+ and any working directory. Point at the folder that holds the skills, never
288
+ at `.agents/skills` itself:
289
+
290
+ ```python
291
+ from mcp.server.mcpserver import MCPServer
292
+
293
+ from mainsequence.server.mcp_skills import skills_extension
294
+
295
+ mcp = MCPServer("refunds", extensions=[skills_extension(".agents/skills/mcp_only")])
296
+ ```
297
+
298
+ 4. Commit the folder and start the app. Every skill in it is published; add or
299
+ remove a skill folder and redeploy to change the list. An invalid skill
300
+ fails startup with the file and the rule; quote frontmatter values such as
301
+ `yes`, dates and `0755`.
302
+
303
+ Clients list the skills with `skills/list` and read them with `skills/get`
304
+ and `resources/read` on the release's `/mcp`, behind the same authentication
305
+ as tools. Do not register your own `skill://` resources. See
306
+ [Skills](https://mainsequence-sdk.github.io/mainsequence-sdk/knowledge/fastapi/#skills).
307
+
254
308
  ### Public provider callbacks and webhooks
255
309
 
256
310
  An external OAuth redirect or webhook cannot supply a Main Sequence Bearer token.
@@ -162,18 +162,22 @@ mainsequence code-repository update-sdk --path . --dry-run
162
162
  mainsequence code-repository update-sdk --path .
163
163
  ```
164
164
 
165
- `update-sdk` updates the lock and local environment. It does not publish the
166
- working tree or authorize a managed-skill or `AGENTS.md` refresh. Report any
167
- resulting pin mismatch and run those updates only when the user explicitly
168
- requests them.
165
+ `update-sdk` updates the lock and local environment, then runs the newly
166
+ installed SDK's skill copier in a fresh process against the same repository.
167
+ It replaces SDK-owned skills, removes retired ones, and updates their sentinel.
168
+ It does not refresh platform skills or `AGENTS.md`, or publish the working tree.
169
+ If copying fails, the command fails but the SDK remains upgraded; report that
170
+ partial result and use the printed refresh command to retry after addressing
171
+ the failure. On success, reload this maintenance skill before continuing.
169
172
 
170
173
  Do not automatically commit or push an SDK update unless the user also asked
171
174
  to publish the CodeRepository changes.
172
175
 
173
176
  ## Refresh Managed Skills And Instructions
174
177
 
175
- Run either managed-scaffold command only when the user explicitly requests that
176
- specific update. When both are requested, refresh the SDK-owned skills first,
178
+ Outside the skill refresh included in `update-sdk`, run either managed-scaffold
179
+ command only when the user explicitly requests that specific update. When both
180
+ are requested, refresh the SDK-owned skills first,
177
181
  then update the managed Main Sequence block in
178
182
  `AGENTS.md`:
179
183
 
@@ -49,7 +49,8 @@ Do not let this skill become a domain manual.
49
49
 
50
50
  - MetaTables, table updates, and domain data discovery: use the installed
51
51
  `metatables` package skills and documentation
52
- - FastAPI APIs serving the Command Center frontend:
52
+ - FastAPI APIs serving the Command Center frontend, and MCP tools and skills
53
+ served from a FastAPI release:
53
54
  `.agents/skills/mainsequence/application_surfaces/api_surfaces/SKILL.md`
54
55
  - CodeRepository audits, blocker analysis, and upstream SDK assessment:
55
56
  `.agents/skills/mainsequence/maintenance/bug_auditor/SKILL.md`
mainsequence/cli/cli.py CHANGED
@@ -7633,7 +7633,10 @@ def code_repository_update_sdk(
7633
7633
  dry_run: bool = typer.Option(False, "--dry-run", help="Print steps but do not execute"),
7634
7634
  ):
7635
7635
  """
7636
- Upgrade the CodeRepository SDK dependency (`mainsequence`) using `uv`.
7636
+ Upgrade the CodeRepository SDK dependency and refresh its SDK-owned skills.
7637
+
7638
+ After installing with `uv`, run the updated SDK's skill copier in a fresh
7639
+ process. Platform skills and AGENTS.md are not changed.
7637
7640
 
7638
7641
  Parameters
7639
7642
  ----------
@@ -7655,10 +7658,20 @@ def code_repository_update_sdk(
7655
7658
  code_repository_dir = _resolve_code_repository_dir(code_repository_id, path)
7656
7659
  ensure_venv(code_repository_dir)
7657
7660
 
7661
+ refresh_args = [
7662
+ "run",
7663
+ "--no-sync",
7664
+ "mainsequence",
7665
+ "code-repository",
7666
+ "update-agent-skills",
7667
+ "--path",
7668
+ str(code_repository_dir),
7669
+ ]
7658
7670
  steps = [
7659
7671
  "resolve uv executable",
7660
7672
  "uv lock --upgrade-package mainsequence",
7661
7673
  "uv sync",
7674
+ f"uv {shlex.join(refresh_args)}",
7662
7675
  ]
7663
7676
  print_table("Update SDK plan", ["Step"], [[s] for s in steps])
7664
7677
 
@@ -7671,34 +7684,18 @@ def code_repository_update_sdk(
7671
7684
  run_uv(uv, ["lock", "--upgrade-package", "mainsequence"], cwd=code_repository_dir)
7672
7685
  run_uv(uv, ["sync"], cwd=code_repository_dir)
7673
7686
 
7674
- success("SDK update complete.")
7675
- _warn_if_sdk_skills_stale(code_repository_dir)
7676
-
7677
-
7678
- def _warn_if_sdk_skills_stale(code_repository_dir: pathlib.Path) -> None:
7687
+ # The current process still has the pre-upgrade CLI loaded.
7679
7688
  try:
7680
- version = _code_repository_installed_package_version(code_repository_dir, "mainsequence")
7681
- sentinel = code_repository_dir / ".agents" / "skills" / "mainsequence" / "PINNED_FROM.txt"
7682
- values = (
7683
- dict(
7684
- line.split("=", 1)
7685
- for line in sentinel.read_text(encoding="utf-8").splitlines()
7686
- if "=" in line
7687
- )
7688
- if sentinel.is_file()
7689
- else {}
7690
- )
7691
- except (OSError, ValueError, typer.Exit):
7692
- warn("Could not check the vendored SDK skill pin. Run update-agent-skills to refresh it.")
7693
- return
7694
- pinned = values.get("pinned_version", values.get("sdk_version"))
7695
- if pinned != version:
7696
- warn(
7697
- f"Vendored SDK skills are stale or missing (pinned: {pinned or 'missing'}; installed: {version}). "
7698
- "No scaffold files were changed. Refresh with "
7699
- "`uv run --offline mainsequence code-repository update-agent-skills --path .` "
7700
- "and `uv run --offline mainsequence code-repository update AGENTS.md --path .`."
7689
+ run_uv(uv, refresh_args, cwd=code_repository_dir)
7690
+ except (OSError, RuntimeError) as exc:
7691
+ error(
7692
+ f"SDK upgraded, but SDK skill refresh failed: {exc}\n"
7693
+ "The SDK upgrade has not been rolled back. "
7694
+ f"Retry from {code_repository_dir} with: uv {shlex.join(refresh_args)}"
7701
7695
  )
7696
+ raise typer.Exit(1) from exc
7697
+
7698
+ success("SDK update and SDK skill refresh complete.")
7702
7699
 
7703
7700
 
7704
7701
  @code_repository.command("update")
@@ -0,0 +1,60 @@
1
+ """The official MCP Skills extension, copied until an ``mcp`` release ships it (ADR 0039).
2
+
3
+ ``server.py`` and ``shared.py`` are ``src/mcp/server/skills.py`` and
4
+ ``src/mcp/shared/skills.py`` of the official MCP Python SDK,
5
+ https://github.com/modelcontextprotocol/python-sdk, at commit
6
+ ``9a754b0aabb873d9707d52dc165b569fb442ec8c`` ("Add `Skills` extension", #3485),
7
+ the server side of the ``io.modelcontextprotocol/skills`` extension. They run on
8
+ the released ``mcp`` 2.3.0.
9
+
10
+ The only change is one import in ``server.py``, which reads ``shared.py`` from
11
+ this package instead of ``mcp.shared.skills``. Nothing else is edited, fixed or
12
+ extended; take an upstream fix by copying both files again from a later commit.
13
+ ``UPSTREAM`` records each file's SHA-256 as upstream published it, and a test
14
+ checks that the copies still match. Delete this package once an ``mcp`` release
15
+ contains the extension.
16
+
17
+ The copied files are under the license of the MCP Python SDK:
18
+
19
+ MIT License
20
+
21
+ Copyright (c) 2024 Anthropic, PBC
22
+
23
+ Permission is hereby granted, free of charge, to any person obtaining a copy
24
+ of this software and associated documentation files (the "Software"), to deal
25
+ in the Software without restriction, including without limitation the rights
26
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
27
+ copies of the Software, and to permit persons to whom the Software is
28
+ furnished to do so, subject to the following conditions:
29
+
30
+ The above copyright notice and this permission notice shall be included in all
31
+ copies or substantial portions of the Software.
32
+
33
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
34
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
35
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
36
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
37
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
38
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
39
+ SOFTWARE.
40
+ """
41
+
42
+ COMMIT = "9a754b0aabb873d9707d52dc165b569fb442ec8c"
43
+
44
+ # Copied file: (upstream path, SHA-256 of the upstream file).
45
+ UPSTREAM = {
46
+ "server.py": (
47
+ "src/mcp/server/skills.py",
48
+ "4a7e9b1db05d693b07c45a4db4ef85ef54713708a96154841255aa3c8d8a5334",
49
+ ),
50
+ "shared.py": (
51
+ "src/mcp/shared/skills.py",
52
+ "2669d7f4fa0ba328bdde70589d410f58274f5e8aff9d01e31e9b500d7a45ccfb",
53
+ ),
54
+ }
55
+
56
+ # The one line changed in server.py: (upstream, copy).
57
+ CHANGED_IMPORT = (
58
+ "from mcp.shared.skills import (",
59
+ "from mainsequence.server._mcp_skills_upstream.shared import (",
60
+ )
@@ -0,0 +1,172 @@
1
+ """The Skills extension (`io.modelcontextprotocol/skills`, SEP-2640).
2
+
3
+ SEP-2640 defines a convention for serving Agent Skills over MCP using the
4
+ Resources primitive: a skill is a directory of files, conventionally exposed
5
+ under the `skill://` scheme, and enumerated and fetched through two required
6
+ methods (`skills/list`, `skills/get`) plus one optional one
7
+ (`resources/directory/read`). See
8
+ https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640.
9
+
10
+ This module provides the protocol-level plumbing only: request/response
11
+ handling, SEP-2640 conformance validation, and capability advertisement. It
12
+ does not discover, read, or hash skills from a filesystem — a server author
13
+ supplies handlers that answer `skills/list`/`skills/get`/`resources/directory/read`
14
+ however their catalog is stored, and serves the underlying `skill://` file
15
+ content through the server's ordinary resource-registration APIs
16
+ (`MCPServer.add_resource`, or an `@mcp.resource(...)` template).
17
+
18
+ async def list_skills(ctx, params):
19
+ return ListSkillsResult(skills=[...])
20
+
21
+ async def get_skill(ctx, params):
22
+ if params.uri != "skill://git-workflow/SKILL.md":
23
+ raise MCPError(code=INVALID_PARAMS, message="unknown skill")
24
+ return GetSkillResult(skill=...)
25
+
26
+ mcp = MCPServer("catalog", extensions=[Skills(list_skills=list_skills, get_skill=get_skill)])
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections.abc import Awaitable, Callable, Sequence
32
+ from typing import Any
33
+
34
+ from mcp_types import CacheableResult
35
+ from mcp_types.jsonrpc import INTERNAL_ERROR, INVALID_PARAMS
36
+ from mcp_types.version import MODERN_PROTOCOL_VERSIONS
37
+ from pydantic import ValidationError
38
+
39
+ from mcp.server.context import HandlerResult, ServerRequestContext
40
+ from mcp.server.extension import Extension, MethodBinding
41
+ from mcp.shared.exceptions import MCPError
42
+ from mainsequence.server._mcp_skills_upstream.shared import (
43
+ EXTENSION_ID,
44
+ METHOD_GET,
45
+ METHOD_LIST,
46
+ METHOD_READ_DIRECTORY,
47
+ GetSkillParams,
48
+ GetSkillResult,
49
+ ListSkillsParams,
50
+ ListSkillsResult,
51
+ ReadDirectoryParams,
52
+ ReadDirectoryResult,
53
+ parse_directory_uri,
54
+ skill_name_from_uri,
55
+ validate_directory_result,
56
+ )
57
+
58
+ __all__ = ["Skills"]
59
+
60
+ ListSkillsHandler = Callable[[ServerRequestContext[Any, Any], ListSkillsParams], Awaitable[ListSkillsResult]]
61
+ GetSkillHandler = Callable[[ServerRequestContext[Any, Any], GetSkillParams], Awaitable[GetSkillResult]]
62
+ ReadDirectoryHandler = Callable[[ServerRequestContext[Any, Any], ReadDirectoryParams], Awaitable[ReadDirectoryResult]]
63
+
64
+
65
+ class Skills(Extension):
66
+ """The Skills extension: serve `skills/list`, `skills/get`, and directory reads.
67
+
68
+ `list_skills` and `get_skill` are required; a server MUST answer both per
69
+ SEP-2640, whether or not a skill appears in the listing. `read_directory`
70
+ is optional — supplying it advertises the `directoryRead` capability
71
+ setting and serves `resources/directory/read`; omitting it advertises
72
+ neither. Handlers run per request, so a catalog that changes over time
73
+ (or is too large to enumerate) can return a partial or empty listing.
74
+
75
+ Handler error contract: raise `MCPError` to return a specific error to the
76
+ caller (e.g. `INVALID_PARAMS` from `get_skill` for a URI it doesn't serve).
77
+ A result that isn't SEP-2640 conformant is caught here and reported as
78
+ `INTERNAL_ERROR` — a server fault, not the caller's bad params.
79
+ """
80
+
81
+ identifier = EXTENSION_ID
82
+
83
+ def __init__(
84
+ self,
85
+ *,
86
+ list_skills: ListSkillsHandler,
87
+ get_skill: GetSkillHandler,
88
+ read_directory: ReadDirectoryHandler | None = None,
89
+ ) -> None:
90
+ self._list_skills = list_skills
91
+ self._get_skill = get_skill
92
+ self._read_directory = read_directory
93
+
94
+ def settings(self) -> dict[str, Any]:
95
+ return {"directoryRead": True} if self._read_directory is not None else {}
96
+
97
+ def methods(self) -> Sequence[MethodBinding]:
98
+ bindings = [
99
+ MethodBinding(METHOD_LIST, ListSkillsParams, self._handle_list),
100
+ MethodBinding(METHOD_GET, GetSkillParams, self._handle_get),
101
+ ]
102
+ if self._read_directory is not None:
103
+ bindings.append(MethodBinding(METHOD_READ_DIRECTORY, ReadDirectoryParams, self._handle_read_directory))
104
+ return bindings
105
+
106
+ async def _handle_list(self, ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> HandlerResult:
107
+ # `ListSkillsResult`/`Skill` self-validate on construction, so a handler that builds a
108
+ # non-conformant listing raises `ValidationError` here — a server fault, surfaced as an
109
+ # Internal error rather than the framework's default Invalid params for a bad body. The
110
+ # models stay mutable, so re-validate the outbound payload too: a handler that mutates a
111
+ # skill after building the result can't ship a non-conformant listing past this point.
112
+ try:
113
+ result = await self._list_skills(ctx, params)
114
+ ListSkillsResult.model_validate(result.model_dump())
115
+ except ValidationError:
116
+ raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
117
+ return _finalize_cacheable(result, ctx.protocol_version)
118
+
119
+ async def _handle_get(self, ctx: ServerRequestContext[Any, Any], params: GetSkillParams) -> HandlerResult:
120
+ _require_skill_md_uri(params.uri)
121
+ # `Skill` self-validates on construction, and the re-validation guards post-construction
122
+ # mutation, both as in `_handle_list`.
123
+ try:
124
+ result = await self._get_skill(ctx, params)
125
+ GetSkillResult.model_validate(result.model_dump())
126
+ except ValidationError:
127
+ raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
128
+ if result.skill.uri != params.uri:
129
+ raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result")
130
+ return _finalize_cacheable(result, ctx.protocol_version)
131
+
132
+ async def _handle_read_directory(
133
+ self, ctx: ServerRequestContext[Any, Any], params: ReadDirectoryParams
134
+ ) -> HandlerResult:
135
+ assert self._read_directory is not None
136
+ _require_directory_uri(params.uri)
137
+ result = await self._read_directory(ctx, params)
138
+ try:
139
+ validate_directory_result(params.uri, result)
140
+ except ValueError:
141
+ raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
142
+ return result
143
+
144
+
145
+ def _require_skill_md_uri(uri: str) -> None:
146
+ try:
147
+ skill_name_from_uri(uri)
148
+ except ValueError as exc:
149
+ raise MCPError(code=INVALID_PARAMS, message=str(exc)) from exc
150
+
151
+
152
+ def _require_directory_uri(uri: str) -> None:
153
+ try:
154
+ parse_directory_uri(uri)
155
+ except ValueError as exc:
156
+ raise MCPError(code=INVALID_PARAMS, message=str(exc)) from exc
157
+
158
+
159
+ def _finalize_cacheable(result: CacheableResult, protocol_version: str) -> HandlerResult:
160
+ """Gate SEP-2549's `ttlMs`/`cacheScope` to protocol version 2026-07-28+.
161
+
162
+ `skills/list` and `skills/get` are extension methods, so — unlike a core spec
163
+ method — the runner's per-version surface sieve never runs on their results;
164
+ nothing else strips these fields for a legacy connection. `CacheableResult`
165
+ defaults to `cache_scope="private"`, keeping user-specific entries out of shared caches.
166
+ """
167
+ if protocol_version in MODERN_PROTOCOL_VERSIONS:
168
+ return result
169
+ dumped = result.model_dump(by_alias=True, mode="json", exclude_none=True)
170
+ dumped.pop("ttlMs", None)
171
+ dumped.pop("cacheScope", None)
172
+ return dumped
@@ -0,0 +1,297 @@
1
+ """Wire types and SEP-2640 conformance checks for the Skills extension.
2
+
3
+ Shared by the server (`mcp.server.skills`) and client (`mcp.client.extensions.skills`)
4
+ surfaces, mirroring how `mcp.shared.extension` hosts the identifier grammar
5
+ both tiers need. See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Annotated, Any, Literal
12
+ from urllib.parse import urlsplit
13
+
14
+ from mcp_types import CacheableResult, PaginatedRequestParams, PaginatedResult, Request, RequestParams, Resource
15
+ from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator
16
+ from pydantic.alias_generators import to_camel
17
+
18
+ __all__ = [
19
+ "Frontmatter",
20
+ "SkillResource",
21
+ "SkillResources",
22
+ "Skill",
23
+ "ListSkillsParams",
24
+ "ListSkillsResult",
25
+ "GetSkillParams",
26
+ "GetSkillResult",
27
+ "ReadDirectoryParams",
28
+ "ReadDirectoryResult",
29
+ ]
30
+
31
+ EXTENSION_ID = "io.modelcontextprotocol/skills"
32
+ """The Skills extension identifier, advertised under `ServerCapabilities.extensions`."""
33
+
34
+ METHOD_LIST = "skills/list"
35
+ METHOD_GET = "skills/get"
36
+ METHOD_READ_DIRECTORY = "resources/directory/read"
37
+
38
+ MAX_RESOURCES_PER_SKILL = 512
39
+ """SEP-2640 per-skill resource-count threshold (`SKILL.md` included).
40
+
41
+ A SHOULD NOT limit, not a hard cap: the spec requires a host to support skills
42
+ *up to and including* 512 entries and permits it to support larger ones, so a
43
+ `Skill` does not reject an over-count manifest."""
44
+
45
+ MAX_TOTAL_SIZE = 16 * 1024 * 1024
46
+ """SEP-2640 per-skill total-byte-size threshold (16 MiB), summed over `resources[].size`.
47
+
48
+ A SHOULD NOT limit, not a hard cap (see `MAX_RESOURCES_PER_SKILL`); an over-size
49
+ manifest is not rejected."""
50
+
51
+ _NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
52
+ _DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
53
+
54
+
55
+ def _check_digest(value: str) -> str:
56
+ if not _DIGEST_RE.fullmatch(value):
57
+ raise ValueError(f"invalid SHA-256 digest {value!r}; expected 'sha256:' + 64 lowercase hex characters")
58
+ return value
59
+
60
+
61
+ class _SkillModel(BaseModel):
62
+ """Base for Skills value types: matches `mcp_types`' internal `MCPModel` config.
63
+
64
+ `MCPModel` itself isn't public; every field defined below is already a
65
+ single word, so this only matters if a future field needs camelCase.
66
+ """
67
+
68
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
69
+
70
+
71
+ class SkillResource(_SkillModel):
72
+ """One file in a skill's manifest: `{uri, digest, size}`.
73
+
74
+ Shape rules are intrinsic: a `digest` that isn't `sha256:` + 64 lowercase
75
+ hex characters, or a negative `size`, is rejected at construction.
76
+ """
77
+
78
+ uri: str
79
+ digest: Annotated[str, AfterValidator(_check_digest)]
80
+ """SHA-256 digest of the file's raw bytes, formatted `sha256:{64 hex chars}`."""
81
+ size: Annotated[int, Field(ge=0)]
82
+ """Length in bytes of the file's raw content."""
83
+
84
+
85
+ Frontmatter = dict[str, Any]
86
+ """A skill's `SKILL.md` YAML frontmatter, rendered verbatim as JSON."""
87
+
88
+ SkillResources = list[SkillResource] | Literal["dynamic"]
89
+ """A skill's complete resource manifest, or the `"dynamic"` marker (SEP-2640 Resources)."""
90
+
91
+
92
+ class Skill(_SkillModel):
93
+ """An entry returned by `skills/list` or `skills/get`.
94
+
95
+ SEP-2640 conformance is intrinsic: constructing (or parsing) a `Skill`
96
+ validates the frontmatter `name`/`description`, and — unless `resources` is
97
+ `"dynamic"` — that every entry names a file within the skill's own directory,
98
+ with no duplicates and `SKILL.md` present. The `MAX_RESOURCES_PER_SKILL`
99
+ (512-entry) and `MAX_TOTAL_SIZE` (16-MiB) limits are SHOULD NOT thresholds,
100
+ not MUST NOT, so an over-limit manifest is accepted.
101
+ """
102
+
103
+ uri: str
104
+ """Resource URI of the skill's `SKILL.md`."""
105
+ frontmatter: Frontmatter
106
+ resources: SkillResources
107
+
108
+ @model_validator(mode="after")
109
+ def _check_conformance(self) -> Skill:
110
+ name = skill_name_from_uri(self.uri)
111
+ frontmatter_name = self.frontmatter.get("name")
112
+ if (
113
+ not isinstance(frontmatter_name, str)
114
+ or not _NAME_RE.fullmatch(frontmatter_name)
115
+ or len(frontmatter_name) > 64
116
+ ):
117
+ raise ValueError(f"skill {self.uri!r} frontmatter name must be 1-64 lowercase, digits, or hyphens")
118
+ if frontmatter_name != name:
119
+ raise ValueError(
120
+ f"skill {self.uri!r} frontmatter name {frontmatter_name!r} does not match URI name {name!r}"
121
+ )
122
+ description = self.frontmatter.get("description")
123
+ if not isinstance(description, str) or not (1 <= len(description) <= 1024):
124
+ raise ValueError(f"skill {self.uri!r} frontmatter description must contain 1 to 1024 characters")
125
+ if self.resources == "dynamic":
126
+ return self
127
+ seen: set[str] = set()
128
+ for resource in self.resources:
129
+ _validate_resource_uri_in_skill(self.uri, resource.uri)
130
+ if resource.uri in seen:
131
+ raise ValueError(f"skill {self.uri!r} lists resource {resource.uri!r} more than once")
132
+ seen.add(resource.uri)
133
+ if self.uri not in seen:
134
+ raise ValueError(f"skill {self.uri!r} resources does not include its own SKILL.md")
135
+ return self
136
+
137
+
138
+ class ListSkillsParams(PaginatedRequestParams):
139
+ """Parameters for `skills/list`."""
140
+
141
+
142
+ class ListSkillsResult(PaginatedResult, CacheableResult):
143
+ """Result of `skills/list`.
144
+
145
+ Each skill self-validates; on top of that, constructing (or parsing) this
146
+ result rejects two entries that share a `uri`.
147
+
148
+ `ttl_ms`/`cache_scope` are SEP-2549 fields inherited from `CacheableResult`;
149
+ unlike a core spec method, nothing sieves them off the wire for a
150
+ pre-2026-07-28 connection automatically (see `mcp.server.skills`), so
151
+ callers constructing this directly for such a connection must omit them.
152
+ """
153
+
154
+ skills: list[Skill]
155
+
156
+ @model_validator(mode="after")
157
+ def _check_unique_uris(self) -> ListSkillsResult:
158
+ seen: set[str] = set()
159
+ for skill in self.skills:
160
+ if skill.uri in seen:
161
+ raise ValueError(f"skills/list result lists skill {skill.uri!r} more than once")
162
+ seen.add(skill.uri)
163
+ return self
164
+
165
+
166
+ class GetSkillParams(RequestParams):
167
+ """Parameters for `skills/get`."""
168
+
169
+ uri: str
170
+ """URI of the skill's `SKILL.md`."""
171
+
172
+
173
+ class GetSkillResult(CacheableResult):
174
+ """Result of `skills/get`.
175
+
176
+ Like `ListSkillsResult`, this extends `CacheableResult`: the stable spec page
177
+ makes `GetSkillResult` carry SEP-2549's `ttl_ms`/`cache_scope`, the same
178
+ freshness hint `resources/read` gives. As on `skills/list`, nothing sieves
179
+ these fields off the wire for a pre-2026-07-28 connection automatically (see
180
+ `mcp.server.skills`), so a caller constructing this directly for such a
181
+ connection must omit them.
182
+ """
183
+
184
+ skill: Skill
185
+
186
+
187
+ class ReadDirectoryParams(PaginatedRequestParams):
188
+ """Parameters for `resources/directory/read`."""
189
+
190
+ uri: str
191
+ """URI of the directory resource whose direct children are listed."""
192
+
193
+
194
+ class ReadDirectoryResult(PaginatedResult):
195
+ """Result of `resources/directory/read`."""
196
+
197
+ resources: list[Resource]
198
+
199
+
200
+ class ListSkillsRequest(Request[ListSkillsParams | None, Literal["skills/list"]]):
201
+ method: Literal["skills/list"] = "skills/list"
202
+ params: ListSkillsParams | None = None
203
+
204
+
205
+ class GetSkillRequest(Request[GetSkillParams, Literal["skills/get"]]):
206
+ method: Literal["skills/get"] = "skills/get"
207
+ params: GetSkillParams
208
+
209
+
210
+ class ReadDirectoryRequest(Request[ReadDirectoryParams, Literal["resources/directory/read"]]):
211
+ method: Literal["resources/directory/read"] = "resources/directory/read"
212
+ params: ReadDirectoryParams
213
+
214
+
215
+ def skill_name_from_uri(uri: str) -> str:
216
+ """Return the skill `name` encoded in a `SKILL.md` resource URI.
217
+
218
+ Per SEP-2640 Resource Mapping, the final `<skill-path>` segment equals the
219
+ skill's `name`; for a bare `skill://<name>/SKILL.md` (no organizational
220
+ prefix) that segment is the authority.
221
+
222
+ Raises:
223
+ ValueError: If `uri` is not an absolute URI ending in `/SKILL.md`.
224
+ """
225
+ parts = urlsplit(uri)
226
+ if not parts.scheme or parts.query or parts.fragment:
227
+ raise ValueError(f"skill URI {uri!r} is not a valid absolute resource URI")
228
+ if not parts.path.endswith("/SKILL.md"):
229
+ raise ValueError(f"skill URI {uri!r} must end in /SKILL.md")
230
+ directory = parts.path[: -len("/SKILL.md")].strip("/")
231
+ if directory:
232
+ name = directory.rsplit("/", 1)[-1]
233
+ else:
234
+ name = parts.hostname or ""
235
+ if not name:
236
+ raise ValueError(f"skill URI {uri!r} has no skill name")
237
+ return name
238
+
239
+
240
+ def _validate_resource_uri_in_skill(skill_uri: str, resource_uri: str) -> None:
241
+ """Raise `ValueError` unless `resource_uri` names a file within `skill_uri`'s directory."""
242
+ skill_parts = urlsplit(skill_uri)
243
+ resource_parts = urlsplit(resource_uri)
244
+ if not resource_parts.scheme or resource_parts.query or resource_parts.fragment:
245
+ raise ValueError(f"resource URI {resource_uri!r} is invalid")
246
+ if resource_uri.endswith("/"):
247
+ raise ValueError(f"resource URI {resource_uri!r} names a directory, not a file")
248
+ if skill_parts.scheme != resource_parts.scheme or skill_parts.netloc != resource_parts.netloc:
249
+ raise ValueError(f"resource URI {resource_uri!r} is outside the skill root {skill_uri!r}")
250
+ root = skill_parts.path[: -len("/SKILL.md")]
251
+ if resource_parts.path != skill_parts.path and not resource_parts.path.startswith(root + "/"):
252
+ raise ValueError(f"resource URI {resource_uri!r} is outside the skill root {skill_uri!r}")
253
+ if any(segment in (".", "..") for segment in resource_parts.path.split("/")):
254
+ raise ValueError(f"resource URI {resource_uri!r} contains a traversal segment")
255
+
256
+
257
+ def parse_directory_uri(uri: str) -> tuple[str, str, str]:
258
+ """Split a directory resource URI into `(scheme, netloc, path)`.
259
+
260
+ Raises:
261
+ ValueError: If `uri` has a trailing slash, or is otherwise not a valid
262
+ absolute resource URI.
263
+ """
264
+ if uri.endswith("/"):
265
+ raise ValueError(f"directory URI {uri!r} must not have a trailing slash")
266
+ parts = urlsplit(uri)
267
+ if not parts.scheme or parts.query or parts.fragment:
268
+ raise ValueError(f"directory URI {uri!r} is not a valid absolute resource URI")
269
+ return parts.scheme, parts.netloc, parts.path
270
+
271
+
272
+ def validate_directory_result(uri: str, result: ReadDirectoryResult) -> None:
273
+ """Validate that each entry in `result.resources` is a unique direct child of `uri`.
274
+
275
+ Checks containment and shape only — that every listed resource is a direct
276
+ child of `uri` with a unique `uri`. It cannot confirm the listing
277
+ is exhaustive, since it has no independent view of the directory's contents.
278
+
279
+ Raises:
280
+ ValueError: If `uri` is malformed, or any entry is not a direct child,
281
+ or two entries share a `uri`.
282
+ """
283
+ scheme, netloc, parent_path = parse_directory_uri(uri)
284
+ seen_uris: set[str] = set()
285
+ prefix = parent_path.rstrip("/") + "/" if parent_path.rstrip("/") else "/"
286
+ for resource in result.resources:
287
+ child = urlsplit(resource.uri)
288
+ if not child.scheme or child.query or child.fragment:
289
+ raise ValueError(f"directory {uri!r} child has invalid URI {resource.uri!r}")
290
+ if child.scheme != scheme or child.netloc != netloc:
291
+ raise ValueError(f"resource {resource.uri!r} is not a child of directory {uri!r}")
292
+ relative = child.path.removeprefix(prefix)
293
+ if relative == child.path or not relative or "/" in relative or relative in (".", ".."):
294
+ raise ValueError(f"resource {resource.uri!r} is not a direct child of directory {uri!r}")
295
+ if resource.uri in seen_uris:
296
+ raise ValueError(f"directory {uri!r} contains a duplicate child {resource.uri!r}")
297
+ seen_uris.add(resource.uri)
@@ -394,7 +394,9 @@ def install_mcp(app, mcp_app, *, lifespan):
394
394
  ``mcp_app`` is the server's ASGI application, answering Streamable HTTP at
395
395
  ``/mcp`` statelessly with JSON responses. ``lifespan`` is a zero-argument
396
396
  callable returning its async context manager. With the official MCP Python
397
- SDK, pass ``mcp.streamable_http_app()`` and ``mcp.session_manager.run``.
397
+ SDK's ``MCPServer``, pass ``mcp.streamable_http_app(stateless_http=True,
398
+ json_response=True, transport_security=...)`` and ``mcp.session_manager.run``;
399
+ its skills come from ``mainsequence.server.mcp_skills`` (ADR 0039).
398
400
 
399
401
  Install request identity first. Tools read the caller with
400
402
  ``User.get_logged_user()`` and, on a requester-bound call, the person it
@@ -0,0 +1,408 @@
1
+ """An application's Agent Skills served through the MCP Skills extension (ADR 0039).
2
+
3
+ ``skills_extension(directory)`` reads a directory of Agent Skills once and returns
4
+ the official ``io.modelcontextprotocol/skills`` extension for the application's
5
+ ``MCPServer``. The extension answers ``skills/list`` and ``skills/get``, and every
6
+ skill file is an ordinary resource served with the exact bytes its manifest
7
+ digests. Needs the ``mcp`` extra.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import math
14
+ import mimetypes
15
+ import os
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import yaml
21
+
22
+ try:
23
+ from mcp.server.extension import ResourceBinding
24
+ from mcp.server.mcpserver.resources import BinaryResource, TextResource
25
+ from mcp.shared.exceptions import MCPError
26
+ from mcp_types.jsonrpc import INVALID_PARAMS
27
+ from pydantic import ValidationError
28
+ except ImportError as exc:
29
+ raise ImportError(
30
+ "Serving skills needs the official MCP Python SDK 2.x: install mainsequence[mcp]."
31
+ ) from exc
32
+
33
+ try:
34
+ from mcp.server.skills import Skills
35
+ from mcp.shared.skills import GetSkillResult, ListSkillsResult, Skill, SkillResource
36
+ except ImportError:
37
+ # Until an mcp release contains the extension, its verbatim copy.
38
+ from ._mcp_skills_upstream.server import Skills
39
+ from ._mcp_skills_upstream.shared import (
40
+ GetSkillResult,
41
+ ListSkillsResult,
42
+ Skill,
43
+ SkillResource,
44
+ )
45
+
46
+ # Content changes only with a new revision, and digests catch a stale entry.
47
+ _TTL_MS = 300_000
48
+ _MAX_FILES = 512
49
+ _MAX_BYTES = 16 * 1024 * 1024
50
+ _SKILL_MD = "SKILL.md"
51
+ _NAME = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*", flags=re.ASCII)
52
+ _NAME_RULE = "1 to 64 characters of a-z, 0-9 and single hyphens, not at either end"
53
+ # Unreserved URI characters only, so a file's URI never needs percent-encoding.
54
+ _SEGMENT = re.compile(r"[A-Za-z0-9._~-]+", flags=re.ASCII)
55
+ # Python's built-in table only, not the host's mime.types files.
56
+ _MIME_TYPES = mimetypes.MimeTypes()
57
+ _MIME_TYPES.add_type("text/markdown", ".md")
58
+
59
+
60
+ class _Yaml11Loader(yaml.SafeLoader):
61
+ """PyYAML's safe loader: YAML 1.1, as hosts using PyYAML read frontmatter."""
62
+
63
+
64
+ class _Yaml12Loader(yaml.SafeLoader):
65
+ """The safe loader with YAML 1.2 core schema scalars, as most other hosts read them."""
66
+
67
+
68
+ def _mapping(loader, node):
69
+ """A mapping without repeated keys, which parsers resolve differently."""
70
+ seen = set()
71
+ for key, _ in node.value:
72
+ if isinstance(key, yaml.ScalarNode):
73
+ if key.value in seen:
74
+ raise yaml.constructor.ConstructorError(
75
+ None, None, f"found duplicate key {key.value!r}", key.start_mark
76
+ )
77
+ seen.add(key.value)
78
+ return loader.construct_mapping(node, deep=True)
79
+
80
+
81
+ def _int_12(loader, node):
82
+ value = loader.construct_scalar(node)
83
+ if value.startswith("0o"):
84
+ return int(value[2:], 8)
85
+ if value.startswith("0x"):
86
+ return int(value[2:], 16)
87
+ return int(value)
88
+
89
+
90
+ _Yaml12Loader.yaml_implicit_resolvers = {}
91
+ for _tag, _pattern, _first in (
92
+ ("null", r"^(?:~|null|Null|NULL|)$", ["~", "n", "N", ""]),
93
+ ("bool", r"^(?:true|True|TRUE|false|False|FALSE)$", list("tTfF")),
94
+ ("int", r"^(?:[-+]?[0-9]+|0o[0-7]+|0x[0-9a-fA-F]+)$", list("-+0123456789")),
95
+ (
96
+ "float",
97
+ r"^(?:[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?"
98
+ r"|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$",
99
+ list("-+.0123456789"),
100
+ ),
101
+ ):
102
+ _Yaml12Loader.add_implicit_resolver(f"tag:yaml.org,2002:{_tag}", re.compile(_pattern), _first)
103
+ _Yaml12Loader.add_constructor("tag:yaml.org,2002:int", _int_12)
104
+ for _loader in (_Yaml11Loader, _Yaml12Loader):
105
+ _loader.add_constructor("tag:yaml.org,2002:map", _mapping)
106
+
107
+
108
+ def _invalid(path, rule):
109
+ return ValueError(f"{path}: {rule}.")
110
+
111
+
112
+ def _listing(path):
113
+ """A directory's entries in name order, without hidden ones."""
114
+ try:
115
+ with os.scandir(path) as entries:
116
+ return sorted(
117
+ (entry for entry in entries if not entry.name.startswith(".")),
118
+ key=lambda entry: entry.name,
119
+ )
120
+ except OSError as exc:
121
+ raise _invalid(path, f"cannot be read ({exc.strerror})") from None
122
+
123
+
124
+ def _files(path, parts=()):
125
+ """Every regular file under a skill directory: its path parts and path, in name order."""
126
+ for entry in _listing(path):
127
+ entry_path = Path(entry.path)
128
+ if entry.is_symlink():
129
+ raise _invalid(entry_path, "is a symbolic link; a skill holds only regular files")
130
+ if not _SEGMENT.fullmatch(entry.name):
131
+ raise _invalid(entry_path, "a skill path may use only A-Z a-z 0-9 . _ ~ -")
132
+ if entry.is_dir(follow_symlinks=False):
133
+ yield from _files(entry_path, (*parts, entry.name))
134
+ elif entry.is_file(follow_symlinks=False):
135
+ yield (*parts, entry.name), entry_path
136
+ else:
137
+ raise _invalid(entry_path, "is not a regular file or directory")
138
+
139
+
140
+ def _json_value(path, value, where):
141
+ if isinstance(value, dict):
142
+ for key, item in value.items():
143
+ if not isinstance(key, str):
144
+ raise _invalid(path, f"{where} has the key {key!r}, which is not a string")
145
+ _json_value(path, item, f"{where}.{key}")
146
+ elif isinstance(value, list):
147
+ for index, item in enumerate(value):
148
+ _json_value(path, item, f"{where}[{index}]")
149
+ elif isinstance(value, float):
150
+ if not math.isfinite(value):
151
+ raise _invalid(path, f"{where} is not a finite number")
152
+ elif value is not None and not isinstance(value, bool | int | str):
153
+ raise _invalid(path, f"{where} is a {type(value).__name__}, not a JSON value")
154
+
155
+
156
+ def _difference(first, second, where):
157
+ """Where two parses of the same YAML disagree, or None."""
158
+ if type(first) is not type(second):
159
+ return where
160
+ if isinstance(first, dict):
161
+ if list(first) != list(second):
162
+ return where
163
+ pairs = [(first[key], second[key], f"{where}.{key}") for key in first]
164
+ elif isinstance(first, list):
165
+ if len(first) != len(second):
166
+ return where
167
+ pairs = [
168
+ (a, b, f"{where}[{index}]")
169
+ for index, (a, b) in enumerate(zip(first, second, strict=True))
170
+ ]
171
+ else:
172
+ return None if first == second else where
173
+ for a, b, inner in pairs:
174
+ found = _difference(a, b, inner)
175
+ if found:
176
+ return found
177
+ return None
178
+
179
+
180
+ def _text(path, frontmatter, key, *, longest=None, required=False):
181
+ if key not in frontmatter:
182
+ if required:
183
+ raise _invalid(path, f"the frontmatter has no {key}")
184
+ return
185
+ value = frontmatter[key]
186
+ if not isinstance(value, str) or not value or (longest and len(value) > longest):
187
+ size = f"1 to {longest} characters" if longest else "a non-empty string"
188
+ raise _invalid(path, f"{key} must be {size}")
189
+
190
+
191
+ def _frontmatter(path, data, name):
192
+ """The SKILL.md frontmatter as a JSON object that every YAML parser reads alike."""
193
+ try:
194
+ lines = data.decode("utf-8").split("\n")
195
+ except UnicodeDecodeError:
196
+ raise _invalid(path, "SKILL.md must be UTF-8") from None
197
+ if lines[0].removesuffix("\r") != "---":
198
+ raise _invalid(path, "SKILL.md must begin with a '---' line that opens its frontmatter")
199
+ end = next(
200
+ (index for index, line in enumerate(lines) if index and line.removesuffix("\r") == "---"),
201
+ None,
202
+ )
203
+ if end is None:
204
+ raise _invalid(path, "the frontmatter has no closing '---' line")
205
+ source = "\n".join(lines[1:end])
206
+ try:
207
+ as_yaml_11 = yaml.load(source, Loader=_Yaml11Loader)
208
+ frontmatter = yaml.load(source, Loader=_Yaml12Loader)
209
+ except yaml.YAMLError as exc:
210
+ raise _invalid(path, f"the frontmatter is not valid YAML ({exc})") from None
211
+ if not isinstance(frontmatter, dict):
212
+ raise _invalid(path, "the frontmatter must be a mapping")
213
+ _json_value(path, frontmatter, "frontmatter")
214
+ differs = _difference(as_yaml_11, frontmatter, "frontmatter")
215
+ if differs:
216
+ raise _invalid(
217
+ path,
218
+ f"{differs} reads differently under YAML 1.1 and YAML 1.2; quote it "
219
+ "(for example yes, no, on, off, dates and numbers with leading zeros)",
220
+ )
221
+ value = frontmatter.get("name")
222
+ if not (isinstance(value, str) and len(value) <= 64 and _NAME.fullmatch(value)):
223
+ raise _invalid(path, f"name must be {_NAME_RULE}")
224
+ if value != name:
225
+ raise _invalid(path, f"name {value!r} must equal its directory's name {name!r}")
226
+ _text(path, frontmatter, "description", longest=1024, required=True)
227
+ _text(path, frontmatter, "compatibility", longest=500)
228
+ _text(path, frontmatter, "license")
229
+ _text(path, frontmatter, "allowed-tools")
230
+ metadata = frontmatter.get("metadata", {})
231
+ if not isinstance(metadata, dict) or not all(isinstance(v, str) for v in metadata.values()):
232
+ raise _invalid(path, "metadata must map strings to strings")
233
+ return frontmatter
234
+
235
+
236
+ def _resource(uri, data, *, name, description=None):
237
+ """A resource serving exactly ``data``: as text when it is UTF-8, else as a blob."""
238
+ guessed, encoding = _MIME_TYPES.guess_type(uri)
239
+ guessed = guessed if encoding is None else None
240
+ try:
241
+ text = data.decode("utf-8")
242
+ except UnicodeDecodeError:
243
+ mime_type = guessed or "application/octet-stream"
244
+ return BinaryResource(
245
+ uri=uri, name=name, description=description, mime_type=mime_type, data=data
246
+ )
247
+ mime_type = guessed or "text/plain"
248
+ return TextResource(uri=uri, name=name, description=description, mime_type=mime_type, text=text)
249
+
250
+
251
+ def _skill(root, skill_path):
252
+ """One skill folder, read once: its entry and the resources serving its files."""
253
+ name = root.name
254
+ files, total = {}, 0
255
+ for parts, path in _files(root):
256
+ if parts[-1] == _SKILL_MD and len(parts) > 1:
257
+ raise _invalid(
258
+ path,
259
+ "a skill inside another skill is not served; "
260
+ "give it its own folder beside that skill or in a group folder",
261
+ )
262
+ try:
263
+ if len(files) == _MAX_FILES or total + path.stat().st_size > _MAX_BYTES:
264
+ raise _invalid(root, f"a skill may hold at most {_MAX_FILES} files and 16 MiB")
265
+ data = path.read_bytes()
266
+ except OSError as exc:
267
+ raise _invalid(path, f"cannot be read ({exc.strerror})") from None
268
+ total += len(data)
269
+ if total > _MAX_BYTES:
270
+ raise _invalid(root, f"a skill may hold at most {_MAX_FILES} files and 16 MiB")
271
+ files["/".join(parts)] = data
272
+ if _SKILL_MD not in files:
273
+ raise _invalid(root, "a skill folder must hold a SKILL.md")
274
+ frontmatter = _frontmatter(root / _SKILL_MD, files[_SKILL_MD], name)
275
+ order = [_SKILL_MD, *sorted(path for path in files if path != _SKILL_MD)]
276
+ uris = {path: f"skill://{skill_path}/{path}" for path in order}
277
+ try:
278
+ entry = Skill(
279
+ uri=uris[_SKILL_MD],
280
+ frontmatter=frontmatter,
281
+ resources=[
282
+ SkillResource(
283
+ uri=uris[path],
284
+ digest="sha256:" + hashlib.sha256(files[path]).hexdigest(),
285
+ size=len(files[path]),
286
+ )
287
+ for path in order
288
+ ],
289
+ )
290
+ except ValidationError as exc:
291
+ raise _invalid(root, f"is not a valid Skills extension entry ({exc})") from None
292
+ resources = [
293
+ _resource(
294
+ uris[_SKILL_MD], files[_SKILL_MD], name=name, description=frontmatter["description"]
295
+ )
296
+ ]
297
+ resources += [
298
+ _resource(uris[path], files[path], name=path.rsplit("/", 1)[-1]) for path in order[1:]
299
+ ]
300
+ return entry, resources
301
+
302
+
303
+ class _DirectorySkills(Skills):
304
+ """The official Skills extension, serving one skills directory read at startup."""
305
+
306
+ def __init__(self, catalog):
307
+ super().__init__(list_skills=self._list_skills, get_skill=self._get_skill)
308
+ self._skills = [entry for entry, _ in catalog]
309
+ self._by_uri = {entry.uri: entry for entry in self._skills}
310
+ self._resources = [
311
+ ResourceBinding(resource) for _, resources in catalog for resource in resources
312
+ ]
313
+
314
+ def resources(self):
315
+ return self._resources
316
+
317
+ async def _list_skills(self, ctx, params):
318
+ if params.cursor is not None:
319
+ raise MCPError(code=INVALID_PARAMS, message="Invalid cursor: skills/list has one page.")
320
+ return ListSkillsResult(skills=self._skills, ttl_ms=_TTL_MS, cache_scope="private")
321
+
322
+ async def _get_skill(self, ctx, params):
323
+ skill = self._by_uri.get(params.uri)
324
+ if skill is None:
325
+ raise MCPError(code=INVALID_PARAMS, message=f"No skill is served at {params.uri}")
326
+ return GetSkillResult(skill=skill, ttl_ms=_TTL_MS, cache_scope="private")
327
+
328
+
329
+ def _skills_directory(directory, caller):
330
+ """``directory`` as an absolute path; a relative one is found above the calling module."""
331
+ path = Path(directory)
332
+ if path.is_absolute():
333
+ return path
334
+ start = Path(caller).resolve().parent if caller else Path.cwd()
335
+ for base in (start, *start.parents):
336
+ if (base / path).is_dir():
337
+ return base / path
338
+ raise ValueError(
339
+ f"No directory {path} was found in {start} or above it: a relative skills directory "
340
+ "is resolved from the module that calls skills_extension, up to the repository root."
341
+ )
342
+
343
+
344
+ def _holds_skill_md(folder):
345
+ return any(
346
+ entry.name == _SKILL_MD and entry.is_file(follow_symlinks=False)
347
+ for entry in _listing(folder)
348
+ )
349
+
350
+
351
+ def _collect(folder, prefix, catalog):
352
+ """The skills under ``folder``: a folder with a SKILL.md is a skill, one without groups."""
353
+ for entry in _listing(folder):
354
+ path = Path(entry.path)
355
+ if entry.is_symlink():
356
+ raise _invalid(path, "is a symbolic link; skills and group folders are directories")
357
+ if not entry.is_dir(follow_symlinks=False):
358
+ continue # A file beside skills, such as a README, belongs to no skill.
359
+ # The first folder becomes the skill URI's host, so every name keeps the name rule.
360
+ if not (len(entry.name) <= 64 and _NAME.fullmatch(entry.name)):
361
+ raise _invalid(path, f"a skill or group folder's name must be {_NAME_RULE}")
362
+ segments = (*prefix, entry.name)
363
+ if _holds_skill_md(path):
364
+ catalog.append(_skill(path, "/".join(segments)))
365
+ continue
366
+ found = len(catalog)
367
+ _collect(path, segments, catalog)
368
+ if len(catalog) == found:
369
+ raise _invalid(
370
+ path,
371
+ "holds no SKILL.md and no skill: a skill folder needs a SKILL.md, "
372
+ "a group folder needs skills",
373
+ )
374
+
375
+
376
+ def skills_extension(directory):
377
+ """The MCP Skills extension serving the Agent Skills in ``directory``.
378
+
379
+ Pass it when constructing the application's server,
380
+ ``MCPServer(name, extensions=[skills_extension(directory)])``. ``directory``
381
+ is the folder the application chooses for the skills it publishes, with any
382
+ name and place in the repository: an absolute path, or a path relative to the
383
+ repository, found from the calling module's directory upwards. Without the
384
+ extension the server exposes no skills, and nothing outside ``directory`` is
385
+ published.
386
+
387
+ A folder in ``directory`` that holds a ``SKILL.md`` is a skill; a folder
388
+ without one groups skills, and its path prefixes their URIs:
389
+ ``billing/refund-review/SKILL.md`` is ``skill://billing/refund-review/SKILL.md``.
390
+ Hidden names are skipped. Every file is read now: manifests digest the bytes
391
+ served, and a change on disk takes effect at the next start. A folder that
392
+ breaks the Agent Skills format or the extension's limits raises
393
+ ``ValueError`` naming the file and the rule. The server's ``skill://``
394
+ resources belong to this extension. See ADR 0039.
395
+ """
396
+ directory = _skills_directory(directory, sys._getframe(1).f_globals.get("__file__"))
397
+ if not directory.is_dir():
398
+ raise _invalid(directory, "the skills folder does not exist")
399
+ if _holds_skill_md(directory):
400
+ raise _invalid(directory, "pass the folder that contains the skills, not a skill")
401
+ catalog = []
402
+ _collect(directory, (), catalog)
403
+ if not catalog:
404
+ raise _invalid(directory, "the skills folder holds no skill")
405
+ return _DirectorySkills(sorted(catalog, key=lambda skill: skill[0].uri))
406
+
407
+
408
+ __all__ = ["skills_extension"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.22.dev65
3
+ Version: 9.0.23.dev66
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -32,7 +32,7 @@ Requires-Dist: structlog
32
32
  Requires-Dist: typer
33
33
  Requires-Dist: urllib3>=2.7.0
34
34
  Provides-Extra: mcp
35
- Requires-Dist: mcp<2,>=1.28; extra == "mcp"
35
+ Requires-Dist: mcp<3,>=2.3; extra == "mcp"
36
36
  Dynamic: license-file
37
37
 
38
38
  <p align="center">
@@ -1,13 +1,13 @@
1
- agent_scaffold/AGENTS.md,sha256=-tK1zUP6j3QAlFXmwFVSNPfbzchZRJSzj26L-RU9Gis,6471
1
+ agent_scaffold/AGENTS.md,sha256=Y_alRuPiX82gfu1ChPCOtBMvF44YiWr4wHxUq3hL43w,6529
2
2
  agent_scaffold/skills/a2a_sdk_execution/SKILL.md,sha256=NcwIQ1QgD4Ku0mG2Hb-Izee8p9fm9LTPHa6gDEWTOSQ,13200
3
- agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md,sha256=QvYPjIwRQ6lBcHo9OSf61GWv8sDAEOxLUUneOozEi34,21685
3
+ agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md,sha256=gdoe860TEQyrMIVcN1L3Cf8lBYv3ErMkTNWcNrWjOKo,24427
4
4
  agent_scaffold/skills/mainsequence-inference/SKILL.md,sha256=yv8aAHLQ2FBBSWPIFadY7pGNTwtR-yiR_-UzaGRN9-g,12502
5
5
  agent_scaffold/skills/maintenance/bug_auditor/SKILL.md,sha256=CCKs7edyZ9RK-WtAgXJXucokJFXZxk8sRUe0_imFtu8,8610
6
- agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md,sha256=pPsZaERlfokSJ6b6vLjpA5U0KuKxbOFzzcxOVW_ikiI,17935
6
+ agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md,sha256=vJ7ksmqvgjdQGS3QYGb6oD7HaRi5LNIo1PYKTEV_eyU,18283
7
7
  agent_scaffold/skills/maintenance/metatables_transition/SKILL.md,sha256=PisgpytcksJnfZMS6aiPCmahsT3fm8DIwk_cefXA2qU,3759
8
8
  agent_scaffold/skills/platform_operations/access_control_and_sharing/SKILL.md,sha256=rB_52v63tR5dptYYviaAHzYmnhLPgWsex2lpZkYDDuk,14765
9
9
  agent_scaffold/skills/platform_operations/orchestration_and_releases/SKILL.md,sha256=_84nnJKl_w-JrcbiTIairbFf9ptoQvW5Y1kMxn_XzkU,16871
10
- agent_scaffold/skills/sdk_code_repository_execution/SKILL.md,sha256=zly7MheB-Sjd0x6l_oPCOTBqkL2E4SSXwTlfSwdIPfQ,10196
10
+ agent_scaffold/skills/sdk_code_repository_execution/SKILL.md,sha256=_CzYF7p0slC8m9oeKgGbWjCjvXW607SCCFC8dClujtE,10254
11
11
  mainsequence/__init__.py,sha256=70CbIo7ZG1mzi5uwkqDtVESunBE0RanHjeFg4ptfsA0,385
12
12
  mainsequence/__main__.py,sha256=KuAP69uduS2ImrPIgNMzbGBRepZzjqvUONu5vFh4v4A,176
13
13
  mainsequence/_request_identity.py,sha256=vsbnQeJ9ycRB2p8oFTUxfPkzDh7-SBQ5Pxzn04pjqVM,4183
@@ -24,7 +24,7 @@ mainsequence/scaffold_skills.py,sha256=k0V6eKC5AwbHr9wB9UGMJNF-8eJ0_1izF4-BU01KH
24
24
  mainsequence/cli/__init__.py,sha256=sMWTnomCaISjnureLuXFRqOzkH2Y77uYEivsd-M6_8Y,28
25
25
  mainsequence/cli/api.py,sha256=u3vjwJ5w_u4o76ufkePXKIh6dixOHDok_r8Os0iA_04,128865
26
26
  mainsequence/cli/browser_auth.py,sha256=Vb5MBOnPC05HLZzVNUDODobJ_p5wEIzH_h8rIlpShlE,9984
27
- mainsequence/cli/cli.py,sha256=8EM0j2rRvGX-t1ByfkVD8HUUx5QgK1BiurCImZfjqYI,269846
27
+ mainsequence/cli/cli.py,sha256=4t0vf-gi_Ez4MjvAEOAfGYwVuSxb1HECRRyaruQvp6M,269468
28
28
  mainsequence/cli/code_repository_status.py,sha256=SHEnU8YSWFowPG6dEdE4Yd8CS1tIcJSE1MXFLfvD3Os,5975
29
29
  mainsequence/cli/config.py,sha256=TJYnM11kg5pH2O7AGWFLpNEkC9UEu0g7k2RLjhAHCzo,45724
30
30
  mainsequence/cli/docker_utils.py,sha256=eAnp9MI042cHWwKasA5IF2I7INL3A6tV2JX-cSIe7rc,10687
@@ -53,10 +53,14 @@ mainsequence/instrumentation/__init__.py,sha256=J_9XmfjprElvz0u2t9rTyJT0kFdyKpxd
53
53
  mainsequence/instrumentation/utils.py,sha256=b-ihjSioAepeLfMpudM2SqCtvnxMqIXileHx3klBCQU,3377
54
54
  mainsequence/server/__init__.py,sha256=BHUAhtdWPDRdxX07NYO_6wrBv5mf0GsXflecO52PmUc,73
55
55
  mainsequence/server/caller_assertions.py,sha256=7OEgPXxiGv-P98K6-XVKFSiNcYZkEFDmehBc_oJKkFM,12563
56
- mainsequence/server/fastapi.py,sha256=ZMtazH8ZN2C82uTkBtl0UQdx6F9mlCHD1HurgZQdHbU,18512
57
- mainsequence-9.0.22.dev65.dist-info/licenses/LICENSE,sha256=b6Ecsk8wjsEby3lbSzdljR6hHiFGHBizt9iWV2p1c_I,1074
58
- mainsequence-9.0.22.dev65.dist-info/METADATA,sha256=iVI-Dap3Gl5LvTSpmppG5uR9Q8vMKnRauM7J-GURhew,7467
59
- mainsequence-9.0.22.dev65.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
60
- mainsequence-9.0.22.dev65.dist-info/entry_points.txt,sha256=XGAOq3-mF2lWWpeRJvoaWyMarRcK167mH1g124aGdUs,88
61
- mainsequence-9.0.22.dev65.dist-info/top_level.txt,sha256=BFeblM_Q9q0cUWBvTxnfWcWQEHtf5XZi9nOS-Gdfwus,28
62
- mainsequence-9.0.22.dev65.dist-info/RECORD,,
56
+ mainsequence/server/fastapi.py,sha256=jdLWtD5roG7kLzZgdsezhDEXVz3mCnpY6rqc2Qio1U4,18667
57
+ mainsequence/server/mcp_skills.py,sha256=-krRwAHCAMipScgGJ19uVkzznhTop1Nz4C_wRY1GUpw,16690
58
+ mainsequence/server/_mcp_skills_upstream/__init__.py,sha256=px283qBMf3z1nxCfN3WHrH9v8Y8jOb1xoBqZMRZcaD8,2694
59
+ mainsequence/server/_mcp_skills_upstream/server.py,sha256=laHsfpoaYX3LcwQ04-GN7r0_C63fG6CzyXDci2rmbHE,7695
60
+ mainsequence/server/_mcp_skills_upstream/shared.py,sha256=JmnX9PoLoyi93nBYnUEPWCdPXor_nQHjHptQDXpFzPs,11978
61
+ mainsequence-9.0.23.dev66.dist-info/licenses/LICENSE,sha256=b6Ecsk8wjsEby3lbSzdljR6hHiFGHBizt9iWV2p1c_I,1074
62
+ mainsequence-9.0.23.dev66.dist-info/METADATA,sha256=wt6keqLEOGf1mggNThtqGuZ-O6fh7s_6SnT0O-9H51k,7466
63
+ mainsequence-9.0.23.dev66.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
64
+ mainsequence-9.0.23.dev66.dist-info/entry_points.txt,sha256=XGAOq3-mF2lWWpeRJvoaWyMarRcK167mH1g124aGdUs,88
65
+ mainsequence-9.0.23.dev66.dist-info/top_level.txt,sha256=BFeblM_Q9q0cUWBvTxnfWcWQEHtf5XZi9nOS-Gdfwus,28
66
+ mainsequence-9.0.23.dev66.dist-info/RECORD,,