intercept-mcp 0.2.2__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.
intercept_mcp/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.2.2
@@ -0,0 +1,16 @@
1
+ """intercept-mcp: MCP server for the Intercept platform."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def _read_version() -> str:
7
+ here = Path(__file__).parent
8
+ for candidate in (here / "VERSION", here.parent / "VERSION"):
9
+ if candidate.exists():
10
+ return candidate.read_text(encoding="utf-8").strip()
11
+ return "0.0.0+unknown"
12
+
13
+
14
+ __version__ = _read_version()
15
+
16
+ __all__ = ["__version__"]
@@ -0,0 +1,175 @@
1
+ """HTTP client for the Intercept REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from intercept_mcp import __version__
11
+
12
+ DEFAULT_BASE_URL = "https://intercept.hijacksecurity.com"
13
+ _CONNECT_TIMEOUT = 30.0
14
+ _READ_TIMEOUT = 60.0
15
+ _MAX_429_RETRIES = 3
16
+
17
+
18
+ class InterceptApiError(Exception):
19
+ """Raised when the Intercept API returns a non-2xx response."""
20
+
21
+ def __init__(self, status_code: int, message: str) -> None:
22
+ super().__init__(message)
23
+ self.status_code = status_code
24
+ self.message = message
25
+
26
+
27
+ def _redact_headers(headers: dict[str, str] | httpx.Headers) -> dict[str, str]:
28
+ """Return a copy of `headers` with credential-bearing values replaced by `[REDACTED]`."""
29
+ redacted: dict[str, str] = {}
30
+ for key, value in dict(headers).items():
31
+ lk = key.lower()
32
+ if lk in ("authorization", "x-api-key", "cookie", "set-cookie"):
33
+ redacted[key] = "[REDACTED]"
34
+ else:
35
+ redacted[key] = value
36
+ return redacted
37
+
38
+
39
+ class InterceptApiClient:
40
+ """Async HTTP client for the Intercept API."""
41
+
42
+ def __init__(
43
+ self,
44
+ api_key: str,
45
+ base_url: str = DEFAULT_BASE_URL,
46
+ *,
47
+ transport: httpx.AsyncBaseTransport | None = None,
48
+ ) -> None:
49
+ if not api_key:
50
+ raise ValueError("api_key must be a non-empty string")
51
+ self._api_key = api_key
52
+ self._base_url = base_url.rstrip("/")
53
+ self._client = httpx.AsyncClient(
54
+ base_url=self._base_url,
55
+ timeout=httpx.Timeout(connect=_CONNECT_TIMEOUT, read=_READ_TIMEOUT, write=_READ_TIMEOUT, pool=_READ_TIMEOUT),
56
+ headers={
57
+ "X-API-Key": api_key,
58
+ "User-Agent": f"intercept-mcp/{__version__}",
59
+ "Accept": "application/json",
60
+ },
61
+ transport=transport,
62
+ )
63
+
64
+ @property
65
+ def base_url(self) -> str:
66
+ return self._base_url
67
+
68
+ async def aclose(self) -> None:
69
+ await self._client.aclose()
70
+
71
+ async def __aenter__(self) -> "InterceptApiClient":
72
+ return self
73
+
74
+ async def __aexit__(self, exc_type, exc, tb) -> None:
75
+ await self.aclose()
76
+
77
+ async def request(
78
+ self,
79
+ method: str,
80
+ path: str,
81
+ *,
82
+ params: dict[str, Any] | None = None,
83
+ json: dict[str, Any] | None = None,
84
+ ) -> Any:
85
+ """Issue an HTTP request and return the parsed JSON body.
86
+
87
+ Raises `InterceptApiError` on non-2xx responses. Retries up to
88
+ `_MAX_429_RETRIES` times on 429, honoring `Retry-After`.
89
+ """
90
+ clean_params = (
91
+ {k: v for k, v in params.items() if v is not None} if params else None
92
+ )
93
+
94
+ attempts = 0
95
+ while True:
96
+ response = await self._client.request(
97
+ method,
98
+ path,
99
+ params=clean_params,
100
+ json=json,
101
+ )
102
+
103
+ if response.status_code == 429 and attempts < _MAX_429_RETRIES:
104
+ retry_after = _parse_retry_after(response.headers.get("Retry-After"))
105
+ attempts += 1
106
+ await asyncio.sleep(retry_after)
107
+ continue
108
+
109
+ if response.is_success:
110
+ if response.status_code == 204 or not response.content:
111
+ return None
112
+ try:
113
+ return response.json()
114
+ except ValueError as exc:
115
+ raise InterceptApiError(
116
+ response.status_code,
117
+ f"Intercept API returned non-JSON body: {exc}",
118
+ ) from exc
119
+
120
+ raise InterceptApiError(
121
+ response.status_code,
122
+ _format_error(response),
123
+ )
124
+
125
+
126
+ def _parse_retry_after(value: str | None) -> float:
127
+ """Parse a numeric `Retry-After` header into seconds. Falls back to 1.0s."""
128
+ if value is None:
129
+ return 1.0
130
+ try:
131
+ return max(0.0, float(value))
132
+ except (TypeError, ValueError):
133
+ return 1.0
134
+
135
+
136
+ def _format_error(response: httpx.Response) -> str:
137
+ """Translate an HTTP error response into a short, readable message."""
138
+ code = response.status_code
139
+ detail = _extract_detail(response)
140
+ if code == 401:
141
+ return (
142
+ "authentication failed: INTERCEPT_MCP_API_KEY is missing or invalid. "
143
+ "Generate a personal-scope key from Settings -> Integrations."
144
+ )
145
+ if code == 403:
146
+ return f"permission denied{_suffix(detail)}"
147
+ if code == 404:
148
+ return f"not found{_suffix(detail)}"
149
+ if code == 422:
150
+ return f"validation error{_suffix(detail)}"
151
+ if code == 429:
152
+ retry = response.headers.get("Retry-After", "?")
153
+ return f"rate limited, retry after {retry}s"
154
+ if 500 <= code < 600:
155
+ return f"server error {code}{_suffix(detail)}"
156
+ return f"HTTP {code}{_suffix(detail)}"
157
+
158
+
159
+ def _extract_detail(response: httpx.Response) -> str | None:
160
+ """Pull `detail` from a JSON error body, or return None."""
161
+ try:
162
+ body = response.json()
163
+ except ValueError:
164
+ return None
165
+ if isinstance(body, dict):
166
+ detail = body.get("detail")
167
+ if isinstance(detail, str):
168
+ return detail
169
+ if detail is not None:
170
+ return str(detail)
171
+ return None
172
+
173
+
174
+ def _suffix(detail: str | None) -> str:
175
+ return f": {detail}" if detail else ""
@@ -0,0 +1,78 @@
1
+ """JSON logging to stderr with API-key redaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ import sys
9
+ from typing import Any
10
+
11
+ _API_KEY_RE = re.compile(r"hsk_[A-Za-z0-9_\-]{8,}")
12
+ _REDACTED = "[REDACTED]"
13
+
14
+
15
+ def _redact(value: Any) -> Any:
16
+ """Recursively replace API-key-shaped substrings with `[REDACTED]`."""
17
+ if isinstance(value, str):
18
+ return _API_KEY_RE.sub(_REDACTED, value)
19
+ if isinstance(value, dict):
20
+ return {k: _redact(v) for k, v in value.items()}
21
+ if isinstance(value, (list, tuple)):
22
+ return [_redact(v) for v in value]
23
+ return value
24
+
25
+
26
+ class _JsonFormatter(logging.Formatter):
27
+ """Format each record as a single-line JSON object."""
28
+
29
+ def format(self, record: logging.LogRecord) -> str:
30
+ payload: dict[str, Any] = {
31
+ "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
32
+ "level": record.levelname,
33
+ "logger": record.name,
34
+ "msg": _redact(record.getMessage()),
35
+ }
36
+ extras = getattr(record, "extra_fields", None)
37
+ if isinstance(extras, dict):
38
+ payload.update({k: _redact(v) for k, v in extras.items()})
39
+ if record.exc_info:
40
+ payload["exc"] = _redact(self.formatException(record.exc_info))
41
+ return json.dumps(payload, default=str)
42
+
43
+
44
+ class _RedactingFilter(logging.Filter):
45
+ """Strip API-key-shaped substrings from the record message and args."""
46
+
47
+ def filter(self, record: logging.LogRecord) -> bool:
48
+ if isinstance(record.msg, str):
49
+ record.msg = _API_KEY_RE.sub(_REDACTED, record.msg)
50
+ if record.args:
51
+ try:
52
+ record.args = tuple(_redact(a) for a in record.args) # type: ignore[assignment]
53
+ except Exception: # noqa: BLE001
54
+ pass
55
+ return True
56
+
57
+
58
+ def configure_logging(level: int = logging.INFO) -> logging.Logger:
59
+ """Install a stderr-only JSON handler on the `intercept_mcp` logger.
60
+
61
+ Idempotent: replaces any previously installed handler so the stream
62
+ reference stays current when test frameworks swap `sys.stderr`.
63
+ """
64
+ logger = logging.getLogger("intercept_mcp")
65
+ logger.setLevel(level)
66
+ logger.propagate = False
67
+
68
+ for existing in list(logger.handlers):
69
+ if getattr(existing, "_intercept_mcp_handler", False):
70
+ logger.removeHandler(existing)
71
+
72
+ handler = logging.StreamHandler(stream=sys.stderr)
73
+ handler.setFormatter(_JsonFormatter())
74
+ handler.addFilter(_RedactingFilter())
75
+ handler._intercept_mcp_handler = True # type: ignore[attr-defined]
76
+ logger.addHandler(handler)
77
+
78
+ return logger
intercept_mcp/main.py ADDED
@@ -0,0 +1,85 @@
1
+ """Console entry point for the `intercept-mcp` server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import sys
9
+
10
+ import mcp.server.stdio
11
+ from mcp.server.lowlevel import NotificationOptions
12
+ from mcp.server.models import InitializationOptions
13
+
14
+ from intercept_mcp import __version__
15
+ from intercept_mcp.client import DEFAULT_BASE_URL, InterceptApiClient
16
+ from intercept_mcp.logging_setup import configure_logging
17
+ from intercept_mcp.server import SERVER_NAME, build_server
18
+
19
+ API_KEY_ENV = "INTERCEPT_MCP_API_KEY"
20
+ API_URL_ENV = "INTERCEPT_API_URL"
21
+
22
+
23
+ def _missing_key_message() -> str:
24
+ return (
25
+ f"{API_KEY_ENV} is not set. Generate a personal-scope MCP API key "
26
+ "from the Intercept web UI: Settings -> Integrations -> Generate "
27
+ "MCP API Key. Then export it from your shell profile, e.g.:\n"
28
+ f" export {API_KEY_ENV}=hsk_...\n"
29
+ "Or set it in your MCP client config's env block."
30
+ )
31
+
32
+
33
+ async def _serve(api_key: str, base_url: str) -> None:
34
+ log = logging.getLogger("intercept_mcp")
35
+ async with InterceptApiClient(api_key=api_key, base_url=base_url) as client:
36
+ server = build_server(client)
37
+ log.info(
38
+ "starting intercept-mcp",
39
+ extra={
40
+ "extra_fields": {
41
+ "version": __version__,
42
+ "base_url": base_url,
43
+ }
44
+ },
45
+ )
46
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
47
+ await server.run(
48
+ read_stream,
49
+ write_stream,
50
+ InitializationOptions(
51
+ server_name=SERVER_NAME,
52
+ server_version=__version__,
53
+ capabilities=server.get_capabilities(
54
+ notification_options=NotificationOptions(),
55
+ experimental_capabilities={},
56
+ ),
57
+ ),
58
+ )
59
+
60
+
61
+ def run() -> None:
62
+ """Validate environment, configure logging, and run the stdio loop.
63
+
64
+ Exit codes: 0 on clean shutdown, 2 on configuration or fatal error.
65
+ """
66
+ log = configure_logging()
67
+
68
+ api_key = os.environ.get(API_KEY_ENV, "").strip()
69
+ if not api_key:
70
+ print(_missing_key_message(), file=sys.stderr)
71
+ sys.exit(2)
72
+
73
+ base_url = os.environ.get(API_URL_ENV, DEFAULT_BASE_URL).strip() or DEFAULT_BASE_URL
74
+
75
+ try:
76
+ asyncio.run(_serve(api_key=api_key, base_url=base_url))
77
+ except KeyboardInterrupt:
78
+ log.info("shutdown: keyboard interrupt")
79
+ except Exception:
80
+ log.exception("fatal error in stdio loop")
81
+ sys.exit(2)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ run()
@@ -0,0 +1,106 @@
1
+ """MCP protocol layer: wires the tool registry into an MCP Server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+
9
+ import mcp.types as types
10
+ from mcp.server.lowlevel import Server
11
+ from pydantic import ValidationError
12
+
13
+ from intercept_mcp import __version__
14
+ from intercept_mcp.client import InterceptApiClient, InterceptApiError
15
+ from intercept_mcp.tools import ALL_TOOLS, ToolSpec
16
+ from intercept_mcp.utils.sanitize import redact_for_error
17
+
18
+ SERVER_NAME = "intercept-mcp"
19
+
20
+ _log = logging.getLogger("intercept_mcp")
21
+
22
+ _MAX_VALIDATION_HINTS = 5
23
+
24
+
25
+ def _summarize_validation_errors(exc: ValidationError) -> str:
26
+ """Render a short summary of a Pydantic ValidationError with redacted echoes."""
27
+ hints: list[str] = []
28
+ for err in exc.errors()[:_MAX_VALIDATION_HINTS]:
29
+ loc_parts = [str(p) for p in err.get("loc", ())]
30
+ loc = ".".join(loc_parts) or "<root>"
31
+ msg = err.get("msg", "invalid")
32
+ safe_msg = redact_for_error(msg)
33
+ hints.append(f"{loc}: {safe_msg}")
34
+ if len(exc.errors()) > _MAX_VALIDATION_HINTS:
35
+ hints.append(
36
+ f"...and {len(exc.errors()) - _MAX_VALIDATION_HINTS} more"
37
+ )
38
+ return "; ".join(hints)
39
+
40
+
41
+ def build_server(client: InterceptApiClient, tools: list[ToolSpec] | None = None) -> Server:
42
+ """Construct an MCP `Server` bound to the supplied API client.
43
+
44
+ Tools default to `ALL_TOOLS`; tests can pass a subset.
45
+ """
46
+ spec_list = tools if tools is not None else ALL_TOOLS
47
+ by_name: dict[str, ToolSpec] = {s.name: s for s in spec_list}
48
+
49
+ server: Server = Server(SERVER_NAME, version=__version__)
50
+
51
+ @server.list_tools()
52
+ async def _list_tools() -> list[types.Tool]:
53
+ return [
54
+ types.Tool(
55
+ name=spec.name,
56
+ description=spec.description,
57
+ inputSchema=spec.input_schema,
58
+ )
59
+ for spec in spec_list
60
+ ]
61
+
62
+ @server.call_tool()
63
+ async def _call_tool(
64
+ name: str, arguments: dict[str, Any] | None
65
+ ) -> list[types.TextContent]:
66
+ spec = by_name.get(name)
67
+ if spec is None:
68
+ _log.warning(
69
+ "call_tool: unknown tool",
70
+ extra={"extra_fields": {"tool": redact_for_error(name)}},
71
+ )
72
+ raise ValueError(f"unknown tool: {redact_for_error(name)}")
73
+
74
+ try:
75
+ args = spec.input_model.model_validate(arguments or {})
76
+ except ValidationError as exc:
77
+ _log.info(
78
+ "call_tool: invalid arguments",
79
+ extra={
80
+ "extra_fields": {
81
+ "tool": name,
82
+ "errors": exc.errors(),
83
+ }
84
+ },
85
+ )
86
+ summary = _summarize_validation_errors(exc)
87
+ raise ValueError(
88
+ f"invalid arguments for {name}: {summary}"
89
+ ) from exc
90
+
91
+ try:
92
+ result = await spec.handler(client, args)
93
+ except InterceptApiError as exc:
94
+ _log.info(
95
+ "call_tool: api error",
96
+ extra={"extra_fields": {"tool": name, "status": exc.status_code}},
97
+ )
98
+ raise ValueError(redact_for_error(exc.message, truncate=False)) from exc
99
+
100
+ if isinstance(result, str):
101
+ text = result
102
+ else:
103
+ text = json.dumps(result, default=str)
104
+ return [types.TextContent(type="text", text=text)]
105
+
106
+ return server
@@ -0,0 +1,9 @@
1
+ """MCP tool definitions for the Intercept server."""
2
+
3
+ from intercept_mcp.tools.read import READ_TOOLS
4
+ from intercept_mcp.tools.spec import ToolSpec
5
+ from intercept_mcp.tools.write import WRITE_TOOLS
6
+
7
+ ALL_TOOLS: list[ToolSpec] = [*READ_TOOLS, *WRITE_TOOLS]
8
+
9
+ __all__ = ["ALL_TOOLS", "ToolSpec"]