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.
- filingexplorer_mcp/__init__.py +3 -0
- filingexplorer_mcp/__main__.py +4 -0
- filingexplorer_mcp/client.py +174 -0
- filingexplorer_mcp/formatting.py +129 -0
- filingexplorer_mcp/server.py +1010 -0
- filingexplorer_mcp-0.1.0.dist-info/METADATA +169 -0
- filingexplorer_mcp-0.1.0.dist-info/RECORD +11 -0
- filingexplorer_mcp-0.1.0.dist-info/WHEEL +4 -0
- filingexplorer_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- filingexplorer_mcp-0.1.0.dist-info/licenses/LICENSE +177 -0
- filingexplorer_mcp-0.1.0.dist-info/licenses/NOTICE +10 -0
|
@@ -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}"
|