toolgraph 0.0.1__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.
@@ -0,0 +1,120 @@
1
+ """Enumerate one MCP server's factual capabilities into a CrawlResult."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from toolgraph.crawler.transports import open_session
6
+ from toolgraph.models import CrawlResult, ResourceRecord, ServerSpec, ToolRecord
7
+ from toolgraph.redaction import endpoint_label
8
+ from toolgraph.uris import normalize_resource_uri
9
+
10
+ # A crawled server controls its own cursors, so an unbounded drain loop lets a
11
+ # buggy or malicious server that keeps returning a cursor pin the crawler and
12
+ # grow memory without limit. The per-server wall-clock timeout only applies on
13
+ # the CLI path (crawl_all), not to library callers of crawl_server. Real
14
+ # catalogs are a handful of pages; hitting this cap means the server is broken.
15
+ MAX_LIST_PAGES = 1000
16
+
17
+
18
+ class PaginationLimitError(RuntimeError):
19
+ """A server kept returning continuation cursors past MAX_LIST_PAGES."""
20
+
21
+
22
+ def _annotation_fields(tool) -> dict:
23
+ """Tool annotations as ToolRecord fields (ADR-0006).
24
+
25
+ Annotations are optional in the protocol; absent ones stay None so the
26
+ loader can clear node properties the server stopped sending (SET to null
27
+ removes the property — same reconciliation lifecycle as description).
28
+ """
29
+ ann = getattr(tool, "annotations", None)
30
+ if ann is None:
31
+ return {}
32
+ return {
33
+ "read_only_hint": ann.readOnlyHint,
34
+ "destructive_hint": ann.destructiveHint,
35
+ "idempotent_hint": ann.idempotentHint,
36
+ "open_world_hint": ann.openWorldHint,
37
+ }
38
+
39
+
40
+ async def _list_all_tools(session) -> list[ToolRecord]:
41
+ """Drain ``list_tools`` pages until ``nextCursor`` is exhausted.
42
+
43
+ Without pagination, a server with more than one page silently drops
44
+ tools on later pages — governance refs to them would then reject at
45
+ ingest and reachability analysis would miss the tools entirely (Codex
46
+ PR #1 review caught this).
47
+ """
48
+ out: list[ToolRecord] = []
49
+ cursor: str | None = None
50
+ for _ in range(MAX_LIST_PAGES):
51
+ listed = await session.list_tools(cursor=cursor)
52
+ out.extend(
53
+ ToolRecord(
54
+ name=t.name,
55
+ description=t.description,
56
+ input_schema=t.inputSchema,
57
+ **_annotation_fields(t),
58
+ )
59
+ for t in listed.tools
60
+ )
61
+ cursor = getattr(listed, "nextCursor", None)
62
+ if cursor is None:
63
+ # Stop ONLY on None: an empty-string cursor is a valid opaque
64
+ # continuation token in MCP and the SDK distinguishes "" from
65
+ # None when sending the next request. `if not cursor` would
66
+ # silently truncate after page 1 against such a server.
67
+ return out
68
+ raise PaginationLimitError(
69
+ f"list_tools returned a continuation cursor on {MAX_LIST_PAGES}"
70
+ " consecutive pages — refusing to drain further"
71
+ )
72
+
73
+
74
+ async def _list_all_resources(session) -> list[ResourceRecord]:
75
+ """Drain ``list_resources`` pages until ``nextCursor`` is exhausted."""
76
+ out: list[ResourceRecord] = []
77
+ cursor: str | None = None
78
+ for _ in range(MAX_LIST_PAGES):
79
+ listed = await session.list_resources(cursor=cursor)
80
+ out.extend(
81
+ ResourceRecord(
82
+ uri=normalize_resource_uri(str(r.uri)),
83
+ name=r.name,
84
+ mime_type=r.mimeType,
85
+ description=r.description,
86
+ )
87
+ for r in listed.resources
88
+ )
89
+ cursor = getattr(listed, "nextCursor", None)
90
+ if cursor is None:
91
+ # Stop ONLY on None: an empty-string cursor is a valid opaque
92
+ # continuation token in MCP and the SDK distinguishes "" from
93
+ # None when sending the next request. `if not cursor` would
94
+ # silently truncate after page 1 against such a server.
95
+ return out
96
+ raise PaginationLimitError(
97
+ f"list_resources returned a continuation cursor on {MAX_LIST_PAGES}"
98
+ " consecutive pages — refusing to drain further"
99
+ )
100
+
101
+
102
+ async def crawl_server(spec: ServerSpec) -> CrawlResult:
103
+ """Connect, initialize, and list tools/resources the server actually advertises."""
104
+ async with open_session(spec) as session:
105
+ init = await session.initialize()
106
+ caps = init.capabilities
107
+
108
+ tools = await _list_all_tools(session) if caps.tools is not None else []
109
+ resources = (
110
+ await _list_all_resources(session) if caps.resources is not None else []
111
+ )
112
+
113
+ return CrawlResult(
114
+ server_name=spec.name or init.serverInfo.name,
115
+ server_version=init.serverInfo.version,
116
+ transport=spec.transport,
117
+ endpoint=endpoint_label(spec),
118
+ tools=tools,
119
+ resources=resources,
120
+ )
@@ -0,0 +1,103 @@
1
+ """Crawl many servers; each server fails independently (partial success)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ from toolgraph.crawler.client import crawl_server
9
+ from toolgraph.models import CrawlResult, ServerSpec, ServersConfig
10
+ from toolgraph.redaction import endpoint_label, redact_text
11
+ from toolgraph.yaml_loader import load_yaml_mapping
12
+
13
+
14
+ def load_servers_config(path: Path) -> list[ServerSpec]:
15
+ data = load_yaml_mapping(path)
16
+ return ServersConfig.model_validate(data).servers
17
+
18
+
19
+ def spec_label(spec: ServerSpec) -> str:
20
+ """Display label for a spec — NOT a graph identity (see fleet_keep_names)."""
21
+ return spec.name or endpoint_label(spec)
22
+
23
+
24
+ DEFAULT_TIMEOUT = 30.0
25
+ DEFAULT_CONCURRENCY = 5
26
+
27
+ # A failure carries its ServerSpec, not a display label: fleet reconciliation
28
+ # must know whether the failed spec had a ``name`` override, and inferring
29
+ # that from a label is unsound (an unnamed spec's command can collide with
30
+ # another spec's name — Codex review of this branch caught it).
31
+ CrawlFailure = tuple[ServerSpec, str]
32
+
33
+
34
+ async def _crawl_one(
35
+ spec: ServerSpec, timeout: float | None, sem: asyncio.Semaphore
36
+ ) -> tuple[CrawlResult | None, CrawlFailure | None]:
37
+ async with sem:
38
+ try:
39
+ return await asyncio.wait_for(crawl_server(spec), timeout), None
40
+ except TimeoutError: # a hanging server must not block the crawl
41
+ return None, (spec, f"timeout after {timeout}s")
42
+ except Exception as exc: # noqa: BLE001 - one bad server must not abort the crawl
43
+ return None, (spec, f"{type(exc).__name__}: {redact_text(exc)}")
44
+
45
+
46
+ async def crawl_all_async(
47
+ specs: list[ServerSpec],
48
+ timeout: float | None = DEFAULT_TIMEOUT,
49
+ concurrency: int = DEFAULT_CONCURRENCY,
50
+ ) -> tuple[list[CrawlResult], list[CrawlFailure]]:
51
+ sem = asyncio.Semaphore(concurrency)
52
+ outcomes = await asyncio.gather(*(_crawl_one(s, timeout, sem) for s in specs))
53
+ results = [r for r, _ in outcomes if r is not None]
54
+ failures = [f for _, f in outcomes if f is not None]
55
+ return results, failures
56
+
57
+
58
+ def crawl_all(
59
+ specs: list[ServerSpec],
60
+ timeout: float | None = DEFAULT_TIMEOUT,
61
+ concurrency: int = DEFAULT_CONCURRENCY,
62
+ ) -> tuple[list[CrawlResult], list[CrawlFailure]]:
63
+ return asyncio.run(crawl_all_async(specs, timeout, concurrency))
64
+
65
+
66
+ def fleet_keep_names(
67
+ specs: list[ServerSpec],
68
+ results: list[CrawlResult],
69
+ failures: list[CrawlFailure],
70
+ ) -> tuple[set[str], list[str]]:
71
+ """Graph names that must survive fleet reconciliation, plus blockers.
72
+
73
+ A failed spec with a ``name`` override is protected by that name. A failed
74
+ spec WITHOUT one cannot be mapped to a graph identity (node names come
75
+ from ``serverInfo.name`` at crawl time, which a failed crawl never saw) —
76
+ its label is returned as a blocker and the caller must skip the prune:
77
+ a server that failed to crawl is not a server that was removed.
78
+ """
79
+ keep = {s.name for s in specs if s.name} | {r.server_name for r in results}
80
+ blockers = [spec_label(spec) for spec, _ in failures if not spec.name]
81
+ return keep, blockers
82
+
83
+
84
+ def duplicate_name_overrides(specs: list[ServerSpec]) -> set[str]:
85
+ """``name:`` overrides claimed by more than one spec — a config error."""
86
+ names = [s.name for s in specs if s.name]
87
+ return {n for n in names if names.count(n) > 1}
88
+
89
+
90
+ def duplicate_identities(
91
+ results: list[CrawlResult], failures: list[CrawlFailure]
92
+ ) -> set[str]:
93
+ """Graph names claimed by more than one server this pass.
94
+
95
+ Two specs resolving to the same ``server_name`` would silently MERGE into
96
+ one MCPServer node and last-load-wins reconcile each other's tools away —
97
+ and give fleet reconciliation an ambiguous identity. The caller must
98
+ reject the pass before any graph mutation. Failed named specs count too:
99
+ an unnamed server self-reporting a failed spec's name is the same
100
+ collision, one crawl later.
101
+ """
102
+ names = [r.server_name for r in results] + [s.name for s, _ in failures if s.name]
103
+ return {n for n in names if names.count(n) > 1}
@@ -0,0 +1,60 @@
1
+ """Open an MCP ClientSession over the right transport for a ServerSpec.
2
+
3
+ Normalizes the transport differences the SDK exposes: stdio yields a 2-tuple
4
+ ``(read, write)`` while streamable-http yields a 3-tuple
5
+ ``(read, write, get_session_id)``. SSE is supported only as a deprecated fallback.
6
+
7
+ Uses the preferred ``streamable_http_client``; since it takes headers via a
8
+ custom httpx client rather than a ``headers=`` kwarg, we build one with the
9
+ SDK's ``create_mcp_http_client`` when auth headers are supplied.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import AsyncIterator
15
+ from contextlib import AsyncExitStack, asynccontextmanager
16
+
17
+ from mcp import ClientSession, StdioServerParameters
18
+ from mcp.client.sse import sse_client
19
+ from mcp.client.stdio import get_default_environment, stdio_client
20
+ from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
21
+
22
+ from toolgraph.models import ServerSpec
23
+
24
+
25
+ @asynccontextmanager
26
+ async def open_session(spec: ServerSpec) -> AsyncIterator[ClientSession]:
27
+ async with AsyncExitStack() as stack:
28
+ if spec.transport == "stdio":
29
+ if not spec.command:
30
+ raise ValueError(f"stdio server {spec.name!r} needs a 'command'")
31
+ # Merge over the SDK's safe default env so PATH etc. survive when the
32
+ # user supplies only secrets (e.g. API keys).
33
+ env = {**get_default_environment(), **spec.env} if spec.env else None
34
+ params = StdioServerParameters(command=spec.command, args=spec.args, env=env)
35
+ read, write = await stack.enter_async_context(stdio_client(params))
36
+
37
+ elif spec.transport == "streamable-http":
38
+ if not spec.url:
39
+ raise ValueError(f"streamable-http server {spec.name!r} needs a 'url'")
40
+ http_client = None
41
+ if spec.headers:
42
+ http_client = await stack.enter_async_context(
43
+ create_mcp_http_client(headers=spec.headers)
44
+ )
45
+ read, write, _get_session_id = await stack.enter_async_context(
46
+ streamable_http_client(spec.url, http_client=http_client)
47
+ )
48
+
49
+ elif spec.transport == "sse":
50
+ if not spec.url:
51
+ raise ValueError(f"sse server {spec.name!r} needs a 'url'")
52
+ read, write = await stack.enter_async_context(
53
+ sse_client(spec.url, headers=spec.headers or None)
54
+ )
55
+
56
+ else: # pragma: no cover - guarded by the Literal type
57
+ raise ValueError(f"unknown transport {spec.transport!r}")
58
+
59
+ session = await stack.enter_async_context(ClientSession(read, write))
60
+ yield session
File without changes
@@ -0,0 +1,275 @@
1
+ """Backend lifecycle and transaction/session compatibility helpers.
2
+
3
+ The public module seam is intentionally small while graph services migrate to
4
+ domain-level ports. Neo4j remains the compatibility default; Ladybug is an
5
+ explicit embedded backend and never runs in the gateway call path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+ from pathlib import Path
13
+ import re
14
+ import threading
15
+ from typing import Any
16
+
17
+ from neo4j import Driver, GraphDatabase, Session
18
+ from neo4j.exceptions import (
19
+ ConnectionAcquisitionTimeoutError,
20
+ DatabaseUnavailable,
21
+ ServiceUnavailable,
22
+ SessionExpired,
23
+ )
24
+
25
+ from toolgraph import config
26
+
27
+ _driver: Driver | None = None
28
+ _ladybug_database: Any | None = None
29
+ # Serializes lazy backend construction. The streamable-HTTP MCP server can run
30
+ # two first-requests concurrently; an unguarded check-then-set would leak one
31
+ # Neo4j driver, or open a second Ladybug Database that trips the file lock and
32
+ # misreports a spurious BACKEND_LOCKED.
33
+ _backend_lock = threading.Lock()
34
+
35
+
36
+ class BackendConfigurationError(RuntimeError):
37
+ pass
38
+
39
+
40
+ class BackendUnavailableError(RuntimeError):
41
+ """A known transient backend outage, raised at the backend boundary.
42
+
43
+ Callers above the driver classify on this type alone — the native
44
+ neo4j/Ladybug taxonomy stays private to this module.
45
+ """
46
+
47
+
48
+ class BackendLockedError(BackendUnavailableError):
49
+ pass
50
+
51
+
52
+ _NATIVE_UNAVAILABLE_ERRORS = (
53
+ ServiceUnavailable,
54
+ SessionExpired,
55
+ ConnectionAcquisitionTimeoutError,
56
+ DatabaseUnavailable,
57
+ )
58
+
59
+
60
+ @contextmanager
61
+ def _translating_backend_errors() -> Iterator[None]:
62
+ """Re-raise the narrow native outage taxonomy as ``BackendUnavailableError``.
63
+
64
+ Deliberately narrower than "any backend exception": invalid configuration,
65
+ authentication, and Cypher/client errors must stay loud contract errors.
66
+ """
67
+ try:
68
+ yield
69
+ except _NATIVE_UNAVAILABLE_ERRORS as exc:
70
+ raise BackendUnavailableError(str(exc)) from exc
71
+
72
+
73
+ def is_backend_unavailable(exc: BaseException) -> bool:
74
+ """Whether *exc* was caused by a backend outage typed at the driver seam.
75
+
76
+ FastMCP wraps tool exceptions, so walk the chain. ``__context__`` is
77
+ followed only when Python did not suppress it: a deliberate
78
+ ``raise ... from None`` while handling an outage is the author saying the
79
+ outer error is the real one, and must not be reclassified as retryable.
80
+ """
81
+ current: BaseException | None = exc
82
+ seen: set[int] = set()
83
+ while current is not None and id(current) not in seen:
84
+ if isinstance(current, BackendUnavailableError):
85
+ return True
86
+ seen.add(id(current))
87
+ if current.__cause__ is not None:
88
+ current = current.__cause__
89
+ elif not current.__suppress_context__:
90
+ current = current.__context__
91
+ else:
92
+ current = None
93
+ return False
94
+
95
+
96
+ class _LadybugResult:
97
+ def __init__(self, result: Any) -> None:
98
+ self._rows: list[dict[str, Any]] = result.rows_as_dict().get_all()
99
+
100
+ def __iter__(self):
101
+ return iter(self._rows)
102
+
103
+ def single(self) -> dict[str, Any] | None:
104
+ if not self._rows:
105
+ return None
106
+ if len(self._rows) > 1:
107
+ raise RuntimeError(f"expected at most one row, received {len(self._rows)}")
108
+ return self._rows[0]
109
+
110
+
111
+ _IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*"
112
+ _DATETIME_CALL = re.compile(r"(?<![A-Za-z0-9_])datetime\s*\(\s*\)")
113
+ _TYPE_CALL = re.compile(rf"(?<![A-Za-z0-9_])type\s*\(\s*({_IDENTIFIER})\s*\)")
114
+ _LABELS_HEAD = re.compile(
115
+ rf"(?<![A-Za-z0-9_])labels\s*\(\s*({_IDENTIFIER})\s*\)\s*\[\s*0\s*\]"
116
+ )
117
+ _UNSUPPORTED_NEO4J_EXPRESSION = re.compile(
118
+ r"(?<![A-Za-z0-9_])(?:datetime|type|labels)\s*\("
119
+ )
120
+
121
+
122
+ def _ladybug_query(query: str) -> str:
123
+ """Translate only the explicitly supported Neo4j expression shapes.
124
+
125
+ This is intentionally a narrow dialect contract, not a Cypher parser.
126
+ Identifier boundaries prevent incidental substrings from being rewritten;
127
+ a remaining Neo4j-only function fails fast instead of reaching Ladybug as
128
+ a subtly different query.
129
+ """
130
+ translated = _DATETIME_CALL.sub("current_timestamp()", query)
131
+ translated = _TYPE_CALL.sub(r"label(\1)", translated)
132
+ translated = _LABELS_HEAD.sub(r"label(\1)", translated)
133
+ for field in (
134
+ "read_only_hint",
135
+ "destructive_hint",
136
+ "idempotent_hint",
137
+ "open_world_hint",
138
+ ):
139
+ translated = re.sub(
140
+ rf"\btool\.{field}\s*=\s*t\.{field}\b",
141
+ f"tool.{field}=CAST(t.{field} AS BOOLEAN)",
142
+ translated,
143
+ )
144
+ if match := _UNSUPPORTED_NEO4J_EXPRESSION.search(translated):
145
+ raise BackendConfigurationError(
146
+ f"unsupported Neo4j expression for Ladybug near {match.group(0)!r}"
147
+ )
148
+ return translated
149
+
150
+
151
+ class LadybugSession:
152
+ """Minimal transaction facade used during the domain-port migration."""
153
+
154
+ def __init__(self, database: Any) -> None:
155
+ import ladybug as lb
156
+
157
+ self._connection = lb.Connection(database)
158
+
159
+ def run(self, query: str, **parameters: Any) -> _LadybugResult:
160
+ result = self._connection.execute(_ladybug_query(query), parameters)
161
+ return _LadybugResult(result)
162
+
163
+ def execute_write(self, fn, *args):
164
+ self._connection.execute("BEGIN TRANSACTION")
165
+ try:
166
+ value = fn(self, *args)
167
+ self._connection.execute("COMMIT")
168
+ return value
169
+ except BaseException:
170
+ # Ladybug aborts the explicit transaction immediately on some
171
+ # binder/runtime errors; in that case there is nothing left to
172
+ # roll back. Preserve the original exception.
173
+ try:
174
+ self._connection.execute("ROLLBACK")
175
+ except RuntimeError as rollback_error:
176
+ if "No active transaction" not in str(rollback_error):
177
+ raise
178
+ raise
179
+
180
+ def close(self) -> None:
181
+ self._connection.close()
182
+
183
+
184
+ def backend_name() -> str:
185
+ backend = config.settings.backend.lower().strip()
186
+ if backend not in {"neo4j", "ladybug"}:
187
+ raise BackendConfigurationError(
188
+ f"unknown TOOLGRAPH_BACKEND {backend!r}; expected 'neo4j' or 'ladybug'"
189
+ )
190
+ return backend
191
+
192
+
193
+ def get_driver() -> Driver:
194
+ """Return the process-wide Neo4j driver."""
195
+ if backend_name() != "neo4j":
196
+ raise BackendConfigurationError("Neo4j driver requested while backend=ladybug")
197
+ global _driver
198
+ if _driver is None:
199
+ with _backend_lock:
200
+ if _driver is None:
201
+ _driver = GraphDatabase.driver(
202
+ config.settings.neo4j_uri,
203
+ auth=(config.settings.neo4j_user, config.settings.neo4j_password),
204
+ )
205
+ return _driver
206
+
207
+
208
+ def _get_ladybug_database() -> Any:
209
+ global _ladybug_database
210
+ if _ladybug_database is not None:
211
+ return _ladybug_database
212
+ try:
213
+ import ladybug as lb
214
+ except ImportError as exc: # pragma: no cover - environment dependent
215
+ raise BackendConfigurationError(
216
+ "backend=ladybug requires the 'ladybug' extra: "
217
+ "install with `uv sync --extra ladybug` or `pip install toolgraph[ladybug]`"
218
+ ) from exc
219
+ with _backend_lock:
220
+ if _ladybug_database is not None:
221
+ return _ladybug_database
222
+ path = Path(config.settings.db_path)
223
+ path.parent.mkdir(parents=True, exist_ok=True)
224
+ try:
225
+ _ladybug_database = lb.Database(path)
226
+ except RuntimeError as exc:
227
+ message = str(exc)
228
+ if "lock" in message.lower() or "another process" in message.lower():
229
+ raise BackendLockedError(
230
+ f"BACKEND_LOCKED: Ladybug database {path} already has an owner"
231
+ ) from exc
232
+ raise
233
+ return _ladybug_database
234
+
235
+
236
+ def verify_connectivity() -> None:
237
+ if backend_name() == "neo4j":
238
+ with _translating_backend_errors():
239
+ get_driver().verify_connectivity()
240
+ return
241
+ with session() as current:
242
+ current.run("RETURN 1 AS ok").single()
243
+
244
+
245
+ @contextmanager
246
+ def session() -> Iterator[Session | LadybugSession]:
247
+ """Yield a backend session, typing outage failures at this seam.
248
+
249
+ The translation wraps the ``yield`` too: a driver outage that surfaces
250
+ mid-query is thrown back in here by the caller's ``with`` block, so one
251
+ boundary covers acquisition and execution alike.
252
+ """
253
+ with _translating_backend_errors():
254
+ if backend_name() == "neo4j":
255
+ with get_driver().session(
256
+ database=config.settings.neo4j_database
257
+ ) as current:
258
+ yield current
259
+ return
260
+ current = LadybugSession(_get_ladybug_database())
261
+ try:
262
+ yield current
263
+ finally:
264
+ current.close()
265
+
266
+
267
+ def close_driver() -> None:
268
+ global _driver, _ladybug_database
269
+ with _backend_lock:
270
+ if _driver is not None:
271
+ _driver.close()
272
+ _driver = None
273
+ if _ladybug_database is not None:
274
+ _ladybug_database.close()
275
+ _ladybug_database = None