roadmap-core 0.2.3__tar.gz → 0.3.0__tar.gz
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.
- {roadmap_core-0.2.3/roadmap_core.egg-info → roadmap_core-0.3.0}/PKG-INFO +36 -1
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/README.md +35 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/pyproject.toml +6 -1
- roadmap_core-0.3.0/roadmap_core/mcp_server.py +662 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0/roadmap_core.egg-info}/PKG-INFO +36 -1
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/roadmap_core.egg-info/SOURCES.txt +2 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/roadmap_core.egg-info/entry_points.txt +1 -0
- roadmap_core-0.3.0/tests/test_mcp_server.py +402 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/LICENSE +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/roadmap_core/__init__.py +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/roadmap_core/cli.py +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/roadmap_core/graph.py +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/roadmap_core/impact.py +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/roadmap_core/store.py +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/roadmap_core/stores.py +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/roadmap_core.egg-info/dependency_links.txt +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/roadmap_core.egg-info/requires.txt +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/roadmap_core.egg-info/top_level.txt +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/setup.cfg +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/templates/roadmap.yml +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/tests/test_adoption.py +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/tests/test_arcs.py +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/tests/test_graph.py +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/tests/test_impact.py +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/tests/test_store.py +0 -0
- {roadmap_core-0.2.3 → roadmap_core-0.3.0}/tests/test_stores.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: roadmap-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: The roadmap work-item and arc graph: status derivation, validation, and markdown rendering. Stdlib-only, so any repo can adopt it without adopting a backend.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Source, https://github.com/gald33/roadmap-core
|
|
@@ -156,6 +156,41 @@ this rather than by reading the code:
|
|
|
156
156
|
`ROADMAP_SOURCE=local` selects the SQLite store. Without it the CLI expects the
|
|
157
157
|
API store, which is how Lucille runs it — see `roadmap_core.stores`.
|
|
158
158
|
|
|
159
|
+
### Agents (MCP)
|
|
160
|
+
|
|
161
|
+
`roadmap-mcp` serves the same graph to a coding agent as MCP tools over stdio —
|
|
162
|
+
`ready`, `list`, `show`, `validate`, `claim`, `release`, `set_status`. Point a
|
|
163
|
+
client at it:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"mcpServers": {
|
|
168
|
+
"roadmap": {
|
|
169
|
+
"command": "roadmap-mcp",
|
|
170
|
+
"env": { "ROADMAP_SOURCE": "local" }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**There is no extra to install and no SDK underneath.** `mcp_server.py` speaks
|
|
177
|
+
the JSON-RPC protocol in stdlib, so it is present wherever the package is —
|
|
178
|
+
which is the same reason the rest of this package is dependency-free, applied to
|
|
179
|
+
one more caller. An extra would be one an adopter can forget, for a server that
|
|
180
|
+
needs nothing.
|
|
181
|
+
|
|
182
|
+
**Reads default to the files**, so an agent in a fresh clone can ask what to
|
|
183
|
+
work on with no store, no server and no token. Writes need a store to arbitrate
|
|
184
|
+
them: set `ROADMAP_SOURCE=local` as above, or the write tools expect the API
|
|
185
|
+
store. There is deliberately no `files` write target — a claim nothing
|
|
186
|
+
adjudicated is not a claim, and two agents could each hold the same item.
|
|
187
|
+
|
|
188
|
+
**A write tells the agent to commit something, and it means it.** `claim`,
|
|
189
|
+
`release` and `set_status` project into `roadmap/items/<key>.yaml`, and on the
|
|
190
|
+
floor that projection *is* the durable record — an unmerged one is a claim no
|
|
191
|
+
other checkout can see. The tool result carries the CLI's own words for this
|
|
192
|
+
under `notes`.
|
|
193
|
+
|
|
159
194
|
### CI
|
|
160
195
|
|
|
161
196
|
Copy `templates/roadmap.yml` to `.github/workflows/roadmap.yml`. That is the
|
|
@@ -135,6 +135,41 @@ this rather than by reading the code:
|
|
|
135
135
|
`ROADMAP_SOURCE=local` selects the SQLite store. Without it the CLI expects the
|
|
136
136
|
API store, which is how Lucille runs it — see `roadmap_core.stores`.
|
|
137
137
|
|
|
138
|
+
### Agents (MCP)
|
|
139
|
+
|
|
140
|
+
`roadmap-mcp` serves the same graph to a coding agent as MCP tools over stdio —
|
|
141
|
+
`ready`, `list`, `show`, `validate`, `claim`, `release`, `set_status`. Point a
|
|
142
|
+
client at it:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"mcpServers": {
|
|
147
|
+
"roadmap": {
|
|
148
|
+
"command": "roadmap-mcp",
|
|
149
|
+
"env": { "ROADMAP_SOURCE": "local" }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**There is no extra to install and no SDK underneath.** `mcp_server.py` speaks
|
|
156
|
+
the JSON-RPC protocol in stdlib, so it is present wherever the package is —
|
|
157
|
+
which is the same reason the rest of this package is dependency-free, applied to
|
|
158
|
+
one more caller. An extra would be one an adopter can forget, for a server that
|
|
159
|
+
needs nothing.
|
|
160
|
+
|
|
161
|
+
**Reads default to the files**, so an agent in a fresh clone can ask what to
|
|
162
|
+
work on with no store, no server and no token. Writes need a store to arbitrate
|
|
163
|
+
them: set `ROADMAP_SOURCE=local` as above, or the write tools expect the API
|
|
164
|
+
store. There is deliberately no `files` write target — a claim nothing
|
|
165
|
+
adjudicated is not a claim, and two agents could each hold the same item.
|
|
166
|
+
|
|
167
|
+
**A write tells the agent to commit something, and it means it.** `claim`,
|
|
168
|
+
`release` and `set_status` project into `roadmap/items/<key>.yaml`, and on the
|
|
169
|
+
floor that projection *is* the durable record — an unmerged one is a claim no
|
|
170
|
+
other checkout can see. The tool result carries the CLI's own words for this
|
|
171
|
+
under `notes`.
|
|
172
|
+
|
|
138
173
|
### CI
|
|
139
174
|
|
|
140
175
|
Copy `templates/roadmap.yml` to `.github/workflows/roadmap.yml`. That is the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "roadmap-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "The roadmap work-item and arc graph: status derivation, validation, and markdown rendering. Stdlib-only, so any repo can adopt it without adopting a backend."
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
readme = "README.md"
|
|
@@ -63,6 +63,11 @@ dev = ["pytest>=8"]
|
|
|
63
63
|
# A dependency-free library whose tool you cannot get is not an adoptable tool.
|
|
64
64
|
[project.scripts]
|
|
65
65
|
roadmap = "roadmap_core.cli:main"
|
|
66
|
+
# The MCP bridge, and no extra to install it: `mcp_server.py` speaks the stdio
|
|
67
|
+
# protocol in stdlib rather than taking the SDK, so it is already installed
|
|
68
|
+
# wherever the package is. An extra here would be an extra that is always
|
|
69
|
+
# empty — and one an adopter could forget, for a server that needs nothing.
|
|
70
|
+
roadmap-mcp = "roadmap_core.mcp_server:main"
|
|
66
71
|
|
|
67
72
|
[project.urls]
|
|
68
73
|
Source = "https://github.com/gald33/roadmap-core"
|
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
"""The roadmap as MCP tools, over stdio, with nothing installed.
|
|
2
|
+
|
|
3
|
+
**Why this is not built on the MCP SDK.** The package's whole claim is that it
|
|
4
|
+
stands alone — ``tests.yml``'s ``isolation`` job installs ``[dev]`` and then
|
|
5
|
+
*fails* if ``app``, ``fastapi``, ``sqlalchemy`` or ``yaml`` is importable at
|
|
6
|
+
all, because the graph and the store have to work in a checkout with nothing
|
|
7
|
+
provisioned. A hard dependency on ``mcp`` would end that, and putting it behind
|
|
8
|
+
an extra would only move the question to whichever environment forgot the
|
|
9
|
+
extra. The protocol is newline-delimited JSON-RPC over two pipes; it is written
|
|
10
|
+
out below in stdlib. This is the same call ``switchboard/mcp_server.py`` made,
|
|
11
|
+
and for the same reason it gives: an SDK that renames its API between majors is
|
|
12
|
+
a second thing that can break a server whose own logic did not change.
|
|
13
|
+
|
|
14
|
+
**STDOUT IS THE PROTOCOL.** Every byte on stdout has to be a JSON-RPC frame, and
|
|
15
|
+
the CLI this server delegates its writes to is a *printing* program — ``claim``
|
|
16
|
+
alone emits four lines, one of them the "when you finish" notice. Those prints
|
|
17
|
+
are not incidental output to be silenced: they are what the CLI tells an
|
|
18
|
+
operator, so they are captured and returned as the tool's own text. That is what
|
|
19
|
+
``_capture`` is for, and it is load-bearing rather than tidy. A single
|
|
20
|
+
uncaptured ``print`` corrupts the stream for the rest of the session, and it
|
|
21
|
+
fails as a client-side parse error a long way from the line that caused it.
|
|
22
|
+
|
|
23
|
+
**Writes go through the CLI; reads do not.** The two halves are asymmetric on
|
|
24
|
+
purpose:
|
|
25
|
+
|
|
26
|
+
writes ``cmd_claim`` / ``cmd_release`` / ``cmd_status`` do more than talk to
|
|
27
|
+
the store. They project the claim into ``roadmap/items/<key>.yaml``,
|
|
28
|
+
take the Switchboard write lock around that read-modify-write, drop
|
|
29
|
+
the claim when a status reaches ``done``, and warn about artifact
|
|
30
|
+
contention. That sequence is the repository's answer to
|
|
31
|
+
"a-claim-cannot-survive-the-floors-ci"; a second copy of it here
|
|
32
|
+
would be a second answer, free to drift from the first.
|
|
33
|
+
|
|
34
|
+
reads ``list`` / ``ready`` / ``show`` / ``validate`` are ``load()`` plus
|
|
35
|
+
``graph``, which are pure. The CLI's versions of these print aligned
|
|
36
|
+
columns for a human; an agent wants the fields. So the reads are
|
|
37
|
+
composed here from the same functions the printers use, rather than
|
|
38
|
+
scraped back out of their output.
|
|
39
|
+
|
|
40
|
+
**Source resolution matches the CLI exactly**, because an agent and a terminal
|
|
41
|
+
in the same checkout disagreeing about which store they are looking at is the
|
|
42
|
+
kind of difference nobody thinks to check. Reads default to ``files`` — no
|
|
43
|
+
server, no JWT, no network, which is what makes a fresh clone answerable —
|
|
44
|
+
and ``ROADMAP_SOURCE`` overrides that for both halves, as it does for the CLI.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import importlib.util
|
|
50
|
+
import io
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import sys
|
|
54
|
+
import traceback
|
|
55
|
+
from argparse import Namespace
|
|
56
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
57
|
+
from typing import Any, Callable
|
|
58
|
+
|
|
59
|
+
from . import graph
|
|
60
|
+
from .cli import installed_version, load, load_arcs
|
|
61
|
+
|
|
62
|
+
__all__ = ["TOOLS", "UnknownTool", "dispatch", "handle_request", "serve_stdio", "main"]
|
|
63
|
+
|
|
64
|
+
# Negotiated in `initialize`. A client asking for something in this set gets it
|
|
65
|
+
# back verbatim; anything else is answered with the newest we speak, which is
|
|
66
|
+
# what the spec asks a server to do rather than failing the handshake.
|
|
67
|
+
LATEST_PROTOCOL = "2025-06-18"
|
|
68
|
+
SUPPORTED_PROTOCOLS = (LATEST_PROTOCOL, "2025-03-26", "2024-11-05")
|
|
69
|
+
|
|
70
|
+
JSONRPC_PARSE_ERROR = -32700
|
|
71
|
+
JSONRPC_INVALID_REQUEST = -32600
|
|
72
|
+
JSONRPC_METHOD_NOT_FOUND = -32601
|
|
73
|
+
JSONRPC_INTERNAL_ERROR = -32603
|
|
74
|
+
|
|
75
|
+
INSTRUCTIONS = (
|
|
76
|
+
"The roadmap is this repository's backlog as a dependency graph. Call `ready` "
|
|
77
|
+
"before starting work — it lists only what is startable right now, in the order "
|
|
78
|
+
"the queue hands work out, and flags items that share a mutable artifact with a "
|
|
79
|
+
"live claim. Call `claim` before you begin so another session does not take the "
|
|
80
|
+
"same item, and `release` when you finish or stop; an unreleased claim keeps the "
|
|
81
|
+
"item off everyone's queue. Use `set_status` to move an item deliberately — "
|
|
82
|
+
"`verifying` keeps the claim (the branch that shipped it still owns confirming "
|
|
83
|
+
"it), `done` drops it.\n\n"
|
|
84
|
+
"Reads default to the committed `roadmap/items/*.yaml` files, so they work with "
|
|
85
|
+
"no server, no database and no token. Set ROADMAP_SOURCE=local to read and write "
|
|
86
|
+
"a SQLite store instead, or ROADMAP_SOURCE=db for a served backend. Writes "
|
|
87
|
+
"project into the item files and tell you to commit them — that projection is "
|
|
88
|
+
"how a claim survives, so do commit it."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def log(message: str) -> None:
|
|
93
|
+
"""Stderr, always. See this module's docstring on why never stdout."""
|
|
94
|
+
print(f"roadmap-mcp: {message}", file=sys.stderr, flush=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --- source resolution --------------------------------------------------------
|
|
98
|
+
#
|
|
99
|
+
# Deliberately not imported from `cli`: the names there (`_ENV_SOURCE`) are
|
|
100
|
+
# private and read at import time, and re-reading the environment here is what
|
|
101
|
+
# lets a test set it. The DEFAULTS are what must agree, and they are stated in
|
|
102
|
+
# terms of the same variable the CLI's `--source` default is built from.
|
|
103
|
+
|
|
104
|
+
READ_SOURCES = ("files", "local", "db")
|
|
105
|
+
WRITE_SOURCES = ("local", "db")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_source(requested: str | None = None) -> str:
|
|
109
|
+
"""Where a read looks, defaulting exactly as `roadmap --source` does.
|
|
110
|
+
|
|
111
|
+
`files` last in the chain and first in practice: it is the only source that
|
|
112
|
+
needs nothing provisioned, which is the property that lets an agent in a
|
|
113
|
+
fresh clone answer "what should I work on" at all.
|
|
114
|
+
"""
|
|
115
|
+
source = requested or os.environ.get("ROADMAP_SOURCE") or "files"
|
|
116
|
+
if source not in READ_SOURCES:
|
|
117
|
+
raise ValueError(f"unknown source {source!r} — one of {', '.join(READ_SOURCES)}")
|
|
118
|
+
return source
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _write_source(requested: str | None = None) -> str:
|
|
122
|
+
"""Where a write goes.
|
|
123
|
+
|
|
124
|
+
`files` is not offered, and its absence is the point rather than an
|
|
125
|
+
oversight: the item files are a PROJECTION of a write the store arbitrated,
|
|
126
|
+
so "write to files" would be a claim nothing adjudicated — two sessions
|
|
127
|
+
could each hold the same item and the transaction that is supposed to decide
|
|
128
|
+
the race would never run. An environment that says `files` therefore falls
|
|
129
|
+
through to `db`, which is the CLI's own default for the write commands.
|
|
130
|
+
"""
|
|
131
|
+
source = requested or os.environ.get("ROADMAP_SOURCE") or "db"
|
|
132
|
+
if source == "files":
|
|
133
|
+
source = "db"
|
|
134
|
+
if source not in WRITE_SOURCES:
|
|
135
|
+
raise ValueError(f"unknown write source {source!r} — one of {', '.join(WRITE_SOURCES)}")
|
|
136
|
+
return source
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --- schemas ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _schema(properties: dict[str, Any], required: list[str] | None = None) -> dict[str, Any]:
|
|
143
|
+
return {
|
|
144
|
+
"type": "object",
|
|
145
|
+
"properties": properties,
|
|
146
|
+
"required": required or [],
|
|
147
|
+
"additionalProperties": False,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
_SOURCE_PROP = {
|
|
152
|
+
"type": "string",
|
|
153
|
+
"enum": list(READ_SOURCES),
|
|
154
|
+
"description": (
|
|
155
|
+
"where to read the graph from. Default: ROADMAP_SOURCE, else 'files' — "
|
|
156
|
+
"the committed YAML, which needs no server and no token."
|
|
157
|
+
),
|
|
158
|
+
}
|
|
159
|
+
_WRITE_SOURCE_PROP = {
|
|
160
|
+
"type": "string",
|
|
161
|
+
"enum": list(WRITE_SOURCES),
|
|
162
|
+
"description": (
|
|
163
|
+
"which store arbitrates the write. Default: ROADMAP_SOURCE, else 'db'. "
|
|
164
|
+
"There is no 'files' — a write nothing arbitrated is not a claim."
|
|
165
|
+
),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
TOOLS: list[dict[str, Any]] = [
|
|
169
|
+
{
|
|
170
|
+
"name": "ready",
|
|
171
|
+
"description": (
|
|
172
|
+
"Items startable right now — nothing blocked, deferred, claimed or done — "
|
|
173
|
+
"in priority-then-key order, the same order ROADMAP.md and the API use. "
|
|
174
|
+
"Each carries its related items and any artifact contention with a live "
|
|
175
|
+
"claim. Call this before starting work."
|
|
176
|
+
),
|
|
177
|
+
"inputSchema": _schema({"source": _SOURCE_PROP}),
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"name": "list",
|
|
181
|
+
"description": (
|
|
182
|
+
"Every item grouped by its DERIVED status (ready, deferred, blocked, "
|
|
183
|
+
"claimed, verifying, done) — derived, so an item with a live claim reads "
|
|
184
|
+
"'claimed' whatever its stored status column says. Optionally filtered to "
|
|
185
|
+
"one status."
|
|
186
|
+
),
|
|
187
|
+
"inputSchema": _schema({
|
|
188
|
+
"source": _SOURCE_PROP,
|
|
189
|
+
"status": {
|
|
190
|
+
"type": "string",
|
|
191
|
+
"enum": list(graph.STATUSES),
|
|
192
|
+
"description": "only this status. Omit for all of them.",
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"name": "show",
|
|
198
|
+
"description": (
|
|
199
|
+
"One item in full: derived status, arc, priority, who holds it and for how "
|
|
200
|
+
"long, what it is blocked on and what it blocks, related items, artifacts "
|
|
201
|
+
"and contention, refs, and the evidence prose."
|
|
202
|
+
),
|
|
203
|
+
"inputSchema": _schema(
|
|
204
|
+
{"key": {"type": "string", "description": "the item key"}, "source": _SOURCE_PROP},
|
|
205
|
+
["key"],
|
|
206
|
+
),
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"name": "validate",
|
|
210
|
+
"description": (
|
|
211
|
+
"Schema problems, dangling dependencies, cycles, and arc coherence. "
|
|
212
|
+
"'problems' are errors; 'findings' are observations about a thin backlog "
|
|
213
|
+
"and never make a graph invalid."
|
|
214
|
+
),
|
|
215
|
+
"inputSchema": _schema({"source": _SOURCE_PROP}),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"name": "claim",
|
|
219
|
+
"description": (
|
|
220
|
+
"Stake an item so no other session starts it. The store arbitrates, so "
|
|
221
|
+
"losing the race is an ordinary refusal and not an error to retry around. "
|
|
222
|
+
"On success the claim is projected into roadmap/items/<key>.yaml — commit "
|
|
223
|
+
"that, or the claim is invisible to anyone in a checkout."
|
|
224
|
+
),
|
|
225
|
+
"inputSchema": _schema({
|
|
226
|
+
"key": {"type": "string", "description": "the item key"},
|
|
227
|
+
"by": {
|
|
228
|
+
"type": "string",
|
|
229
|
+
"description": "who is claiming. Default: the current git branch.",
|
|
230
|
+
},
|
|
231
|
+
"force": {
|
|
232
|
+
"type": "boolean",
|
|
233
|
+
"description": "take an item someone else holds. Default false.",
|
|
234
|
+
},
|
|
235
|
+
"source": _WRITE_SOURCE_PROP,
|
|
236
|
+
}, ["key"]),
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"name": "release",
|
|
240
|
+
"description": (
|
|
241
|
+
"Drop a claim, and project the drop into the item file. Call this when you "
|
|
242
|
+
"finish or when you stop — an unreleased claim keeps the item off "
|
|
243
|
+
"everyone's queue until it goes stale."
|
|
244
|
+
),
|
|
245
|
+
"inputSchema": _schema(
|
|
246
|
+
{"key": {"type": "string", "description": "the item key"},
|
|
247
|
+
"source": _WRITE_SOURCE_PROP},
|
|
248
|
+
["key"],
|
|
249
|
+
),
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"name": "set_status",
|
|
253
|
+
"description": (
|
|
254
|
+
"Move one item's status deliberately — the only writer for that field. "
|
|
255
|
+
"'verifying' keeps the claim, because the branch that shipped the work "
|
|
256
|
+
"still owns observing that it landed; 'done' drops the claim. The new "
|
|
257
|
+
"status is projected into the item file for you to commit."
|
|
258
|
+
),
|
|
259
|
+
"inputSchema": _schema({
|
|
260
|
+
"key": {"type": "string", "description": "the item key"},
|
|
261
|
+
"status": {"type": "string", "enum": list(graph.STATUSES)},
|
|
262
|
+
"source": _WRITE_SOURCE_PROP,
|
|
263
|
+
}, ["key", "status"]),
|
|
264
|
+
},
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --- reads --------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _summary(item: dict[str, Any], by_key: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
|
272
|
+
"""One item as a queue line: what decides whether to start it, and nothing else."""
|
|
273
|
+
view: dict[str, Any] = {
|
|
274
|
+
"key": item["key"],
|
|
275
|
+
"title": item["title"],
|
|
276
|
+
"status": graph.derive_status(item, by_key),
|
|
277
|
+
"priority": graph.priority_of(item),
|
|
278
|
+
"arc": item.get("arc"),
|
|
279
|
+
}
|
|
280
|
+
if item.get("claimed_by"):
|
|
281
|
+
view["claimed_by"] = item["claimed_by"]
|
|
282
|
+
# Formatted, not raw: PyYAML resolves a timestamp to a datetime while the
|
|
283
|
+
# API returns a string, so the two sources emit different JSON types for
|
|
284
|
+
# one field unless this is normalised — the same drift `cmd_list` fixes.
|
|
285
|
+
view["claimed_at"] = graph.fmt_timestamp(item.get("claimed_at"))
|
|
286
|
+
age = graph.claim_age_days(item)
|
|
287
|
+
if age is not None:
|
|
288
|
+
view["claim_age_days"] = round(age, 1)
|
|
289
|
+
view["claim_stale"] = age >= graph.STALE_CLAIM_DAYS
|
|
290
|
+
if unmet := graph.unmet_deps(item, by_key):
|
|
291
|
+
view["unmet_deps"] = unmet
|
|
292
|
+
if related := graph.relations_for(item["key"], by_key):
|
|
293
|
+
view["related"] = related
|
|
294
|
+
# A merge cost, not a blocker — said against the item so it is read by
|
|
295
|
+
# whoever is looking at that line, exactly as `cmd_ready` prints it.
|
|
296
|
+
if contention := graph.artifact_contention(item["key"], by_key):
|
|
297
|
+
view["artifact_contention"] = contention
|
|
298
|
+
return view
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def tool_ready(source: str | None = None) -> dict[str, Any]:
|
|
302
|
+
by_key = load(_read_source(source))
|
|
303
|
+
items = [_summary(item, by_key) for item in graph.ready_items(by_key)]
|
|
304
|
+
result: dict[str, Any] = {"source": _read_source(source), "ready": items}
|
|
305
|
+
if not items:
|
|
306
|
+
result["note"] = "nothing ready — everything is claimed, blocked, deferred, or done"
|
|
307
|
+
# The stale-claim nudge the CLI prints. A session asking what to start is the
|
|
308
|
+
# one most able to notice a hold that should have been dropped.
|
|
309
|
+
stale = graph.stale_claims(by_key)
|
|
310
|
+
if stale:
|
|
311
|
+
# `claim_age_days` comes from `stale_claims` rather than being
|
|
312
|
+
# recomputed here — it copies the item with the age already on it
|
|
313
|
+
# precisely so every surface reports one number from one clock.
|
|
314
|
+
result["stale_claims"] = [
|
|
315
|
+
{"key": i["key"], "claimed_by": i.get("claimed_by"),
|
|
316
|
+
"age_days": i["claim_age_days"]}
|
|
317
|
+
for i in stale
|
|
318
|
+
]
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def tool_list(source: str | None = None, status: str | None = None) -> dict[str, Any]:
|
|
323
|
+
by_key = load(_read_source(source))
|
|
324
|
+
wanted = (status,) if status else graph.STATUSES
|
|
325
|
+
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
326
|
+
for name in wanted:
|
|
327
|
+
group = sorted(
|
|
328
|
+
(i for i in by_key.values() if graph.derive_status(i, by_key) == name),
|
|
329
|
+
key=graph.queue_sort_key,
|
|
330
|
+
)
|
|
331
|
+
if group:
|
|
332
|
+
grouped[name] = [_summary(item, by_key) for item in group]
|
|
333
|
+
return {"source": _read_source(source), "total": len(by_key), "by_status": grouped}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def tool_show(key: str, source: str | None = None) -> dict[str, Any]:
|
|
337
|
+
by_key = load(_read_source(source))
|
|
338
|
+
item = by_key.get(key)
|
|
339
|
+
if item is None:
|
|
340
|
+
raise LookupError(f"no such item: {key}")
|
|
341
|
+
view = _summary(item, by_key)
|
|
342
|
+
view["source"] = _read_source(source)
|
|
343
|
+
view["blocked_on"] = [
|
|
344
|
+
{"key": dep,
|
|
345
|
+
"title": by_key[dep]["title"] if dep in by_key else None,
|
|
346
|
+
"done": by_key.get(dep, {}).get("status") == "done",
|
|
347
|
+
"known": dep in by_key}
|
|
348
|
+
for dep in item.get("blocked_on") or []
|
|
349
|
+
]
|
|
350
|
+
view["blocks"] = sorted(
|
|
351
|
+
other_key for other_key, other in by_key.items()
|
|
352
|
+
if key in (other.get("blocked_on") or [])
|
|
353
|
+
)
|
|
354
|
+
view["artifacts"] = graph.normalize_artifacts(item)
|
|
355
|
+
view["refs"] = list(item.get("refs") or [])
|
|
356
|
+
if (item.get("defer_reason") or "").strip():
|
|
357
|
+
view["defer_reason"] = " ".join(item["defer_reason"].split())
|
|
358
|
+
if item.get("evidence"):
|
|
359
|
+
view["evidence"] = item["evidence"].rstrip()
|
|
360
|
+
return view
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def tool_validate(source: str | None = None) -> dict[str, Any]:
|
|
364
|
+
resolved = _read_source(source)
|
|
365
|
+
by_key = load(resolved)
|
|
366
|
+
arcs = load_arcs(resolved)
|
|
367
|
+
problems = graph.validate_graph(by_key) + graph.validate_arcs(arcs, by_key)
|
|
368
|
+
return {
|
|
369
|
+
"source": resolved,
|
|
370
|
+
"ok": not problems,
|
|
371
|
+
"items": len(by_key),
|
|
372
|
+
"arcs": len(arcs),
|
|
373
|
+
"problems": problems,
|
|
374
|
+
# Never a failure. An arc with no startable work describes a thin
|
|
375
|
+
# backlog, not a malformed graph — see `graph.arc_findings`.
|
|
376
|
+
"findings": graph.arc_findings(arcs, by_key),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# --- writes -------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _capture(command: Callable[[Namespace], int], args: Namespace) -> dict[str, Any]:
|
|
384
|
+
"""Run one CLI command with its output diverted off the protocol stream.
|
|
385
|
+
|
|
386
|
+
The redirect is the entire reason this wrapper exists. `cmd_claim` and
|
|
387
|
+
friends `print` — to stdout for what happened, to stderr for what to do next
|
|
388
|
+
— and stdout here is the JSON-RPC transport. Swapping both means those lines
|
|
389
|
+
reach the agent as the tool's result, which is where they were always meant
|
|
390
|
+
to go, instead of desynchronising the client's parser.
|
|
391
|
+
|
|
392
|
+
`SystemExit` is caught rather than allowed to propagate because the served
|
|
393
|
+
path raises it: `cli._api` turns an HTTP error into `SystemExit` after
|
|
394
|
+
printing the server's own message. Letting that escape would take the whole
|
|
395
|
+
MCP server down over one failed request against a backend that is merely
|
|
396
|
+
unreachable.
|
|
397
|
+
"""
|
|
398
|
+
out, err = io.StringIO(), io.StringIO()
|
|
399
|
+
try:
|
|
400
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
401
|
+
code = command(args)
|
|
402
|
+
except SystemExit as exc:
|
|
403
|
+
code = exc.code if isinstance(exc.code, int) else 1
|
|
404
|
+
if exc.code and not isinstance(exc.code, int):
|
|
405
|
+
err.write(f"{exc.code}\n")
|
|
406
|
+
result: dict[str, Any] = {
|
|
407
|
+
"ok": code == 0,
|
|
408
|
+
"exit_code": code,
|
|
409
|
+
"output": out.getvalue().strip(),
|
|
410
|
+
}
|
|
411
|
+
# Kept under its own key rather than merged: the CLI uses stderr for the
|
|
412
|
+
# advice that follows a SUCCESSFUL command ("when you finish: release"), so
|
|
413
|
+
# folding it into `output` would read as failure and dropping it would lose
|
|
414
|
+
# the one instruction a claiming session most needs.
|
|
415
|
+
if notes := err.getvalue().strip():
|
|
416
|
+
result["notes"] = notes
|
|
417
|
+
return result
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def tool_claim(key: str, by: str | None = None, force: bool = False,
|
|
421
|
+
source: str | None = None) -> dict[str, Any]:
|
|
422
|
+
from .cli import cmd_claim
|
|
423
|
+
|
|
424
|
+
return _capture(cmd_claim, Namespace(
|
|
425
|
+
key=key, by=by, force=bool(force), source=_write_source(source),
|
|
426
|
+
))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def tool_release(key: str, source: str | None = None) -> dict[str, Any]:
|
|
430
|
+
from .cli import cmd_release
|
|
431
|
+
|
|
432
|
+
return _capture(cmd_release, Namespace(key=key, source=_write_source(source)))
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def tool_set_status(key: str, status: str, source: str | None = None) -> dict[str, Any]:
|
|
436
|
+
from .cli import cmd_status
|
|
437
|
+
|
|
438
|
+
if status not in graph.STATUSES:
|
|
439
|
+
raise ValueError(f"unknown status {status!r} — one of {', '.join(graph.STATUSES)}")
|
|
440
|
+
return _capture(cmd_status, Namespace(
|
|
441
|
+
key=key, status=status, source=_write_source(source),
|
|
442
|
+
))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
HANDLERS: dict[str, Callable[..., Any]] = {
|
|
446
|
+
"ready": tool_ready,
|
|
447
|
+
"list": tool_list,
|
|
448
|
+
"show": tool_show,
|
|
449
|
+
"validate": tool_validate,
|
|
450
|
+
"claim": tool_claim,
|
|
451
|
+
"release": tool_release,
|
|
452
|
+
"set_status": tool_set_status,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class UnknownTool(ValueError):
|
|
457
|
+
"""A name this bridge does not serve.
|
|
458
|
+
|
|
459
|
+
Its own type, and a `ValueError` subclass so anything catching that still
|
|
460
|
+
catches this. The distinction is for the agent on the other end: a bad
|
|
461
|
+
*value* ("finished" is not a status) and a bad *name* ("nonesuch" is not a
|
|
462
|
+
tool) send it looking in completely different places, and one message
|
|
463
|
+
cannot serve both. Reported before the ValueError branch below for the
|
|
464
|
+
same reason switchboard orders its SpecError ahead of one.
|
|
465
|
+
"""
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def dispatch(name: str, arguments: dict[str, Any]) -> Any:
|
|
469
|
+
handler = HANDLERS.get(name)
|
|
470
|
+
if handler is None:
|
|
471
|
+
raise UnknownTool(f"unknown tool: {name} — have {', '.join(sorted(HANDLERS))}")
|
|
472
|
+
return handler(**arguments)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# --- JSON-RPC -----------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _response(request_id: Any, result: Any) -> dict[str, Any]:
|
|
479
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _error(request_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
483
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _tool_result(payload: Any, is_error: bool = False) -> dict[str, Any]:
|
|
487
|
+
text = payload if isinstance(payload, str) else json.dumps(payload, indent=2, default=str)
|
|
488
|
+
return {"content": [{"type": "text", "text": text}], "isError": is_error}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def handle_request(request: dict[str, Any]) -> dict[str, Any] | None:
|
|
492
|
+
"""Handle one JSON-RPC message. Returns None for notifications."""
|
|
493
|
+
method = request.get("method")
|
|
494
|
+
request_id = request.get("id")
|
|
495
|
+
params = request.get("params") or {}
|
|
496
|
+
is_notification = "id" not in request
|
|
497
|
+
|
|
498
|
+
if method == "initialize":
|
|
499
|
+
requested = params.get("protocolVersion")
|
|
500
|
+
version = requested if requested in SUPPORTED_PROTOCOLS else LATEST_PROTOCOL
|
|
501
|
+
return _response(request_id, {
|
|
502
|
+
"protocolVersion": version,
|
|
503
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
504
|
+
"serverInfo": {"name": "roadmap", "version": installed_version()},
|
|
505
|
+
"instructions": INSTRUCTIONS,
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
if method in ("notifications/initialized", "initialized"):
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
if method == "ping":
|
|
512
|
+
return _response(request_id, {})
|
|
513
|
+
|
|
514
|
+
if method == "tools/list":
|
|
515
|
+
return _response(request_id, {"tools": TOOLS})
|
|
516
|
+
|
|
517
|
+
if method == "tools/call":
|
|
518
|
+
name = params.get("name", "")
|
|
519
|
+
arguments = params.get("arguments") or {}
|
|
520
|
+
try:
|
|
521
|
+
result = dispatch(name, arguments)
|
|
522
|
+
except LookupError as exc:
|
|
523
|
+
return _response(request_id, _tool_result(
|
|
524
|
+
{"error": "no_such_item", "detail": str(exc)}, is_error=True))
|
|
525
|
+
except ImportError as exc:
|
|
526
|
+
# The `files` source needs PyYAML and the bare package does not ship
|
|
527
|
+
# it. Named rather than folded into `bad_request`, because the fix is
|
|
528
|
+
# an install and nothing about the request was wrong. Note that
|
|
529
|
+
# `cli.load` converts its own missing-PyYAML ImportError into a
|
|
530
|
+
# SystemExit, so that particular case lands in the branch below;
|
|
531
|
+
# this one remains for an ImportError raised anywhere else.
|
|
532
|
+
return _response(request_id, _tool_result(
|
|
533
|
+
{"error": "missing_dependency", "detail": str(exc),
|
|
534
|
+
"fix": "pip install 'roadmap-core[files]'"}, is_error=True))
|
|
535
|
+
except UnknownTool as exc:
|
|
536
|
+
return _response(request_id, _tool_result(
|
|
537
|
+
{"error": "unknown_tool", "detail": str(exc)}, is_error=True))
|
|
538
|
+
except TypeError as exc:
|
|
539
|
+
return _response(request_id, _tool_result(
|
|
540
|
+
{"error": "bad_arguments", "detail": str(exc)}, is_error=True))
|
|
541
|
+
except ValueError as exc:
|
|
542
|
+
return _response(request_id, _tool_result(
|
|
543
|
+
{"error": "bad_request", "detail": str(exc)}, is_error=True))
|
|
544
|
+
except OSError as exc:
|
|
545
|
+
# A missing SQLite file, an unwritable directory, a backend that will
|
|
546
|
+
# not answer. Environmental, and worth distinguishing from a graph
|
|
547
|
+
# that is genuinely malformed.
|
|
548
|
+
return _response(request_id, _tool_result(
|
|
549
|
+
{"error": "store_unavailable", "detail": str(exc)}, is_error=True))
|
|
550
|
+
except SystemExit as exc:
|
|
551
|
+
# THE READS ARE NOT WRAPPED IN `_capture`, AND THIS IS THE SEAM.
|
|
552
|
+
# `cli.load` reports every graph problem by raising SystemExit —
|
|
553
|
+
# unreadable YAML, an id that disagrees with its filename, a
|
|
554
|
+
# non-kebab-case key, PyYAML missing for `source=files`. SystemExit
|
|
555
|
+
# descends from BaseException, so it passes straight through every
|
|
556
|
+
# `except Exception` between here and the top and ends the process.
|
|
557
|
+
# On a stdio server that is not an exit: it is the client's pipe
|
|
558
|
+
# closing mid-session, with the diagnosis going to a stderr nobody
|
|
559
|
+
# is reading. It bites hardest on `validate`, whose whole job is to
|
|
560
|
+
# report exactly these problems — an agent asking what is wrong with
|
|
561
|
+
# the graph would get a dead server instead of the answer.
|
|
562
|
+
#
|
|
563
|
+
# The writes never reach here because `_capture` already catches
|
|
564
|
+
# SystemExit for the same reason. This is the reads' half of it.
|
|
565
|
+
detail = str(exc.code) if exc.code not in (None, 0) else "the graph could not be read"
|
|
566
|
+
payload = {"error": "graph_unreadable", "detail": detail}
|
|
567
|
+
# Semantic rather than sniffed out of the message: if PyYAML is not
|
|
568
|
+
# importable at all, a `files` read cannot have failed for any other
|
|
569
|
+
# reason, and the fix is an install rather than an edit.
|
|
570
|
+
if importlib.util.find_spec("yaml") is None:
|
|
571
|
+
payload["fix"] = "pip install 'roadmap-core[files]'"
|
|
572
|
+
return _response(request_id, _tool_result(payload, is_error=True))
|
|
573
|
+
except Exception as exc: # noqa: BLE001 - a tool bug must not kill the server
|
|
574
|
+
log("tool error:\n" + traceback.format_exc())
|
|
575
|
+
return _response(request_id, _tool_result(
|
|
576
|
+
{"error": "tool_failed", "detail": f"{type(exc).__name__}: {exc}"},
|
|
577
|
+
is_error=True))
|
|
578
|
+
return _response(request_id, _tool_result(result))
|
|
579
|
+
|
|
580
|
+
if is_notification:
|
|
581
|
+
return None
|
|
582
|
+
return _error(request_id, JSONRPC_METHOD_NOT_FOUND, f"unknown method: {method}")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def serve_stdio(stdin: Any = None, stdout: Any = None) -> None:
|
|
586
|
+
"""Read newline-delimited JSON-RPC from stdin, write responses to stdout."""
|
|
587
|
+
stdin = stdin or sys.stdin
|
|
588
|
+
stdout = stdout or sys.stdout
|
|
589
|
+
for line in stdin:
|
|
590
|
+
line = line.strip()
|
|
591
|
+
if not line:
|
|
592
|
+
continue
|
|
593
|
+
try:
|
|
594
|
+
request = json.loads(line)
|
|
595
|
+
except ValueError:
|
|
596
|
+
payload = _error(None, JSONRPC_PARSE_ERROR, "invalid JSON")
|
|
597
|
+
stdout.write(json.dumps(payload) + "\n")
|
|
598
|
+
stdout.flush()
|
|
599
|
+
continue
|
|
600
|
+
if not isinstance(request, dict):
|
|
601
|
+
payload = _error(None, JSONRPC_INVALID_REQUEST, "expected a JSON object")
|
|
602
|
+
stdout.write(json.dumps(payload) + "\n")
|
|
603
|
+
stdout.flush()
|
|
604
|
+
continue
|
|
605
|
+
try:
|
|
606
|
+
response = handle_request(request)
|
|
607
|
+
except (Exception, SystemExit): # noqa: BLE001 - one bad request must not end the session
|
|
608
|
+
# SystemExit named explicitly alongside Exception: it is not an
|
|
609
|
+
# `Exception` subclass, and a `sys.exit` reached from anywhere under
|
|
610
|
+
# here is a request that failed, never a server that should stop
|
|
611
|
+
# answering the ones after it.
|
|
612
|
+
log("unhandled error:\n" + traceback.format_exc())
|
|
613
|
+
response = _error(
|
|
614
|
+
request.get("id"), JSONRPC_INTERNAL_ERROR, "internal error (see stderr)")
|
|
615
|
+
if response is not None:
|
|
616
|
+
stdout.write(json.dumps(response, default=str) + "\n")
|
|
617
|
+
stdout.flush()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
USAGE = """usage: roadmap-mcp
|
|
621
|
+
|
|
622
|
+
Serve the roadmap as MCP tools over stdio. Reads newline-delimited JSON-RPC on
|
|
623
|
+
stdin and writes one frame per line to stdout, so it is normally started by an
|
|
624
|
+
MCP client rather than run by hand.
|
|
625
|
+
|
|
626
|
+
-h, --help this message
|
|
627
|
+
-V, --version the installed roadmap-core version
|
|
628
|
+
|
|
629
|
+
environment:
|
|
630
|
+
ROADMAP_SOURCE files (the default for reads) | local | db. Writes never
|
|
631
|
+
go to files; an environment saying so writes to db.
|
|
632
|
+
ROADMAP_STORE path to the SQLite store used by source=local
|
|
633
|
+
ROADMAP_REPO_ROOT the checkout to resolve roadmap/items against
|
|
634
|
+
|
|
635
|
+
Seven tools: ready, list, show, validate, claim, release, set_status."""
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def main(argv: list[str] | None = None) -> int:
|
|
639
|
+
args = sys.argv[1:] if argv is None else list(argv)
|
|
640
|
+
if args:
|
|
641
|
+
# A stdio server is launched by a client and takes no arguments, so
|
|
642
|
+
# anything here is a misconfiguration. It used to be answered by
|
|
643
|
+
# ignoring argv and blocking on stdin, which is the worst available
|
|
644
|
+
# response to a typo in an `.mcp.json`: the client sees a server that
|
|
645
|
+
# never completes a handshake, and nothing anywhere says why. `--help`
|
|
646
|
+
# from a terminal hung the same way.
|
|
647
|
+
if args[0] in ("-h", "--help"):
|
|
648
|
+
# stdout, because this path never serves — no frames follow it.
|
|
649
|
+
print(USAGE)
|
|
650
|
+
return 0
|
|
651
|
+
if args[0] in ("-V", "--version"):
|
|
652
|
+
print(installed_version())
|
|
653
|
+
return 0
|
|
654
|
+
print(f"roadmap-mcp: unrecognised argument: {args[0]}\n", file=sys.stderr)
|
|
655
|
+
print(USAGE, file=sys.stderr)
|
|
656
|
+
return 2
|
|
657
|
+
serve_stdio()
|
|
658
|
+
return 0
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
if __name__ == "__main__": # pragma: no cover - process entry point
|
|
662
|
+
raise SystemExit(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: roadmap-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: The roadmap work-item and arc graph: status derivation, validation, and markdown rendering. Stdlib-only, so any repo can adopt it without adopting a backend.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Source, https://github.com/gald33/roadmap-core
|
|
@@ -156,6 +156,41 @@ this rather than by reading the code:
|
|
|
156
156
|
`ROADMAP_SOURCE=local` selects the SQLite store. Without it the CLI expects the
|
|
157
157
|
API store, which is how Lucille runs it — see `roadmap_core.stores`.
|
|
158
158
|
|
|
159
|
+
### Agents (MCP)
|
|
160
|
+
|
|
161
|
+
`roadmap-mcp` serves the same graph to a coding agent as MCP tools over stdio —
|
|
162
|
+
`ready`, `list`, `show`, `validate`, `claim`, `release`, `set_status`. Point a
|
|
163
|
+
client at it:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"mcpServers": {
|
|
168
|
+
"roadmap": {
|
|
169
|
+
"command": "roadmap-mcp",
|
|
170
|
+
"env": { "ROADMAP_SOURCE": "local" }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**There is no extra to install and no SDK underneath.** `mcp_server.py` speaks
|
|
177
|
+
the JSON-RPC protocol in stdlib, so it is present wherever the package is —
|
|
178
|
+
which is the same reason the rest of this package is dependency-free, applied to
|
|
179
|
+
one more caller. An extra would be one an adopter can forget, for a server that
|
|
180
|
+
needs nothing.
|
|
181
|
+
|
|
182
|
+
**Reads default to the files**, so an agent in a fresh clone can ask what to
|
|
183
|
+
work on with no store, no server and no token. Writes need a store to arbitrate
|
|
184
|
+
them: set `ROADMAP_SOURCE=local` as above, or the write tools expect the API
|
|
185
|
+
store. There is deliberately no `files` write target — a claim nothing
|
|
186
|
+
adjudicated is not a claim, and two agents could each hold the same item.
|
|
187
|
+
|
|
188
|
+
**A write tells the agent to commit something, and it means it.** `claim`,
|
|
189
|
+
`release` and `set_status` project into `roadmap/items/<key>.yaml`, and on the
|
|
190
|
+
floor that projection *is* the durable record — an unmerged one is a claim no
|
|
191
|
+
other checkout can see. The tool result carries the CLI's own words for this
|
|
192
|
+
under `notes`.
|
|
193
|
+
|
|
159
194
|
### CI
|
|
160
195
|
|
|
161
196
|
Copy `templates/roadmap.yml` to `.github/workflows/roadmap.yml`. That is the
|
|
@@ -5,6 +5,7 @@ roadmap_core/__init__.py
|
|
|
5
5
|
roadmap_core/cli.py
|
|
6
6
|
roadmap_core/graph.py
|
|
7
7
|
roadmap_core/impact.py
|
|
8
|
+
roadmap_core/mcp_server.py
|
|
8
9
|
roadmap_core/store.py
|
|
9
10
|
roadmap_core/stores.py
|
|
10
11
|
roadmap_core.egg-info/PKG-INFO
|
|
@@ -18,5 +19,6 @@ tests/test_adoption.py
|
|
|
18
19
|
tests/test_arcs.py
|
|
19
20
|
tests/test_graph.py
|
|
20
21
|
tests/test_impact.py
|
|
22
|
+
tests/test_mcp_server.py
|
|
21
23
|
tests/test_store.py
|
|
22
24
|
tests/test_stores.py
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""The MCP bridge: the protocol it speaks, and the stream it must not corrupt.
|
|
2
|
+
|
|
3
|
+
Everything here runs against a ``LocalStore`` in ``tmp_path`` and never touches
|
|
4
|
+
``roadmap/items/``. That is the same rule the rest of this suite follows — a
|
|
5
|
+
package claiming to be adoptable cannot prove it with tests that need this
|
|
6
|
+
repository's own backlog — and it has a second payoff here: the write tools
|
|
7
|
+
reach the item files through ``cmd_claim``, so a test that let them find real
|
|
8
|
+
ones would edit the checkout it is running in.
|
|
9
|
+
|
|
10
|
+
The isolation job installs ``[dev]`` and fails if ``yaml`` is importable at all,
|
|
11
|
+
so nothing below may use ``source="files"``. Every read is ``local``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from roadmap_core import cli, mcp_server
|
|
22
|
+
from roadmap_core.stores import LocalStore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(autouse=True)
|
|
26
|
+
def store(tmp_path, monkeypatch):
|
|
27
|
+
"""A store with three items in it, and the CLI pointed at that store.
|
|
28
|
+
|
|
29
|
+
``ROADMAP_STORE_PATH`` is patched rather than the environment variable it is
|
|
30
|
+
built from, because ``cli`` reads that variable once at import — setting the
|
|
31
|
+
env here would leave every command still opening the real path, and the
|
|
32
|
+
tests would pass while proving nothing about the store they set up.
|
|
33
|
+
"""
|
|
34
|
+
path = tmp_path / "roadmap.db"
|
|
35
|
+
with LocalStore(path) as local:
|
|
36
|
+
local.upsert_item({"key": "alpha", "title": "Alpha", "status": "ready"})
|
|
37
|
+
local.upsert_item({"key": "beta", "title": "Beta", "status": "ready",
|
|
38
|
+
"blocked_on": ["alpha"]})
|
|
39
|
+
local.upsert_item({"key": "gamma", "title": "Gamma", "status": "done"})
|
|
40
|
+
monkeypatch.setattr(cli, "ROADMAP_STORE_PATH", str(path))
|
|
41
|
+
monkeypatch.setenv("ROADMAP_SOURCE", "local")
|
|
42
|
+
# The advisory lease is a no-op without a token, and an inherited one would
|
|
43
|
+
# make these tests reach a hub over the network.
|
|
44
|
+
monkeypatch.delenv("SWITCHBOARD_TOKEN", raising=False)
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _rpc(*requests: dict) -> list[dict]:
|
|
49
|
+
"""Drive `serve_stdio` end to end and return the frames it wrote.
|
|
50
|
+
|
|
51
|
+
Through the real loop rather than calling `handle_request` directly: the
|
|
52
|
+
thing most worth pinning is what lands on stdout, and only the loop writes
|
|
53
|
+
there.
|
|
54
|
+
"""
|
|
55
|
+
stdin = io.StringIO("".join(json.dumps(r) + "\n" for r in requests))
|
|
56
|
+
stdout = io.StringIO()
|
|
57
|
+
mcp_server.serve_stdio(stdin=stdin, stdout=stdout)
|
|
58
|
+
return [json.loads(line) for line in stdout.getvalue().splitlines() if line.strip()]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _call(name: str, **arguments):
|
|
62
|
+
"""One tools/call, with its result decoded back out of the text content."""
|
|
63
|
+
frames = _rpc({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
64
|
+
"params": {"name": name, "arguments": arguments}})
|
|
65
|
+
assert len(frames) == 1
|
|
66
|
+
result = frames[0]["result"]
|
|
67
|
+
payload = json.loads(result["content"][0]["text"])
|
|
68
|
+
return payload, result.get("isError", False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- the protocol -------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_initialize_answers_with_tools_and_a_version():
|
|
75
|
+
frames = _rpc({"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
76
|
+
"params": {"protocolVersion": "2024-11-05"}})
|
|
77
|
+
result = frames[0]["result"]
|
|
78
|
+
# Echoed, not overridden: a client that asked for a version we speak gets it.
|
|
79
|
+
assert result["protocolVersion"] == "2024-11-05"
|
|
80
|
+
assert result["capabilities"]["tools"] == {"listChanged": False}
|
|
81
|
+
assert result["serverInfo"]["name"] == "roadmap"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_an_unknown_protocol_version_gets_the_newest_we_speak():
|
|
85
|
+
frames = _rpc({"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
86
|
+
"params": {"protocolVersion": "1999-01-01"}})
|
|
87
|
+
assert frames[0]["result"]["protocolVersion"] == mcp_server.LATEST_PROTOCOL
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_notifications_are_not_answered():
|
|
91
|
+
"""A response to a notification is a protocol violation, and clients that
|
|
92
|
+
validate strictly drop the connection over it."""
|
|
93
|
+
assert _rpc({"jsonrpc": "2.0", "method": "notifications/initialized"}) == []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_every_tool_advertises_a_schema_and_has_a_handler():
|
|
97
|
+
names = {tool["name"] for tool in mcp_server.TOOLS}
|
|
98
|
+
assert names == set(mcp_server.HANDLERS)
|
|
99
|
+
for tool in mcp_server.TOOLS:
|
|
100
|
+
assert tool["description"]
|
|
101
|
+
assert tool["inputSchema"]["type"] == "object"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_a_malformed_line_is_reported_without_ending_the_session():
|
|
105
|
+
stdin = io.StringIO(
|
|
106
|
+
"{not json\n"
|
|
107
|
+
+ json.dumps({"jsonrpc": "2.0", "id": 2, "method": "ping"}) + "\n"
|
|
108
|
+
)
|
|
109
|
+
stdout = io.StringIO()
|
|
110
|
+
mcp_server.serve_stdio(stdin=stdin, stdout=stdout)
|
|
111
|
+
frames = [json.loads(line) for line in stdout.getvalue().splitlines()]
|
|
112
|
+
assert frames[0]["error"]["code"] == mcp_server.JSONRPC_PARSE_ERROR
|
|
113
|
+
# The point of the test: the second request was still served.
|
|
114
|
+
assert frames[1]["id"] == 2
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- stdout is the protocol ---------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_a_write_does_not_leak_the_cli_prints_onto_the_stream():
|
|
121
|
+
"""THE regression this module's `_capture` exists for.
|
|
122
|
+
|
|
123
|
+
`cmd_claim` prints four lines a human is meant to read. stdout here is the
|
|
124
|
+
JSON-RPC transport, so an uncaptured one is not noise — it desynchronises
|
|
125
|
+
the client's parser for the rest of the session, and the error surfaces a
|
|
126
|
+
long way from the print that caused it.
|
|
127
|
+
|
|
128
|
+
Asserted as "every line on stdout parses as JSON-RPC", which is the property
|
|
129
|
+
that actually matters, rather than by matching the strings the CLI happens
|
|
130
|
+
to print today.
|
|
131
|
+
"""
|
|
132
|
+
stdin = io.StringIO(json.dumps({
|
|
133
|
+
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
134
|
+
"params": {"name": "claim", "arguments": {"key": "alpha", "by": "tester"}},
|
|
135
|
+
}) + "\n")
|
|
136
|
+
stdout = io.StringIO()
|
|
137
|
+
mcp_server.serve_stdio(stdin=stdin, stdout=stdout)
|
|
138
|
+
|
|
139
|
+
lines = [line for line in stdout.getvalue().splitlines() if line.strip()]
|
|
140
|
+
assert len(lines) == 1, f"the CLI's prints reached the protocol stream: {lines}"
|
|
141
|
+
for line in lines:
|
|
142
|
+
assert json.loads(line)["jsonrpc"] == "2.0"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_the_cli_output_is_returned_instead_of_discarded():
|
|
146
|
+
"""Captured, not silenced. What `claim` says is what the agent needs."""
|
|
147
|
+
payload, is_error = _call("claim", key="alpha", by="tester")
|
|
148
|
+
assert not is_error
|
|
149
|
+
assert payload["ok"] is True
|
|
150
|
+
assert "claimed alpha for tester" in payload["output"]
|
|
151
|
+
# The "when you finish" notice goes to stderr, and it is the one instruction
|
|
152
|
+
# a claiming session most needs — so it is kept, under its own key.
|
|
153
|
+
assert "release" in payload["notes"]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --- reads --------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_ready_offers_only_what_is_startable():
|
|
160
|
+
payload, is_error = _call("ready")
|
|
161
|
+
assert not is_error
|
|
162
|
+
keys = [item["key"] for item in payload["ready"]]
|
|
163
|
+
# beta is blocked on alpha, gamma is done.
|
|
164
|
+
assert keys == ["alpha"]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_ready_reflects_a_claim_taken_through_the_bridge():
|
|
168
|
+
_call("claim", key="alpha", by="tester")
|
|
169
|
+
payload, _ = _call("ready")
|
|
170
|
+
assert payload["ready"] == []
|
|
171
|
+
assert "nothing ready" in payload["note"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_list_groups_by_derived_status_not_the_stored_column():
|
|
175
|
+
"""An item with a live claim reads `claimed` whatever its status column says
|
|
176
|
+
— the divergence `a-claim-cannot-survive-the-floors-ci` documents."""
|
|
177
|
+
_call("claim", key="alpha", by="tester")
|
|
178
|
+
payload, _ = _call("list")
|
|
179
|
+
assert [i["key"] for i in payload["by_status"]["claimed"]] == ["alpha"]
|
|
180
|
+
assert payload["total"] == 3
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_list_can_be_filtered_to_one_status():
|
|
184
|
+
payload, _ = _call("list", status="done")
|
|
185
|
+
assert set(payload["by_status"]) == {"done"}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_show_names_both_directions_of_the_dependency_edge():
|
|
189
|
+
payload, is_error = _call("show", key="alpha")
|
|
190
|
+
assert not is_error
|
|
191
|
+
assert payload["blocks"] == ["beta"]
|
|
192
|
+
assert payload["blocked_on"] == []
|
|
193
|
+
|
|
194
|
+
payload, _ = _call("show", key="beta")
|
|
195
|
+
assert payload["blocked_on"][0]["key"] == "alpha"
|
|
196
|
+
assert payload["blocked_on"][0]["done"] is False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_validate_reports_a_dangling_dependency():
|
|
200
|
+
with LocalStore(cli.ROADMAP_STORE_PATH) as local:
|
|
201
|
+
local.upsert_item({"key": "delta", "title": "Delta", "blocked_on": ["nope"]})
|
|
202
|
+
payload, _ = _call("validate")
|
|
203
|
+
assert payload["ok"] is False
|
|
204
|
+
assert any("nope" in problem for problem in payload["problems"])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --- failures are answers, not crashes ----------------------------------------
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_an_unknown_item_is_a_named_error():
|
|
211
|
+
payload, is_error = _call("show", key="does-not-exist")
|
|
212
|
+
assert is_error
|
|
213
|
+
assert payload["error"] == "no_such_item"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_an_unknown_tool_is_a_named_error():
|
|
217
|
+
payload, is_error = _call("nonesuch")
|
|
218
|
+
assert is_error
|
|
219
|
+
assert payload["error"] == "unknown_tool"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_a_bad_argument_is_a_named_error():
|
|
223
|
+
payload, is_error = _call("show") # `key` is required
|
|
224
|
+
assert is_error
|
|
225
|
+
assert payload["error"] == "bad_arguments"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_an_unknown_status_is_refused_before_the_store_is_touched():
|
|
229
|
+
payload, is_error = _call("set_status", key="alpha", status="finished")
|
|
230
|
+
assert is_error
|
|
231
|
+
assert payload["error"] == "bad_request"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_a_failing_tool_leaves_the_server_answering():
|
|
235
|
+
frames = _rpc(
|
|
236
|
+
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
237
|
+
"params": {"name": "show", "arguments": {"key": "does-not-exist"}}},
|
|
238
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
|
|
239
|
+
)
|
|
240
|
+
assert frames[0]["result"]["isError"] is True
|
|
241
|
+
assert frames[1]["result"]["tools"]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --- source resolution --------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_a_write_never_goes_to_the_files_source(monkeypatch):
|
|
248
|
+
"""`files` is a projection of a write something else arbitrated. Offering it
|
|
249
|
+
as a write target would be a claim no transaction decided — two sessions
|
|
250
|
+
could each hold the same item."""
|
|
251
|
+
monkeypatch.setenv("ROADMAP_SOURCE", "files")
|
|
252
|
+
assert mcp_server._write_source() == "db"
|
|
253
|
+
assert "files" not in mcp_server.WRITE_SOURCES
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_reads_default_to_files_when_nothing_says_otherwise(monkeypatch):
|
|
257
|
+
"""The default that makes a fresh clone answerable: no server, no token."""
|
|
258
|
+
monkeypatch.delenv("ROADMAP_SOURCE", raising=False)
|
|
259
|
+
assert mcp_server._read_source() == "files"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_an_explicit_source_beats_the_environment(monkeypatch):
|
|
263
|
+
monkeypatch.setenv("ROADMAP_SOURCE", "db")
|
|
264
|
+
assert mcp_server._read_source("local") == "local"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_an_unknown_source_is_refused():
|
|
268
|
+
with pytest.raises(ValueError):
|
|
269
|
+
mcp_server._read_source("postgres")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --- writes -------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_release_hands_the_item_back_to_the_queue():
|
|
276
|
+
_call("claim", key="alpha", by="tester")
|
|
277
|
+
payload, is_error = _call("release", key="alpha")
|
|
278
|
+
assert not is_error and payload["ok"]
|
|
279
|
+
assert [i["key"] for i in _call("ready")[0]["ready"]] == ["alpha"]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_losing_a_claim_race_is_a_refusal_and_not_a_crash():
|
|
283
|
+
"""An ordinary outcome. The store arbitrates, so the second caller is told
|
|
284
|
+
who holds it rather than handed a traceback."""
|
|
285
|
+
_call("claim", key="alpha", by="first")
|
|
286
|
+
payload, _ = _call("claim", key="alpha", by="second")
|
|
287
|
+
assert payload["ok"] is False
|
|
288
|
+
assert "first" in (payload["notes"] or "") + payload["output"]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_set_status_done_drops_the_claim():
|
|
292
|
+
_call("claim", key="alpha", by="tester")
|
|
293
|
+
payload, is_error = _call("set_status", key="alpha", status="done")
|
|
294
|
+
assert not is_error and payload["ok"]
|
|
295
|
+
shown, _ = _call("show", key="alpha")
|
|
296
|
+
assert shown["status"] == "done"
|
|
297
|
+
assert "claimed_by" not in shown
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_set_status_verifying_keeps_the_claim():
|
|
301
|
+
"""The branch that shipped the work still owns observing that it landed."""
|
|
302
|
+
_call("claim", key="alpha", by="tester")
|
|
303
|
+
_call("set_status", key="alpha", status="verifying")
|
|
304
|
+
shown, _ = _call("show", key="alpha")
|
|
305
|
+
assert shown["claimed_by"] == "tester"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# --- the load path reports failure without ending the session -----------------
|
|
309
|
+
#
|
|
310
|
+
# `cli.load` raises SystemExit for every graph problem it finds, and SystemExit
|
|
311
|
+
# is not an `Exception`. The reads call `load` directly rather than through
|
|
312
|
+
# `_capture`, so nothing between them and the interpreter catches it. These
|
|
313
|
+
# monkeypatch `load` rather than pointing a read at a broken `roadmap/items/`,
|
|
314
|
+
# because this file may not use `source="files"` at all — the isolation job runs
|
|
315
|
+
# it with PyYAML absent. What is being pinned is the exception type, and that
|
|
316
|
+
# does not need a real YAML file to be raised.
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@pytest.mark.parametrize("tool", ["ready", "list", "validate", "show"])
|
|
320
|
+
def test_an_unreadable_graph_is_an_error_frame_not_a_dead_server(tool, monkeypatch):
|
|
321
|
+
"""The bug this guards: a single malformed item file used to take the whole
|
|
322
|
+
server down, and `validate` — the tool whose entire job is to report that
|
|
323
|
+
file — went down with it."""
|
|
324
|
+
def explode(*_args, **_kwargs):
|
|
325
|
+
raise SystemExit("roadmap/items/oops.yaml: invalid YAML: mapping values not allowed")
|
|
326
|
+
|
|
327
|
+
monkeypatch.setattr(mcp_server, "load", explode)
|
|
328
|
+
arguments = {"key": "alpha"} if tool == "show" else {}
|
|
329
|
+
frames = _rpc(
|
|
330
|
+
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
331
|
+
"params": {"name": tool, "arguments": arguments}},
|
|
332
|
+
# The point of the test is this second frame: the session outlives it.
|
|
333
|
+
{"jsonrpc": "2.0", "id": 2, "method": "ping"},
|
|
334
|
+
)
|
|
335
|
+
assert len(frames) == 2, "the server stopped answering after an unreadable graph"
|
|
336
|
+
payload = json.loads(frames[0]["result"]["content"][0]["text"])
|
|
337
|
+
assert frames[0]["result"]["isError"] is True
|
|
338
|
+
assert payload["error"] == "graph_unreadable"
|
|
339
|
+
assert "oops.yaml" in payload["detail"]
|
|
340
|
+
assert frames[1]["id"] == 2
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_a_missing_pyyaml_names_the_install_that_fixes_it(monkeypatch):
|
|
344
|
+
"""`load` turns its own ImportError into SystemExit, so the missing-PyYAML
|
|
345
|
+
case arrives as one. It still has to arrive carrying the install."""
|
|
346
|
+
def explode(*_args, **_kwargs):
|
|
347
|
+
raise SystemExit("PyYAML required for --source files (pip install pyyaml)")
|
|
348
|
+
|
|
349
|
+
monkeypatch.setattr(mcp_server, "load", explode)
|
|
350
|
+
monkeypatch.setattr(mcp_server.importlib.util, "find_spec", lambda name: None)
|
|
351
|
+
payload, is_error = _call("ready")
|
|
352
|
+
assert is_error is True
|
|
353
|
+
assert payload["error"] == "graph_unreadable"
|
|
354
|
+
assert payload["fix"] == "pip install 'roadmap-core[files]'"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_a_readable_graph_carries_no_install_advice(monkeypatch):
|
|
358
|
+
"""The `fix` key is conditional. A malformed file in a checkout that HAS
|
|
359
|
+
PyYAML is an edit, not an install, and saying otherwise sends the reader to
|
|
360
|
+
the wrong place."""
|
|
361
|
+
def explode(*_args, **_kwargs):
|
|
362
|
+
raise SystemExit("roadmap/items/oops.yaml: expected a mapping at the top level")
|
|
363
|
+
|
|
364
|
+
monkeypatch.setattr(mcp_server, "load", explode)
|
|
365
|
+
monkeypatch.setattr(mcp_server.importlib.util, "find_spec", lambda name: object())
|
|
366
|
+
payload, _ = _call("validate")
|
|
367
|
+
assert "fix" not in payload
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_a_systemexit_escaping_the_handler_still_leaves_the_loop_running(monkeypatch):
|
|
371
|
+
"""Defence in depth for the loop itself, not the tools/call branch: a
|
|
372
|
+
SystemExit raised outside a tool must not end the session either."""
|
|
373
|
+
def explode(_request):
|
|
374
|
+
raise SystemExit("something above the tools")
|
|
375
|
+
|
|
376
|
+
monkeypatch.setattr(mcp_server, "handle_request", explode)
|
|
377
|
+
frames = _rpc({"jsonrpc": "2.0", "id": 1, "method": "ping"})
|
|
378
|
+
assert len(frames) == 1
|
|
379
|
+
assert frames[0]["error"]["code"] == mcp_server.JSONRPC_INTERNAL_ERROR
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# --- the entry point ----------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_help_and_version_answer_instead_of_blocking_on_stdin(capsys):
|
|
386
|
+
"""`main` used to ignore argv and fall through to reading stdin, so
|
|
387
|
+
`roadmap-mcp --help` hung with no output — and so did a mistyped flag in an
|
|
388
|
+
`.mcp.json`, which surfaced as a server that never finished a handshake."""
|
|
389
|
+
assert mcp_server.main(["--help"]) == 0
|
|
390
|
+
assert "usage: roadmap-mcp" in capsys.readouterr().out
|
|
391
|
+
|
|
392
|
+
assert mcp_server.main(["--version"]) == 0
|
|
393
|
+
assert capsys.readouterr().out.strip() == cli.installed_version()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_an_unrecognised_argument_is_refused_on_stderr(capsys):
|
|
397
|
+
"""Non-zero, and said on stderr: the client is owed a reason it can see, and
|
|
398
|
+
stdout is the protocol even on the path that never gets to speak it."""
|
|
399
|
+
assert mcp_server.main(["--sorce", "files"]) == 2
|
|
400
|
+
captured = capsys.readouterr()
|
|
401
|
+
assert captured.out == ""
|
|
402
|
+
assert "unrecognised argument: --sorce" in captured.err
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|