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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- 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
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())
|