nimblebrain-server-sdk 0.3.0__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.
- nimblebrain_server_sdk/__init__.py +92 -0
- nimblebrain_server_sdk/annotations.py +22 -0
- nimblebrain_server_sdk/announce.py +46 -0
- nimblebrain_server_sdk/errors.py +38 -0
- nimblebrain_server_sdk/extensions/__init__.py +5 -0
- nimblebrain_server_sdk/extensions/facets.py +61 -0
- nimblebrain_server_sdk/extensions/lifecycle.py +141 -0
- nimblebrain_server_sdk/extensions/platform.py +47 -0
- nimblebrain_server_sdk/extensions/views.py +33 -0
- nimblebrain_server_sdk/host.py +294 -0
- nimblebrain_server_sdk/ids.py +104 -0
- nimblebrain_server_sdk/methods.py +33 -0
- nimblebrain_server_sdk/rows.py +61 -0
- nimblebrain_server_sdk/skills.py +521 -0
- nimblebrain_server_sdk/testing.py +142 -0
- nimblebrain_server_sdk-0.3.0.dist-info/METADATA +442 -0
- nimblebrain_server_sdk-0.3.0.dist-info/RECORD +19 -0
- nimblebrain_server_sdk-0.3.0.dist-info/WHEEL +4 -0
- nimblebrain_server_sdk-0.3.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The SDK for building MCP servers that run on NimbleBrain.
|
|
2
|
+
|
|
3
|
+
What a server shares with the host it runs in: the tool annotation hints, the change
|
|
4
|
+
announcement its views re-read on, the platform extensions (facets, lifecycle handlers, the
|
|
5
|
+
view a record opens in), skills served through the MCP Skills extension
|
|
6
|
+
(``io.modelcontextprotocol/skills``), the bounded row a listing returns, the opaque record id it
|
|
7
|
+
mints, and reads of workspace files through ``ai.nimblebrain/host-resources``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from nimblebrain_server_sdk.annotations import ADDS, CHANGES, READS
|
|
11
|
+
from nimblebrain_server_sdk.announce import announce_resource_list_changed
|
|
12
|
+
from nimblebrain_server_sdk.errors import HostCapabilityMissing
|
|
13
|
+
from nimblebrain_server_sdk.extensions.facets import (
|
|
14
|
+
FACET_LEVELS,
|
|
15
|
+
FACETS_EXTENSION_ID,
|
|
16
|
+
FacetLevel,
|
|
17
|
+
facet_count,
|
|
18
|
+
facet_meta,
|
|
19
|
+
)
|
|
20
|
+
from nimblebrain_server_sdk.extensions.lifecycle import (
|
|
21
|
+
LIFECYCLE_EVENTS,
|
|
22
|
+
LIFECYCLE_EXTENSION_ID,
|
|
23
|
+
LifecycleEvent,
|
|
24
|
+
LifecycleExtension,
|
|
25
|
+
add_lifecycle_extension,
|
|
26
|
+
lifecycle_meta,
|
|
27
|
+
)
|
|
28
|
+
from nimblebrain_server_sdk.extensions.platform import PlatformExtension, add_platform_extension
|
|
29
|
+
from nimblebrain_server_sdk.extensions.views import RECORD_VIEW_META, record_view_meta
|
|
30
|
+
from nimblebrain_server_sdk.host import HostResources, host
|
|
31
|
+
from nimblebrain_server_sdk.ids import new_prefixed_id, new_ulid
|
|
32
|
+
from nimblebrain_server_sdk.methods import (
|
|
33
|
+
HOST_RESOURCES_CAPABILITY_KEY,
|
|
34
|
+
HOST_RESOURCES_LIST_METHOD,
|
|
35
|
+
HOST_RESOURCES_READ_METHOD,
|
|
36
|
+
INVALID_PARAMS,
|
|
37
|
+
RATE_LIMITED,
|
|
38
|
+
RESOURCE_NOT_FOUND,
|
|
39
|
+
RESPONSE_TOO_LARGE,
|
|
40
|
+
)
|
|
41
|
+
from nimblebrain_server_sdk.rows import PREVIEW_CHARS, listing_row, preview_text
|
|
42
|
+
from nimblebrain_server_sdk.skills import (
|
|
43
|
+
SKILLS_EXTENSION_ID,
|
|
44
|
+
MCPServerSkillsExtension,
|
|
45
|
+
SkillDefinition,
|
|
46
|
+
SkillsExtension,
|
|
47
|
+
add_skills,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"ADDS",
|
|
52
|
+
"CHANGES",
|
|
53
|
+
"FACETS_EXTENSION_ID",
|
|
54
|
+
"FACET_LEVELS",
|
|
55
|
+
"HOST_RESOURCES_CAPABILITY_KEY",
|
|
56
|
+
"HOST_RESOURCES_LIST_METHOD",
|
|
57
|
+
"HOST_RESOURCES_READ_METHOD",
|
|
58
|
+
"INVALID_PARAMS",
|
|
59
|
+
"LIFECYCLE_EVENTS",
|
|
60
|
+
"LIFECYCLE_EXTENSION_ID",
|
|
61
|
+
"PREVIEW_CHARS",
|
|
62
|
+
"RATE_LIMITED",
|
|
63
|
+
"READS",
|
|
64
|
+
"RECORD_VIEW_META",
|
|
65
|
+
"RESOURCE_NOT_FOUND",
|
|
66
|
+
"RESPONSE_TOO_LARGE",
|
|
67
|
+
"SKILLS_EXTENSION_ID",
|
|
68
|
+
"FacetLevel",
|
|
69
|
+
"HostCapabilityMissing",
|
|
70
|
+
"HostResources",
|
|
71
|
+
"LifecycleEvent",
|
|
72
|
+
"LifecycleExtension",
|
|
73
|
+
"MCPServerSkillsExtension",
|
|
74
|
+
"PlatformExtension",
|
|
75
|
+
"SkillDefinition",
|
|
76
|
+
"SkillsExtension",
|
|
77
|
+
"add_lifecycle_extension",
|
|
78
|
+
"add_platform_extension",
|
|
79
|
+
"add_skills",
|
|
80
|
+
"announce_resource_list_changed",
|
|
81
|
+
"facet_count",
|
|
82
|
+
"facet_meta",
|
|
83
|
+
"host",
|
|
84
|
+
"lifecycle_meta",
|
|
85
|
+
"listing_row",
|
|
86
|
+
"new_prefixed_id",
|
|
87
|
+
"new_ulid",
|
|
88
|
+
"preview_text",
|
|
89
|
+
"record_view_meta",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""What a tool call can change, as the MCP spec's annotation hints say it.
|
|
2
|
+
|
|
3
|
+
``readOnlyHint`` is how a client tells a read from a write without knowing the tool. Under the
|
|
4
|
+
spec's defaults a tool with no annotation reads as a destructive write, so leaving one off
|
|
5
|
+
does not leave a tool unclassified: it declares the tool the most dangerous kind. Every tool
|
|
6
|
+
a server built on this SDK serves carries one of these. On a server with views, exactly the tools that
|
|
7
|
+
are not read-only announce a change.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from mcp.types import ToolAnnotations
|
|
13
|
+
|
|
14
|
+
READS = ToolAnnotations(read_only_hint=True)
|
|
15
|
+
"""Changes nothing a view shows. A read that records an audit row or refreshes provider state
|
|
16
|
+
on the way in is still a read."""
|
|
17
|
+
|
|
18
|
+
ADDS = ToolAnnotations(read_only_hint=False, destructive_hint=False)
|
|
19
|
+
"""A write that loses nothing already there."""
|
|
20
|
+
|
|
21
|
+
CHANGES = ToolAnnotations(read_only_hint=False, destructive_hint=True)
|
|
22
|
+
"""A write that overwrites, removes, or acts on what is already there."""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Tell the client that made a write that this server's resource list changed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from fastmcp import Context
|
|
8
|
+
from fastmcp.server.dependencies import get_context
|
|
9
|
+
from mcp.types import ResourceListChangedNotification
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def announce_resource_list_changed(
|
|
15
|
+
ctx: Context | None = None, *, tool: str | None = None
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Send ``notifications/resources/list_changed`` in the response to the call that wrote.
|
|
18
|
+
|
|
19
|
+
That call came from the host that holds this workspace, so the notification is scoped to
|
|
20
|
+
the workspace without the server keeping a map of sessions. The host relays it to this
|
|
21
|
+
server's views, which re-read.
|
|
22
|
+
|
|
23
|
+
Call it only from a write, once the write has committed. A read never calls it: a view
|
|
24
|
+
re-reads when told the list changed, so a read that announced would have every open view
|
|
25
|
+
refreshing itself forever.
|
|
26
|
+
|
|
27
|
+
``ctx`` defaults to the active MCP request's context. With neither there is no response to
|
|
28
|
+
announce in — a tool body called directly, or a write made outside any tool call — and
|
|
29
|
+
that is not a failure. Nor is a send that fails: the write is stored either way, and
|
|
30
|
+
reporting a stored write as failed would have the agent make it a second time. A view
|
|
31
|
+
that misses one catches up on its next read.
|
|
32
|
+
|
|
33
|
+
``tool`` names the calling tool on that failure's log line.
|
|
34
|
+
"""
|
|
35
|
+
if ctx is None:
|
|
36
|
+
try:
|
|
37
|
+
ctx = get_context()
|
|
38
|
+
except RuntimeError:
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
# Tied to the request, so it travels in the call's own response. A 2026-07-28
|
|
42
|
+
# connection has no other way out, and a session-era client need not hold the
|
|
43
|
+
# standalone stream open.
|
|
44
|
+
await ctx.send_notification(ResourceListChangedNotification())
|
|
45
|
+
except Exception: # noqa: BLE001 — the write already committed; see above
|
|
46
|
+
logger.warning("announce_resource_list_changed_failed", exc_info=True, extra={"tool": tool})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""SDK-specific exceptions.
|
|
2
|
+
|
|
3
|
+
Wire-level errors from the platform come through as
|
|
4
|
+
`mcp.shared.exceptions.McpError` — the SDK does not wrap those, so
|
|
5
|
+
bundle authors can match on the JSON-RPC error code via the standard
|
|
6
|
+
exception path. `HostCapabilityMissing` is the only error the SDK
|
|
7
|
+
itself raises, and it signals a local condition ("this host hasn't
|
|
8
|
+
advertised the extension") rather than a wire response.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HostCapabilityMissing(Exception):
|
|
13
|
+
"""Raised when bundle code calls a host-resources method but the
|
|
14
|
+
host hasn't advertised the capability in `initialize`.
|
|
15
|
+
|
|
16
|
+
Catch this to implement the Level-C fallback pattern: when the
|
|
17
|
+
capability is absent, return a structured tool error that tells
|
|
18
|
+
the agent to call the tool again with inline content instead.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
```python
|
|
22
|
+
try:
|
|
23
|
+
result = await host(ctx).read(seed_uri)
|
|
24
|
+
except HostCapabilityMissing:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"This host doesn't support ai.nimblebrain/host-resources. "
|
|
27
|
+
"Pass file contents inline via `seed_data` instead."
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, capability_key: str):
|
|
33
|
+
super().__init__(
|
|
34
|
+
f"Host has not advertised the {capability_key!r} capability. "
|
|
35
|
+
"Check `host(ctx).available` before calling read/list, or pass "
|
|
36
|
+
"content via tool arguments instead."
|
|
37
|
+
)
|
|
38
|
+
self.capability_key = capability_key
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""NimbleBrain's platform extensions: advertised identifiers whose contract rides a primitive.
|
|
2
|
+
|
|
3
|
+
``platform`` advertises any such extension; ``facets``, ``lifecycle`` and ``views`` are the
|
|
4
|
+
``ai.nimblebrain/*`` extensions a host reads from marked resources, tools and templates.
|
|
5
|
+
"""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Serve facets: how many things are waiting in this server (``ai.nimblebrain/facets``).
|
|
2
|
+
|
|
3
|
+
A facet is one countable obligation, such as drafts awaiting review. The extension adds no
|
|
4
|
+
method. A server advertises the identifier, lists each facet as a resource whose ``_meta``
|
|
5
|
+
carries the identifier (holding the facet's urgency level) and whose ``title`` is the label a
|
|
6
|
+
host renders after the count, and answers ``resources/read`` with ``{"count": n}``. The
|
|
7
|
+
specification is https://docs.nimblebrain.ai/extensions/facets/.
|
|
8
|
+
|
|
9
|
+
A bundle registers a facet where it builds its server::
|
|
10
|
+
|
|
11
|
+
add_platform_extension(mcp, FACETS_EXTENSION_ID)
|
|
12
|
+
|
|
13
|
+
@mcp.resource(
|
|
14
|
+
"acme://facets/blocked",
|
|
15
|
+
name="tickets-blocked",
|
|
16
|
+
title="Tickets blocked",
|
|
17
|
+
mime_type="application/json",
|
|
18
|
+
meta=facet_meta("critical"),
|
|
19
|
+
)
|
|
20
|
+
async def tickets_blocked() -> str:
|
|
21
|
+
return facet_count(await count_blocked())
|
|
22
|
+
|
|
23
|
+
The read takes no arguments: it counts for the identity the transport established, with the
|
|
24
|
+
same session and identity function the server's tools use.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
from typing import Any, Literal
|
|
31
|
+
|
|
32
|
+
FACETS_EXTENSION_ID = "ai.nimblebrain/facets"
|
|
33
|
+
|
|
34
|
+
FacetLevel = Literal["critical", "warning", "info"]
|
|
35
|
+
#: How urgent a non-zero count is, most urgent first: something has stopped, someone should
|
|
36
|
+
#: act, worth knowing. A host orders by it and shows it; one that sees none reads ``warning``.
|
|
37
|
+
FACET_LEVELS: tuple[FacetLevel, ...] = ("critical", "warning", "info")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def facet_meta(level: FacetLevel) -> dict[str, Any]:
|
|
41
|
+
"""The ``_meta`` a facet resource carries: the extension's marker, holding its level.
|
|
42
|
+
|
|
43
|
+
The level belongs to the facet, not to a reading, so it is declared once here and never
|
|
44
|
+
changes with the count. Raises :class:`ValueError` for a level the extension does not
|
|
45
|
+
define, which a host would read as ``warning``.
|
|
46
|
+
"""
|
|
47
|
+
if level not in FACET_LEVELS:
|
|
48
|
+
raise ValueError(f"a facet level is one of {FACET_LEVELS}, not {level!r}")
|
|
49
|
+
return {FACETS_EXTENSION_ID: {"level": level}}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def facet_count(count: int) -> str:
|
|
53
|
+
"""The body of a facet read: ``{"count": count}`` as JSON text.
|
|
54
|
+
|
|
55
|
+
Raises :class:`ValueError` for anything but a non-negative integer, because a host treats
|
|
56
|
+
any other count as a failed read. Failing here names the server's own bug instead of
|
|
57
|
+
leaving it to show up as a facet that never renders.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
|
|
60
|
+
raise ValueError(f"a facet count is a non-negative integer, not {count!r}")
|
|
61
|
+
return json.dumps({"count": count})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Serve lifecycle handlers: the tools a host calls when it installs and removes this server.
|
|
2
|
+
|
|
3
|
+
``ai.nimblebrain/lifecycle`` tells a server that a host made it available to a scope
|
|
4
|
+
(``ready``) and that the host is about to take it away (``removing``). The extension adds no
|
|
5
|
+
method. A server advertises the identifier and marks one tool per event with
|
|
6
|
+
``_meta["ai.nimblebrain/lifecycle"]: {"event": ...}``. The host calls that tool as a plain
|
|
7
|
+
``tools/call``, with ``{"reason": "install" | "resume"}`` to a ``ready`` handler that declares
|
|
8
|
+
``reason``, and with ``{}`` otherwise. The specification is
|
|
9
|
+
https://docs.nimblebrain.ai/extensions/lifecycle/.
|
|
10
|
+
|
|
11
|
+
A bundle advertises the extension and marks its handlers where it builds its server::
|
|
12
|
+
|
|
13
|
+
lifecycle = add_lifecycle_extension(mcp)
|
|
14
|
+
|
|
15
|
+
@lifecycle.handler("ready", annotations=CHANGES)
|
|
16
|
+
async def workspace_ready(reason: str | None = None) -> str: ...
|
|
17
|
+
|
|
18
|
+
@lifecycle.handler("removing", annotations=CHANGES)
|
|
19
|
+
async def workspace_removing() -> str: ...
|
|
20
|
+
|
|
21
|
+
Registering through the extension refuses, at import, what a host would refuse at install:
|
|
22
|
+
a handler whose input schema requires a property (spec §3), and a second handler for one event,
|
|
23
|
+
which a host treats as no handler at all. It also registers each handler without task support,
|
|
24
|
+
because the host calls synchronously and the NimbleBrain host refuses a handler that offers
|
|
25
|
+
tasks. A tool marked by hand with ``meta=lifecycle_meta(...)`` gets none of these checks.
|
|
26
|
+
|
|
27
|
+
**Handlers stay listed to every client.** The spec (§6) says a server SHOULD NOT list its
|
|
28
|
+
handlers to a client that did not advertise the extension, and FastMCP 4 could do that without
|
|
29
|
+
patching it: a ``Middleware.on_list_tools`` reading ``Context.client_extension_settings``. This
|
|
30
|
+
library does not, by default or as an option. A host that reads the declaration from its
|
|
31
|
+
operator catalog rather than from the wire finds each handler by name in ``tools/list`` and
|
|
32
|
+
advertises nothing, so withholding would break that host's lifecycle calls for as long as it
|
|
33
|
+
reads the catalog. What makes listing safe is the server's own duty (spec §5): every handler is
|
|
34
|
+
safe for any caller holding the scope's identity, so ``removing`` records intent and never
|
|
35
|
+
destroys. Revisit once no host reads ``lifecycle`` from a catalog.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from collections.abc import Callable
|
|
41
|
+
from typing import Any, Literal, TypeVar
|
|
42
|
+
|
|
43
|
+
from fastmcp import FastMCP
|
|
44
|
+
from fastmcp.tools import FunctionTool
|
|
45
|
+
from mcp_types import ToolAnnotations
|
|
46
|
+
|
|
47
|
+
from nimblebrain_server_sdk.extensions.platform import PlatformExtension
|
|
48
|
+
|
|
49
|
+
LIFECYCLE_EXTENSION_ID = "ai.nimblebrain/lifecycle"
|
|
50
|
+
|
|
51
|
+
LifecycleEvent = Literal["ready", "removing"]
|
|
52
|
+
#: The two moments a host can reach the server: it has been made available to the scope,
|
|
53
|
+
#: and it is about to be removed from it.
|
|
54
|
+
LIFECYCLE_EVENTS: tuple[LifecycleEvent, ...] = ("ready", "removing")
|
|
55
|
+
|
|
56
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def lifecycle_meta(event: LifecycleEvent) -> dict[str, Any]:
|
|
60
|
+
"""The ``_meta`` a handler tool carries: the extension's marker, naming its event.
|
|
61
|
+
|
|
62
|
+
Raises :class:`ValueError` for an event the extension does not define, which a host
|
|
63
|
+
ignores, so the handler would never be called.
|
|
64
|
+
"""
|
|
65
|
+
if event not in LIFECYCLE_EVENTS:
|
|
66
|
+
raise ValueError(f"a lifecycle event is one of {LIFECYCLE_EVENTS}, not {event!r}")
|
|
67
|
+
return {LIFECYCLE_EXTENSION_ID: {"event": event}}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class LifecycleExtension(PlatformExtension):
|
|
71
|
+
"""The advertised extension, and the record of which tool handles which event."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
super().__init__(LIFECYCLE_EXTENSION_ID)
|
|
75
|
+
self._handlers: dict[LifecycleEvent, str] = {}
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def handlers(self) -> dict[LifecycleEvent, str]:
|
|
79
|
+
"""The tool registered for each event, by event."""
|
|
80
|
+
return dict(self._handlers)
|
|
81
|
+
|
|
82
|
+
def handler(
|
|
83
|
+
self,
|
|
84
|
+
event: LifecycleEvent,
|
|
85
|
+
*,
|
|
86
|
+
name: str | None = None,
|
|
87
|
+
title: str | None = None,
|
|
88
|
+
description: str | None = None,
|
|
89
|
+
annotations: ToolAnnotations | None = None,
|
|
90
|
+
meta: dict[str, Any] | None = None,
|
|
91
|
+
) -> Callable[[F], F]:
|
|
92
|
+
"""Register the decorated function as this server's handler for ``event``.
|
|
93
|
+
|
|
94
|
+
The tool is an ordinary tool carrying :func:`lifecycle_meta` merged into ``meta``.
|
|
95
|
+
Raises :class:`ValueError`, before anything is registered, for a second handler for
|
|
96
|
+
``event``, for a handler whose input schema requires a property, and for a ``meta``
|
|
97
|
+
that already carries the marker.
|
|
98
|
+
"""
|
|
99
|
+
marker = lifecycle_meta(event)
|
|
100
|
+
if LIFECYCLE_EXTENSION_ID in (meta or {}):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"the {LIFECYCLE_EXTENSION_ID} marker comes from the event, not from meta"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def register(fn: F) -> F:
|
|
106
|
+
tool = FunctionTool.from_function(
|
|
107
|
+
fn,
|
|
108
|
+
name=name,
|
|
109
|
+
title=title,
|
|
110
|
+
description=description,
|
|
111
|
+
annotations=annotations,
|
|
112
|
+
meta={**(meta or {}), **marker},
|
|
113
|
+
task=False,
|
|
114
|
+
)
|
|
115
|
+
if event in self._handlers:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"{tool.name!r} and {self._handlers[event]!r} both handle {event!r}; a host "
|
|
118
|
+
"treats an event with two handlers as having none"
|
|
119
|
+
)
|
|
120
|
+
required = tool.parameters.get("required") or []
|
|
121
|
+
if required:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"lifecycle handler {tool.name!r} requires {sorted(required)}; a host calls "
|
|
124
|
+
"it with no required arguments, so give each parameter a default"
|
|
125
|
+
)
|
|
126
|
+
self.server.add_tool(tool)
|
|
127
|
+
self._handlers[event] = tool.name
|
|
128
|
+
return fn
|
|
129
|
+
|
|
130
|
+
return register
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def add_lifecycle_extension(mcp: FastMCP) -> LifecycleExtension:
|
|
134
|
+
"""Advertise ``ai.nimblebrain/lifecycle`` on ``mcp`` and return it, to register handlers on.
|
|
135
|
+
|
|
136
|
+
Like any platform extension it is advertised in ``server/discover`` only, so a host on a
|
|
137
|
+
2025-era connection sees no extension and makes no lifecycle calls (spec §2, §6).
|
|
138
|
+
"""
|
|
139
|
+
extension = LifecycleExtension()
|
|
140
|
+
mcp.add_extension(extension)
|
|
141
|
+
return extension
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Advertise a platform extension under ``capabilities.extensions``.
|
|
2
|
+
|
|
3
|
+
An MCP extension (SEP-2133) is a capability a server offers under a reverse-DNS identifier,
|
|
4
|
+
negotiated per request and discovered through ``server/discover``. A legacy ``initialize``
|
|
5
|
+
never carries the map, so only a 2026-07-28 peer sees an extension.
|
|
6
|
+
|
|
7
|
+
The set is fixed at construction: FastMCP refuses a registration once the server's lifespan
|
|
8
|
+
has started, because a peer that already discovered the map would never see the new entry.
|
|
9
|
+
So a bundle advertises its extensions where it builds its ``FastMCP``, before serving.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fastmcp import FastMCP
|
|
18
|
+
from fastmcp.server.extensions import ServerExtension
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PlatformExtension(ServerExtension):
|
|
22
|
+
"""An extension that only advertises: an identifier and its settings, no methods.
|
|
23
|
+
|
|
24
|
+
What a platform extension serves rides the primitives a peer already calls (a marked
|
|
25
|
+
resource, a tool), so the advertisement is the whole contract. An extension that serves
|
|
26
|
+
methods of its own subclasses ``ServerExtension`` directly.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, identifier: str, settings: Mapping[str, Any] | None = None) -> None:
|
|
30
|
+
self.identifier = identifier
|
|
31
|
+
self._settings = dict(settings or {})
|
|
32
|
+
|
|
33
|
+
def settings(self) -> dict[str, Any]:
|
|
34
|
+
return dict(self._settings)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def add_platform_extension(
|
|
38
|
+
mcp: FastMCP, identifier: str, settings: Mapping[str, Any] | None = None
|
|
39
|
+
) -> PlatformExtension:
|
|
40
|
+
"""Advertise ``identifier`` with ``settings`` at ``capabilities.extensions[identifier]``.
|
|
41
|
+
|
|
42
|
+
The SDK validates the identifier (a ``vendor-prefix/name`` pair), and FastMCP refuses a
|
|
43
|
+
second registration of the same one and any registration after the server started.
|
|
44
|
+
"""
|
|
45
|
+
extension = PlatformExtension(identifier, settings)
|
|
46
|
+
mcp.add_extension(extension)
|
|
47
|
+
return extension
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Name the view that shows a kind of record (``ai.nimblebrain/view``).
|
|
2
|
+
|
|
3
|
+
A server with several sidebar views keeps each kind of record in one of them. Its resource
|
|
4
|
+
template for that record carries the key, holding the ``ui://`` URI of the view (its
|
|
5
|
+
placement's ``resourceUri``), and a host that opens a record by its URI opens it in that view
|
|
6
|
+
rather than the server's first. A host that does not read the key opens the first view; the
|
|
7
|
+
key names only one of the server's own views.
|
|
8
|
+
|
|
9
|
+
@mcp.resource(
|
|
10
|
+
"acme://boards/{board_id}",
|
|
11
|
+
name="board",
|
|
12
|
+
mime_type="application/json",
|
|
13
|
+
meta=record_view_meta("ui://acme/boards"),
|
|
14
|
+
)
|
|
15
|
+
async def board(board_id: str) -> str: ...
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
RECORD_VIEW_META = "ai.nimblebrain/view"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def record_view_meta(view_uri: str) -> dict[str, Any]:
|
|
26
|
+
"""The ``_meta`` a record's resource template carries to name the view that shows it.
|
|
27
|
+
|
|
28
|
+
Raises :class:`ValueError` for anything but a ``ui://`` URI, which a host would ignore and
|
|
29
|
+
which would leave the record opening in the server's first view without a sign of why.
|
|
30
|
+
"""
|
|
31
|
+
if not view_uri.startswith("ui://") or len(view_uri) <= len("ui://"):
|
|
32
|
+
raise ValueError(f"a record's view is a ui:// URI, not {view_uri!r}")
|
|
33
|
+
return {RECORD_VIEW_META: view_uri}
|