filingexplorer-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
+ """MCP server for the FilingExplorer API."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from filingexplorer_mcp.server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,174 @@
1
+ """Thin async HTTP client for the FilingExplorer API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+ import httpx
10
+ from mcp.server.fastmcp.exceptions import ToolError
11
+
12
+ from filingexplorer_mcp import __version__
13
+
14
+ DEFAULT_BASE_URL = "https://api.filingexplorer.com"
15
+ TOKEN_ENV = "FILING_EXPLORER_API_TOKEN"
16
+ BASE_URL_ENV = "FILING_EXPLORER_API_URL"
17
+ TIMEOUT_ENV = "FILING_EXPLORER_TIMEOUT"
18
+
19
+
20
+ class ApiError(ToolError):
21
+ """An error response from the API, rendered as a readable tool error."""
22
+
23
+ def __init__(self, status: int, message: str):
24
+ self.status = status
25
+ super().__init__(message)
26
+
27
+
28
+ def segment(value: str) -> str:
29
+ """Percent-encode one path segment (list names may contain spaces or slashes)."""
30
+ return quote(str(value), safe="")
31
+
32
+
33
+ def _base_url() -> str:
34
+ return os.environ.get(BASE_URL_ENV, DEFAULT_BASE_URL).rstrip("/")
35
+
36
+
37
+ def _token() -> str:
38
+ token = os.environ.get(TOKEN_ENV, "").strip()
39
+ if not token:
40
+ raise ApiError(
41
+ 401,
42
+ f"No API token configured. Set the {TOKEN_ENV} environment variable "
43
+ "to your FilingExplorer API token.",
44
+ )
45
+ return token
46
+
47
+
48
+ def _timeout() -> float:
49
+ try:
50
+ return float(os.environ.get(TIMEOUT_ENV, "60"))
51
+ except ValueError:
52
+ return 60.0
53
+
54
+
55
+ def clean_params(params: dict[str, Any] | None) -> list[tuple[str, str]]:
56
+ """Drop unset values, render booleans as true/false, expand lists."""
57
+ out: list[tuple[str, str]] = []
58
+ for key, value in (params or {}).items():
59
+ if value is None or value == "" or value == []:
60
+ continue
61
+ if isinstance(value, (list, tuple)):
62
+ out.extend((key, str(v)) for v in value)
63
+ elif isinstance(value, bool):
64
+ out.append((key, "true" if value else "false"))
65
+ else:
66
+ out.append((key, str(value)))
67
+ return out
68
+
69
+
70
+ def _error_detail(response: httpx.Response) -> tuple[str, Any]:
71
+ try:
72
+ body = response.json()
73
+ except ValueError:
74
+ text = response.text.strip()
75
+ return (text[:300] if text and "<html" not in text.lower() else ""), None
76
+ if isinstance(body, dict):
77
+ err = body.get("error", body.get("errors", body))
78
+ if isinstance(err, str):
79
+ return err, None
80
+ if isinstance(err, dict):
81
+ message = str(err.get("message") or err.get("code") or "")
82
+ hint = err.get("hint")
83
+ if hint:
84
+ message = f"{message} ({hint})" if message else str(hint)
85
+ return message, err.get("details")
86
+ if isinstance(err, list):
87
+ return "; ".join(str(e) for e in err), None
88
+ return "", None
89
+
90
+
91
+ def _raise_for_status(response: httpx.Response) -> None:
92
+ status = response.status_code
93
+ if status < 400:
94
+ return
95
+ message, details = _error_detail(response)
96
+ suffix = f": {message}" if message else ""
97
+ if status == 400:
98
+ text = f"Invalid request (400){suffix}"
99
+ elif status == 401:
100
+ text = (
101
+ "Authentication failed (401): the API token is missing, invalid or expired. "
102
+ f"Check {TOKEN_ENV}."
103
+ )
104
+ elif status == 402:
105
+ text = f"This request needs a paid FilingExplorer plan (402){suffix}"
106
+ elif status == 403:
107
+ text = f"Not permitted for this account (403){suffix}"
108
+ elif status == 404:
109
+ text = f"Not found (404){suffix}"
110
+ elif status == 409:
111
+ text = f"Ambiguous identifier (409){suffix}"
112
+ matches = details.get("matches") if isinstance(details, dict) else None
113
+ if matches:
114
+ options = []
115
+ for m in matches[:10]:
116
+ if isinstance(m, dict):
117
+ parts = [str(m[k]) for k in ("symbol", "exchange", "cusip", "name") if m.get(k)]
118
+ options.append(" / ".join(parts))
119
+ text += ". Candidates: " + "; ".join(options) + ". Retry with a more specific value."
120
+ elif status == 413:
121
+ text = f"Document too large to extract (413){suffix}"
122
+ elif status == 422:
123
+ text = f"Validation failed (422){suffix}"
124
+ if isinstance(details, dict) and details:
125
+ text += " " + "; ".join(
126
+ f"{k} {', '.join(map(str, v)) if isinstance(v, list) else v}"
127
+ for k, v in details.items()
128
+ )
129
+ elif status == 429:
130
+ retry = response.headers.get("Retry-After")
131
+ wait = f" Retry after {retry} seconds." if retry else " Wait a moment and retry."
132
+ text = f"Rate limited by the FilingExplorer API (429).{wait}"
133
+ elif status >= 500:
134
+ text = f"FilingExplorer API error ({status}){suffix}. Try again later."
135
+ else:
136
+ text = f"Request failed ({status}){suffix}"
137
+ raise ApiError(status, text)
138
+
139
+
140
+ async def request(
141
+ method: str,
142
+ path: str,
143
+ *,
144
+ params: dict[str, Any] | None = None,
145
+ body: dict[str, Any] | None = None,
146
+ text: bool = False,
147
+ ) -> Any:
148
+ """Call the API and return parsed JSON (or text when ``text`` is true).
149
+
150
+ Returns ``None`` for 204 responses.
151
+ """
152
+ headers = {
153
+ "Authorization": f"Bearer {_token()}",
154
+ "Accept": "text/plain" if text else "application/json",
155
+ "User-Agent": f"filingexplorer-mcp/{__version__}",
156
+ }
157
+ try:
158
+ async with httpx.AsyncClient(base_url=_base_url(), timeout=_timeout()) as client:
159
+ response = await client.request(
160
+ method, path, params=clean_params(params), json=body, headers=headers
161
+ )
162
+ except httpx.TimeoutException as exc:
163
+ raise ApiError(504, "The FilingExplorer API did not respond in time. Try again.") from exc
164
+ except httpx.HTTPError as exc:
165
+ raise ApiError(503, f"Could not reach the FilingExplorer API: {exc}") from exc
166
+ _raise_for_status(response)
167
+ if response.status_code == 204 or not response.content:
168
+ return None
169
+ if text:
170
+ return response.text
171
+ try:
172
+ return response.json()
173
+ except ValueError as exc:
174
+ raise ApiError(502, "The FilingExplorer API returned an unreadable response.") from exc
@@ -0,0 +1,129 @@
1
+ """Render API payloads as compact, YAML-like text.
2
+
3
+ The goal is a format that is cheap in tokens and easy for a model to read:
4
+ null and empty mapping values are dropped, short scalar lists stay on one line,
5
+ and multi-line strings use a literal block. Inside a list a null stays as
6
+ ``null``: lists are often aligned to periods, and dropping an item would shift
7
+ every later value onto the wrong period.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from typing import Any
15
+
16
+ _PLAIN = re.compile(r"^[A-Za-z0-9_./()%$@+\-][^:#\n]*$")
17
+ _RESERVED = {"true", "false", "null", "yes", "no", "on", "off", "~"}
18
+ _INLINE_LIST_MAX = 120
19
+
20
+
21
+ def _is_empty(value: Any) -> bool:
22
+ if isinstance(value, list):
23
+ return all(item is None for item in value)
24
+ return value is None or value == "" or value == {}
25
+
26
+
27
+ def _scalar(value: Any) -> str:
28
+ if value is None:
29
+ return "null"
30
+ if isinstance(value, bool):
31
+ return "true" if value else "false"
32
+ if isinstance(value, int):
33
+ return str(value)
34
+ if isinstance(value, float):
35
+ return repr(value) if value != int(value) or abs(value) >= 1e16 else str(int(value))
36
+ text = str(value)
37
+ if (
38
+ _PLAIN.match(text)
39
+ and text == text.strip()
40
+ and text.lower() not in _RESERVED
41
+ and not text.endswith(":")
42
+ ):
43
+ return text
44
+ return json.dumps(text, ensure_ascii=False)
45
+
46
+
47
+ def _is_scalar(value: Any) -> bool:
48
+ return not isinstance(value, (dict, list))
49
+
50
+
51
+ def _lines(value: Any, indent: int) -> list[str]:
52
+ pad = " " * indent
53
+ out: list[str] = []
54
+ if isinstance(value, dict):
55
+ for key, item in value.items():
56
+ if _is_empty(item):
57
+ continue
58
+ out.extend(_keyed(str(key), item, indent))
59
+ elif isinstance(value, list):
60
+ for item in value:
61
+ if isinstance(item, dict):
62
+ body = _lines(item, indent + 1)
63
+ if not body:
64
+ continue
65
+ first = body[0].lstrip()
66
+ out.append(f"{pad}- {first}")
67
+ out.extend(body[1:])
68
+ elif isinstance(item, list):
69
+ out.append(f"{pad}-")
70
+ out.extend(_lines(item, indent + 1))
71
+ else:
72
+ out.extend(_block_or_line(f"{pad}- ", item, indent + 1))
73
+ else:
74
+ out.extend(_block_or_line(pad, value, indent))
75
+ return out
76
+
77
+
78
+ def _block_or_line(prefix: str, value: Any, indent: int) -> list[str]:
79
+ if isinstance(value, str) and "\n" in value:
80
+ pad = " " * indent
81
+ return [f"{prefix}|"] + [f"{pad}{line}" if line else "" for line in value.split("\n")]
82
+ return [f"{prefix}{_scalar(value)}"]
83
+
84
+
85
+ def _keyed(key: str, item: Any, indent: int) -> list[str]:
86
+ pad = " " * indent
87
+ label = _scalar(key)
88
+ if isinstance(item, list) and all(_is_scalar(v) for v in item):
89
+ rendered = ", ".join(_scalar(v) for v in item)
90
+ if len(rendered) <= _INLINE_LIST_MAX and not any(
91
+ isinstance(v, str) and "\n" in v for v in item
92
+ ):
93
+ return [f"{pad}{label}: [{rendered}]"]
94
+ if isinstance(item, (dict, list)):
95
+ body = _lines(item, indent + 1)
96
+ return [f"{pad}{label}:"] + body if body else []
97
+ return _block_or_line(f"{pad}{label}: ", item, indent + 1)
98
+
99
+
100
+ def to_text(value: Any) -> str:
101
+ """Render ``value`` as compact YAML-like text."""
102
+ if _is_empty(value):
103
+ return "(no data)"
104
+ return "\n".join(_lines(value, 0))
105
+
106
+
107
+ def page_note(total: int | None, offset: int, returned: int, has_more: bool | None) -> str:
108
+ """One-line pagination footer, or an empty string when everything was returned."""
109
+ more = has_more if has_more is not None else (total is not None and offset + returned < total)
110
+ if not more:
111
+ return ""
112
+ total_part = f" of {total}" if total is not None else ""
113
+ return (
114
+ f"\n\n[showing {offset + 1}-{offset + returned}{total_part}; "
115
+ f"call again with offset={offset + returned} for more]"
116
+ )
117
+
118
+
119
+ def slice_text(text: str, offset: int, max_chars: int) -> str:
120
+ """Return one window of a long text with a header describing the window."""
121
+ total = len(text)
122
+ offset = max(0, offset)
123
+ chunk = text[offset : offset + max_chars]
124
+ end = offset + len(chunk)
125
+ if offset == 0 and end >= total:
126
+ return chunk
127
+ note = f"[characters {offset}-{end} of {total}"
128
+ note += f"; call again with offset={end} for more]" if end < total else "; end of text]"
129
+ return f"{note}\n\n{chunk}"