openquantum-mcp 0.1.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
+ """Open Quantum MCP server — agent tools for backends, jobs, and results."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,43 @@
1
+ """Entry point: python -m openquantum_mcp"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main(argv: list[str] | None = None) -> None:
9
+ args = list(sys.argv[1:] if argv is None else argv)
10
+
11
+ if any(a in ("-h", "--help") for a in args):
12
+ print(
13
+ "usage: python -m openquantum_mcp [--help] [--version]\n"
14
+ "\n"
15
+ "Open Quantum MCP server (stdio). Set SDK credentials via\n"
16
+ "OPENQUANTUM_CLIENT_ID / OPENQUANTUM_CLIENT_SECRET or\n"
17
+ "OPENQUANTUM_SDK_KEY, then start this process from an MCP client.\n"
18
+ "\n"
19
+ "Docs: https://docs.openquantum.com/mcp/overview/\n"
20
+ )
21
+ return
22
+
23
+ if any(a in ("-V", "--version") for a in args):
24
+ from . import __version__
25
+
26
+ print(__version__)
27
+ return
28
+
29
+ if args:
30
+ print(
31
+ f"error: unrecognized arguments: {' '.join(args)}\n"
32
+ "Try 'python -m openquantum_mcp --help'.",
33
+ file=sys.stderr,
34
+ )
35
+ raise SystemExit(2)
36
+
37
+ from .server import run
38
+
39
+ run()
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
@@ -0,0 +1,91 @@
1
+ """Credential resolution and SDK client construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Optional, Tuple
9
+
10
+ from openquantum_sdk.auth import ClientCredentials, ClientCredentialsAuth
11
+ from openquantum_sdk.clients import ManagementClient, SchedulerClient
12
+
13
+ from .config import McpConfig
14
+
15
+
16
+ class AuthError(RuntimeError):
17
+ """Raised when credentials are missing or invalid."""
18
+
19
+
20
+ def load_creds_from_file(path: str) -> ClientCredentials:
21
+ with open(path, "r", encoding="utf-8") as f:
22
+ data = json.load(f)
23
+ try:
24
+ return ClientCredentials(
25
+ client_id=data["client_id"], client_secret=data["client_secret"]
26
+ )
27
+ except KeyError as e:
28
+ raise AuthError(f"SDK key file missing field: {e}") from e
29
+
30
+
31
+ def resolve_credentials() -> ClientCredentials:
32
+ """Resolve SDK credentials from environment (same rules as openquantum-sdk CLI)."""
33
+ sdk_key_env = os.getenv("OPENQUANTUM_SDK_KEY")
34
+ if sdk_key_env:
35
+ # Set-but-missing path is a config error, not a silent fallback.
36
+ if not os.path.exists(sdk_key_env):
37
+ raise AuthError(
38
+ f"OPENQUANTUM_SDK_KEY is set to {sdk_key_env!r}, but that path "
39
+ "does not exist. Fix the path or unset OPENQUANTUM_SDK_KEY and "
40
+ "use OPENQUANTUM_CLIENT_ID / OPENQUANTUM_CLIENT_SECRET instead."
41
+ )
42
+ return load_creds_from_file(sdk_key_env)
43
+
44
+ env_id = os.getenv("OPENQUANTUM_CLIENT_ID")
45
+ env_secret = os.getenv("OPENQUANTUM_CLIENT_SECRET")
46
+ if env_id and env_secret:
47
+ return ClientCredentials(client_id=env_id, client_secret=env_secret)
48
+
49
+ raise AuthError(
50
+ "Missing credentials. Set OPENQUANTUM_CLIENT_ID and "
51
+ "OPENQUANTUM_CLIENT_SECRET, or OPENQUANTUM_SDK_KEY to a JSON key file "
52
+ "from the Open Quantum portal (SDK keys)."
53
+ )
54
+
55
+
56
+ @dataclass
57
+ class SdkClients:
58
+ """Paired management + scheduler clients sharing auth."""
59
+
60
+ management: ManagementClient
61
+ scheduler: SchedulerClient
62
+ config: McpConfig
63
+
64
+
65
+ def build_clients(config: Optional[McpConfig] = None) -> SdkClients:
66
+ cfg = config or McpConfig.from_env()
67
+ creds = resolve_credentials()
68
+ auth = ClientCredentialsAuth(
69
+ creds=creds,
70
+ keycloak_base=cfg.keycloak_base,
71
+ realm=cfg.keycloak_realm,
72
+ )
73
+ management = ManagementClient(base_url=cfg.management_base, auth=auth)
74
+ scheduler = SchedulerClient(
75
+ base_url=cfg.scheduler_base,
76
+ auth=auth,
77
+ management_client=management,
78
+ )
79
+ return SdkClients(management=management, scheduler=scheduler, config=cfg)
80
+
81
+
82
+ def redact_secrets(text: str) -> str:
83
+ """Best-effort redaction of credential material from error strings."""
84
+ secret = os.getenv("OPENQUANTUM_CLIENT_SECRET") or ""
85
+ client_id = os.getenv("OPENQUANTUM_CLIENT_ID") or ""
86
+ out = text
87
+ if secret:
88
+ out = out.replace(secret, "[REDACTED_SECRET]")
89
+ if client_id and len(client_id) > 8:
90
+ out = out.replace(client_id, "[REDACTED_CLIENT_ID]")
91
+ return out
@@ -0,0 +1,77 @@
1
+ """Environment-driven configuration for the MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+
9
+ def _env_float(name: str, default: float) -> float:
10
+ raw = os.getenv(name)
11
+ if raw is None or raw.strip() == "":
12
+ return default
13
+ return float(raw)
14
+
15
+
16
+ def _env_int(name: str, default: int) -> int:
17
+ raw = os.getenv(name)
18
+ if raw is None or raw.strip() == "":
19
+ return default
20
+ return int(raw)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class McpConfig:
25
+ """Runtime configuration loaded from environment variables."""
26
+
27
+ max_credits: float = 10.0
28
+ default_wait_timeout_seconds: int = 600
29
+ default_poll_interval_seconds: float = 5.0
30
+ preparation_timeout_seconds: int = 300
31
+ preparation_poll_interval: float = 2.0
32
+ default_job_subcategory_id: str = "oth:oth"
33
+ default_shots: int = 128
34
+ max_result_raw_chars: int = 8_000
35
+ max_calibration_raw_chars: int = 8_000
36
+ submitted_with: str = "mcp"
37
+ management_base: str = "https://management.openquantum.com"
38
+ scheduler_base: str = "https://scheduler.openquantum.com"
39
+ keycloak_base: str = "https://id.openquantum.com"
40
+ keycloak_realm: str = "platform"
41
+
42
+ @classmethod
43
+ def from_env(cls) -> "McpConfig":
44
+ return cls(
45
+ max_credits=_env_float("OPENQUANTUM_MCP_MAX_CREDITS", 10.0),
46
+ default_wait_timeout_seconds=_env_int(
47
+ "OPENQUANTUM_MCP_WAIT_TIMEOUT_SECONDS", 600
48
+ ),
49
+ default_poll_interval_seconds=_env_float(
50
+ "OPENQUANTUM_MCP_POLL_INTERVAL_SECONDS", 5.0
51
+ ),
52
+ preparation_timeout_seconds=_env_int(
53
+ "OPENQUANTUM_MCP_PREP_TIMEOUT_SECONDS", 300
54
+ ),
55
+ default_job_subcategory_id=os.getenv(
56
+ "OPENQUANTUM_MCP_DEFAULT_SUBCATEGORY", "oth:oth"
57
+ ),
58
+ default_shots=_env_int("OPENQUANTUM_MCP_DEFAULT_SHOTS", 128),
59
+ max_result_raw_chars=_env_int(
60
+ "OPENQUANTUM_MCP_MAX_RESULT_RAW_CHARS", 8_000
61
+ ),
62
+ max_calibration_raw_chars=_env_int(
63
+ "OPENQUANTUM_MCP_MAX_CALIBRATION_RAW_CHARS", 8_000
64
+ ),
65
+ management_base=os.getenv(
66
+ "OPENQUANTUM_MANAGEMENT_BASE",
67
+ "https://management.openquantum.com",
68
+ ),
69
+ scheduler_base=os.getenv(
70
+ "OPENQUANTUM_SCHEDULER_BASE",
71
+ "https://scheduler.openquantum.com",
72
+ ),
73
+ keycloak_base=os.getenv(
74
+ "OPENQUANTUM_KEYCLOAK_BASE", "https://id.openquantum.com"
75
+ ),
76
+ keycloak_realm=os.getenv("OPENQUANTUM_KEYCLOAK_REALM", "platform"),
77
+ )
@@ -0,0 +1,50 @@
1
+ """Local-only structured logging for MCP tool calls (no secrets/circuits)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from typing import Any, Dict, Optional
8
+
9
+ _LOG = logging.getLogger("openquantum_mcp")
10
+
11
+
12
+ def configure_logging() -> None:
13
+ if _LOG.handlers:
14
+ return
15
+ handler = logging.StreamHandler(sys.stderr)
16
+ handler.setFormatter(
17
+ logging.Formatter("%(asctime)s %(levelname)s [openquantum_mcp] %(message)s")
18
+ )
19
+ _LOG.addHandler(handler)
20
+ _LOG.setLevel(logging.INFO)
21
+ _LOG.propagate = False
22
+
23
+
24
+ def log_tool(
25
+ name: str,
26
+ *,
27
+ job_id: Optional[str] = None,
28
+ backend: Optional[str] = None,
29
+ shots: Optional[int] = None,
30
+ organization_id: Optional[str] = None,
31
+ extra: Optional[Dict[str, Any]] = None,
32
+ error: Optional[str] = None,
33
+ ) -> None:
34
+ parts = [f"tool={name}"]
35
+ if job_id:
36
+ parts.append(f"job_id={job_id}")
37
+ if backend:
38
+ parts.append(f"backend={backend}")
39
+ if shots is not None:
40
+ parts.append(f"shots={shots}")
41
+ if organization_id:
42
+ parts.append(f"org={organization_id}")
43
+ if extra:
44
+ for k, v in extra.items():
45
+ parts.append(f"{k}={v}")
46
+ if error:
47
+ parts.append(f"error={error}")
48
+ _LOG.error(" ".join(parts))
49
+ else:
50
+ _LOG.info(" ".join(parts))
@@ -0,0 +1,153 @@
1
+ """Spend gates: credit cap, Spark auto-spend, Full requires confirm."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ from openquantum_sdk.enums import ExecutionPlanType
9
+
10
+
11
+ class SpendBlockedError(RuntimeError):
12
+ """Raised when submit is blocked by safety policy."""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SpendDecision:
17
+ allowed: bool
18
+ reason: str
19
+ requires_confirm: bool
20
+ estimated_credits: float
21
+ spark_credits: float
22
+ full_credits: float
23
+ plan_is_public: bool
24
+ would_use_full_credits: bool
25
+
26
+
27
+ def is_public_plan(execution_plan_id: str) -> bool:
28
+ return execution_plan_id == ExecutionPlanType.PUBLIC.value
29
+
30
+
31
+ def evaluate_spend(
32
+ *,
33
+ estimated_credits: float,
34
+ spark_credits: float,
35
+ full_credits: float,
36
+ execution_plan_id: str,
37
+ confirm_spend: bool,
38
+ max_credits: float,
39
+ ) -> SpendDecision:
40
+ """Decide whether a job create is allowed under MCP spend policy.
41
+
42
+ Policy (decided):
43
+ - Hard reject if estimated cost > max_credits (default 10).
44
+ - Free (cost == 0): allow without confirm.
45
+ - Public plan and spark_credits >= cost and cost <= max: auto-allow.
46
+ - Any spend that would consume Full credits: require confirm_spend=True.
47
+ """
48
+ plan_public = is_public_plan(execution_plan_id)
49
+ cost = float(estimated_credits)
50
+
51
+ if cost > max_credits:
52
+ return SpendDecision(
53
+ allowed=False,
54
+ reason=(
55
+ f"Estimated cost {cost} credits exceeds MCP hard cap of "
56
+ f"{max_credits}. Reduce shots/backend cost or raise "
57
+ f"OPENQUANTUM_MCP_MAX_CREDITS (not recommended without care)."
58
+ ),
59
+ requires_confirm=False,
60
+ estimated_credits=cost,
61
+ spark_credits=spark_credits,
62
+ full_credits=full_credits,
63
+ plan_is_public=plan_public,
64
+ would_use_full_credits=True,
65
+ )
66
+
67
+ if cost <= 0:
68
+ return SpendDecision(
69
+ allowed=True,
70
+ reason="Job is free (0 credits).",
71
+ requires_confirm=False,
72
+ estimated_credits=cost,
73
+ spark_credits=spark_credits,
74
+ full_credits=full_credits,
75
+ plan_is_public=plan_public,
76
+ would_use_full_credits=False,
77
+ )
78
+
79
+ # Public + enough Spark → auto-spend
80
+ if plan_public and spark_credits >= cost:
81
+ return SpendDecision(
82
+ allowed=True,
83
+ reason=(
84
+ f"Auto-approved: {cost} credits covered by Spark balance "
85
+ f"({spark_credits})."
86
+ ),
87
+ requires_confirm=False,
88
+ estimated_credits=cost,
89
+ spark_credits=spark_credits,
90
+ full_credits=full_credits,
91
+ plan_is_public=plan_public,
92
+ would_use_full_credits=False,
93
+ )
94
+
95
+ # Would use Full credits (Private plan and/or insufficient Spark)
96
+ would_use_full = True
97
+ if confirm_spend:
98
+ total_available = spark_credits + full_credits
99
+ if total_available < cost:
100
+ return SpendDecision(
101
+ allowed=False,
102
+ reason=(
103
+ f"Insufficient credits: need {cost}, have spark="
104
+ f"{spark_credits} full={full_credits}."
105
+ ),
106
+ requires_confirm=False,
107
+ estimated_credits=cost,
108
+ spark_credits=spark_credits,
109
+ full_credits=full_credits,
110
+ plan_is_public=plan_public,
111
+ would_use_full_credits=would_use_full,
112
+ )
113
+ return SpendDecision(
114
+ allowed=True,
115
+ reason=(
116
+ f"Approved with confirm_spend=true: {cost} credits "
117
+ f"(may use Full credits; spark={spark_credits}, full={full_credits})."
118
+ ),
119
+ requires_confirm=True,
120
+ estimated_credits=cost,
121
+ spark_credits=spark_credits,
122
+ full_credits=full_credits,
123
+ plan_is_public=plan_public,
124
+ would_use_full_credits=would_use_full,
125
+ )
126
+
127
+ why = (
128
+ "Private plan (Full credits only)"
129
+ if not plan_public
130
+ else (
131
+ f"Spark balance ({spark_credits}) is less than cost ({cost}); "
132
+ "would consume Full credits"
133
+ )
134
+ )
135
+ return SpendDecision(
136
+ allowed=False,
137
+ reason=(
138
+ f"Spend confirmation required: {why}. "
139
+ f"Re-call with confirm_spend=true to approve spending up to "
140
+ f"{cost} credits (cap {max_credits})."
141
+ ),
142
+ requires_confirm=True,
143
+ estimated_credits=cost,
144
+ spark_credits=spark_credits,
145
+ full_credits=full_credits,
146
+ plan_is_public=plan_public,
147
+ would_use_full_credits=would_use_full,
148
+ )
149
+
150
+
151
+ def assert_spend_allowed(decision: SpendDecision) -> None:
152
+ if not decision.allowed:
153
+ raise SpendBlockedError(decision.reason)