capitalcom-mcp 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- capital_mcp/__init__.py +3 -0
- capital_mcp/__main__.py +6 -0
- capital_mcp/cli.py +157 -0
- capital_mcp/context.py +52 -0
- capital_mcp/serialization.py +18 -0
- capital_mcp/server.py +1653 -0
- capitalcom_mcp-0.2.0.dist-info/METADATA +194 -0
- capitalcom_mcp-0.2.0.dist-info/RECORD +13 -0
- capitalcom_mcp-0.2.0.dist-info/WHEEL +5 -0
- capitalcom_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- capitalcom_mcp-0.2.0.dist-info/licenses/LICENSE +202 -0
- capitalcom_mcp-0.2.0.dist-info/licenses/NOTICE +21 -0
- capitalcom_mcp-0.2.0.dist-info/top_level.txt +1 -0
capital_mcp/__init__.py
ADDED
capital_mcp/__main__.py
ADDED
capital_mcp/cli.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Console entry point for the Capital.com MCP server.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
run — start the server (stdio default, or streamable-HTTP)
|
|
5
|
+
doctor — validate configuration + login WITHOUT printing secrets
|
|
6
|
+
init — interactive wizard that writes a 0600 credentials file
|
|
7
|
+
|
|
8
|
+
run flags fall back to env: CAP_MCP_TRANSPORT, CAP_MCP_HOST, CAP_MCP_PORT.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import getpass
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
19
|
+
DEFAULT_PORT = 8000
|
|
20
|
+
DEFAULT_ENV_PATH = Path.home() / ".config" / "capital-mcp" / ".env"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---- small I/O seams (monkeypatched in tests) ----------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _prompt(label: str, default: str | None = None) -> str:
|
|
27
|
+
suffix = f" [{default}]" if default else ""
|
|
28
|
+
value = input(f"{label}{suffix}: ").strip()
|
|
29
|
+
return value or (default or "")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _prompt_secret(label: str) -> str:
|
|
33
|
+
return getpass.getpass(f"{label}: ").strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_config():
|
|
37
|
+
"""Build the SDK config from env/.env/_CMD (raises on missing creds)."""
|
|
38
|
+
from capital_cli.sdk import CapitalComConfig
|
|
39
|
+
|
|
40
|
+
return CapitalComConfig.from_env()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---- run -----------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
47
|
+
from capital_mcp.server import mcp
|
|
48
|
+
|
|
49
|
+
transport = args.transport or os.environ.get("CAP_MCP_TRANSPORT") or "stdio"
|
|
50
|
+
if transport == "stdio":
|
|
51
|
+
mcp.run(transport="stdio")
|
|
52
|
+
return 0
|
|
53
|
+
host = args.host or os.environ.get("CAP_MCP_HOST") or DEFAULT_HOST
|
|
54
|
+
port = int(args.port or os.environ.get("CAP_MCP_PORT") or DEFAULT_PORT)
|
|
55
|
+
mcp.run(transport="http", host=host, port=port)
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---- doctor --------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _redact(value: str | None) -> str:
|
|
63
|
+
if not value:
|
|
64
|
+
return "(unset)"
|
|
65
|
+
return value[:2] + "***" if len(value) > 2 else "***"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
69
|
+
try:
|
|
70
|
+
cfg = _load_config()
|
|
71
|
+
except Exception as exc: # noqa: BLE001 — report any config failure cleanly
|
|
72
|
+
print(f"x Configuration error: {exc}")
|
|
73
|
+
return 1
|
|
74
|
+
print("Configuration loaded")
|
|
75
|
+
print(f" CAP_ENV = {cfg.cap_env.value}")
|
|
76
|
+
print(f" CAP_API_KEY = {_redact(cfg.cap_api_key)}")
|
|
77
|
+
print(f" CAP_IDENTIFIER = {_redact(cfg.cap_identifier)}")
|
|
78
|
+
print(f" CAP_ALLOW_TRADING= {cfg.cap_allow_trading}")
|
|
79
|
+
print(f" CAP_ALLOWED_EPICS= {cfg.cap_allowed_epics or '(none)'}")
|
|
80
|
+
print(f" base_url = {cfg.base_url}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---- init ----------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write_env_file(
|
|
88
|
+
path: Path, *, env: str, api_key: str, identifier: str, api_password: str
|
|
89
|
+
) -> Path:
|
|
90
|
+
"""Write a 0600 .env with the four core settings; create parent dirs."""
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
body = (
|
|
93
|
+
f"CAP_ENV={env}\n"
|
|
94
|
+
f"CAP_API_KEY={api_key}\n"
|
|
95
|
+
f"CAP_IDENTIFIER={identifier}\n"
|
|
96
|
+
f"CAP_API_PASSWORD={api_password}\n"
|
|
97
|
+
"CAP_ALLOW_TRADING=false\n"
|
|
98
|
+
"CAP_ALLOWED_EPICS=\n"
|
|
99
|
+
)
|
|
100
|
+
path.write_text(body)
|
|
101
|
+
path.chmod(0o600)
|
|
102
|
+
return path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
106
|
+
target = Path(args.path) if args.path else DEFAULT_ENV_PATH
|
|
107
|
+
print("Capital.com MCP - credential setup")
|
|
108
|
+
print("Get your API key at: Settings > API integrations (use a DEMO key first).\n")
|
|
109
|
+
env = _prompt("Environment (demo/live)", "demo")
|
|
110
|
+
api_key = _prompt_secret("CAP_API_KEY")
|
|
111
|
+
identifier = _prompt("CAP_IDENTIFIER (login email)")
|
|
112
|
+
api_password = _prompt_secret("CAP_API_PASSWORD (API key custom password)")
|
|
113
|
+
written = write_env_file(
|
|
114
|
+
target, env=env, api_key=api_key, identifier=identifier, api_password=api_password
|
|
115
|
+
)
|
|
116
|
+
print(f"\nWrote {written} (mode 0600)")
|
|
117
|
+
print("\nAdd this to your MCP client config (no secrets in the client file):\n")
|
|
118
|
+
print(
|
|
119
|
+
' "capitalcom": {\n'
|
|
120
|
+
' "command": "uvx",\n'
|
|
121
|
+
' "args": ["capitalcom-mcp"],\n'
|
|
122
|
+
f' "env": {{ "CAP_ENV_FILE": "{written}" }}\n'
|
|
123
|
+
" }"
|
|
124
|
+
)
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---- parser / main -------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
132
|
+
parser = argparse.ArgumentParser(prog="capitalcom-mcp", description="Capital.com MCP server")
|
|
133
|
+
sub = parser.add_subparsers(dest="command")
|
|
134
|
+
|
|
135
|
+
p_run = sub.add_parser("run", help="start the server")
|
|
136
|
+
p_run.add_argument("--transport", choices=["stdio", "http"], default=None)
|
|
137
|
+
p_run.add_argument("--host", default=None)
|
|
138
|
+
p_run.add_argument("--port", default=None)
|
|
139
|
+
p_run.set_defaults(func=cmd_run)
|
|
140
|
+
|
|
141
|
+
p_doctor = sub.add_parser("doctor", help="validate config + credentials")
|
|
142
|
+
p_doctor.set_defaults(func=cmd_doctor)
|
|
143
|
+
|
|
144
|
+
p_init = sub.add_parser("init", help="interactive credential setup")
|
|
145
|
+
p_init.add_argument("--path", default=None, help="env-file path (default ~/.config/capital-mcp/.env)")
|
|
146
|
+
p_init.set_defaults(func=cmd_init)
|
|
147
|
+
|
|
148
|
+
return parser
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main(argv: list[str] | None = None) -> int:
|
|
152
|
+
parser = build_parser()
|
|
153
|
+
args = parser.parse_args(argv)
|
|
154
|
+
if not getattr(args, "command", None):
|
|
155
|
+
# Bare `capitalcom-mcp` -> run with stdio so client launchers work.
|
|
156
|
+
args = parser.parse_args(["run"])
|
|
157
|
+
return args.func(args) or 0
|
capital_mcp/context.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Shared CapitalComApp lifecycle for the MCP server.
|
|
2
|
+
|
|
3
|
+
The capital_cli SDK uses process-global singletons ("one app per process"),
|
|
4
|
+
which fits an MCP server (a single process). We hold one lazily-constructed
|
|
5
|
+
CapitalComApp. Construction does NOT log in — login happens lazily per tool via
|
|
6
|
+
app.session.ensure_logged_in(). The FastMCP lifespan closes the shared HTTP
|
|
7
|
+
client on shutdown.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from capital_cli.sdk import CapitalComApp
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from fastmcp import FastMCP
|
|
19
|
+
|
|
20
|
+
_app: CapitalComApp | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_app() -> CapitalComApp:
|
|
24
|
+
"""Return the process-wide CapitalComApp, building it on first use."""
|
|
25
|
+
global _app
|
|
26
|
+
if _app is None:
|
|
27
|
+
_app = CapitalComApp()
|
|
28
|
+
return _app
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def reset_app() -> None:
|
|
32
|
+
"""Drop the cached app (tests / re-init)."""
|
|
33
|
+
global _app
|
|
34
|
+
_app = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@asynccontextmanager
|
|
38
|
+
async def lifespan(_server: FastMCP):
|
|
39
|
+
"""FastMCP lifespan: build the app on startup, close its HTTP client on exit.
|
|
40
|
+
|
|
41
|
+
We deliberately do NOT log out on shutdown so the cached session token can
|
|
42
|
+
be reused by the next process (the SDK persists it to a 0600 state file).
|
|
43
|
+
"""
|
|
44
|
+
get_app() # construct eagerly so import/config errors surface at startup
|
|
45
|
+
try:
|
|
46
|
+
yield
|
|
47
|
+
finally:
|
|
48
|
+
global _app
|
|
49
|
+
if _app is not None:
|
|
50
|
+
# CapitalComApp.__aexit__ closes the shared httpx client (no logout).
|
|
51
|
+
await _app.__aexit__(None, None, None)
|
|
52
|
+
_app = None
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Small JSON serialization helpers for SDK return values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def preview_to_dict(preview: Any) -> dict[str, Any]:
|
|
9
|
+
"""Serialize a capital_cli PreviewResult to the MCP tool's response shape."""
|
|
10
|
+
return {
|
|
11
|
+
"preview_id": preview.preview_id,
|
|
12
|
+
"normalized_request": preview.normalized_request,
|
|
13
|
+
"checks": [c.model_dump() for c in preview.checks],
|
|
14
|
+
"all_checks_passed": preview.all_checks_passed,
|
|
15
|
+
"estimated_entry": preview.estimated_entry,
|
|
16
|
+
"estimated_risk_notes": preview.estimated_risk_notes,
|
|
17
|
+
"expires_in_seconds": 120,
|
|
18
|
+
}
|