forge-mcp-base 0.1.0a1__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.
- forge_mcp_base-0.1.0a1/LICENSE +21 -0
- forge_mcp_base-0.1.0a1/PKG-INFO +62 -0
- forge_mcp_base-0.1.0a1/README.md +45 -0
- forge_mcp_base-0.1.0a1/forge_mcp/__init__.py +19 -0
- forge_mcp_base-0.1.0a1/forge_mcp/mcp_client.py +141 -0
- forge_mcp_base-0.1.0a1/forge_mcp/server.py +258 -0
- forge_mcp_base-0.1.0a1/forge_mcp_base.egg-info/PKG-INFO +62 -0
- forge_mcp_base-0.1.0a1/forge_mcp_base.egg-info/SOURCES.txt +12 -0
- forge_mcp_base-0.1.0a1/forge_mcp_base.egg-info/dependency_links.txt +1 -0
- forge_mcp_base-0.1.0a1/forge_mcp_base.egg-info/entry_points.txt +2 -0
- forge_mcp_base-0.1.0a1/forge_mcp_base.egg-info/requires.txt +1 -0
- forge_mcp_base-0.1.0a1/forge_mcp_base.egg-info/top_level.txt +1 -0
- forge_mcp_base-0.1.0a1/pyproject.toml +31 -0
- forge_mcp_base-0.1.0a1/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sanjay Davis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forge-mcp-base
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Forge MCP server: JSON-RPC 2.0 over stdio, six tools, one SDK call each. A transport, nothing more.
|
|
5
|
+
Author-email: Sanjay Davis <psanjuknl@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: forge,mcp,sdk,autonomous-software,json-rpc
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: forge-foundation
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# forge-mcp
|
|
19
|
+
|
|
20
|
+
The MCP server for [Forge](https://github.com/SanjayDavis/Forge) — a
|
|
21
|
+
deterministic, stdlib-only transport that exposes the whole Forge SDK to
|
|
22
|
+
any MCP client. JSON-RPC 2.0 over stdio, six tools, one SDK call each,
|
|
23
|
+
zero business logic.
|
|
24
|
+
|
|
25
|
+
This package is the ecosystem proof behind the SDK boundary: it is a
|
|
26
|
+
*separate, installable package* that consumes only the public
|
|
27
|
+
`forge.*` surface — no kernel internals.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install forge-foundation forge-mcp-base
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Use
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
forge-mcp -d myproject
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or from Python:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
python -m forge_mcp.server -d myproject
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
An MCP client (Claude, Hermes, the `mcp` SDK, any editor) connects over
|
|
48
|
+
stdio and gets six tools — `forge_next`, `forge_context`,
|
|
49
|
+
`forge_propose`, `forge_verify`, `forge_query`, `forge_replay` — each a
|
|
50
|
+
thin pass-through to one SDK method. The kernel decides; this
|
|
51
|
+
translates.
|
|
52
|
+
|
|
53
|
+
## Reference client
|
|
54
|
+
|
|
55
|
+
A walk-through client that proves the server is a real MCP server:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
python -m forge_mcp.mcp_client -d myproject
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
It performs the handshake, lists tools, and drives the six-tool loop
|
|
62
|
+
over the wire.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# forge-mcp
|
|
2
|
+
|
|
3
|
+
The MCP server for [Forge](https://github.com/SanjayDavis/Forge) — a
|
|
4
|
+
deterministic, stdlib-only transport that exposes the whole Forge SDK to
|
|
5
|
+
any MCP client. JSON-RPC 2.0 over stdio, six tools, one SDK call each,
|
|
6
|
+
zero business logic.
|
|
7
|
+
|
|
8
|
+
This package is the ecosystem proof behind the SDK boundary: it is a
|
|
9
|
+
*separate, installable package* that consumes only the public
|
|
10
|
+
`forge.*` surface — no kernel internals.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pip install forge-foundation forge-mcp-base
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
forge-mcp -d myproject
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or from Python:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
python -m forge_mcp.server -d myproject
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
An MCP client (Claude, Hermes, the `mcp` SDK, any editor) connects over
|
|
31
|
+
stdio and gets six tools — `forge_next`, `forge_context`,
|
|
32
|
+
`forge_propose`, `forge_verify`, `forge_query`, `forge_replay` — each a
|
|
33
|
+
thin pass-through to one SDK method. The kernel decides; this
|
|
34
|
+
translates.
|
|
35
|
+
|
|
36
|
+
## Reference client
|
|
37
|
+
|
|
38
|
+
A walk-through client that proves the server is a real MCP server:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
python -m forge_mcp.mcp_client -d myproject
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
It performs the handshake, lists tools, and drives the six-tool loop
|
|
45
|
+
over the wire.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""forge_mcp — the Forge MCP server distribution.
|
|
2
|
+
|
|
3
|
+
The Forge wire protocol as an installable package: JSON-RPC 2.0 over
|
|
4
|
+
stdio, six tools, one SDK call each, zero business logic. Consumes only
|
|
5
|
+
the public SDK (forge.ForgeClient). This is the transport that lets any
|
|
6
|
+
MCP client (Claude, Hermes, the `mcp` SDK, a text editor) drive a Forge
|
|
7
|
+
project.
|
|
8
|
+
|
|
9
|
+
forge-mcp -d PROJECT
|
|
10
|
+
python -m forge_mcp.server -d PROJECT
|
|
11
|
+
|
|
12
|
+
The distribution is named `forge-mcp`; the import name is `forge_mcp`.
|
|
13
|
+
The server is stdlib-only and never touches kernel internals.
|
|
14
|
+
"""
|
|
15
|
+
from .server import (ForgeMCPServer, PROTOCOL_VERSION, SERVER_INFO, TOOLS,
|
|
16
|
+
main)
|
|
17
|
+
|
|
18
|
+
__all__ = ["ForgeMCPServer", "PROTOCOL_VERSION", "SERVER_INFO", "TOOLS",
|
|
19
|
+
"main"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Reference MCP client — the MCP slot, runnable.
|
|
2
|
+
|
|
3
|
+
Speaks the MCP wire protocol (JSON-RPC 2.0 over stdio) to a spawned
|
|
4
|
+
forge-mcp server, exactly like any MCP client would: initialize,
|
|
5
|
+
notifications/initialized, tools/list, then the six tools. This is the
|
|
6
|
+
roadmap's forge_next / forge_context / forge_propose / forge_verify /
|
|
7
|
+
forge_query / forge_replay surface, exercised over the wire instead of
|
|
8
|
+
through the SDK directly — proving the server is a real MCP server,
|
|
9
|
+
not a local helper.
|
|
10
|
+
|
|
11
|
+
python -m forge_mcp.mcp_client -d PROJECT [--limit N]
|
|
12
|
+
|
|
13
|
+
A walk: propose (if given a proposal file), next, context, verify,
|
|
14
|
+
query, replay — the six tools in one pass over whatever is ready.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
# This client may be run from a checkout (packages/forge-mcp) or from an
|
|
25
|
+
# installed package; either way the directory containing the `forge_mcp`
|
|
26
|
+
# package is the parent of this file, so `python -m forge_mcp.server`
|
|
27
|
+
# resolves whether the distribution is installed or not.
|
|
28
|
+
PKG_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rpc(p: subprocess.Popen, msg_id: int, method: str,
|
|
32
|
+
params: dict | None = None) -> dict:
|
|
33
|
+
"""Send one JSON-RPC request over stdio, read one response line."""
|
|
34
|
+
payload = {"jsonrpc": "2.0", "id": msg_id, "method": method}
|
|
35
|
+
if params is not None:
|
|
36
|
+
payload["params"] = params
|
|
37
|
+
assert p.stdin is not None and p.stdout is not None
|
|
38
|
+
p.stdin.write(json.dumps(payload) + "\n")
|
|
39
|
+
p.stdin.flush()
|
|
40
|
+
line = p.stdout.readline()
|
|
41
|
+
if not line:
|
|
42
|
+
raise RuntimeError("server closed the connection")
|
|
43
|
+
return json.loads(line)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main(argv: list[str] | None = None) -> int:
|
|
47
|
+
ap = argparse.ArgumentParser(
|
|
48
|
+
prog="mcp-client",
|
|
49
|
+
description="reference MCP client: walk the six forge tools "
|
|
50
|
+
"over the wire")
|
|
51
|
+
ap.add_argument("-d", "--dir", default=".",
|
|
52
|
+
help="project directory (default: .)")
|
|
53
|
+
ap.add_argument("--proposal", default=None,
|
|
54
|
+
help="proposal JSON file to commit first (optional)")
|
|
55
|
+
args = ap.parse_args(argv)
|
|
56
|
+
|
|
57
|
+
env = dict(os.environ)
|
|
58
|
+
env["PYTHONPATH"] = PKG_ROOT + os.pathsep + env.get("PYTHONPATH", "")
|
|
59
|
+
server = [sys.executable, "-m", "forge_mcp.server", "-d", args.dir]
|
|
60
|
+
p = subprocess.Popen(server,
|
|
61
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
62
|
+
stderr=subprocess.PIPE, text=True,
|
|
63
|
+
encoding="utf-8", env=env)
|
|
64
|
+
|
|
65
|
+
msg_id = [0]
|
|
66
|
+
|
|
67
|
+
def call(method: str, params: dict | None = None) -> dict:
|
|
68
|
+
msg_id[0] += 1
|
|
69
|
+
return rpc(p, msg_id[0], method, params)
|
|
70
|
+
|
|
71
|
+
# ---- handshake
|
|
72
|
+
init = call("initialize", {"protocolVersion": "2025-11-25",
|
|
73
|
+
"capabilities": {},
|
|
74
|
+
"clientInfo": {"name": "mcp-client",
|
|
75
|
+
"version": "0.1.0"}})
|
|
76
|
+
print(f"server: {init['result']['serverInfo']['name']} "
|
|
77
|
+
f"{init['result']['serverInfo']['version']} — protocol "
|
|
78
|
+
f"{init['result']['protocolVersion']}")
|
|
79
|
+
# notifications/initialized: no response expected
|
|
80
|
+
p.stdin.write(json.dumps(
|
|
81
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
|
|
82
|
+
p.stdin.flush()
|
|
83
|
+
|
|
84
|
+
tools = call("tools/list")
|
|
85
|
+
names = [t["name"] for t in tools["result"]["tools"]]
|
|
86
|
+
print(f"tools: {len(names)} — {', '.join(names)}")
|
|
87
|
+
|
|
88
|
+
# ---- optional proposal commit
|
|
89
|
+
if args.proposal:
|
|
90
|
+
with open(args.proposal, encoding="utf-8") as f:
|
|
91
|
+
proposal = json.load(f)
|
|
92
|
+
res = call("tools/call", {"name": "forge_propose",
|
|
93
|
+
"arguments": {"proposal": proposal}})
|
|
94
|
+
body = res["result"]
|
|
95
|
+
if body.get("isError"):
|
|
96
|
+
print(f"propose: ERROR — {body['content'][0]['text']}")
|
|
97
|
+
else:
|
|
98
|
+
info = json.loads(body["content"][0]["text"])
|
|
99
|
+
print(f"propose: {info['committed']} events committed "
|
|
100
|
+
f"({len(info['tasks'])} tasks)")
|
|
101
|
+
|
|
102
|
+
# ---- the six-tool walk
|
|
103
|
+
nxt = call("tools/call", {"name": "forge_next", "arguments": {}})
|
|
104
|
+
body = nxt["result"]
|
|
105
|
+
if body.get("isError"):
|
|
106
|
+
print(f"next: ERROR — {body['content'][0]['text']}")
|
|
107
|
+
elif body["content"][0]["text"] == "null":
|
|
108
|
+
print("next: none")
|
|
109
|
+
else:
|
|
110
|
+
task = json.loads(body["content"][0]["text"])
|
|
111
|
+
print(f"next: {task['id']} — {task['title']} ({task['status']})")
|
|
112
|
+
|
|
113
|
+
ctx = call("tools/call", {"name": "forge_context",
|
|
114
|
+
"arguments": {"task_id": task["id"]}})
|
|
115
|
+
print(f"context: {ctx['result']['content'][0]['text']}")
|
|
116
|
+
|
|
117
|
+
ver = call("tools/call", {"name": "forge_verify",
|
|
118
|
+
"arguments": {"task_id": task["id"]}})
|
|
119
|
+
vbody = ver["result"]
|
|
120
|
+
if vbody.get("isError"):
|
|
121
|
+
print(f"verify: ERROR — {vbody['content'][0]['text']}")
|
|
122
|
+
else:
|
|
123
|
+
print(f"verify: {json.loads(vbody['content'][0]['text'])}")
|
|
124
|
+
|
|
125
|
+
q = call("tools/call", {"name": "forge_query",
|
|
126
|
+
"arguments": {"expr": "status == in_progress"}})
|
|
127
|
+
ids = json.loads(q["result"]["content"][0]["text"])
|
|
128
|
+
print(f"query: {len(ids)} in-progress task(s)")
|
|
129
|
+
|
|
130
|
+
rp = call("tools/call", {"name": "forge_replay", "arguments": {}})
|
|
131
|
+
rep = json.loads(rp["result"]["content"][0]["text"])
|
|
132
|
+
print(f"replay: {rep['events']} events, {rep['tasks']} tasks, "
|
|
133
|
+
f"{rep['done']} done")
|
|
134
|
+
|
|
135
|
+
p.stdin.close()
|
|
136
|
+
p.wait(timeout=10)
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
sys.exit(main())
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""M5 — the MCP server (SPEC Appendix A: MCP interfaces may evolve
|
|
2
|
+
freely). Ships as the `forge-mcp` distribution; import name `forge_mcp`.
|
|
3
|
+
|
|
4
|
+
The Forge wire protocol in one file, stdlib-only: JSON-RPC 2.0 over
|
|
5
|
+
stdio, one JSON object per line (the MCP stdio framing), six tools —
|
|
6
|
+
forge_next, forge_context, forge_propose, forge_verify, forge_query,
|
|
7
|
+
forge_replay — each a thin pass-through to one SDK method. No business
|
|
8
|
+
logic: the server is a transport, and that is the whole point. The
|
|
9
|
+
kernel decides; the SDK exposes; this translates JSON-RPC to SDK calls
|
|
10
|
+
and back.
|
|
11
|
+
|
|
12
|
+
forge-mcp -d PROJECT
|
|
13
|
+
python -m forge_mcp.server -d PROJECT
|
|
14
|
+
|
|
15
|
+
Clients: any MCP client (Claude, Hermes, `mcp` SDK) speaks to it over
|
|
16
|
+
stdio. This reference server implements the protocol itself — no `mcp`
|
|
17
|
+
SDK dependency — so the plugin stays as boring as the roadmap promises.
|
|
18
|
+
It consumes ONLY the public SDK (forge.ForgeClient). No kernel
|
|
19
|
+
internals, no graph, no replay of its own: everything the tools return
|
|
20
|
+
comes from SDK methods.
|
|
21
|
+
|
|
22
|
+
Tool semantics (one SDK call each, nothing more):
|
|
23
|
+
forge_next -> ForgeClient.next()
|
|
24
|
+
forge_context -> ForgeClient.context(task_id) (the YAML package)
|
|
25
|
+
forge_propose -> ForgeClient.propose(proposal)
|
|
26
|
+
forge_verify -> ForgeClient.verify(task_id)
|
|
27
|
+
forge_query -> ForgeClient.query(expr)
|
|
28
|
+
forge_replay -> ForgeClient.replay()
|
|
29
|
+
|
|
30
|
+
Errors: a tool that raises GraphError / ProposalError comes back as an
|
|
31
|
+
MCP tool error (isError result, message = the kernel's), never as a
|
|
32
|
+
JSON-RPC fault — the protocol layer itself only faults on protocol
|
|
33
|
+
violations (parse errors, unknown methods, missing arguments).
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import sys
|
|
40
|
+
from typing import Any, Callable
|
|
41
|
+
|
|
42
|
+
from forge import ForgeClient, GraphError, ProposalError
|
|
43
|
+
|
|
44
|
+
# The MCP protocol version this server speaks. Clients negotiate at
|
|
45
|
+
# initialize; we echo a known version back (2024-11-05 and later are
|
|
46
|
+
# wire-compatible for tools-only servers), defaulting to the newest.
|
|
47
|
+
PROTOCOL_VERSION = "2025-11-25"
|
|
48
|
+
KNOWN_VERSIONS = ("2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25")
|
|
49
|
+
|
|
50
|
+
SERVER_INFO = {"name": "forge", "version": "0.1.0-alpha"}
|
|
51
|
+
|
|
52
|
+
JSON_RPC = "2.0"
|
|
53
|
+
# JSON-RPC error codes (the MCP spec reuses these verbatim).
|
|
54
|
+
PARSE_ERROR = -32700
|
|
55
|
+
INVALID_REQUEST = -32600
|
|
56
|
+
METHOD_NOT_FOUND = -32601
|
|
57
|
+
INVALID_PARAMS = -32602
|
|
58
|
+
INTERNAL_ERROR = -32603
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MethodNotFound(Exception):
|
|
62
|
+
"""A JSON-RPC-level fault: the client called a method this server
|
|
63
|
+
does not implement. Distinct from a tool that failed (isError)."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MissingArgument(Exception):
|
|
67
|
+
"""A tools/call request omitted a required inputSchema argument.
|
|
68
|
+
A JSON-RPC fault (-32602), distinct from KeyErrors a tool handler
|
|
69
|
+
may raise internally (those are tool failures, isError)."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _json_text(value: Any) -> str:
|
|
73
|
+
"""Tool results are JSON text; the context package is already YAML."""
|
|
74
|
+
return json.dumps(value, ensure_ascii=False, indent=2)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _tool(name: str, description: str, props: dict[str, Any],
|
|
78
|
+
required: list[str], fn: Callable[[ForgeClient, dict], str]) -> dict:
|
|
79
|
+
return {"name": name, "description": description,
|
|
80
|
+
"inputSchema": {"type": "object", "properties": props,
|
|
81
|
+
"required": required},
|
|
82
|
+
"handler": fn}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
TOOLS: list[dict[str, Any]] = [
|
|
86
|
+
_tool(
|
|
87
|
+
"forge_next",
|
|
88
|
+
"The single next work item: highest priority, then creation "
|
|
89
|
+
"order. Returns a task snapshot, or null when nothing is ready.",
|
|
90
|
+
{}, [],
|
|
91
|
+
lambda c, a: _json_text(c.next())),
|
|
92
|
+
_tool(
|
|
93
|
+
"forge_context",
|
|
94
|
+
"The Context Contract package for TASK (Task / Description / "
|
|
95
|
+
"Acceptance / Dependencies / Knowledge / Relevant Files / "
|
|
96
|
+
"Evidence / Constraints), as the same YAML every client reads.",
|
|
97
|
+
{"task_id": {"type": "string"}}, ["task_id"],
|
|
98
|
+
lambda c, a: c.context(a["task_id"])),
|
|
99
|
+
_tool(
|
|
100
|
+
"forge_propose",
|
|
101
|
+
"Commit a proposal atomically (SPEC §9): envelope validated, "
|
|
102
|
+
"then the kernel validates and applies — whole or nothing. "
|
|
103
|
+
"Takes the full proposal object (proposal_id, confidence, "
|
|
104
|
+
"events) and returns the commit result.",
|
|
105
|
+
{"proposal": {"type": "object"}}, ["proposal"],
|
|
106
|
+
lambda c, a: _json_text(c.propose(a["proposal"]))),
|
|
107
|
+
_tool(
|
|
108
|
+
"forge_verify",
|
|
109
|
+
"Run the verifier gate (I6) on TASK: only started tasks with "
|
|
110
|
+
"all dependencies done can pass; the kernel decides done.",
|
|
111
|
+
{"task_id": {"type": "string"}}, ["task_id"],
|
|
112
|
+
lambda c, a: _json_text(c.verify(a["task_id"]))),
|
|
113
|
+
_tool(
|
|
114
|
+
"forge_query",
|
|
115
|
+
"Run a query over the task graph (safe expression subset: "
|
|
116
|
+
"status, priority, evidence_count, files, depends_on, and/or/"
|
|
117
|
+
"not, comparison operators). Returns matching task ids.",
|
|
118
|
+
{"expr": {"type": "string"}}, ["expr"],
|
|
119
|
+
lambda c, a: _json_text(c.query(a["expr"]))),
|
|
120
|
+
_tool(
|
|
121
|
+
"forge_replay",
|
|
122
|
+
"Replay the event log and report the project state: event "
|
|
123
|
+
"count, task count, done count.",
|
|
124
|
+
{}, [],
|
|
125
|
+
lambda c, a: _json_text(c.replay())),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ForgeMCPServer:
|
|
130
|
+
"""JSON-RPC 2.0 over stdio, one JSON object per line. Every request
|
|
131
|
+
maps to exactly one SDK call; every result is a kernel verdict."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, directory: str = ".") -> None:
|
|
134
|
+
self.client = ForgeClient(directory)
|
|
135
|
+
self.tools = {t["name"]: t for t in TOOLS}
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------ wire
|
|
138
|
+
def handle_line(self, line: str) -> str | None:
|
|
139
|
+
"""One incoming JSON-RPC message -> one response line (or None
|
|
140
|
+
for notifications, which never get a response)."""
|
|
141
|
+
try:
|
|
142
|
+
msg = json.loads(line)
|
|
143
|
+
except json.JSONDecodeError:
|
|
144
|
+
return self._error(None, PARSE_ERROR, "parse error")
|
|
145
|
+
if not isinstance(msg, dict) or msg.get("jsonrpc") != JSON_RPC:
|
|
146
|
+
return self._error(msg.get("id") if isinstance(msg, dict) else None,
|
|
147
|
+
INVALID_REQUEST, "invalid request")
|
|
148
|
+
method = msg.get("method")
|
|
149
|
+
if not isinstance(method, str):
|
|
150
|
+
return self._error(msg.get("id"), INVALID_REQUEST, "invalid request")
|
|
151
|
+
params = msg.get("params") or {}
|
|
152
|
+
if not isinstance(params, dict):
|
|
153
|
+
return self._error(msg.get("id"), INVALID_PARAMS,
|
|
154
|
+
"params must be an object")
|
|
155
|
+
is_notification = "id" not in msg
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
result = self._dispatch(method, params)
|
|
159
|
+
except MethodNotFound:
|
|
160
|
+
return self._error(msg.get("id"), METHOD_NOT_FOUND,
|
|
161
|
+
f"method not found: {method}")
|
|
162
|
+
except GraphError as exc:
|
|
163
|
+
return self._tool_error(msg.get("id"), method, str(exc))
|
|
164
|
+
except ProposalError as exc:
|
|
165
|
+
return self._tool_error(msg.get("id"), method, str(exc))
|
|
166
|
+
except MissingArgument as exc:
|
|
167
|
+
return self._error(msg.get("id"), INVALID_PARAMS,
|
|
168
|
+
f"missing required argument: {exc}")
|
|
169
|
+
except Exception as exc: # tool failure, not a protocol fault
|
|
170
|
+
return self._tool_error(msg.get("id"), method, str(exc))
|
|
171
|
+
|
|
172
|
+
if is_notification:
|
|
173
|
+
return None
|
|
174
|
+
return self._result(msg["id"], result)
|
|
175
|
+
|
|
176
|
+
def _dispatch(self, method: str, params: dict) -> Any:
|
|
177
|
+
if method == "initialize":
|
|
178
|
+
requested = params.get("protocolVersion")
|
|
179
|
+
return {"protocolVersion": requested if requested in KNOWN_VERSIONS
|
|
180
|
+
else PROTOCOL_VERSION,
|
|
181
|
+
"capabilities": {"tools": {}},
|
|
182
|
+
"serverInfo": SERVER_INFO}
|
|
183
|
+
if method == "ping":
|
|
184
|
+
return {}
|
|
185
|
+
if method == "tools/list":
|
|
186
|
+
return {"tools": [{"name": t["name"],
|
|
187
|
+
"description": t["description"],
|
|
188
|
+
"inputSchema": t["inputSchema"]}
|
|
189
|
+
for t in TOOLS]}
|
|
190
|
+
if method == "tools/call":
|
|
191
|
+
name = params.get("name")
|
|
192
|
+
tool = self.tools.get(name)
|
|
193
|
+
if tool is None:
|
|
194
|
+
raise GraphError(f"unknown tool: {name}")
|
|
195
|
+
args = params.get("arguments") or {}
|
|
196
|
+
if not isinstance(args, dict):
|
|
197
|
+
raise MissingArgument("arguments (must be an object)")
|
|
198
|
+
for req in tool["inputSchema"].get("required", []):
|
|
199
|
+
if req not in args:
|
|
200
|
+
raise MissingArgument(req)
|
|
201
|
+
text = tool["handler"](self.client, args)
|
|
202
|
+
return {"content": [{"type": "text", "text": text}],
|
|
203
|
+
"isError": False}
|
|
204
|
+
# notifications we accept silently
|
|
205
|
+
if method in ("notifications/initialized", "notifications/cancelled"):
|
|
206
|
+
return None
|
|
207
|
+
raise MethodNotFound(method)
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------ json
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _result(msg_id: Any, result: Any) -> str:
|
|
212
|
+
return json.dumps({"jsonrpc": JSON_RPC, "id": msg_id, "result": result},
|
|
213
|
+
ensure_ascii=False)
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _error(msg_id: Any, code: int, message: str) -> str:
|
|
217
|
+
return json.dumps({"jsonrpc": JSON_RPC, "id": msg_id,
|
|
218
|
+
"error": {"code": code, "message": message}},
|
|
219
|
+
ensure_ascii=False)
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _tool_error(msg_id: Any, method: str, message: str) -> str:
|
|
223
|
+
"""A tool that failed is a tool result (isError), not a JSON-RPC
|
|
224
|
+
fault — the transport stays healthy; the kernel's verdict is
|
|
225
|
+
the message."""
|
|
226
|
+
return json.dumps({"jsonrpc": JSON_RPC, "id": msg_id,
|
|
227
|
+
"result": {"content": [{"type": "text",
|
|
228
|
+
"text": message}],
|
|
229
|
+
"isError": True}},
|
|
230
|
+
ensure_ascii=False)
|
|
231
|
+
|
|
232
|
+
# ------------------------------------------------------------------ loop
|
|
233
|
+
def run(self) -> int:
|
|
234
|
+
"""Serve until EOF. One JSON object per line, per the MCP stdio
|
|
235
|
+
framing. Errors go to stderr; stdout carries protocol only."""
|
|
236
|
+
for line in sys.stdin:
|
|
237
|
+
line = line.strip()
|
|
238
|
+
if not line:
|
|
239
|
+
continue
|
|
240
|
+
response = self.handle_line(line)
|
|
241
|
+
if response is not None:
|
|
242
|
+
print(response, flush=True)
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def main(argv: list[str] | None = None) -> int:
|
|
247
|
+
ap = argparse.ArgumentParser(
|
|
248
|
+
prog="forge-mcp",
|
|
249
|
+
description="Forge MCP server: JSON-RPC 2.0 over stdio, six "
|
|
250
|
+
"tools, one SDK call each")
|
|
251
|
+
ap.add_argument("-d", "--dir", default=".",
|
|
252
|
+
help="project directory (default: .)")
|
|
253
|
+
args = ap.parse_args(argv)
|
|
254
|
+
return ForgeMCPServer(args.dir).run()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
sys.exit(main())
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forge-mcp-base
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Forge MCP server: JSON-RPC 2.0 over stdio, six tools, one SDK call each. A transport, nothing more.
|
|
5
|
+
Author-email: Sanjay Davis <psanjuknl@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: forge,mcp,sdk,autonomous-software,json-rpc
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: forge-foundation
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# forge-mcp
|
|
19
|
+
|
|
20
|
+
The MCP server for [Forge](https://github.com/SanjayDavis/Forge) — a
|
|
21
|
+
deterministic, stdlib-only transport that exposes the whole Forge SDK to
|
|
22
|
+
any MCP client. JSON-RPC 2.0 over stdio, six tools, one SDK call each,
|
|
23
|
+
zero business logic.
|
|
24
|
+
|
|
25
|
+
This package is the ecosystem proof behind the SDK boundary: it is a
|
|
26
|
+
*separate, installable package* that consumes only the public
|
|
27
|
+
`forge.*` surface — no kernel internals.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install forge-foundation forge-mcp-base
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Use
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
forge-mcp -d myproject
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or from Python:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
python -m forge_mcp.server -d myproject
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
An MCP client (Claude, Hermes, the `mcp` SDK, any editor) connects over
|
|
48
|
+
stdio and gets six tools — `forge_next`, `forge_context`,
|
|
49
|
+
`forge_propose`, `forge_verify`, `forge_query`, `forge_replay` — each a
|
|
50
|
+
thin pass-through to one SDK method. The kernel decides; this
|
|
51
|
+
translates.
|
|
52
|
+
|
|
53
|
+
## Reference client
|
|
54
|
+
|
|
55
|
+
A walk-through client that proves the server is a real MCP server:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
python -m forge_mcp.mcp_client -d myproject
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
It performs the handshake, lists tools, and drives the six-tool loop
|
|
62
|
+
over the wire.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
forge_mcp/__init__.py
|
|
5
|
+
forge_mcp/mcp_client.py
|
|
6
|
+
forge_mcp/server.py
|
|
7
|
+
forge_mcp_base.egg-info/PKG-INFO
|
|
8
|
+
forge_mcp_base.egg-info/SOURCES.txt
|
|
9
|
+
forge_mcp_base.egg-info/dependency_links.txt
|
|
10
|
+
forge_mcp_base.egg-info/entry_points.txt
|
|
11
|
+
forge_mcp_base.egg-info/requires.txt
|
|
12
|
+
forge_mcp_base.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forge-foundation
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forge_mcp
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "forge-mcp-base"
|
|
7
|
+
version = "0.1.0a1"
|
|
8
|
+
description = "Forge MCP server: JSON-RPC 2.0 over stdio, six tools, one SDK call each. A transport, nothing more."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Sanjay Davis", email = "psanjuknl@gmail.com" }]
|
|
13
|
+
keywords = ["forge", "mcp", "sdk", "autonomous-software", "json-rpc"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
]
|
|
20
|
+
# Depends on the forge-foundation distribution (PyPI name; the import name
|
|
21
|
+
# is "forge"). NEVER depend on plain "forge" — that name is owned by an
|
|
22
|
+
# unrelated Django project on PyPI.
|
|
23
|
+
dependencies = ["forge-foundation"]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
# The MCP server is a standalone process, not a `forge` subcommand — it
|
|
27
|
+
# gets its own console script, like any MCP server binary.
|
|
28
|
+
forge-mcp = "forge_mcp.server:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
packages = ["forge_mcp"]
|