Skip to content

Commit 28e48e7

Browse files
authored
Merge pull request #4 from MukundaKatta/codex/mcpforge-first-cli
feat: add initial MCPForge CLI
2 parents a0e2d1b + 67b4fcb commit 28e48e7

File tree

6 files changed

+387
-41
lines changed

6 files changed

+387
-41
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.egg-info/
2+
__pycache__/

README.md

Lines changed: 64 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,95 @@
11
# MCPForge
22

3-
MCPForge is an early-stage toolkit for scaffolding, testing, and shipping Model Context Protocol servers faster.
3+
MCPForge is a lightweight toolkit for scaffolding and validating Model Context Protocol servers locally.
44

5-
Model Context Protocol is becoming an important part of modern AI tooling, but the developer workflow around MCP servers is still repetitive. Teams often rebuild the same project structure, validation flow, local testing loop, and deployment glue from scratch. MCPForge exists to make that workflow more repeatable and easier to adopt.
5+
This first slice focuses on two practical workflows:
6+
7+
- `mcpforge init` creates a minimal stdio MCP starter project
8+
- `mcpforge check` runs a smoke test against a local server and validates the basic MCP handshake
69

710
## Why MCPForge
811

9-
The goal of MCPForge is simple: make MCP server development feel more like modern application development.
12+
MCP server development still has a lot of repeated setup work:
13+
14+
- creating a starter project structure
15+
- wiring up a local stdio server loop
16+
- checking whether the server starts and responds correctly
17+
- documenting a runnable local workflow for the next contributor
18+
19+
MCPForge exists to make that first setup and validation loop repeatable.
1020

11-
That means reducing setup friction, encouraging sensible defaults, and creating a smoother path from first prototype to something a team can actually maintain.
21+
## Install
22+
23+
From the repository root:
24+
25+
```bash
26+
python -m pip install -e .
27+
```
1228

13-
## Current Status
29+
## Usage
1430

15-
MCPForge is currently at an early public stage.
31+
Create a starter project:
1632

17-
This repository is being used to define the project direction, document the intended developer experience, and shape the first version of the toolkit. As the implementation grows, this README will evolve from project overview into full setup and usage documentation.
33+
```bash
34+
mcpforge init ./my-server
35+
```
1836

19-
## Project Direction
37+
That command generates:
2038

21-
MCPForge is intended to help with:
39+
- `server.py` with a minimal stdio MCP server
40+
- `README.md` with local run instructions
41+
- `.gitignore` for basic Python artifacts
2242

23-
- scaffolding new MCP servers faster
24-
- standardizing common server patterns
25-
- improving local development and testing workflows
26-
- reducing one-off setup for tools, resources, and transports
27-
- making MCP adoption easier for teams building AI tooling
43+
Run the generated server locally:
2844

29-
## What I Want This Repo To Become
45+
```bash
46+
cd my-server
47+
python server.py
48+
```
3049

31-
Over time, MCPForge should grow into a practical toolkit for:
50+
In another terminal, validate the server:
3251

33-
- bootstrapping new MCP server projects
34-
- validating server behavior locally
35-
- providing reusable templates and examples
36-
- supporting smoother packaging and deployment workflows
37-
- improving the developer experience around MCP as an ecosystem
52+
```bash
53+
mcpforge check .
54+
```
3855

39-
## Why This Matters
56+
Successful output looks like:
4057

41-
If MCP is going to become a durable interface layer for tools and context in AI systems, the surrounding developer workflow needs to become easier to teach, easier to test, and easier to repeat.
58+
```text
59+
PASS: stdio server handshake succeeded.
60+
Server: starter-mcp-server 0.1.0
61+
Verified methods: initialize, tools/list, resources/list, prompts/list
62+
```
4263

43-
MCPForge is my attempt to help build that layer.
64+
## Current Scope
4465

45-
## Roadmap
66+
The current implementation is intentionally small:
4667

47-
Near-term priorities include:
68+
- one scaffold command
69+
- one local stdio smoke-test command
70+
- one minimal project template
4871

49-
- establishing the initial project structure
50-
- defining the first scaffold and template workflow
51-
- adding local testing and validation utilities
52-
- creating example MCP server setups
53-
- documenting a production-minded developer path
72+
That is enough to make the repo usable while keeping the first implementation easy to understand and extend.
5473

55-
## Contributing
74+
## Next Steps
5675

57-
Contributions, ideas, and feedback are welcome, especially around:
76+
Likely next improvements include:
5877

59-
- MCP server templates
60-
- developer workflow pain points
61-
- local testing patterns
62-
- deployment and packaging ideas
63-
- documentation and onboarding improvements
78+
- richer starter templates
79+
- configurable server names and metadata
80+
- deeper MCP protocol validation
81+
- CI-friendly check output
82+
- packaging and deployment helpers
6483

6584
## Project Structure
6685

6786
```text
6887
MCPForge/
69-
└── README.md
88+
├── .gitignore
89+
├── README.md
90+
├── pyproject.toml
91+
└── mcpforge/
92+
├── __init__.py
93+
├── cli.py
94+
└── templates.py
7095
```
71-
72-
The repository is intentionally minimal right now while the first implementation direction is being defined.

