Skip to content

Commit 702da02

Browse files
added support for periodic refresh at regular interval of skills
1 parent da96a6f commit 702da02

13 files changed

Lines changed: 1401 additions & 178 deletions

File tree

‎docs/advanced/extensions.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,30 @@ The client is its own program, and it carries both halves of the client story:
133133
so the client declares them for itself. A published extension would ship them in
134134
a package that both sides import.
135135

136+
### Dynamic resources and background work
137+
138+
Normally `resources()` is called once, when the server is built. If your resources change while
139+
the server runs, set `resources_are_dynamic = True` and the server asks `resources()` again on
140+
every listing and lookup.
141+
142+
Build the new resources completely before you start returning them, so a request never sees a
143+
half-updated set. Return them as a tuple, and the same tuple until they change, so the server can
144+
look a resource up by URI instead of scanning every resource on each request. If two resources
145+
share a URI, the one registered directly on the server wins,
146+
then dynamic ones in extension order, then templates.
147+
148+
To run background work, such as a task that keeps those resources up to date, override
149+
`lifespan()` with an `@asynccontextmanager`. Code before its `yield` runs when the server starts,
150+
and code after it runs when the server stops.
151+
152+
The server enters it after your application's own lifespan, in extension order, and leaves it in
153+
the reverse order. The default does nothing, so you only pay for it if you override it.
154+
155+
!!! note
156+
Some transports enter the lifespan once per connection, so the same extension can be inside
157+
several at once. If a worker must run only once, coordinate that yourself.
158+
[`SkillCatalog`](skills.md#reloading-on-a-timer) does this for its refresh timer.
159+
136160
### Intercepting `tools/call`
137161

138162
The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit,

‎docs/advanced/skills.md‎

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -204,14 +204,77 @@ are not served.
204204
(an error with `strict=True`). Use `FilesystemSkillProvider(Path("skills"))` instead.
205205

206206
!!! note
207-
The catalog reads and hashes every file **once**, when `load()` runs, and serves that snapshot.
208-
That's how the `digest` and `size` in each skill's manifest are guaranteed to match what
209-
`resources/read` returns.
207+
The catalog reads and hashes every file when `load()` runs. By default, it reads files from
208+
the provider again on each `resources/read` and checks their bytes against that fingerprint.
210209

211-
It also means the catalog is fixed for the lifetime of the server. If a file changes
212-
afterwards, reading it fails with an internal error (`-32603`) until you restart the server or
213-
create a new catalog. And because `load()` reads every file, a large asset directory makes
214-
startup slower.
210+
If a file changes afterwards, reading it fails with an internal error (`-32603`) until you
211+
[reload the catalog](#reloading-skills) or restart the server. And because `load()`
212+
reads every file, a large asset directory makes startup slower.
213+
214+
### Reloading skills
215+
216+
While you're writing a skill you'll keep editing its files, adding skills and deleting others.
217+
Call `reload()` to pick all of it up without restarting the server:
218+
219+
```python
220+
async def refresh(skills_catalog: SkillCatalog) -> None:
221+
changes = await skills_catalog.reload()
222+
print(changes.added, changes.removed, changes.changed) # SKILL.md URIs
223+
```
224+
225+
`reload()` scans the whole provider again, and only when every skill has been read and validated
226+
does it replace everything the catalog serves, in one step. `skills/list`, `skills/get`, resource
227+
and directory listings, and file reads all switch together, so no request sees a half-updated
228+
catalog. Skills and files can be added, removed or edited. It returns a `SkillCatalogChanges`
229+
with the `added`, `removed` and `changed` skills.
230+
231+
It is all or nothing. If the scan fails, `reload()` raises and the previous catalog stays installed:
232+
233+
* the provider fails (`OSError`), or lists a path that isn't valid;
234+
* `strict=True` and any skill is invalid, as with `load()`;
235+
* a skill the catalog is serving has become invalid. This holds even without `strict`, so a typo in
236+
the frontmatter mid-edit doesn't take a skill down. A skill that was already skipped at `load()`
237+
stays skipped and doesn't block reloads.
238+
239+
Calls to `reload()` run one after another.
240+
241+
Without `auto_refresh`, file reads still go to the provider: a file that has changed or disappeared
242+
can fail to read even when the previous catalog stays installed. Enable `auto_refresh` to retain
243+
the previous files' bytes as well.
244+
245+
#### Reloading on a timer
246+
247+
Calling `reload()` yourself gets old fast. To have the catalog do it, turn on `auto_refresh`:
248+
249+
```python title="server.py" hl_lines="14 15"
250+
--8<-- "docs_src/skills/tutorial003.py"
251+
```
252+
253+
Every 30 seconds here, the catalog calls `reload()`. Automatic refresh is **off by default**. With
254+
`auto_refresh=True` alone, `refresh_interval` defaults to 60 seconds; it must be a finite number
255+
greater than zero.
256+
257+
There's no task to start and nothing to stop: the timer runs with the server's lifespan, on stdio,
258+
SSE and streamable HTTP. SSE enters the lifespan once per connection, so there the timer runs only
259+
while a client is connected.
260+
261+
* **A failed refresh changes nothing.** The error is logged and the previous skills keep serving.
262+
The catalog tries again after the next interval.
263+
* **Scans never overlap.** The next interval starts after the previous scan finishes.
264+
265+
!!! note
266+
With `auto_refresh`, the catalog keeps the bytes of every served file **in memory**. That's
267+
what lets the previous skills keep serving consistently, even if you edit or delete a file on
268+
disk while a refresh is still running. Without it, a file edited on disk reads as an internal
269+
error until the next `reload()`.
270+
271+
!!! warning
272+
Clients don't hear about a reload. They may keep a `skills/list` result for `ttl_ms`, and a
273+
manifest fetched before a reload can describe different bytes than a file fetched after it.
274+
Re-fetch the manifest and verify the digest before loading a changed skill.
275+
276+
Pagination cursors are offsets, not snapshots, so restart a paged listing if the catalog
277+
changed between pages.
215278

216279
### Directory reads from a catalog
217280

@@ -286,7 +349,9 @@ File content is returned unchanged. A file whose bytes are valid UTF-8 is return
286349
including any byte order mark and line endings; any other file is returned as a base64 blob. In
287350
both cases the content matches the `digest` and `size` in the skill's manifest. Each read is
288351
verified against the values recorded at load time, and a file that has changed is reported as an
289-
internal error (`-32603`) until you restart the server or create a new catalog.
352+
internal error (`-32603`) until you [reload the catalog](#reloading-skills).
353+
With [`auto_refresh`](#reloading-on-a-timer), reads instead return the retained snapshot's
354+
bytes until the next successful reload.
290355

291356
`load()` reads and hashes every file of every valid skill once, so a large asset directory makes
292357
startup slower and temporarily uses memory for the largest file.
@@ -318,7 +383,7 @@ ones found while reading are refused, so a skill directory doesn't expose files
318383

319384
The root is trusted. This is a best-effort check, not a defence against someone who can modify the
320385
directory while the server runs. As a backstop, the catalog refuses to serve any file whose bytes
321-
no longer match the digest taken at `load()`. Blocking file system calls run in a worker thread.
386+
no longer match the digest taken at `load()` or the last `reload()`. Blocking file system calls run in a worker thread.
322387

323388
## Protocol version and caching
324389

@@ -333,14 +398,13 @@ You don't have to branch on protocol version yourself.
333398
## What this SDK doesn't do
334399

335400
`Skills` is a protocol adapter, not a skills store. It has no opinion on where a skill's bytes
336-
live or when a catalog is refreshed.
401+
live. `SkillCatalog` supplies `reload()` and an optional refresh timer.
337402

338403
`SkillCatalog` covers the common case of a directory (or any other file source behind a
339404
`SkillProvider`), with a few limits:
340405

341406
* It doesn't include a YAML parser. `parse_frontmatter` supplies one.
342-
* The catalog isn't reloaded. To publish changes, create a new catalog.
407+
* It doesn't watch the file system or tell clients about a reload; call `reload()` or set `auto_refresh`.
343408
* Empty directories aren't served by directory reads.
344409

345-
For anything else, such as a database or a catalog that changes while the server runs, write your
346-
own handlers for `Skills`.
410+
For anything else, write your own handlers for `Skills`.

‎docs_src/skills/tutorial003.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from pathlib import Path
2+
3+
import anyio
4+
import yaml
5+
6+
from mcp.server.mcpserver import MCPServer
7+
from mcp.server.skills import FilesystemSkillProvider, SkillCatalog
8+
9+
10+
async def build_server(root: Path) -> MCPServer:
11+
skills_catalog = await SkillCatalog.load(
12+
FilesystemSkillProvider(root),
13+
parse_frontmatter=yaml.safe_load,
14+
auto_refresh=True,
15+
refresh_interval=30,
16+
)
17+
return MCPServer("MCP-over-skills", extensions=[skills_catalog])
18+
19+
20+
async def main() -> None:
21+
server = await build_server(Path(__file__).parent / "skills")
22+
await server.run_stdio_async()
23+
24+
25+
if __name__ == "__main__":
26+
anyio.run(main)

‎src/mcp/server/extension.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919

2020
from __future__ import annotations
2121

22-
from collections.abc import Awaitable, Callable, Sequence
22+
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
23+
from contextlib import asynccontextmanager
2324
from dataclasses import dataclass, field
2425
from typing import TYPE_CHECKING, Any
2526

@@ -104,6 +105,8 @@ class Extension:
104105

105106
#: Reverse-DNS extension identifier, advertised under `ServerCapabilities.extensions`.
106107
identifier: str
108+
resources_are_dynamic: bool = False
109+
"""When true, `resources()` is consulted on each resource listing or lookup."""
107110

108111
def __init_subclass__(cls, **kwargs: Any) -> None:
109112
super().__init_subclass__(**kwargs)
@@ -130,6 +133,14 @@ def resources(self) -> Sequence[ResourceBinding]:
130133
"""Resources this extension contributes (additive)."""
131134
return ()
132135

136+
@asynccontextmanager
137+
async def lifespan(self) -> AsyncIterator[None]:
138+
"""Manage extension background work for the server's lifespan.
139+
140+
May be entered concurrently by transports with per-connection lifespans.
141+
"""
142+
yield
143+
133144
def methods(self) -> Sequence[MethodBinding]:
134145
"""New request methods this extension serves (additive)."""
135146
return ()

‎src/mcp/server/mcpserver/resources/resource_manager.py‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Callable
5+
from collections.abc import Callable, Mapping, Sequence
66
from typing import TYPE_CHECKING, Any
77

88
from mcp_types import Annotations, Icon, InputRequiredResult
@@ -25,12 +25,34 @@
2525
logger = get_logger(__name__)
2626

2727

28+
class _ResourceSource:
29+
"""A dynamic resource provider and the URI index of the tuple it last returned."""
30+
31+
def __init__(self, provider: Callable[[], Sequence[Resource]]) -> None:
32+
self._provider = provider
33+
self._indexed: Sequence[Resource] | None = None
34+
self._by_uri: dict[str, Resource] = {}
35+
36+
def by_uri(self) -> Mapping[str, Resource]:
37+
resources = self._provider()
38+
if resources is self._indexed:
39+
return self._by_uri
40+
by_uri: dict[str, Resource] = {}
41+
for resource in resources:
42+
by_uri.setdefault(str(resource.uri), resource)
43+
# A tuple can't change in place, so its index stays valid until the provider returns another one.
44+
if isinstance(resources, tuple):
45+
self._indexed, self._by_uri = resources, by_uri
46+
return by_uri
47+
48+
2849
class ResourceManager:
2950
"""Manages MCPServer resources."""
3051

3152
def __init__(self, warn_on_duplicate_resources: bool = True, *, resources: list[Resource] | None = None):
3253
self._resources: dict[str, Resource] = {}
3354
self._templates: dict[str, ResourceTemplate] = {}
55+
self._resource_sources: list[_ResourceSource] = []
3456
self.warn_on_duplicate_resources = warn_on_duplicate_resources
3557

3658
for resource in resources or ():
@@ -86,6 +108,14 @@ def add_template(
86108
self._templates[template.uri_template] = template
87109
return template
88110

111+
def add_resource_provider(self, provider: Callable[[], Sequence[Resource]]) -> None:
112+
"""Register a dynamic source of concrete resources, consulted on every listing and lookup.
113+
114+
Resources added with `add_resource` take precedence, then providers in the order they were added.
115+
A provider that returns the same tuple again is looked up by URI without rescanning it.
116+
"""
117+
self._resource_sources.append(_ResourceSource(provider))
118+
89119
async def get_resource(
90120
self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT]
91121
) -> Resource | InputRequiredResult:
@@ -114,6 +144,9 @@ async def get_resource(
114144
# First check concrete resources
115145
if resource := self._resources.get(uri_str):
116146
return resource
147+
for source in self._resource_sources:
148+
if resource := source.by_uri().get(uri_str):
149+
return resource
117150

118151
# Then check templates
119152
for template in self._templates.values():
@@ -129,7 +162,11 @@ async def get_resource(
129162
def list_resources(self) -> list[Resource]:
130163
"""List all registered resources."""
131164
logger.debug("Listing resources", extra={"count": len(self._resources)})
132-
return list(self._resources.values())
165+
resources = dict(self._resources)
166+
for source in self._resource_sources:
167+
for uri, resource in source.by_uri().items():
168+
resources.setdefault(uri, resource)
169+
return list(resources.values())
133170

134171
def list_templates(self) -> list[ResourceTemplate]:
135172
"""List all registered templates."""

‎src/mcp/server/mcpserver/server.py‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import base64
66
import inspect
77
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence
8-
from contextlib import AbstractAsyncContextManager, asynccontextmanager
8+
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
99
from typing import Any, Generic, Literal, TypeVar, cast, overload
1010

1111
import anyio
@@ -64,6 +64,7 @@
6464
Extension,
6565
MethodBinding,
6666
RequestHandler,
67+
ResourceBinding,
6768
compose_tool_call_handler,
6869
validate_extension_identifier,
6970
)
@@ -270,6 +271,7 @@ def __init__(
270271
for extension in extensions or ():
271272
self._apply_extension(extension)
272273
self._install_extension_interceptor()
274+
self._install_extension_lifespan()
273275

274276
@property
275277
def name(self) -> str:
@@ -338,8 +340,11 @@ def _apply_extension(self, extension: Extension) -> None:
338340

339341
for tool in extension.tools():
340342
self.add_tool(tool.fn, meta=tool.meta, **tool.kwargs)
341-
for resource in extension.resources():
342-
self.add_resource(resource.resource)
343+
if extension.resources_are_dynamic:
344+
self._resource_manager.add_resource_provider(_dynamic_resources(extension))
345+
else:
346+
for resource in extension.resources():
347+
self.add_resource(resource.resource)
343348
for method in extension.methods():
344349
if self._lowlevel_server.get_request_handler(method.method) is not None:
345350
raise ValueError(
@@ -367,6 +372,22 @@ def _install_extension_interceptor(self) -> None:
367372
compose_tool_call_handler(self._extensions, self._handle_call_tool),
368373
)
369374

375+
def _install_extension_lifespan(self) -> None:
376+
extensions = [e for e in self._extensions if type(e).lifespan is not Extension.lifespan]
377+
if not extensions:
378+
return
379+
lifespan = self._lowlevel_server.lifespan
380+
381+
@asynccontextmanager
382+
async def wrap(server: Server[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
383+
async with AsyncExitStack() as stack:
384+
context = await stack.enter_async_context(lifespan(server))
385+
for extension in extensions:
386+
await stack.enter_async_context(extension.lifespan())
387+
yield context
388+
389+
self._lowlevel_server.lifespan = wrap
390+
370391
@overload
371392
def run(self, transport: Literal["stdio"] = ...) -> None: ...
372393

@@ -1376,6 +1397,26 @@ async def get_prompt(
13761397
raise ValueError(str(e)) from e
13771398

13781399

1400+
def _dynamic_resources(extension: Extension) -> Callable[[], Sequence[Resource]]:
1401+
"""Adapt a dynamic extension's `resources()` for the resource manager.
1402+
1403+
The same tuple of bindings maps to the same tuple of resources, so the manager can reuse its
1404+
URI index instead of rescanning every resource on each lookup.
1405+
"""
1406+
last: tuple[Sequence[ResourceBinding], tuple[Resource, ...]] | None = None
1407+
1408+
def resources() -> Sequence[Resource]:
1409+
nonlocal last
1410+
bindings = extension.resources()
1411+
if not isinstance(bindings, tuple):
1412+
return [binding.resource for binding in bindings]
1413+
if last is None or last[0] is not bindings:
1414+
last = (bindings, tuple(binding.resource for binding in bindings))
1415+
return last[1]
1416+
1417+
return resources
1418+
1419+
13791420
def _version_gated(method: MethodBinding) -> RequestHandler:
13801421
"""Wrap a method handler so a request at a disallowed protocol version is rejected.
13811422

0 commit comments

Comments
 (0)