stndp-cli 0.1.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.
- stndp/__init__.py +0 -0
- stndp/main.py +2969 -0
- stndp/mcp_server.py +582 -0
- stndp_cli-0.1.0.dist-info/METADATA +7 -0
- stndp_cli-0.1.0.dist-info/RECORD +8 -0
- stndp_cli-0.1.0.dist-info/WHEEL +5 -0
- stndp_cli-0.1.0.dist-info/entry_points.txt +2 -0
- stndp_cli-0.1.0.dist-info/top_level.txt +1 -0
stndp/mcp_server.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""A local stdio MCP server bundled in the CLI (`stn mcp`).
|
|
2
|
+
|
|
3
|
+
Lets a coding agent (Claude Code, Codex, Copilot, …) read and write the shared
|
|
4
|
+
context graph through typed tools — no hosted server and no second login: it reuses
|
|
5
|
+
the user's `stn login` token via the CLI's HTTP client. One line of agent config
|
|
6
|
+
(`{"command": "stn", "args": ["mcp"]}`) is the entire integration; `stn agent setup`
|
|
7
|
+
writes it for you.
|
|
8
|
+
|
|
9
|
+
A minimal, spec-compliant JSON-RPC 2.0 server over newline-delimited stdio:
|
|
10
|
+
`initialize`, `tools/list`, `tools/call`, `ping`. Tool descriptions are
|
|
11
|
+
**prescriptive about when to call them**, because recent models under-reach for
|
|
12
|
+
tools without an explicit trigger — that's what drives agents to log decisions and
|
|
13
|
+
checkpoints as a byproduct of work rather than on request.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib.metadata
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
from stndp import main
|
|
26
|
+
|
|
27
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
28
|
+
try:
|
|
29
|
+
_VERSION = importlib.metadata.version("stndp-cli")
|
|
30
|
+
except importlib.metadata.PackageNotFoundError: # not installed (e.g. raw source run)
|
|
31
|
+
_VERSION = "0.0.0"
|
|
32
|
+
SERVER_INFO = {"name": "stndp", "version": _VERSION}
|
|
33
|
+
_TYPES = ("entry", "decision", "checkpoint", "episode", "bug") # graph-anchor node kinds (get_context)
|
|
34
|
+
# Every result type search can return — the originals plus the identity-only entities
|
|
35
|
+
# the server's trigram channel surfaces. Mirrors the API's `_IDENT_SPECS`.
|
|
36
|
+
_SEARCH_TYPES = _TYPES + (
|
|
37
|
+
"decision_version", "entry_version", "dump", "snapshot", "draft",
|
|
38
|
+
"repo", "branch", "directory", "file", "tracker_project", "workitem",
|
|
39
|
+
"user", "team", "org", "project",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _agent_header() -> dict[str, str]:
|
|
44
|
+
"""The `X-Stndp-Agent` label stamped on writes so the API attributes
|
|
45
|
+
agent-authored context. Configurable per agent via STNDP_AGENT_LABEL (e.g.
|
|
46
|
+
"claude-code", "codex"); defaults to "mcp"."""
|
|
47
|
+
return {"X-Stndp-Agent": os.environ.get("STNDP_AGENT_LABEL") or "mcp"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _call(method: str, path: str, **kwargs: Any) -> Any:
|
|
51
|
+
return main._api_request(method, path, **kwargs)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InvalidParams(Exception):
|
|
55
|
+
"""An agent-supplied argument failed validation; surfaced as JSON-RPC -32602
|
|
56
|
+
so junk never reaches the API."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _as_int(value: Any, field: str) -> int:
|
|
60
|
+
"""Coerce an agent-supplied id to int, rejecting junk (floats with a
|
|
61
|
+
fractional part, non-numeric strings, bools)."""
|
|
62
|
+
if isinstance(value, bool) or value is None:
|
|
63
|
+
raise InvalidParams(f"{field!r} must be an integer")
|
|
64
|
+
try:
|
|
65
|
+
return int(value)
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
raise InvalidParams(f"{field!r} must be an integer, got {value!r}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _as_type(value: Any) -> str:
|
|
71
|
+
"""Validate a graph-anchor node-type arg (get_context) against the declared enum."""
|
|
72
|
+
if value not in _TYPES:
|
|
73
|
+
raise InvalidParams(f"'type' must be one of {_TYPES}, got {value!r}")
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _as_search_type(value: Any) -> str:
|
|
78
|
+
"""Validate a search `type` filter against the broader set of result kinds."""
|
|
79
|
+
if value not in _SEARCH_TYPES:
|
|
80
|
+
raise InvalidParams(f"'type' must be one of {_SEARCH_TYPES}, got {value!r}")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── tool handlers (each returns the API's JSON result) ────────────────────────
|
|
85
|
+
def _t_resume(a: dict) -> Any:
|
|
86
|
+
return _call("GET", "/v1/resume", params=({"team": a["team"]} if a.get("team") else None))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _t_checkpoint(a: dict) -> Any:
|
|
90
|
+
body: dict[str, Any] = {"focus": a["focus"], "kind": "state"}
|
|
91
|
+
for k in ("reason", "next_step", "status"):
|
|
92
|
+
if a.get(k):
|
|
93
|
+
body[k] = a[k]
|
|
94
|
+
params: dict[str, Any] = {}
|
|
95
|
+
if a.get("team"):
|
|
96
|
+
params["team"] = a["team"]
|
|
97
|
+
ep_id = main._open_episode_id() # auto-attach to the open arc, if any
|
|
98
|
+
if ep_id:
|
|
99
|
+
params["episode"] = ep_id
|
|
100
|
+
return _call("POST", "/v1/checkpoints", params=(params or None), json=body,
|
|
101
|
+
headers=_agent_header())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _t_decide(a: dict) -> Any:
|
|
105
|
+
body: dict[str, Any] = {"title": a["title"], "body": a.get("why") or a["title"]}
|
|
106
|
+
if a.get("tags"):
|
|
107
|
+
body["tags"] = a["tags"]
|
|
108
|
+
params: dict[str, Any] = {"team": a["team"]}
|
|
109
|
+
ep_id = main._open_episode_id()
|
|
110
|
+
if ep_id:
|
|
111
|
+
params["episode"] = ep_id
|
|
112
|
+
return _call("POST", "/v1/decisions", params=params, json=body, headers=_agent_header())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _t_episode_start(a: dict) -> Any:
|
|
116
|
+
body: dict[str, Any] = {"title": a["title"]}
|
|
117
|
+
if a.get("plan"):
|
|
118
|
+
body["plan"] = a["plan"]
|
|
119
|
+
ep = _call("POST", "/v1/episodes", params={"team": a["team"]}, json=body,
|
|
120
|
+
headers=_agent_header())
|
|
121
|
+
if isinstance(ep, dict) and ep.get("id"):
|
|
122
|
+
main._set_open_episode(ep) # subsequent checkpoint/decide/bug auto-attach
|
|
123
|
+
return ep
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _t_episode_close(a: dict) -> Any:
|
|
127
|
+
eid = _as_int(a.get("episode_id"), "episode_id")
|
|
128
|
+
body: dict[str, Any] = {}
|
|
129
|
+
for src, dst in (("did", "outcome"), ("result", "result"), ("surprise", "surprise")):
|
|
130
|
+
if a.get(src):
|
|
131
|
+
body[dst] = a[src]
|
|
132
|
+
ep = _call("POST", f"/v1/episodes/{eid}/close", json=body, headers=_agent_header())
|
|
133
|
+
main._clear_open_episode(eid)
|
|
134
|
+
return ep
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _t_bug(a: dict) -> Any:
|
|
138
|
+
body: dict[str, Any] = {"symptom": a["symptom"]}
|
|
139
|
+
for src, dst in (("root_cause", "root_cause"), ("fix", "fix"), ("where", "where_ref")):
|
|
140
|
+
if a.get(src):
|
|
141
|
+
body[dst] = a[src]
|
|
142
|
+
body["episode_id"] = (_as_int(a["episode_id"], "episode_id")
|
|
143
|
+
if a.get("episode_id") else main._open_episode_id())
|
|
144
|
+
return _call("POST", "/v1/bugs", params={"team": a["team"]}, json=body,
|
|
145
|
+
headers=_agent_header())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _t_search(a: dict) -> Any:
|
|
149
|
+
params: dict[str, Any] = {"q": a["query"], "limit": a.get("limit", 10)}
|
|
150
|
+
if a.get("type"):
|
|
151
|
+
params["type"] = _as_search_type(a["type"])
|
|
152
|
+
if a.get("team"):
|
|
153
|
+
params["team"] = a["team"]
|
|
154
|
+
return _call("GET", "/v1/search", params=params)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _t_get_context(a: dict) -> Any:
|
|
158
|
+
node_type = _as_type(a.get("type"))
|
|
159
|
+
node_id = _as_int(a.get("id"), "id")
|
|
160
|
+
return _call("GET", "/v1/graph/neighbors", params={"type": node_type, "id": node_id})
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _t_list_blockers(a: dict) -> Any:
|
|
164
|
+
return _call("GET", "/v1/mentions", params={"unresolved": True})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _t_link(a: dict) -> Any:
|
|
168
|
+
body = {"object_type": _as_type(a.get("type")), "object_id": _as_int(a.get("id"), "id"),
|
|
169
|
+
"ref": a["ref"]}
|
|
170
|
+
return _call("POST", "/v1/links", json=body, headers=_agent_header())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _t_file_context(a: dict) -> Any:
|
|
174
|
+
params: dict[str, Any] = {"path": a["path"], "limit": a.get("limit", 20)}
|
|
175
|
+
if a.get("repo"):
|
|
176
|
+
params["repo"] = _as_int(a["repo"], "repo")
|
|
177
|
+
return _call("GET", "/v1/files/context", params=params)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _t_whoami(a: dict) -> Any:
|
|
181
|
+
"""The authenticated identity behind this MCP session (the user who ran `stn
|
|
182
|
+
login`), plus the agent label writes are attributed to. Local config — same
|
|
183
|
+
source as `stn whoami` — so it needs no API round-trip."""
|
|
184
|
+
cfg = main._load_config()
|
|
185
|
+
return {"logged_in": bool(cfg.get("user_id")), "handle": cfg.get("handle"),
|
|
186
|
+
"user_id": cfg.get("user_id"), "team_id": cfg.get("team_id"),
|
|
187
|
+
"email": cfg.get("email"), "api_url": cfg.get("api_url"),
|
|
188
|
+
"agent_label": os.environ.get("STNDP_AGENT_LABEL") or "mcp"}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _t_glob(a: dict) -> Any:
|
|
192
|
+
"""Capture this session's local git/repo/commit/GitHub context into the graph.
|
|
193
|
+
Idempotent — safe to call at session start. Runs the same capture as `stn glob`."""
|
|
194
|
+
return main._glob_capture(team=a.get("team"), no_github=bool(a.get("no_github")),
|
|
195
|
+
force=bool(a.get("force")))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _t_timeline(a: dict) -> Any:
|
|
199
|
+
"""Recent team context (entries/decisions/checkpoints/episodes/bugs) merged
|
|
200
|
+
newest-first — server-side via /v1/timeline, the same as `stn timeline`."""
|
|
201
|
+
params: dict[str, Any] = {"team": a["team"]}
|
|
202
|
+
if a.get("days") is not None:
|
|
203
|
+
params["days"] = int(a["days"])
|
|
204
|
+
return _call("GET", "/v1/timeline", params=params)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _t_guide(a: dict) -> Any:
|
|
208
|
+
"""The stndp workflow playbook — the SAME content as `stn guide` and the rules
|
|
209
|
+
block, served from main's single source of truth so the three never drift."""
|
|
210
|
+
topic = (a or {}).get("topic")
|
|
211
|
+
sections = main.GUIDE_TOPICS
|
|
212
|
+
if topic:
|
|
213
|
+
sections = [t for t in main.GUIDE_TOPICS if t[0] == topic]
|
|
214
|
+
if not sections:
|
|
215
|
+
raise InvalidParams(f"'topic' must be one of {main.GUIDE_KEYS}, got {topic!r}")
|
|
216
|
+
return {"intro": main.GUIDE_INTRO,
|
|
217
|
+
"topics": [{"key": k, "title": ti, "body": b} for k, ti, b in sections]}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
TOOLS: list[dict[str, Any]] = [
|
|
221
|
+
{"name": "guide",
|
|
222
|
+
"description": "How to use stndp — the workflow playbook (load context → record as "
|
|
223
|
+
"you go → draft the standup) and the available tools. Call this if "
|
|
224
|
+
"you're unsure when or how to use stndp's tools. Optional `topic` "
|
|
225
|
+
"narrows to one section: start, files, record, standup, discover.",
|
|
226
|
+
"inputSchema": {"type": "object",
|
|
227
|
+
"properties": {"topic": {"type": "string",
|
|
228
|
+
"enum": list(main.GUIDE_KEYS)}}},
|
|
229
|
+
"handler": _t_guide},
|
|
230
|
+
{"name": "resume",
|
|
231
|
+
"description": "Reload your working state before starting work: your last checkpoint "
|
|
232
|
+
"(focus, why, next step), open blockers, and live ticket/PR status. Call "
|
|
233
|
+
"this FIRST at the start of a task to pick up where you (or another agent) "
|
|
234
|
+
"left off.",
|
|
235
|
+
"inputSchema": {"type": "object", "properties": {"team": {"type": "string"}}},
|
|
236
|
+
"handler": _t_resume},
|
|
237
|
+
{"name": "glob",
|
|
238
|
+
"description": "Capture this session's local git/repo context (current branch, commit, "
|
|
239
|
+
"recent commits, and your GitHub activity) into the graph. Call this at the "
|
|
240
|
+
"START of a session so your work is anchored to the right repo/branch — it's "
|
|
241
|
+
"idempotent, so calling it again on the same commit is a no-op.",
|
|
242
|
+
"inputSchema": {"type": "object",
|
|
243
|
+
"properties": {"team": {"type": "string"},
|
|
244
|
+
"no_github": {"type": "boolean"},
|
|
245
|
+
"force": {"type": "boolean"}}},
|
|
246
|
+
"handler": _t_glob},
|
|
247
|
+
{"name": "search",
|
|
248
|
+
"description": "Search the team's shared memory BEFORE reasoning from scratch — find out "
|
|
249
|
+
"what was already decided and why. Covers decisions, entries, and your "
|
|
250
|
+
"checkpoints, and (by identity) any other entity the platform tracks: "
|
|
251
|
+
"repos, work items, files, people, teams. Great for absolute-identity "
|
|
252
|
+
"lookups too ('hydra service', a Jira key, a file path). Each hit comes "
|
|
253
|
+
"with its connected context (tickets/PRs, supersession chain).",
|
|
254
|
+
"inputSchema": {"type": "object",
|
|
255
|
+
"properties": {"query": {"type": "string"},
|
|
256
|
+
"type": {"type": "string",
|
|
257
|
+
"enum": list(_SEARCH_TYPES)},
|
|
258
|
+
"team": {"type": "string"},
|
|
259
|
+
"limit": {"type": "integer"}},
|
|
260
|
+
"required": ["query"]},
|
|
261
|
+
"handler": _t_search},
|
|
262
|
+
{"name": "timeline",
|
|
263
|
+
"description": "A unified, time-ordered feed of recent context in a team — entries, "
|
|
264
|
+
"decisions, and your checkpoints merged newest-first. Use it to catch up on "
|
|
265
|
+
"what's happened lately before diving in.",
|
|
266
|
+
"inputSchema": {"type": "object",
|
|
267
|
+
"properties": {"team": {"type": "string"},
|
|
268
|
+
"days": {"type": "integer"}},
|
|
269
|
+
"required": ["team"]},
|
|
270
|
+
"handler": _t_timeline},
|
|
271
|
+
{"name": "decide",
|
|
272
|
+
"description": "Record a durable, team-visible decision or learning into shared memory. "
|
|
273
|
+
"Call this whenever you CHOOSE between alternatives or REJECT an approach "
|
|
274
|
+
"(especially 'why we did NOT do X') — before moving on, so the rationale "
|
|
275
|
+
"isn't lost. Put the trade-offs and rejected options in `why`.",
|
|
276
|
+
"inputSchema": {"type": "object",
|
|
277
|
+
"properties": {"title": {"type": "string"},
|
|
278
|
+
"why": {"type": "string",
|
|
279
|
+
"description": "Rationale, including rejected alternatives"},
|
|
280
|
+
"tags": {"type": "array", "items": {"type": "string"}},
|
|
281
|
+
"team": {"type": "string"}},
|
|
282
|
+
"required": ["title", "team"]},
|
|
283
|
+
"handler": _t_decide},
|
|
284
|
+
{"name": "checkpoint",
|
|
285
|
+
"description": "Save your current working state (what you're doing, why, and the next "
|
|
286
|
+
"step) so it can be reloaded later with `resume`. Call this when you pause, "
|
|
287
|
+
"switch tasks, or finish a chunk of work. Private to the user.",
|
|
288
|
+
"inputSchema": {"type": "object",
|
|
289
|
+
"properties": {"focus": {"type": "string",
|
|
290
|
+
"description": "What you're working on"},
|
|
291
|
+
"reason": {"type": "string", "description": "Why"},
|
|
292
|
+
"next_step": {"type": "string", "description": "The next step"},
|
|
293
|
+
"team": {"type": "string"}},
|
|
294
|
+
"required": ["focus"]},
|
|
295
|
+
"handler": _t_checkpoint},
|
|
296
|
+
{"name": "episode_start",
|
|
297
|
+
"description": "Open an EPISODE — the arc of one non-trivial unit of work (plan → done → "
|
|
298
|
+
"result). Call this at the START of genuine multi-step work whose plan-vs-"
|
|
299
|
+
"outcome delta is worth keeping; the checkpoints, decisions and bugs you "
|
|
300
|
+
"record while it's open auto-attach to it. Do NOT open one for a one-line "
|
|
301
|
+
"change, a single decision, or a single bug — record those standalone. Most "
|
|
302
|
+
"work needs no episode.",
|
|
303
|
+
"inputSchema": {"type": "object",
|
|
304
|
+
"properties": {"title": {"type": "string"},
|
|
305
|
+
"plan": {"type": "string",
|
|
306
|
+
"description": "What you intend to do, and why"},
|
|
307
|
+
"team": {"type": "string"}},
|
|
308
|
+
"required": ["title", "team"]},
|
|
309
|
+
"handler": _t_episode_start},
|
|
310
|
+
{"name": "episode_close",
|
|
311
|
+
"description": "Close the open EPISODE when the work is done: record what actually happened "
|
|
312
|
+
"(`did`), the observable `result`, and the `surprise` — what changed from the "
|
|
313
|
+
"plan (the real, durable learning). This promotes the episode to team-visible "
|
|
314
|
+
"shared memory. Capture the surprise honestly; the delta is the whole point.",
|
|
315
|
+
"inputSchema": {"type": "object",
|
|
316
|
+
"properties": {"episode_id": {"type": "integer"},
|
|
317
|
+
"did": {"type": "string",
|
|
318
|
+
"description": "What actually happened"},
|
|
319
|
+
"result": {"type": "string",
|
|
320
|
+
"description": "The observable result"},
|
|
321
|
+
"surprise": {"type": "string",
|
|
322
|
+
"description": "What changed from the plan"}},
|
|
323
|
+
"required": ["episode_id"]},
|
|
324
|
+
"handler": _t_episode_close},
|
|
325
|
+
{"name": "bug",
|
|
326
|
+
"description": "Record a BUG you hit and (usually) fixed during the work — the highest-reuse, "
|
|
327
|
+
"least-recorded knowledge. Call this whenever you diagnose and fix a "
|
|
328
|
+
"non-obvious bug, so a future agent searching the symptom finds your exact "
|
|
329
|
+
"{cause, fix}. Team-visible; attaches to the open episode automatically.",
|
|
330
|
+
"inputSchema": {"type": "object",
|
|
331
|
+
"properties": {"symptom": {"type": "string",
|
|
332
|
+
"description": "What went wrong (observed)"},
|
|
333
|
+
"root_cause": {"type": "string",
|
|
334
|
+
"description": "Why it happened"},
|
|
335
|
+
"fix": {"type": "string", "description": "How you fixed it"},
|
|
336
|
+
"where": {"type": "string",
|
|
337
|
+
"description": "File / commit / area"},
|
|
338
|
+
"team": {"type": "string"}},
|
|
339
|
+
"required": ["symptom", "team"]},
|
|
340
|
+
"handler": _t_bug},
|
|
341
|
+
{"name": "get_context",
|
|
342
|
+
"description": "Get everything connected to a context node (an entry/decision/checkpoint/"
|
|
343
|
+
"episode/bug id returned by search or resume): linked decisions, the "
|
|
344
|
+
"supersession chain, the episode an item belongs to, and the tickets/PRs it "
|
|
345
|
+
"touches.",
|
|
346
|
+
"inputSchema": {"type": "object",
|
|
347
|
+
"properties": {"type": {"type": "string", "enum": list(_TYPES)},
|
|
348
|
+
"id": {"type": "integer"}},
|
|
349
|
+
"required": ["type", "id"]},
|
|
350
|
+
"handler": _t_get_context},
|
|
351
|
+
{"name": "list_blockers",
|
|
352
|
+
"description": "List open blockers that name the user — things others have put on them "
|
|
353
|
+
"that are still unresolved.",
|
|
354
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
355
|
+
"handler": _t_list_blockers},
|
|
356
|
+
{"name": "link",
|
|
357
|
+
"description": "Attach an external work item — a Jira key (PROJ-123), a GitHub PR url, or "
|
|
358
|
+
"any url — to a context node (an entry/decision/checkpoint id from search or "
|
|
359
|
+
"resume). Call this when your work connects to a ticket or PR, so the graph "
|
|
360
|
+
"can show live ticket/PR status next to the decision or entry it relates to.",
|
|
361
|
+
"inputSchema": {"type": "object",
|
|
362
|
+
"properties": {"type": {"type": "string", "enum": list(_TYPES)},
|
|
363
|
+
"id": {"type": "integer"},
|
|
364
|
+
"ref": {"type": "string",
|
|
365
|
+
"description": "PROJ-123, a PR url, or any url"}},
|
|
366
|
+
"required": ["type", "id", "ref"]},
|
|
367
|
+
"handler": _t_link},
|
|
368
|
+
{"name": "file_context",
|
|
369
|
+
"description": "Before you EDIT or REASON ABOUT a file, call this to load what's already "
|
|
370
|
+
"known about it: decisions made about it, who last touched it and why, and "
|
|
371
|
+
"the tickets/PRs it relates to. Pass a repo-relative file path "
|
|
372
|
+
"(app/billing/services.py) — or a directory to get the whole subtree's "
|
|
373
|
+
"context, or path#symbol (services.py#charge) to narrow to a class/function. "
|
|
374
|
+
"Don't reason from scratch about code that already has recorded history.",
|
|
375
|
+
"inputSchema": {"type": "object",
|
|
376
|
+
"properties": {"path": {"type": "string",
|
|
377
|
+
"description": "Repo-relative file/dir path, or path#symbol"},
|
|
378
|
+
"repo": {"type": "integer",
|
|
379
|
+
"description": "Optional repo id; omit to match any visible repo"},
|
|
380
|
+
"limit": {"type": "integer"}},
|
|
381
|
+
"required": ["path"]},
|
|
382
|
+
"handler": _t_file_context},
|
|
383
|
+
{"name": "whoami",
|
|
384
|
+
"description": "Identify who you're acting as: the authenticated user (handle, team) behind "
|
|
385
|
+
"this session and the agent label your writes are attributed to. Call this if "
|
|
386
|
+
"you're unsure which user/team context decisions and checkpoints will be saved "
|
|
387
|
+
"under.",
|
|
388
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
389
|
+
"handler": _t_whoami},
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
_BY_NAME = {t["name"]: t for t in TOOLS}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ── MCP resources ─────────────────────────────────────────────────────────────
|
|
396
|
+
# Read-only project context the agent can *load* as ambient state — cheaper and
|
|
397
|
+
# cleaner than a tool round-trip when it just wants the current memory in context.
|
|
398
|
+
def _r_recent_decisions() -> Any:
|
|
399
|
+
return _call("GET", "/v1/decisions")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _r_open_blockers() -> Any:
|
|
403
|
+
return _call("GET", "/v1/mentions", params={"unresolved": True})
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _r_recent_checkpoints() -> Any:
|
|
407
|
+
return _call("GET", "/v1/checkpoints")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
RESOURCES: list[dict[str, Any]] = [
|
|
411
|
+
{"uri": "stndp://decisions/recent", "name": "Recent decisions",
|
|
412
|
+
"description": "The team's recent durable decisions — what was chosen and why.",
|
|
413
|
+
"mimeType": "application/json", "fetch": _r_recent_decisions},
|
|
414
|
+
{"uri": "stndp://blockers/open", "name": "Open blockers",
|
|
415
|
+
"description": "Unresolved blockers that name you — what others are waiting on.",
|
|
416
|
+
"mimeType": "application/json", "fetch": _r_open_blockers},
|
|
417
|
+
{"uri": "stndp://checkpoints/recent", "name": "Recent checkpoints",
|
|
418
|
+
"description": "Your recent working-state checkpoints (focus, why, next step).",
|
|
419
|
+
"mimeType": "application/json", "fetch": _r_recent_checkpoints},
|
|
420
|
+
]
|
|
421
|
+
_RES_BY_URI = {r["uri"]: r for r in RESOURCES}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ── MCP prompts ───────────────────────────────────────────────────────────────
|
|
425
|
+
# Canned flows that encode the resume→work→checkpoint→decide protocol as first-class,
|
|
426
|
+
# invocable steps (the agent gets the trigger as a message, not buried in prose).
|
|
427
|
+
def _p_start_task(args: dict) -> dict:
|
|
428
|
+
task = (args or {}).get("task", "").strip()
|
|
429
|
+
suffix = f' for: "{task}"' if task else ""
|
|
430
|
+
text = ("Before starting, reload shared context with the stndp MCP tools:\n"
|
|
431
|
+
"1. Call `resume` to pick up your last checkpoint and open blockers.\n"
|
|
432
|
+
f"2. Call `search`{suffix} to find what was already decided and why — don't "
|
|
433
|
+
"reason from scratch if a decision already exists.\n"
|
|
434
|
+
"3. Then proceed, calling `decide` as you make choices.")
|
|
435
|
+
return {"description": "Start a task by reloading shared context first.",
|
|
436
|
+
"messages": [{"role": "user", "content": {"type": "text", "text": text}}]}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _p_wrap_up(args: dict) -> dict:
|
|
440
|
+
text = ("Wrap up this chunk of work in shared context:\n"
|
|
441
|
+
"1. Call `checkpoint` with your current focus, why, and the next step, so it can "
|
|
442
|
+
"be reloaded later with `resume`.\n"
|
|
443
|
+
"2. For every choice you made or approach you rejected, call `decide` (put the "
|
|
444
|
+
"rejected alternatives in `why`) so the rationale isn't lost.")
|
|
445
|
+
return {"description": "Save working state and log decisions before stopping.",
|
|
446
|
+
"messages": [{"role": "user", "content": {"type": "text", "text": text}}]}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _p_log_decision(args: dict) -> dict:
|
|
450
|
+
a = args or {}
|
|
451
|
+
title = a.get("title", "").strip()
|
|
452
|
+
why = a.get("why", "").strip()
|
|
453
|
+
text = ("Record this decision in shared team memory by calling the `decide` tool:\n"
|
|
454
|
+
f"- title: {title or '<the decision in one line>'}\n"
|
|
455
|
+
f"- why: {why or '<the rationale, including the alternatives you rejected>'}\n"
|
|
456
|
+
"Include the trade-offs and any 'why we did NOT do X' — the most valuable, "
|
|
457
|
+
"most-often-lost part.")
|
|
458
|
+
return {"description": "Log a decision with its rationale and rejected alternatives.",
|
|
459
|
+
"messages": [{"role": "user", "content": {"type": "text", "text": text}}]}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
PROMPTS: list[dict[str, Any]] = [
|
|
463
|
+
{"name": "start-task",
|
|
464
|
+
"description": "Reload shared context (resume + search) before starting a task.",
|
|
465
|
+
"arguments": [{"name": "task", "description": "What you're about to work on",
|
|
466
|
+
"required": False}],
|
|
467
|
+
"build": _p_start_task},
|
|
468
|
+
{"name": "wrap-up",
|
|
469
|
+
"description": "Checkpoint your state and log decisions before you stop.",
|
|
470
|
+
"arguments": [], "build": _p_wrap_up},
|
|
471
|
+
{"name": "log-decision",
|
|
472
|
+
"description": "Record a decision with its rationale and rejected alternatives.",
|
|
473
|
+
"arguments": [{"name": "title", "description": "The decision in one line", "required": False},
|
|
474
|
+
{"name": "why", "description": "Rationale incl. rejected alternatives",
|
|
475
|
+
"required": False}],
|
|
476
|
+
"build": _p_log_decision},
|
|
477
|
+
]
|
|
478
|
+
_PROMPT_BY_NAME = {p["name"]: p for p in PROMPTS}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _result(req_id: Any, result: Any) -> dict:
|
|
482
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _error(req_id: Any, code: int, message: str) -> dict:
|
|
486
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def handle_message(msg: dict) -> dict | None:
|
|
490
|
+
"""Process one JSON-RPC request; return the response dict, or None for a
|
|
491
|
+
notification (no id, e.g. `notifications/initialized`) that takes no reply."""
|
|
492
|
+
method = msg.get("method")
|
|
493
|
+
req_id = msg.get("id")
|
|
494
|
+
if method is None or req_id is None:
|
|
495
|
+
return None
|
|
496
|
+
if method == "initialize":
|
|
497
|
+
return _result(req_id, {"protocolVersion": PROTOCOL_VERSION,
|
|
498
|
+
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
|
|
499
|
+
"serverInfo": SERVER_INFO})
|
|
500
|
+
if method == "ping":
|
|
501
|
+
return _result(req_id, {})
|
|
502
|
+
if method == "tools/list":
|
|
503
|
+
return _result(req_id, {"tools": [
|
|
504
|
+
{"name": t["name"], "description": t["description"], "inputSchema": t["inputSchema"]}
|
|
505
|
+
for t in TOOLS
|
|
506
|
+
]})
|
|
507
|
+
if method == "tools/call":
|
|
508
|
+
params = msg.get("params") or {}
|
|
509
|
+
tool = _BY_NAME.get(params.get("name"))
|
|
510
|
+
if tool is None:
|
|
511
|
+
return _error(req_id, -32602, f"unknown tool: {params.get('name')}")
|
|
512
|
+
try:
|
|
513
|
+
data = tool["handler"](params.get("arguments") or {})
|
|
514
|
+
except InvalidParams as exc: # bad agent args → JSON-RPC invalid params
|
|
515
|
+
return _error(req_id, -32602, f"invalid params: {exc}")
|
|
516
|
+
except Exception as exc: # surface tool errors to the agent, not the transport
|
|
517
|
+
return _result(req_id, {"content": [{"type": "text", "text": f"error: {exc}"}],
|
|
518
|
+
"isError": True})
|
|
519
|
+
return _result(req_id, {
|
|
520
|
+
"content": [{"type": "text", "text": json.dumps(data, default=str)}],
|
|
521
|
+
"structuredContent": {"result": data},
|
|
522
|
+
"isError": False,
|
|
523
|
+
})
|
|
524
|
+
if method == "resources/list":
|
|
525
|
+
return _result(req_id, {"resources": [
|
|
526
|
+
{"uri": r["uri"], "name": r["name"], "description": r["description"],
|
|
527
|
+
"mimeType": r["mimeType"]} for r in RESOURCES
|
|
528
|
+
]})
|
|
529
|
+
if method == "resources/read":
|
|
530
|
+
params = msg.get("params") or {}
|
|
531
|
+
uri = params.get("uri")
|
|
532
|
+
res = _RES_BY_URI.get(uri)
|
|
533
|
+
if res is None:
|
|
534
|
+
return _error(req_id, -32602, f"unknown resource: {uri}")
|
|
535
|
+
try:
|
|
536
|
+
data = res["fetch"]()
|
|
537
|
+
except Exception as exc: # an API hiccup is the transport's problem to report
|
|
538
|
+
return _error(req_id, -32603, f"resource error: {exc}")
|
|
539
|
+
return _result(req_id, {"contents": [
|
|
540
|
+
{"uri": uri, "mimeType": "application/json", "text": json.dumps(data, default=str)}
|
|
541
|
+
]})
|
|
542
|
+
if method == "prompts/list":
|
|
543
|
+
return _result(req_id, {"prompts": [
|
|
544
|
+
{"name": p["name"], "description": p["description"], "arguments": p["arguments"]}
|
|
545
|
+
for p in PROMPTS
|
|
546
|
+
]})
|
|
547
|
+
if method == "prompts/get":
|
|
548
|
+
params = msg.get("params") or {}
|
|
549
|
+
prompt = _PROMPT_BY_NAME.get(params.get("name"))
|
|
550
|
+
if prompt is None:
|
|
551
|
+
return _error(req_id, -32602, f"unknown prompt: {params.get('name')}")
|
|
552
|
+
return _result(req_id, prompt["build"](params.get("arguments") or {}))
|
|
553
|
+
return _error(req_id, -32601, f"method not found: {method}")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _write(response: dict) -> None:
|
|
557
|
+
sys.stdout.write(json.dumps(response) + "\n")
|
|
558
|
+
sys.stdout.flush()
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def serve() -> None:
|
|
562
|
+
"""Run the MCP server over newline-delimited JSON-RPC on stdio (blocking).
|
|
563
|
+
|
|
564
|
+
stdout carries ONLY the JSON-RPC protocol. The CLI's HTTP error paths print
|
|
565
|
+
Rich diagnostics to `main.console`, which defaults to stdout and would corrupt
|
|
566
|
+
the stream mid-tool-call — so reroute that console to stderr before serving.
|
|
567
|
+
"""
|
|
568
|
+
main.console = Console(stderr=True)
|
|
569
|
+
for line in sys.stdin:
|
|
570
|
+
line = line.strip()
|
|
571
|
+
if not line:
|
|
572
|
+
continue
|
|
573
|
+
try:
|
|
574
|
+
msg = json.loads(line)
|
|
575
|
+
except json.JSONDecodeError:
|
|
576
|
+
# Don't silently drop malformed input — answer with a spec parse error.
|
|
577
|
+
_write({"jsonrpc": "2.0", "id": None,
|
|
578
|
+
"error": {"code": -32700, "message": "parse error"}})
|
|
579
|
+
continue
|
|
580
|
+
response = handle_message(msg)
|
|
581
|
+
if response is not None:
|
|
582
|
+
_write(response)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
stndp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
stndp/main.py,sha256=RdkTImwQB2UNXYG-rK9XoaivT7g96MSsMQcf25WIJbk,130504
|
|
3
|
+
stndp/mcp_server.py,sha256=Au2udXJ78xmI5WB44IkfSkks9yWO6eAeBRnId1Tsg9E,29374
|
|
4
|
+
stndp_cli-0.1.0.dist-info/METADATA,sha256=0a5OrNEqRhdp7Pma71IMNeWPMEiMN9R6JF_Kw-U0Srw,163
|
|
5
|
+
stndp_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
stndp_cli-0.1.0.dist-info/entry_points.txt,sha256=Ji8SrWfwFbOCUP9zDxFDGBMLQMHFbXVQJ1oTw9nlHRU,39
|
|
7
|
+
stndp_cli-0.1.0.dist-info/top_level.txt,sha256=28ULs1Po-J9guFphP4BpMMBLTCxDSExme0-5Prbc1vo,6
|
|
8
|
+
stndp_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
stndp
|