graphite-code 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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/llm_probe.py ADDED
@@ -0,0 +1,157 @@
1
+ """Isolated synthetic LLM connectivity worker with fixed-schema output."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ import sys
7
+ from collections.abc import Callable
8
+ from dataclasses import replace
9
+ from pathlib import Path
10
+ from typing import Literal, TypedDict
11
+
12
+ if __package__ in {None, ""}:
13
+ source = Path(__file__).resolve(strict=True)
14
+ source_root = source.parent.parent
15
+ sys.path.insert(0, str(source_root))
16
+ import graphite
17
+ from graphite import llm_probe as trusted_worker
18
+
19
+ if (
20
+ Path(graphite.__file__).resolve(strict=True) != source.parent / "__init__.py"
21
+ or Path(trusted_worker.__file__).resolve(strict=True) != source
22
+ ):
23
+ raise SystemExit(70)
24
+ raise SystemExit(trusted_worker.main())
25
+
26
+ from .config import Config
27
+ from .llm import (
28
+ CompletionProvider,
29
+ LLMConfigurationError,
30
+ LLMProviderError,
31
+ PROBE_MAX_OUTPUT_TOKENS,
32
+ ProviderErrorCategory,
33
+ make_provider,
34
+ )
35
+
36
+ SYSTEM_PROMPT = "You are a connectivity probe. Reply with READY only."
37
+ USER_PROMPT = "Synthetic Graphite connectivity test. No repository data is included."
38
+ WORKER_INPUT_LIMIT_BYTES = 16 * 1024
39
+ _INPUT_KEYS = frozenset(
40
+ {
41
+ "mode",
42
+ "provider",
43
+ "model",
44
+ "base_url",
45
+ "api_key",
46
+ "timeout_seconds",
47
+ "seed",
48
+ "system",
49
+ "user",
50
+ }
51
+ )
52
+
53
+
54
+ class ReadyProbeResult(TypedDict):
55
+ status: Literal["ready"]
56
+ response_present: Literal[True]
57
+
58
+
59
+ class DegradedProbeResult(TypedDict):
60
+ status: Literal["degraded"]
61
+ category: ProviderErrorCategory
62
+
63
+
64
+ ProbeResult = ReadyProbeResult | DegradedProbeResult
65
+ ProviderFactory = Callable[[Config], CompletionProvider]
66
+
67
+
68
+ def _failure(category: ProviderErrorCategory) -> DegradedProbeResult:
69
+ return {"status": "degraded", "category": category}
70
+
71
+
72
+ def _classify_exception(exc: Exception) -> ProviderErrorCategory:
73
+ if isinstance(exc, LLMConfigurationError):
74
+ return "configuration"
75
+ if isinstance(exc, LLMProviderError):
76
+ return exc.category
77
+ if isinstance(exc, TimeoutError):
78
+ return "timeout"
79
+ if isinstance(exc, (ConnectionError, OSError)):
80
+ return "connection"
81
+ return "provider_error"
82
+
83
+
84
+ def run_synthetic_probe(
85
+ cfg: Config,
86
+ *,
87
+ provider_factory: ProviderFactory = make_provider,
88
+ ) -> ProbeResult:
89
+ """Make exactly one constant-content completion and discard the response text."""
90
+ try:
91
+ probe_cfg = replace(cfg, llm_max_output_tokens=PROBE_MAX_OUTPUT_TOKENS)
92
+ provider = provider_factory(probe_cfg)
93
+ completion = provider.complete(SYSTEM_PROMPT, USER_PROMPT)
94
+ text = completion.text
95
+ except Exception as exc:
96
+ return _failure(_classify_exception(exc))
97
+ if not isinstance(text, str) or not text.strip():
98
+ return _failure("provider_error")
99
+ return {"status": "ready", "response_present": True}
100
+
101
+
102
+ def _optional_string(value: object, *, limit: int) -> str | None:
103
+ if value is None:
104
+ return None
105
+ if not isinstance(value, str) or len(value) > limit:
106
+ raise ValueError
107
+ return value
108
+
109
+
110
+ def _config_from_payload(payload: object) -> Config:
111
+ if not isinstance(payload, dict) or set(payload) != _INPUT_KEYS:
112
+ raise ValueError
113
+ mode = payload["mode"]
114
+ provider = payload["provider"]
115
+ timeout = payload["timeout_seconds"]
116
+ seed = payload["seed"]
117
+ if (
118
+ not isinstance(mode, str)
119
+ or len(mode) > 16
120
+ or mode.strip().lower() not in {"auto", "local", "cloud"}
121
+ or not isinstance(provider, str)
122
+ or len(provider) > 128
123
+ or not isinstance(timeout, (int, float))
124
+ or isinstance(timeout, bool)
125
+ or not math.isfinite(timeout)
126
+ or timeout <= 0
127
+ or timeout > 60
128
+ or not isinstance(seed, int)
129
+ or isinstance(seed, bool)
130
+ or payload["system"] != SYSTEM_PROMPT
131
+ or payload["user"] != USER_PROMPT
132
+ ):
133
+ raise ValueError
134
+ return Config(
135
+ llm_mode=mode,
136
+ llm_provider=provider,
137
+ llm_model=_optional_string(payload["model"], limit=512),
138
+ llm_base_url=_optional_string(payload["base_url"], limit=2048),
139
+ llm_api_key=_optional_string(payload["api_key"], limit=4096),
140
+ llm_timeout_seconds=float(timeout),
141
+ llm_max_output_tokens=PROBE_MAX_OUTPUT_TOKENS,
142
+ seed=seed,
143
+ )
144
+
145
+
146
+ def main() -> int:
147
+ """Read bounded configuration from stdin and emit only fixed-schema JSON."""
148
+ try:
149
+ raw = sys.stdin.buffer.read(WORKER_INPUT_LIMIT_BYTES + 1)
150
+ if len(raw) > WORKER_INPUT_LIMIT_BYTES:
151
+ raise ValueError
152
+ payload = json.loads(raw.decode("utf-8"))
153
+ result = run_synthetic_probe(_config_from_payload(payload))
154
+ except Exception:
155
+ result = _failure("configuration")
156
+ sys.stdout.write(json.dumps(result, separators=(",", ":")))
157
+ return 0
graphite/mcp.py ADDED
@@ -0,0 +1,7 @@
1
+ """Thin alias for mcp_server so `python -m graphite.mcp` works."""
2
+ from __future__ import annotations
3
+
4
+ from .mcp_server import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
graphite/mcp_server.py ADDED
@@ -0,0 +1,450 @@
1
+ """MCP server exposing Graphite graph queries as tools for Claude Code."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ try:
11
+ from mcp.server import Server
12
+ from mcp.server.stdio import stdio_server
13
+ from mcp.types import Tool, TextContent
14
+ except ImportError:
15
+ print(
16
+ "[graphite-mcp] MCP package not installed. Run: pip install -e 'tools/graphite[mcp]'",
17
+ file=sys.stderr,
18
+ )
19
+ raise
20
+
21
+ import networkx as nx
22
+
23
+ from .analyze import analyze
24
+ from .graph_io import GraphReadError, load_validated_graph_bundle
25
+ from .query import query
26
+
27
+
28
+ class GraphiteMCPServer:
29
+ """In-memory Graphite graph + optional rebuild for MCP tools."""
30
+
31
+ def __init__(self, project_root: Path | None = None) -> None:
32
+ self.project_root = (project_root or Path.cwd()).resolve()
33
+ self.graph_json = self.project_root / "graph-out" / "graph.json"
34
+ self.report_md = self.project_root / "graph-out" / "GRAPH_REPORT.md"
35
+ self._g: nx.DiGraph | None = None
36
+ self._load_error: str | None = None
37
+ self._load_attempts = 0
38
+
39
+ def _load(self) -> bool:
40
+ if self._g is not None:
41
+ return True
42
+ if self._load_attempts >= 3:
43
+ self._load_error = "Graph unavailable: retry_limit"
44
+ return False
45
+ self._load_attempts += 1
46
+ try:
47
+ _, self._g = load_validated_graph_bundle(
48
+ self.graph_json,
49
+ root=self.project_root,
50
+ )
51
+ self._load_error = None
52
+ self._load_attempts = 0
53
+ return True
54
+ except GraphReadError as exc:
55
+ self._load_error = f"Graph unavailable: {exc.code}"
56
+ return False
57
+
58
+ def refresh(self) -> dict[str, Any]:
59
+ """Rebuild the graph and reload it."""
60
+ try:
61
+ result = subprocess.run(
62
+ [sys.executable, "-P", "-m", "graphite", "build", str(self.project_root)],
63
+ cwd=self.project_root,
64
+ stdin=subprocess.DEVNULL,
65
+ capture_output=True,
66
+ text=True,
67
+ # graphite forces UTF-8 on redirected output (#17), so decoding
68
+ # its child with the locale codec is wrong on both sides.
69
+ encoding="utf-8",
70
+ errors="replace",
71
+ check=False,
72
+ timeout=300,
73
+ )
74
+ except Exception as e:
75
+ return {"success": False, "error": f"Failed to run graphite build: {e}"}
76
+ if result.returncode != 0:
77
+ return {"success": False, "error": result.stderr or result.stdout}
78
+ self._g = None
79
+ self._load_attempts = 0
80
+ loaded = self._load()
81
+ return {
82
+ "success": loaded,
83
+ "error": self._load_error,
84
+ "output": result.stdout.strip(),
85
+ }
86
+
87
+ def query_tool(self, q: str) -> dict[str, Any]:
88
+ if not self._load():
89
+ return {"error": self._load_error}
90
+ assert self._g is not None
91
+ return query(self._g, q)
92
+
93
+ def community_tool(self, node_id: str) -> dict[str, Any]:
94
+ if not self._load():
95
+ return {"error": self._load_error}
96
+ assert self._g is not None
97
+ g = self._g
98
+ if node_id not in g:
99
+ # Try fuzzy match.
100
+ for n in g.nodes():
101
+ if node_id in n or g.nodes[n].get("name", "").lower() == node_id.lower():
102
+ node_id = n
103
+ break
104
+ else:
105
+ return {"error": f"Node not found: {node_id}"}
106
+ comm = g.nodes[node_id].get("community")
107
+ members = [n for n in g.nodes() if g.nodes[n].get("community") == comm]
108
+ return {
109
+ "node": node_id,
110
+ "community": comm,
111
+ "size": len(members),
112
+ "members": sorted(members)[:50],
113
+ }
114
+
115
+ def summary_tool(self) -> dict[str, Any]:
116
+ if not self._load():
117
+ return {"error": self._load_error}
118
+ assert self._g is not None
119
+ analysis = analyze(self._g, top_n=10)
120
+ return {
121
+ "node_count": self._g.number_of_nodes(),
122
+ "edge_count": self._g.number_of_edges(),
123
+ "density": nx.density(self._g),
124
+ "god_nodes": analysis.get("god_nodes", []),
125
+ "entry_points": analysis.get("entry_points", []),
126
+ "top_files": analysis.get("top_files_by_links", []),
127
+ "surprising_connections": analysis.get("surprising_connections", [])[:5],
128
+ }
129
+
130
+
131
+ # --- agent channel -----------------------------------------------------
132
+ #
133
+ # The only tools here that write outside the selected project. They exist
134
+ # because some agents are sandboxed to their own workspace and cannot reach
135
+ # the shared channel at all -- so access is mediated rather than granted.
136
+ #
137
+ # None of them take an author. Identity is derived from `self.project_root`,
138
+ # the repo this server was launched in, because every agent commits under
139
+ # the operator's git identity and the trailer is therefore the only answer
140
+ # to "who wrote this".
141
+
142
+ def _channel_call(self, fn, *args, **kwargs) -> dict[str, Any]:
143
+ from .channel import ChannelError, require_channel
144
+
145
+ try:
146
+ root = require_channel()
147
+ return fn(root, *args, **kwargs)
148
+ except ChannelError as exc:
149
+ return {"error": exc.code, "message": str(exc)}
150
+
151
+ def channel_post_tool(
152
+ self,
153
+ *,
154
+ title: str,
155
+ body: str,
156
+ to: list[str] | None = None,
157
+ supersedes: int | None = None,
158
+ ) -> dict[str, Any]:
159
+ from .channel import post_round
160
+
161
+ return self._channel_call(
162
+ lambda root: post_round(
163
+ root,
164
+ self.project_root,
165
+ title=title,
166
+ body=body,
167
+ to=to or [],
168
+ supersedes=supersedes,
169
+ )
170
+ )
171
+
172
+ def channel_inbox_tool(self) -> dict[str, Any]:
173
+ from .channel import inbox
174
+
175
+ return self._channel_call(
176
+ lambda root: {
177
+ "ok": True,
178
+ "rounds": [
179
+ {
180
+ "round": entry.number,
181
+ "title": entry.title,
182
+ "author": entry.author,
183
+ "posted": entry.posted,
184
+ "body": entry.body,
185
+ }
186
+ for entry in inbox(root, self.project_root)
187
+ ],
188
+ }
189
+ )
190
+
191
+ def channel_status_tool(
192
+ self, *, number: int, status: str, reason: str | None = None
193
+ ) -> dict[str, Any]:
194
+ from .channel import record_status
195
+
196
+ return self._channel_call(
197
+ lambda root: record_status(root, self.project_root, number, status, reason=reason)
198
+ )
199
+
200
+ def channel_list_tool(self) -> dict[str, Any]:
201
+ from .channel import current_status, list_rounds
202
+
203
+ def _run(root):
204
+ entries = list_rounds(root)
205
+ return {
206
+ "ok": True,
207
+ "rounds": [
208
+ {
209
+ "round": entry.number,
210
+ "title": entry.title,
211
+ "author": entry.author,
212
+ "to": entry.to,
213
+ "posted": entry.posted,
214
+ "legacy": entry.legacy,
215
+ "status": (
216
+ (current_status(root, entry.number) or {}).get("status")
217
+ if entry.number is not None
218
+ else None
219
+ ),
220
+ }
221
+ for entry in entries
222
+ ],
223
+ }
224
+
225
+ return self._channel_call(_run)
226
+
227
+ def channel_read_tool(self, *, number: int) -> dict[str, Any]:
228
+ from .channel import current_status, read_round
229
+
230
+ def _run(root):
231
+ entry = read_round(root, number)
232
+ return {
233
+ "ok": True,
234
+ "round": entry.number,
235
+ "title": entry.title,
236
+ "author": entry.author,
237
+ "to": entry.to,
238
+ "posted": entry.posted,
239
+ "body": entry.body,
240
+ "status": (current_status(root, entry.number) or {}).get("status"),
241
+ }
242
+
243
+ return self._channel_call(_run)
244
+
245
+
246
+ def channel_tool_definitions() -> list[Tool]:
247
+ """Advertised so agents can discover them; a tool nobody can find is the
248
+ problem this was built to solve."""
249
+ return [
250
+ Tool(
251
+ name="graphite_channel_inbox",
252
+ description=(
253
+ "Messages addressed to you that you have not been handed yet. Call this at "
254
+ "the start of a session. Delivery is recorded as they are returned."
255
+ ),
256
+ inputSchema={"type": "object", "properties": {}},
257
+ ),
258
+ Tool(
259
+ name="graphite_channel_post",
260
+ description=(
261
+ "Post a new round to the shared agent channel. You are identified by the "
262
+ "repository this server runs in; you cannot post as another agent. Rounds are "
263
+ "immutable -- correct one by posting another with `supersedes`."
264
+ ),
265
+ inputSchema={
266
+ "type": "object",
267
+ "properties": {
268
+ "title": {"type": "string", "description": "One-line subject"},
269
+ "body": {"type": "string", "description": "Markdown body"},
270
+ "to": {
271
+ "type": "array",
272
+ "items": {"type": "string"},
273
+ "description": "Recipient agent ids, e.g. ['aramid-agent']",
274
+ },
275
+ "supersedes": {"type": "integer", "description": "Round this replaces"},
276
+ },
277
+ "required": ["title", "body"],
278
+ },
279
+ ),
280
+ Tool(
281
+ name="graphite_channel_status",
282
+ description=(
283
+ "Update a message's status: acknowledged, blocked (give a reason), done, or "
284
+ "withdrawn if you wrote it. Only the recipient may acknowledge/block/complete."
285
+ ),
286
+ inputSchema={
287
+ "type": "object",
288
+ "properties": {
289
+ "number": {"type": "integer"},
290
+ "status": {
291
+ "type": "string",
292
+ "enum": ["acknowledged", "blocked", "done", "withdrawn"],
293
+ },
294
+ "reason": {"type": "string"},
295
+ },
296
+ "required": ["number", "status"],
297
+ },
298
+ ),
299
+ Tool(
300
+ name="graphite_channel_list",
301
+ description="List every round in the channel with its author and current status.",
302
+ inputSchema={"type": "object", "properties": {}},
303
+ ),
304
+ Tool(
305
+ name="graphite_channel_read",
306
+ description="Read one round by number.",
307
+ inputSchema={
308
+ "type": "object",
309
+ "properties": {"number": {"type": "integer"}},
310
+ "required": ["number"],
311
+ },
312
+ ),
313
+ ]
314
+
315
+
316
+ def _result(content: dict[str, Any]) -> list[TextContent]:
317
+ return [TextContent(type="text", text=json.dumps(content, ensure_ascii=False, indent=2))]
318
+
319
+
320
+ def _tool_definitions() -> list[Tool]:
321
+ return [
322
+ Tool(
323
+ name="graphite_query",
324
+ description="Query the Graphite knowledge graph. Supported queries: depends-on <node>, imported-by <node>, path <a> -> <b>, stats.",
325
+ inputSchema={
326
+ "type": "object",
327
+ "properties": {
328
+ "query": {
329
+ "type": "string",
330
+ "description": "Graphite query string, e.g. 'depends-on db.ts'",
331
+ }
332
+ },
333
+ "required": ["query"],
334
+ },
335
+ ),
336
+ Tool(
337
+ name="graphite_community",
338
+ description="Describe the community/cluster a node belongs to, including fellow members.",
339
+ inputSchema={
340
+ "type": "object",
341
+ "properties": {
342
+ "node_id": {
343
+ "type": "string",
344
+ "description": "Node id, name, or file path fragment, e.g. 'db.ts'",
345
+ }
346
+ },
347
+ "required": ["node_id"],
348
+ },
349
+ ),
350
+ Tool(
351
+ name="graphite_summary",
352
+ description="Return high-level graph stats, god nodes, entry points, and top files.",
353
+ inputSchema={"type": "object", "properties": {}},
354
+ ),
355
+ Tool(
356
+ name="graphite_refresh",
357
+ description="Rebuild graph-out/graph.json and reload it.",
358
+ inputSchema={"type": "object", "properties": {}},
359
+ ),
360
+ *channel_tool_definitions(),
361
+ ]
362
+
363
+
364
+ def _dispatch(
365
+ graphite: GraphiteMCPServer, name: str, arguments: dict[str, Any]
366
+ ) -> list[TextContent]:
367
+ if name == "graphite_channel_inbox":
368
+ return _result(graphite.channel_inbox_tool())
369
+ if name == "graphite_channel_post":
370
+ return _result(graphite.channel_post_tool(
371
+ title=arguments.get("title", ""),
372
+ body=arguments.get("body", ""),
373
+ to=arguments.get("to") or [],
374
+ supersedes=arguments.get("supersedes"),
375
+ ))
376
+ if name == "graphite_channel_status":
377
+ return _result(graphite.channel_status_tool(
378
+ number=arguments.get("number", 0),
379
+ status=arguments.get("status", ""),
380
+ reason=arguments.get("reason"),
381
+ ))
382
+ if name == "graphite_channel_list":
383
+ return _result(graphite.channel_list_tool())
384
+ if name == "graphite_channel_read":
385
+ return _result(graphite.channel_read_tool(number=arguments.get("number", 0)))
386
+ if name == "graphite_query":
387
+ return _result(graphite.query_tool(arguments.get("query", "")))
388
+ if name == "graphite_community":
389
+ return _result(graphite.community_tool(arguments.get("node_id", "")))
390
+ if name == "graphite_summary":
391
+ return _result(graphite.summary_tool())
392
+ if name == "graphite_refresh":
393
+ return _result(graphite.refresh())
394
+ return _result({"error": f"Unknown tool: {name}"})
395
+
396
+
397
+ def _register_tool_handlers(server: Any, graphite: GraphiteMCPServer) -> None:
398
+ """Wire tools/list and tools/call across both supported mcp lines.
399
+
400
+ mcp 2.x removed the `@server.list_tools()` / `@server.call_tool()`
401
+ decorators in favour of explicit `add_request_handler` registration. Both
402
+ paths serve the same tool definitions and the same dispatch, so the wire
403
+ contract is identical either way.
404
+ """
405
+ if hasattr(server, "list_tools"): # mcp 1.x decorator API
406
+ @server.list_tools()
407
+ async def list_tools() -> list[Tool]:
408
+ return _tool_definitions()
409
+
410
+ @server.call_tool()
411
+ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
412
+ return _dispatch(graphite, name, arguments)
413
+
414
+ return
415
+
416
+ from mcp.types import ( # mcp 2.x handler-registration API
417
+ CallToolRequestParams,
418
+ CallToolResult,
419
+ ListToolsResult,
420
+ PaginatedRequestParams,
421
+ )
422
+
423
+ async def handle_list_tools(ctx: Any, params: Any) -> ListToolsResult:
424
+ return ListToolsResult(tools=_tool_definitions())
425
+
426
+ async def handle_call_tool(ctx: Any, params: Any) -> CallToolResult:
427
+ return CallToolResult(
428
+ content=_dispatch(graphite, params.name, params.arguments or {})
429
+ )
430
+
431
+ server.add_request_handler("tools/list", PaginatedRequestParams, handle_list_tools)
432
+ server.add_request_handler("tools/call", CallToolRequestParams, handle_call_tool)
433
+
434
+
435
+ def main() -> int:
436
+ server = Server("graphite")
437
+ graphite = GraphiteMCPServer()
438
+ _register_tool_handlers(server, graphite)
439
+
440
+ async def run() -> None:
441
+ async with stdio_server() as (read_stream, write_stream):
442
+ await server.run(read_stream, write_stream, server.create_initialization_options())
443
+
444
+ import asyncio
445
+ asyncio.run(run())
446
+ return 0
447
+
448
+
449
+ if __name__ == "__main__":
450
+ raise SystemExit(main())