mcpforge/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""MCPForge package."""
2+
3+
__all__ = ["__version__"]
4+
5+
__version__ = "0.1.0"

mcpforge/cli.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Command line interface for MCPForge."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
from typing import Any
11+
12+
from .templates import GITIGNORE, STARTER_README, STARTER_SERVER
13+
14+
15+
class CheckError(RuntimeError):
16+
"""Raised when server validation fails."""
17+
18+
19+
def send_message(stream, message: dict[str, Any]) -> None:
20+
encoded = json.dumps(message).encode("utf-8")
21+
stream.write(f"Content-Length: {len(encoded)}\r\n\r\n".encode("utf-8"))
22+
stream.write(encoded)
23+
stream.flush()
24+
25+
26+
def read_message(stream) -> dict[str, Any]:
27+
content_length = None
28+
while True:
29+
line = stream.readline()
30+
if not line:
31+
raise CheckError("Server closed the stdio stream unexpectedly.")
32+
if line in (b"\r\n", b"\n"):
33+
break
34+
header = line.decode("utf-8").strip()
35+
if header.lower().startswith("content-length:"):
36+
content_length = int(header.split(":", 1)[1].strip())
37+
if content_length is None:
38+
raise CheckError("Server response was missing a Content-Length header.")
39+
body = stream.read(content_length)
40+
return json.loads(body.decode("utf-8"))
41+
42+
43+
def create_project(path: Path, force: bool) -> None:
44+
if path.exists() and any(path.iterdir()) and not force:
45+
raise SystemExit(
46+
f"Refusing to initialize non-empty directory: {path}. Use --force to overwrite files."
47+
)
48+
49+
path.mkdir(parents=True, exist_ok=True)
50+
(path / "server.py").write_text(STARTER_SERVER, encoding="utf-8")
51+
(path / "README.md").write_text(STARTER_README, encoding="utf-8")
52+
(path / ".gitignore").write_text(GITIGNORE, encoding="utf-8")
53+
54+
print(f"Created starter MCP server at {path}")
55+
print("Next steps:")
56+
print(f" cd {path}")
57+
print(" python server.py")
58+
print(" mcpforge check .")
59+
60+
61+
def run_check(path: Path) -> None:
62+
server_path = path / "server.py" if path.is_dir() else path
63+
if not server_path.exists():
64+
raise SystemExit(f"Could not find server entry point at {server_path}")
65+
66+
process = subprocess.Popen(
67+
[sys.executable, str(server_path)],
68+
cwd=str(server_path.parent),
69+
stdin=subprocess.PIPE,
70+
stdout=subprocess.PIPE,
71+
stderr=subprocess.PIPE,
72+
)
73+
74+
if process.stdin is None or process.stdout is None or process.stderr is None:
75+
raise SystemExit("Could not start validation process.")
76+
77+
try:
78+
send_message(
79+
process.stdin,
80+
{
81+
"jsonrpc": "2.0",
82+
"id": 1,
83+
"method": "initialize",
84+
"params": {
85+
"protocolVersion": "2025-03-26",
86+
"capabilities": {},
87+
"clientInfo": {"name": "mcpforge", "version": "0.1.0"},
88+
},
89+
},
90+
)
91+
initialize_response = read_message(process.stdout)
92+
result = initialize_response.get("result", {})
93+
if "serverInfo" not in result or "capabilities" not in result:
94+
raise CheckError("Initialize response did not include serverInfo and capabilities.")
95+
96+
send_message(
97+
process.stdin,
98+
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
99+
)
100+
101+
for request_id, method, expected_key in (
102+
(2, "tools/list", "tools"),
103+
(3, "resources/list", "resources"),
104+
(4, "prompts/list", "prompts"),
105+
):
106+
send_message(
107+
process.stdin,
108+
{"jsonrpc": "2.0", "id": request_id, "method": method, "params": {}},
109+
)
110+
response = read_message(process.stdout)
111+
payload = response.get("result", {})
112+
if expected_key not in payload:
113+
raise CheckError(f"{method} did not return a '{expected_key}' payload.")
114+
115+
server_info = result["serverInfo"]
116+
print("PASS: stdio server handshake succeeded.")
117+
print(f"Server: {server_info.get('name', 'unknown')} {server_info.get('version', '')}".strip())
118+
print("Verified methods: initialize, tools/list, resources/list, prompts/list")
119+
except CheckError as exc:
120+
stderr_output = process.stderr.read().decode("utf-8").strip()
121+
print(f"FAIL: {exc}", file=sys.stderr)
122+
if stderr_output:
123+
print("\nServer stderr:\n" + stderr_output, file=sys.stderr)
124+
raise SystemExit(1) from exc
125+
finally:
126+
process.terminate()
127+
try:
128+
process.wait(timeout=2)
129+
except subprocess.TimeoutExpired:
130+
process.kill()
131+
132+
133+
def build_parser() -> argparse.ArgumentParser:
134+
parser = argparse.ArgumentParser(description="Scaffold and validate MCP server projects.")
135+
subparsers = parser.add_subparsers(dest="command", required=True)
136+
137+
init_parser = subparsers.add_parser("init", help="Create a starter MCP server project.")
138+
init_parser.add_argument("path", help="Directory to create.")
139+
init_parser.add_argument("--force", action="store_true", help="Overwrite generated files in a non-empty directory.")
140+
141+
check_parser = subparsers.add_parser("check", help="Run a local stdio smoke check against a server.")
142+
check_parser.add_argument("path", help="Project directory or server.py path.")
143+
144+
return parser
145+
146+
147+
def main(argv: list[str] | None = None) -> int:
148+
parser = build_parser()
149+
args = parser.parse_args(argv)
150+
151+
if args.command == "init":
152+
create_project(Path(args.path).resolve(), args.force)
153+
return 0
154+
155+
if args.command == "check":
156+
run_check(Path(args.path).resolve())
157+
return 0
158+
159+
parser.error(f"Unknown command: {args.command}")
160+
return 1
161+
162+
163+
if __name__ == "__main__":
164+
raise SystemExit(main())

0 commit comments

Comments
 (0)