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.
@@ -0,0 +1,3 @@
1
+ """Capital.com MCP server — a thin FastMCP layer over the capital_cli SDK."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,6 @@
1
+ """Enable `python -m capital_mcp`."""
2
+
3
+ from capital_mcp.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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
+ }