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/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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: stndp-cli
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.11
5
+ Requires-Dist: httpx>=0.28.1
6
+ Requires-Dist: rich>=15.0.0
7
+ Requires-Dist: typer>=0.26.7
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stn = stndp.main:app
@@ -0,0 +1 @@
1
+ stndp