continuum-mcp-server 0.1.1__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.
- continuum_mcp_server-0.1.1/PKG-INFO +8 -0
- continuum_mcp_server-0.1.1/README.md +22 -0
- continuum_mcp_server-0.1.1/examples/smoke_test.py +94 -0
- continuum_mcp_server-0.1.1/pyproject.toml +20 -0
- continuum_mcp_server-0.1.1/setup.cfg +4 -0
- continuum_mcp_server-0.1.1/src/continuum_mcp/__init__.py +0 -0
- continuum_mcp_server-0.1.1/src/continuum_mcp/server.py +389 -0
- continuum_mcp_server-0.1.1/src/continuum_mcp_server.egg-info/PKG-INFO +8 -0
- continuum_mcp_server-0.1.1/src/continuum_mcp_server.egg-info/SOURCES.txt +12 -0
- continuum_mcp_server-0.1.1/src/continuum_mcp_server.egg-info/dependency_links.txt +1 -0
- continuum_mcp_server-0.1.1/src/continuum_mcp_server.egg-info/entry_points.txt +2 -0
- continuum_mcp_server-0.1.1/src/continuum_mcp_server.egg-info/requires.txt +2 -0
- continuum_mcp_server-0.1.1/src/continuum_mcp_server.egg-info/top_level.txt +1 -0
- continuum_mcp_server-0.1.1/tests/test_mcp_e2e.py +305 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Continuum MCP Server
|
|
2
|
+
|
|
3
|
+
MCP server exposing Continuum decision tools: inspect, resolve, enforce, commit, supersede.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install continuum-mcp-server
|
|
9
|
+
pip install "mcp>=1.0"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Run
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
continuum-mcp serve
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Point at a repo-local store:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
CONTINUUM_STORE="/path/to/.continuum" continuum-mcp serve
|
|
22
|
+
```
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Manual MCP smoke test.
|
|
3
|
+
|
|
4
|
+
This script starts the Continuum MCP server over stdio, calls a few tools,
|
|
5
|
+
and verifies the repo-local store is consistent with the local SDK.
|
|
6
|
+
|
|
7
|
+
Prereqs:
|
|
8
|
+
pip install continuum-mcp-server continuum-sdk "mcp>=1.0"
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _run() -> int:
|
|
19
|
+
try:
|
|
20
|
+
from mcp import ClientSession, StdioServerParameters
|
|
21
|
+
from mcp.client.stdio import stdio_client
|
|
22
|
+
except Exception as exc: # pragma: no cover
|
|
23
|
+
print("ERROR: Missing MCP client deps. Install with: pip install 'mcp>=1.0'")
|
|
24
|
+
print(f"Details: {exc}")
|
|
25
|
+
return 1
|
|
26
|
+
|
|
27
|
+
from continuum.client import ContinuumClient
|
|
28
|
+
|
|
29
|
+
with tempfile.TemporaryDirectory() as td:
|
|
30
|
+
store_dir = os.path.join(td, ".continuum")
|
|
31
|
+
|
|
32
|
+
server_params = StdioServerParameters(
|
|
33
|
+
command="python3",
|
|
34
|
+
args=["-m", "continuum_mcp.server", "serve"],
|
|
35
|
+
env={**os.environ, "CONTINUUM_STORE": store_dir},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async with stdio_client(server_params) as (read_stream, write_stream):
|
|
39
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
40
|
+
await session.initialize()
|
|
41
|
+
|
|
42
|
+
scope = "repo:smoke"
|
|
43
|
+
|
|
44
|
+
commit_res = await session.call_tool(
|
|
45
|
+
"continuum_commit",
|
|
46
|
+
{
|
|
47
|
+
"title": "Reject full rewrites",
|
|
48
|
+
"scope": scope,
|
|
49
|
+
"decision_type": "rejection",
|
|
50
|
+
"options": [
|
|
51
|
+
{"title": "Incremental refactor", "selected": True},
|
|
52
|
+
{
|
|
53
|
+
"title": "Full rewrite",
|
|
54
|
+
"selected": False,
|
|
55
|
+
"rejected_reason": "Too risky",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
"rationale": "Prefer incremental refactors.",
|
|
59
|
+
"activate": True,
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
print("commit:", commit_res)
|
|
63
|
+
|
|
64
|
+
inspect_res = await session.call_tool(
|
|
65
|
+
"continuum_inspect",
|
|
66
|
+
{"scope": scope},
|
|
67
|
+
)
|
|
68
|
+
print("inspect(scope):", inspect_res)
|
|
69
|
+
|
|
70
|
+
resolve_res = await session.call_tool(
|
|
71
|
+
"continuum_resolve",
|
|
72
|
+
{
|
|
73
|
+
"prompt": "Reject full rewrites",
|
|
74
|
+
"scope": scope,
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
print("resolve:", resolve_res)
|
|
78
|
+
|
|
79
|
+
# Verify local SDK sees the same store.
|
|
80
|
+
client = ContinuumClient(storage_dir=store_dir)
|
|
81
|
+
binding = client.inspect(scope)
|
|
82
|
+
assert any(d["title"] == "Reject full rewrites" for d in binding)
|
|
83
|
+
|
|
84
|
+
print("SMOKE TEST PASSED")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main() -> None:
|
|
89
|
+
raise SystemExit(asyncio.run(_run()))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|
|
94
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "continuum-mcp-server"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "MCP server exposing Continuum decision tools"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = {text = "Apache-2.0"}
|
|
11
|
+
dependencies = [
|
|
12
|
+
"continuum-sdk>=0.1.1",
|
|
13
|
+
"mcp>=1.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
continuum-mcp = "continuum_mcp.server:main"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
File without changes
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Continuum MCP Server.
|
|
2
|
+
|
|
3
|
+
Exposes Continuum decision operations as MCP tools:
|
|
4
|
+
- inspect: binding set by scope OR a decision by ID
|
|
5
|
+
- resolve: ambiguity gate against prior decisions
|
|
6
|
+
- enforce: enforcement verdict for a proposed action
|
|
7
|
+
- commit: persist a new decision (optionally activate)
|
|
8
|
+
- supersede: replace an existing decision with a new active one
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# SDK
|
|
19
|
+
from continuum.client import ContinuumClient
|
|
20
|
+
from continuum.exceptions import ContinuumError
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# MCP SDK imports — gracefully degrade if not installed
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
try:
|
|
26
|
+
from mcp.server import Server
|
|
27
|
+
from mcp.types import Tool, TextContent
|
|
28
|
+
|
|
29
|
+
_HAS_MCP = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
_HAS_MCP = False
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Tool definitions
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
TOOLS: list[dict[str, Any]] = [
|
|
38
|
+
{
|
|
39
|
+
"name": "continuum_inspect",
|
|
40
|
+
"description": (
|
|
41
|
+
"Inspect Continuum decisions. Provide either `decision_id` to fetch a single decision, "
|
|
42
|
+
"or `scope` to fetch the active binding set for that scope."
|
|
43
|
+
),
|
|
44
|
+
"inputSchema": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"decision_id": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "The unique decision identifier.",
|
|
50
|
+
},
|
|
51
|
+
"scope": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Scope to inspect (returns active binding set).",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
"anyOf": [{"required": ["decision_id"]}, {"required": ["scope"]}],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "continuum_resolve",
|
|
61
|
+
"description": (
|
|
62
|
+
"Check whether a prior decision already covers the given prompt and scope. "
|
|
63
|
+
"Returns resolved context or a needs_clarification signal."
|
|
64
|
+
),
|
|
65
|
+
"inputSchema": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"properties": {
|
|
68
|
+
"prompt": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"description": "The agent prompt to resolve against prior decisions.",
|
|
71
|
+
},
|
|
72
|
+
"scope": {
|
|
73
|
+
"type": "string",
|
|
74
|
+
"description": "Hierarchical scope identifier (e.g. repo:acme/backend).",
|
|
75
|
+
},
|
|
76
|
+
"candidates": {
|
|
77
|
+
"type": "array",
|
|
78
|
+
"description": "Optional candidate options (id, title) for disambiguation.",
|
|
79
|
+
"items": {"type": "object"},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
"required": ["prompt", "scope"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "continuum_enforce",
|
|
87
|
+
"description": (
|
|
88
|
+
"Evaluate enforcement rules for a proposed action in a scope. "
|
|
89
|
+
"Returns a verdict: allow, confirm, or block (deterministic)."
|
|
90
|
+
),
|
|
91
|
+
"inputSchema": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"properties": {
|
|
94
|
+
"scope": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"description": "Scope to evaluate enforcement within.",
|
|
97
|
+
},
|
|
98
|
+
"action": {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"description": "Proposed action (type, description, metadata).",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
"required": ["scope", "action"],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"name": "continuum_commit",
|
|
108
|
+
"description": "Persist a new decision with title, scope, options, and rationale.",
|
|
109
|
+
"inputSchema": {
|
|
110
|
+
"type": "object",
|
|
111
|
+
"properties": {
|
|
112
|
+
"title": {
|
|
113
|
+
"type": "string",
|
|
114
|
+
"description": "Short title describing the decision.",
|
|
115
|
+
},
|
|
116
|
+
"scope": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"description": "Hierarchical scope identifier.",
|
|
119
|
+
},
|
|
120
|
+
"decision_type": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"description": "Type of decision (e.g. rejection, selection).",
|
|
123
|
+
},
|
|
124
|
+
"options": {
|
|
125
|
+
"type": "array",
|
|
126
|
+
"description": "List of options considered.",
|
|
127
|
+
"items": {"type": "object"},
|
|
128
|
+
},
|
|
129
|
+
"rationale": {
|
|
130
|
+
"type": "string",
|
|
131
|
+
"description": "Why this decision was made.",
|
|
132
|
+
},
|
|
133
|
+
"stakeholders": {
|
|
134
|
+
"type": "array",
|
|
135
|
+
"description": "Optional list of stakeholders.",
|
|
136
|
+
"items": {"type": "string"},
|
|
137
|
+
},
|
|
138
|
+
"metadata": {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"description": "Optional decision metadata.",
|
|
141
|
+
},
|
|
142
|
+
"override_policy": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"description": "Override policy: invalid_by_default | warn | allow",
|
|
145
|
+
},
|
|
146
|
+
"precedence": {
|
|
147
|
+
"type": "integer",
|
|
148
|
+
"description": "Optional precedence for conflict resolution.",
|
|
149
|
+
},
|
|
150
|
+
"supersedes": {
|
|
151
|
+
"type": "string",
|
|
152
|
+
"description": "Optional decision id this decision supersedes.",
|
|
153
|
+
},
|
|
154
|
+
"activate": {
|
|
155
|
+
"type": "boolean",
|
|
156
|
+
"description": "If true, transition the decision to active immediately.",
|
|
157
|
+
"default": False,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
"required": ["title", "scope", "decision_type", "rationale"],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"name": "continuum_supersede",
|
|
165
|
+
"description": "Supersede an existing decision by committing a replacement and activating it.",
|
|
166
|
+
"inputSchema": {
|
|
167
|
+
"type": "object",
|
|
168
|
+
"properties": {
|
|
169
|
+
"old_id": {
|
|
170
|
+
"type": "string",
|
|
171
|
+
"description": "ID of the decision being replaced.",
|
|
172
|
+
},
|
|
173
|
+
"new_title": {
|
|
174
|
+
"type": "string",
|
|
175
|
+
"description": "Title for the replacement decision.",
|
|
176
|
+
},
|
|
177
|
+
"rationale": {
|
|
178
|
+
"type": "string",
|
|
179
|
+
"description": "Rationale for the replacement decision.",
|
|
180
|
+
},
|
|
181
|
+
"options": {
|
|
182
|
+
"type": "array",
|
|
183
|
+
"description": "Optional list of options considered.",
|
|
184
|
+
"items": {"type": "object"},
|
|
185
|
+
},
|
|
186
|
+
"stakeholders": {
|
|
187
|
+
"type": "array",
|
|
188
|
+
"description": "Optional list of stakeholders.",
|
|
189
|
+
"items": {"type": "string"},
|
|
190
|
+
},
|
|
191
|
+
"metadata": {
|
|
192
|
+
"type": "object",
|
|
193
|
+
"description": "Optional decision metadata.",
|
|
194
|
+
},
|
|
195
|
+
"override_policy": {
|
|
196
|
+
"type": "string",
|
|
197
|
+
"description": "Override policy: invalid_by_default | warn | allow",
|
|
198
|
+
},
|
|
199
|
+
"precedence": {
|
|
200
|
+
"type": "integer",
|
|
201
|
+
"description": "Optional precedence for conflict resolution.",
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
"required": ["old_id", "new_title"],
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
# Tool handlers
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _client() -> ContinuumClient:
|
|
215
|
+
storage_dir = os.environ.get("CONTINUUM_STORE")
|
|
216
|
+
return ContinuumClient(storage_dir=storage_dir) if storage_dir else ContinuumClient()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _ok(payload: Any) -> str:
|
|
220
|
+
return json.dumps({"status": "ok", "result": payload}, default=str)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _err(message: str) -> str:
|
|
224
|
+
return json.dumps({"status": "error", "error": message})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _handle_inspect(arguments: dict[str, Any]) -> str:
|
|
228
|
+
"""Inspect by decision_id (single record) OR by scope (binding set)."""
|
|
229
|
+
try:
|
|
230
|
+
client = _client()
|
|
231
|
+
if "decision_id" in arguments and arguments["decision_id"]:
|
|
232
|
+
dec = client.get(str(arguments["decision_id"]))
|
|
233
|
+
return _ok(dec.model_dump(mode="json"))
|
|
234
|
+
if "scope" in arguments and arguments["scope"]:
|
|
235
|
+
binding = client.inspect(str(arguments["scope"]))
|
|
236
|
+
return _ok(binding)
|
|
237
|
+
return _err("Provide either 'decision_id' or 'scope'.")
|
|
238
|
+
except ContinuumError as exc:
|
|
239
|
+
return _err(str(exc))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _handle_resolve(arguments: dict[str, Any]) -> str:
|
|
243
|
+
"""Resolve a prompt against prior decisions."""
|
|
244
|
+
try:
|
|
245
|
+
client = _client()
|
|
246
|
+
prompt = str(arguments.get("prompt", ""))
|
|
247
|
+
scope = str(arguments.get("scope", ""))
|
|
248
|
+
candidates = arguments.get("candidates")
|
|
249
|
+
result = client.resolve(query=prompt, scope=scope, candidates=candidates)
|
|
250
|
+
return _ok(result)
|
|
251
|
+
except ContinuumError as exc:
|
|
252
|
+
return _err(str(exc))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _handle_enforce(arguments: dict[str, Any]) -> str:
|
|
256
|
+
"""Enforce rules for a proposed action within a scope."""
|
|
257
|
+
try:
|
|
258
|
+
client = _client()
|
|
259
|
+
scope = str(arguments.get("scope", ""))
|
|
260
|
+
action = arguments.get("action") or {}
|
|
261
|
+
result = client.enforce(action=action, scope=scope)
|
|
262
|
+
return _ok(result)
|
|
263
|
+
except ContinuumError as exc:
|
|
264
|
+
return _err(str(exc))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _handle_commit(arguments: dict[str, Any]) -> str:
|
|
268
|
+
"""Commit a new decision."""
|
|
269
|
+
try:
|
|
270
|
+
client = _client()
|
|
271
|
+
dec = client.commit(
|
|
272
|
+
title=str(arguments["title"]),
|
|
273
|
+
scope=str(arguments["scope"]),
|
|
274
|
+
decision_type=str(arguments["decision_type"]),
|
|
275
|
+
options=arguments.get("options"),
|
|
276
|
+
rationale=arguments.get("rationale"),
|
|
277
|
+
stakeholders=arguments.get("stakeholders"),
|
|
278
|
+
metadata=arguments.get("metadata"),
|
|
279
|
+
override_policy=arguments.get("override_policy"),
|
|
280
|
+
precedence=arguments.get("precedence"),
|
|
281
|
+
supersedes=arguments.get("supersedes"),
|
|
282
|
+
)
|
|
283
|
+
if arguments.get("activate"):
|
|
284
|
+
dec = client.update_status(dec.id, "active")
|
|
285
|
+
return _ok(dec.model_dump(mode="json"))
|
|
286
|
+
except (KeyError, TypeError) as exc:
|
|
287
|
+
return _err(f"Invalid arguments: {exc}")
|
|
288
|
+
except ContinuumError as exc:
|
|
289
|
+
return _err(str(exc))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _handle_supersede(arguments: dict[str, Any]) -> str:
|
|
293
|
+
"""Supersede an existing decision."""
|
|
294
|
+
try:
|
|
295
|
+
client = _client()
|
|
296
|
+
dec = client.supersede(
|
|
297
|
+
old_id=str(arguments["old_id"]),
|
|
298
|
+
new_title=str(arguments["new_title"]),
|
|
299
|
+
rationale=arguments.get("rationale"),
|
|
300
|
+
options=arguments.get("options"),
|
|
301
|
+
stakeholders=arguments.get("stakeholders"),
|
|
302
|
+
metadata=arguments.get("metadata"),
|
|
303
|
+
override_policy=arguments.get("override_policy"),
|
|
304
|
+
precedence=arguments.get("precedence"),
|
|
305
|
+
)
|
|
306
|
+
return _ok(dec.model_dump(mode="json"))
|
|
307
|
+
except (KeyError, TypeError) as exc:
|
|
308
|
+
return _err(f"Invalid arguments: {exc}")
|
|
309
|
+
except ContinuumError as exc:
|
|
310
|
+
return _err(str(exc))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
_HANDLERS: dict[str, Any] = {
|
|
314
|
+
"continuum_inspect": _handle_inspect,
|
|
315
|
+
"continuum_resolve": _handle_resolve,
|
|
316
|
+
"continuum_enforce": _handle_enforce,
|
|
317
|
+
"continuum_commit": _handle_commit,
|
|
318
|
+
"continuum_supersede": _handle_supersede,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Server setup
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main() -> None:
|
|
327
|
+
"""Entry point for the `continuum-mcp` console script.
|
|
328
|
+
|
|
329
|
+
Usage:
|
|
330
|
+
continuum-mcp serve
|
|
331
|
+
|
|
332
|
+
Environment:
|
|
333
|
+
CONTINUUM_STORE=/path/to/.continuum
|
|
334
|
+
"""
|
|
335
|
+
# Minimal CLI wrapper (avoid extra deps).
|
|
336
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
|
|
337
|
+
if cmd in ("-h", "--help", "help"):
|
|
338
|
+
print(
|
|
339
|
+
"Continuum MCP Server\n\n"
|
|
340
|
+
"Usage:\n"
|
|
341
|
+
" continuum-mcp serve\n\n"
|
|
342
|
+
"Environment:\n"
|
|
343
|
+
" CONTINUUM_STORE Path to repo-local .continuum directory\n",
|
|
344
|
+
file=sys.stdout,
|
|
345
|
+
)
|
|
346
|
+
return
|
|
347
|
+
if cmd != "serve":
|
|
348
|
+
print(f"Unknown command: {cmd}\nRun: continuum-mcp --help", file=sys.stderr)
|
|
349
|
+
sys.exit(2)
|
|
350
|
+
|
|
351
|
+
serve()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def serve() -> None:
|
|
355
|
+
"""Start the Continuum MCP server (stdio transport)."""
|
|
356
|
+
if not _HAS_MCP:
|
|
357
|
+
print(
|
|
358
|
+
"ERROR: The 'mcp' package is not installed. "
|
|
359
|
+
"Install it with: pip install 'mcp>=1.0'",
|
|
360
|
+
file=sys.stderr,
|
|
361
|
+
)
|
|
362
|
+
sys.exit(1)
|
|
363
|
+
|
|
364
|
+
server = Server("continuum-mcp")
|
|
365
|
+
|
|
366
|
+
@server.list_tools()
|
|
367
|
+
async def list_tools() -> list[Tool]:
|
|
368
|
+
return [Tool(**t) for t in TOOLS]
|
|
369
|
+
|
|
370
|
+
@server.call_tool()
|
|
371
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
372
|
+
handler = _HANDLERS.get(name)
|
|
373
|
+
if handler is None:
|
|
374
|
+
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
375
|
+
result = handler(arguments)
|
|
376
|
+
return [TextContent(type="text", text=result)]
|
|
377
|
+
|
|
378
|
+
import asyncio
|
|
379
|
+
from mcp.server.stdio import stdio_server
|
|
380
|
+
|
|
381
|
+
async def _run() -> None:
|
|
382
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
383
|
+
await server.run(read_stream, write_stream)
|
|
384
|
+
|
|
385
|
+
asyncio.run(_run())
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
if __name__ == "__main__":
|
|
389
|
+
main()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
examples/smoke_test.py
|
|
4
|
+
src/continuum_mcp/__init__.py
|
|
5
|
+
src/continuum_mcp/server.py
|
|
6
|
+
src/continuum_mcp_server.egg-info/PKG-INFO
|
|
7
|
+
src/continuum_mcp_server.egg-info/SOURCES.txt
|
|
8
|
+
src/continuum_mcp_server.egg-info/dependency_links.txt
|
|
9
|
+
src/continuum_mcp_server.egg-info/entry_points.txt
|
|
10
|
+
src/continuum_mcp_server.egg-info/requires.txt
|
|
11
|
+
src/continuum_mcp_server.egg-info/top_level.txt
|
|
12
|
+
tests/test_mcp_e2e.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
continuum_mcp
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""End-to-end tests for the Continuum MCP server.
|
|
2
|
+
|
|
3
|
+
These tests exercise the MCP tool handlers directly (no stdio transport)
|
|
4
|
+
against a temporary store directory, covering the full lifecycle:
|
|
5
|
+
commit -> inspect -> resolve -> enforce -> supersede
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
# Import handlers directly for fast, reliable testing without MCP transport.
|
|
16
|
+
from continuum_mcp.server import (
|
|
17
|
+
_handle_commit,
|
|
18
|
+
_handle_enforce,
|
|
19
|
+
_handle_inspect,
|
|
20
|
+
_handle_resolve,
|
|
21
|
+
_handle_supersede,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(autouse=True)
|
|
26
|
+
def _use_temp_store(tmp_path, monkeypatch):
|
|
27
|
+
"""Point the MCP server at a temp store."""
|
|
28
|
+
store = str(tmp_path / ".continuum")
|
|
29
|
+
monkeypatch.setenv("CONTINUUM_STORE", store)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse(result: str) -> dict:
|
|
33
|
+
"""Parse a handler JSON response."""
|
|
34
|
+
data = json.loads(result)
|
|
35
|
+
assert data["status"] == "ok", f"Handler error: {data.get('error')}"
|
|
36
|
+
return data["result"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_err(result: str) -> str:
|
|
40
|
+
"""Parse a handler error response."""
|
|
41
|
+
data = json.loads(result)
|
|
42
|
+
assert data["status"] == "error"
|
|
43
|
+
return data["error"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# commit
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestCommit:
|
|
52
|
+
def test_basic_commit(self):
|
|
53
|
+
result = _parse(_handle_commit({
|
|
54
|
+
"title": "Test decision",
|
|
55
|
+
"scope": "repo:test",
|
|
56
|
+
"decision_type": "rejection",
|
|
57
|
+
"rationale": "For testing.",
|
|
58
|
+
}))
|
|
59
|
+
assert result["title"] == "Test decision"
|
|
60
|
+
assert result["id"].startswith("dec_")
|
|
61
|
+
assert result["status"] == "draft"
|
|
62
|
+
|
|
63
|
+
def test_commit_with_activate(self):
|
|
64
|
+
result = _parse(_handle_commit({
|
|
65
|
+
"title": "Active decision",
|
|
66
|
+
"scope": "repo:test",
|
|
67
|
+
"decision_type": "preference",
|
|
68
|
+
"rationale": "Immediately active.",
|
|
69
|
+
"activate": True,
|
|
70
|
+
}))
|
|
71
|
+
assert result["status"] == "active"
|
|
72
|
+
|
|
73
|
+
def test_commit_with_options(self):
|
|
74
|
+
result = _parse(_handle_commit({
|
|
75
|
+
"title": "With options",
|
|
76
|
+
"scope": "repo:test",
|
|
77
|
+
"decision_type": "rejection",
|
|
78
|
+
"rationale": "Testing options.",
|
|
79
|
+
"options": [
|
|
80
|
+
{"title": "A", "selected": True},
|
|
81
|
+
{"title": "B", "selected": False, "rejected_reason": "Not chosen"},
|
|
82
|
+
],
|
|
83
|
+
}))
|
|
84
|
+
assert len(result["options_considered"]) == 2
|
|
85
|
+
|
|
86
|
+
def test_commit_with_full_params(self):
|
|
87
|
+
result = _parse(_handle_commit({
|
|
88
|
+
"title": "Full params",
|
|
89
|
+
"scope": "repo:test",
|
|
90
|
+
"decision_type": "interpretation",
|
|
91
|
+
"rationale": "Testing all params.",
|
|
92
|
+
"stakeholders": ["alice", "bob"],
|
|
93
|
+
"metadata": {"team": "platform"},
|
|
94
|
+
"override_policy": "warn",
|
|
95
|
+
"precedence": 10,
|
|
96
|
+
}))
|
|
97
|
+
assert result["stakeholders"] == ["alice", "bob"]
|
|
98
|
+
assert result["metadata"]["team"] == "platform"
|
|
99
|
+
|
|
100
|
+
def test_commit_missing_required(self):
|
|
101
|
+
raw = _handle_commit({"title": "Missing scope"})
|
|
102
|
+
data = json.loads(raw)
|
|
103
|
+
assert data["status"] == "error"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# inspect
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TestInspect:
|
|
112
|
+
def test_inspect_by_id(self):
|
|
113
|
+
dec = _parse(_handle_commit({
|
|
114
|
+
"title": "Inspectable",
|
|
115
|
+
"scope": "repo:test",
|
|
116
|
+
"decision_type": "rejection",
|
|
117
|
+
"rationale": "For inspect test.",
|
|
118
|
+
}))
|
|
119
|
+
result = _parse(_handle_inspect({"decision_id": dec["id"]}))
|
|
120
|
+
assert result["id"] == dec["id"]
|
|
121
|
+
|
|
122
|
+
def test_inspect_by_scope(self):
|
|
123
|
+
_parse(_handle_commit({
|
|
124
|
+
"title": "Scope inspect",
|
|
125
|
+
"scope": "repo:inspect-scope",
|
|
126
|
+
"decision_type": "rejection",
|
|
127
|
+
"rationale": "For scope test.",
|
|
128
|
+
"activate": True,
|
|
129
|
+
}))
|
|
130
|
+
result = _parse(_handle_inspect({"scope": "repo:inspect-scope"}))
|
|
131
|
+
assert isinstance(result, list)
|
|
132
|
+
assert len(result) >= 1
|
|
133
|
+
|
|
134
|
+
def test_inspect_no_args(self):
|
|
135
|
+
err = _parse_err(_handle_inspect({}))
|
|
136
|
+
assert "Provide either" in err
|
|
137
|
+
|
|
138
|
+
def test_inspect_nonexistent_id(self):
|
|
139
|
+
err = _parse_err(_handle_inspect({"decision_id": "dec_nonexistent"}))
|
|
140
|
+
assert "not found" in err.lower()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
# resolve
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestResolve:
|
|
149
|
+
def test_resolve_no_decisions(self):
|
|
150
|
+
result = _parse(_handle_resolve({
|
|
151
|
+
"prompt": "something new",
|
|
152
|
+
"scope": "repo:test",
|
|
153
|
+
}))
|
|
154
|
+
assert "status" in result
|
|
155
|
+
|
|
156
|
+
def test_resolve_with_matching_decision(self):
|
|
157
|
+
_parse(_handle_commit({
|
|
158
|
+
"title": "Reject full rewrites",
|
|
159
|
+
"scope": "repo:resolve-test",
|
|
160
|
+
"decision_type": "rejection",
|
|
161
|
+
"rationale": "Too risky.",
|
|
162
|
+
"activate": True,
|
|
163
|
+
}))
|
|
164
|
+
result = _parse(_handle_resolve({
|
|
165
|
+
"prompt": "Reject full rewrites",
|
|
166
|
+
"scope": "repo:resolve-test",
|
|
167
|
+
}))
|
|
168
|
+
assert result["status"] == "resolved"
|
|
169
|
+
|
|
170
|
+
def test_resolve_with_candidates(self):
|
|
171
|
+
result = _parse(_handle_resolve({
|
|
172
|
+
"prompt": "Pick approach",
|
|
173
|
+
"scope": "repo:test",
|
|
174
|
+
"candidates": [
|
|
175
|
+
{"id": "a", "title": "Approach A"},
|
|
176
|
+
{"id": "b", "title": "Approach B"},
|
|
177
|
+
],
|
|
178
|
+
}))
|
|
179
|
+
assert "status" in result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# enforce
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TestEnforce:
|
|
188
|
+
def test_enforce_no_decisions(self):
|
|
189
|
+
result = _parse(_handle_enforce({
|
|
190
|
+
"scope": "repo:test",
|
|
191
|
+
"action": {"type": "code_change", "description": "minor fix"},
|
|
192
|
+
}))
|
|
193
|
+
assert "verdict" in result
|
|
194
|
+
|
|
195
|
+
def test_enforce_with_rejection(self):
|
|
196
|
+
_parse(_handle_commit({
|
|
197
|
+
"title": "Reject full rewrites",
|
|
198
|
+
"scope": "repo:enforce-test",
|
|
199
|
+
"decision_type": "rejection",
|
|
200
|
+
"rationale": "Too risky.",
|
|
201
|
+
"options": [
|
|
202
|
+
{"title": "Incremental refactor", "selected": True},
|
|
203
|
+
{"title": "Full rewrite", "selected": False, "rejected_reason": "Too risky"},
|
|
204
|
+
],
|
|
205
|
+
"activate": True,
|
|
206
|
+
}))
|
|
207
|
+
result = _parse(_handle_enforce({
|
|
208
|
+
"scope": "repo:enforce-test",
|
|
209
|
+
"action": {"type": "code_change", "description": "Do a full rewrite of auth"},
|
|
210
|
+
}))
|
|
211
|
+
assert "verdict" in result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
# supersede
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class TestSupersede:
|
|
220
|
+
def test_supersede_lifecycle(self):
|
|
221
|
+
# Commit and activate
|
|
222
|
+
dec = _parse(_handle_commit({
|
|
223
|
+
"title": "V1 decision",
|
|
224
|
+
"scope": "repo:supersede-test",
|
|
225
|
+
"decision_type": "rejection",
|
|
226
|
+
"rationale": "Original.",
|
|
227
|
+
"activate": True,
|
|
228
|
+
}))
|
|
229
|
+
assert dec["status"] == "active"
|
|
230
|
+
|
|
231
|
+
# Supersede
|
|
232
|
+
new_dec = _parse(_handle_supersede({
|
|
233
|
+
"old_id": dec["id"],
|
|
234
|
+
"new_title": "V2 decision",
|
|
235
|
+
"rationale": "Updated approach.",
|
|
236
|
+
}))
|
|
237
|
+
assert new_dec["title"] == "V2 decision"
|
|
238
|
+
assert new_dec["status"] == "active"
|
|
239
|
+
|
|
240
|
+
# Verify old is superseded
|
|
241
|
+
old = _parse(_handle_inspect({"decision_id": dec["id"]}))
|
|
242
|
+
assert old["status"] == "superseded"
|
|
243
|
+
|
|
244
|
+
def test_supersede_nonexistent(self):
|
|
245
|
+
err = _parse_err(_handle_supersede({
|
|
246
|
+
"old_id": "dec_nonexistent",
|
|
247
|
+
"new_title": "Won't work",
|
|
248
|
+
}))
|
|
249
|
+
assert "not found" in err.lower()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
# Full lifecycle (integration)
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class TestFullLifecycle:
|
|
258
|
+
def test_commit_inspect_resolve_enforce_supersede(self):
|
|
259
|
+
scope = "repo:lifecycle"
|
|
260
|
+
|
|
261
|
+
# 1. Commit
|
|
262
|
+
dec = _parse(_handle_commit({
|
|
263
|
+
"title": "Reject full rewrites",
|
|
264
|
+
"scope": scope,
|
|
265
|
+
"decision_type": "rejection",
|
|
266
|
+
"rationale": "Too risky.",
|
|
267
|
+
"options": [
|
|
268
|
+
{"title": "Incremental refactor", "selected": True},
|
|
269
|
+
{"title": "Full rewrite", "selected": False, "rejected_reason": "Too risky"},
|
|
270
|
+
],
|
|
271
|
+
"activate": True,
|
|
272
|
+
}))
|
|
273
|
+
dec_id = dec["id"]
|
|
274
|
+
|
|
275
|
+
# 2. Inspect by scope
|
|
276
|
+
binding = _parse(_handle_inspect({"scope": scope}))
|
|
277
|
+
assert any(d["id"] == dec_id for d in binding)
|
|
278
|
+
|
|
279
|
+
# 3. Resolve
|
|
280
|
+
resolved = _parse(_handle_resolve({
|
|
281
|
+
"prompt": "Reject full rewrites",
|
|
282
|
+
"scope": scope,
|
|
283
|
+
}))
|
|
284
|
+
assert resolved["status"] == "resolved"
|
|
285
|
+
|
|
286
|
+
# 4. Enforce
|
|
287
|
+
enforcement = _parse(_handle_enforce({
|
|
288
|
+
"scope": scope,
|
|
289
|
+
"action": {"type": "code_change", "description": "full rewrite"},
|
|
290
|
+
}))
|
|
291
|
+
assert "verdict" in enforcement
|
|
292
|
+
|
|
293
|
+
# 5. Supersede
|
|
294
|
+
new_dec = _parse(_handle_supersede({
|
|
295
|
+
"old_id": dec_id,
|
|
296
|
+
"new_title": "V2: Allow rewrites for tests only",
|
|
297
|
+
"rationale": "Relaxed policy for test modules.",
|
|
298
|
+
}))
|
|
299
|
+
assert new_dec["status"] == "active"
|
|
300
|
+
|
|
301
|
+
# 6. Verify final state
|
|
302
|
+
final_binding = _parse(_handle_inspect({"scope": scope}))
|
|
303
|
+
active_ids = [d["id"] for d in final_binding]
|
|
304
|
+
assert new_dec["id"] in active_ids
|
|
305
|
+
assert dec_id not in active_ids # Old decision should be superseded
|