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 +2 -1
- agent_scaffold/skills/application_surfaces/api_surfaces/SKILL.md +59 -5
- agent_scaffold/skills/maintenance/code_repository_maintenance/SKILL.md +10 -6
- agent_scaffold/skills/sdk_code_repository_execution/SKILL.md +2 -1
- mainsequence/cli/cli.py +24 -27
- mainsequence/server/_mcp_skills_upstream/__init__.py +60 -0
- mainsequence/server/_mcp_skills_upstream/server.py +172 -0
- mainsequence/server/_mcp_skills_upstream/shared.py +297 -0
- mainsequence/server/fastapi.py +3 -1
- mainsequence/server/mcp_skills.py +408 -0
- {mainsequence-9.0.22.dev65.dist-info → mainsequence-9.0.23.dev66.dist-info}/METADATA +2 -2
- {mainsequence-9.0.22.dev65.dist-info → mainsequence-9.0.23.dev66.dist-info}/RECORD +16 -12
- {mainsequence-9.0.22.dev65.dist-info → mainsequence-9.0.23.dev66.dist-info}/WHEEL +0 -0
- {mainsequence-9.0.22.dev65.dist-info → mainsequence-9.0.23.dev66.dist-info}/entry_points.txt +0 -0
- {mainsequence-9.0.22.dev65.dist-info → mainsequence-9.0.23.dev66.dist-info}/licenses/LICENSE +0 -0
- {mainsequence-9.0.22.dev65.dist-info → mainsequence-9.0.23.dev66.dist-info}/top_level.txt +0 -0
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
7681
|
-
|
|
7682
|
-
|
|
7683
|
-
|
|
7684
|
-
|
|
7685
|
-
|
|
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)
|
mainsequence/server/fastapi.py
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
57
|
-
mainsequence
|
|
58
|
-
mainsequence
|
|
59
|
-
mainsequence
|
|
60
|
-
mainsequence
|
|
61
|
-
mainsequence-9.0.
|
|
62
|
-
mainsequence-9.0.
|
|
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,,
|
|
File without changes
|
{mainsequence-9.0.22.dev65.dist-info → mainsequence-9.0.23.dev66.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{mainsequence-9.0.22.dev65.dist-info → mainsequence-9.0.23.dev66.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|