openscour 0.3.1__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.
- openscour/__init__.py +38 -0
- openscour/client.py +267 -0
- openscour/errors.py +35 -0
- openscour/mcp_server.py +161 -0
- openscour/py.typed +0 -0
- openscour/server/__init__.py +0 -0
- openscour/server/answer.py +56 -0
- openscour/server/cache.py +33 -0
- openscour/server/config.py +59 -0
- openscour/server/fetcher.py +222 -0
- openscour/server/main.py +279 -0
- openscour/server/models.py +54 -0
- openscour/server/netguard.py +110 -0
- openscour/server/providers.py +138 -0
- openscour/server/ranker.py +419 -0
- openscour/server/ratelimit.py +41 -0
- openscour/server/robots.py +77 -0
- openscour/types.py +69 -0
- openscour-0.3.1.dist-info/METADATA +649 -0
- openscour-0.3.1.dist-info/RECORD +23 -0
- openscour-0.3.1.dist-info/WHEEL +4 -0
- openscour-0.3.1.dist-info/entry_points.txt +3 -0
- openscour-0.3.1.dist-info/licenses/LICENSE +21 -0
openscour/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""OpenScour: self-hosted web search for LLMs and agents.
|
|
2
|
+
|
|
3
|
+
from openscour import OpenScour
|
|
4
|
+
|
|
5
|
+
client = OpenScour(api_key="YOUR_API_KEY")
|
|
6
|
+
results = client.search("Latest AWS Bedrock updates", max_results=10)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from openscour.client import AsyncOpenScour, OpenScour
|
|
10
|
+
from openscour.errors import (
|
|
11
|
+
APIConnectionError,
|
|
12
|
+
APIStatusError,
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
InvalidRequestError,
|
|
15
|
+
NoResultsError,
|
|
16
|
+
OpenScourError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
)
|
|
19
|
+
from openscour.types import ExtractResponse, ExtractResult, FailedResult, SearchResponse, SearchResult
|
|
20
|
+
|
|
21
|
+
__version__ = "0.3.1"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"OpenScour",
|
|
25
|
+
"AsyncOpenScour",
|
|
26
|
+
"SearchResponse",
|
|
27
|
+
"SearchResult",
|
|
28
|
+
"ExtractResponse",
|
|
29
|
+
"ExtractResult",
|
|
30
|
+
"FailedResult",
|
|
31
|
+
"OpenScourError",
|
|
32
|
+
"APIConnectionError",
|
|
33
|
+
"APIStatusError",
|
|
34
|
+
"AuthenticationError",
|
|
35
|
+
"InvalidRequestError",
|
|
36
|
+
"NoResultsError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
]
|
openscour/client.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from openscour.errors import (
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
APIStatusError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
InvalidRequestError,
|
|
16
|
+
NoResultsError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
)
|
|
19
|
+
from openscour.types import ExtractResponse, SearchResponse
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "http://localhost:8000"
|
|
22
|
+
DEFAULT_TIMEOUT = 60.0
|
|
23
|
+
DEFAULT_MAX_RETRIES = 2
|
|
24
|
+
# Longest wait between retries; a longer Retry-After is raised to the caller instead.
|
|
25
|
+
MAX_RETRY_WAIT = 30.0
|
|
26
|
+
|
|
27
|
+
# Connection failures that happen before the server could act on the request.
|
|
28
|
+
# Read timeouts are not retried: the search may still be running.
|
|
29
|
+
_RETRYABLE_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout, httpx.RemoteProtocolError)
|
|
30
|
+
|
|
31
|
+
# Module-level so tests can skip the waiting.
|
|
32
|
+
_sleep = time.sleep
|
|
33
|
+
_async_sleep = asyncio.sleep
|
|
34
|
+
|
|
35
|
+
SearchDepth = Literal["basic", "advanced"]
|
|
36
|
+
Topic = Literal["general", "news"]
|
|
37
|
+
TimeRange = Literal["day", "week", "month", "year"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _search_body(
|
|
41
|
+
query: str,
|
|
42
|
+
search_depth: SearchDepth,
|
|
43
|
+
topic: Topic,
|
|
44
|
+
max_results: int,
|
|
45
|
+
include_answer: bool,
|
|
46
|
+
include_raw_content: bool,
|
|
47
|
+
include_domains: list[str] | None,
|
|
48
|
+
exclude_domains: list[str] | None,
|
|
49
|
+
time_range: TimeRange | None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"query": query,
|
|
53
|
+
"search_depth": search_depth,
|
|
54
|
+
"topic": topic,
|
|
55
|
+
"max_results": max_results,
|
|
56
|
+
"include_answer": include_answer,
|
|
57
|
+
"include_raw_content": include_raw_content,
|
|
58
|
+
"include_domains": include_domains or [],
|
|
59
|
+
"exclude_domains": exclude_domains or [],
|
|
60
|
+
"time_range": time_range,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_body(urls: str | list[str], query: str | None, chunks_per_source: int) -> dict[str, Any]:
|
|
65
|
+
body: dict[str, Any] = {"urls": [urls] if isinstance(urls, str) else urls}
|
|
66
|
+
if query:
|
|
67
|
+
body["query"] = query
|
|
68
|
+
body["chunks_per_source"] = chunks_per_source
|
|
69
|
+
return body
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_json(resp: httpx.Response) -> bool:
|
|
73
|
+
return resp.headers.get("content-type", "").startswith("application/json")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _retry_after(resp: httpx.Response) -> float | None:
|
|
77
|
+
try:
|
|
78
|
+
return max(0.0, float(resp.headers["retry-after"]))
|
|
79
|
+
except (KeyError, ValueError):
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _retry_wait(attempt: int, resp: httpx.Response | None = None) -> float | None:
|
|
84
|
+
"""Seconds to wait before retrying, or None if this outcome should not be retried."""
|
|
85
|
+
if resp is not None:
|
|
86
|
+
# OpenScour's own 502 means no search results, which a retry won't change;
|
|
87
|
+
# a 502 page from a proxy in front of it is transient.
|
|
88
|
+
transient = resp.status_code in (429, 503, 504) or (resp.status_code == 502 and not _is_json(resp))
|
|
89
|
+
if not transient:
|
|
90
|
+
return None
|
|
91
|
+
if (wait := _retry_after(resp)) is not None:
|
|
92
|
+
return wait if wait <= MAX_RETRY_WAIT else None
|
|
93
|
+
# Exponential backoff with jitter, so many clients don't retry in step.
|
|
94
|
+
return min(MAX_RETRY_WAIT, 0.5 * 2**attempt) * random.uniform(0.5, 1.0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _raise_for_status(resp: httpx.Response) -> None:
|
|
98
|
+
if resp.is_success:
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
body = resp.json()
|
|
102
|
+
detail = body.get("detail", body) if isinstance(body, dict) else body
|
|
103
|
+
except ValueError:
|
|
104
|
+
body = detail = resp.text
|
|
105
|
+
message = detail if isinstance(detail, str) else str(detail)
|
|
106
|
+
if resp.status_code == 429:
|
|
107
|
+
raise RateLimitError(message, 429, body, retry_after=_retry_after(resp))
|
|
108
|
+
if resp.status_code == 502 and not _is_json(resp):
|
|
109
|
+
raise APIStatusError(message, 502, body)
|
|
110
|
+
error = {401: AuthenticationError, 422: InvalidRequestError, 502: NoResultsError}.get(
|
|
111
|
+
resp.status_code, APIStatusError
|
|
112
|
+
)
|
|
113
|
+
raise error(message, resp.status_code, body)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _BaseClient:
|
|
117
|
+
def __init__(self, api_key: str | None, base_url: str | None, max_retries: int):
|
|
118
|
+
self.max_retries = max_retries
|
|
119
|
+
self.api_key = api_key if api_key is not None else os.environ.get("OPENSCOUR_API_KEY")
|
|
120
|
+
self.base_url = (base_url or os.environ.get("OPENSCOUR_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
121
|
+
|
|
122
|
+
def _headers(self) -> dict[str, str]:
|
|
123
|
+
return {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class OpenScour(_BaseClient):
|
|
127
|
+
"""Client for an OpenScour server.
|
|
128
|
+
|
|
129
|
+
>>> client = OpenScour(api_key="...", base_url="http://localhost:8000")
|
|
130
|
+
>>> response = client.search("Latest AWS Bedrock updates", max_results=10)
|
|
131
|
+
>>> response.results[0].content
|
|
132
|
+
|
|
133
|
+
``api_key`` and ``base_url`` default to the ``OPENSCOUR_API_KEY`` and
|
|
134
|
+
``OPENSCOUR_BASE_URL`` environment variables. Requests that fail to connect
|
|
135
|
+
or get 429, 503 or 504 are retried up to ``max_retries`` times, honoring the
|
|
136
|
+
server's Retry-After.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
api_key: str | None = None,
|
|
142
|
+
base_url: str | None = None,
|
|
143
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
144
|
+
http_client: httpx.Client | None = None,
|
|
145
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
146
|
+
):
|
|
147
|
+
super().__init__(api_key, base_url, max_retries)
|
|
148
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
149
|
+
|
|
150
|
+
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
151
|
+
attempt = 0
|
|
152
|
+
while True:
|
|
153
|
+
try:
|
|
154
|
+
resp = self._client.post(f"{self.base_url}{path}", json=body, headers=self._headers())
|
|
155
|
+
except httpx.HTTPError as e:
|
|
156
|
+
if isinstance(e, _RETRYABLE_ERRORS) and attempt < self.max_retries:
|
|
157
|
+
_sleep(_retry_wait(attempt))
|
|
158
|
+
attempt += 1
|
|
159
|
+
continue
|
|
160
|
+
raise APIConnectionError(f"Could not reach OpenScour at {self.base_url}: {e}") from e
|
|
161
|
+
if attempt < self.max_retries and (wait := _retry_wait(attempt, resp)) is not None:
|
|
162
|
+
_sleep(wait)
|
|
163
|
+
attempt += 1
|
|
164
|
+
continue
|
|
165
|
+
_raise_for_status(resp)
|
|
166
|
+
return resp.json()
|
|
167
|
+
|
|
168
|
+
def search(
|
|
169
|
+
self,
|
|
170
|
+
query: str,
|
|
171
|
+
*,
|
|
172
|
+
search_depth: SearchDepth = "basic",
|
|
173
|
+
topic: Topic = "general",
|
|
174
|
+
max_results: int = 5,
|
|
175
|
+
include_answer: bool = False,
|
|
176
|
+
include_raw_content: bool = False,
|
|
177
|
+
include_domains: list[str] | None = None,
|
|
178
|
+
exclude_domains: list[str] | None = None,
|
|
179
|
+
time_range: TimeRange | None = None,
|
|
180
|
+
) -> SearchResponse:
|
|
181
|
+
body = _search_body(
|
|
182
|
+
query, search_depth, topic, max_results, include_answer,
|
|
183
|
+
include_raw_content, include_domains, exclude_domains, time_range,
|
|
184
|
+
) # fmt: skip
|
|
185
|
+
return SearchResponse.from_dict(self._post("/search", body))
|
|
186
|
+
|
|
187
|
+
def extract(
|
|
188
|
+
self, urls: str | list[str], *, query: str | None = None, chunks_per_source: int = 3
|
|
189
|
+
) -> ExtractResponse:
|
|
190
|
+
"""Clean text of each page; with ``query``, only its most relevant passages."""
|
|
191
|
+
return ExtractResponse.from_dict(self._post("/extract", _extract_body(urls, query, chunks_per_source)))
|
|
192
|
+
|
|
193
|
+
def close(self) -> None:
|
|
194
|
+
self._client.close()
|
|
195
|
+
|
|
196
|
+
def __enter__(self) -> OpenScour:
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
def __exit__(self, *exc: object) -> None:
|
|
200
|
+
self.close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class AsyncOpenScour(_BaseClient):
|
|
204
|
+
"""Async version of :class:`OpenScour`; same methods, awaited."""
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
api_key: str | None = None,
|
|
209
|
+
base_url: str | None = None,
|
|
210
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
211
|
+
http_client: httpx.AsyncClient | None = None,
|
|
212
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
213
|
+
):
|
|
214
|
+
super().__init__(api_key, base_url, max_retries)
|
|
215
|
+
self._client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
216
|
+
|
|
217
|
+
async def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
218
|
+
attempt = 0
|
|
219
|
+
while True:
|
|
220
|
+
try:
|
|
221
|
+
resp = await self._client.post(f"{self.base_url}{path}", json=body, headers=self._headers())
|
|
222
|
+
except httpx.HTTPError as e:
|
|
223
|
+
if isinstance(e, _RETRYABLE_ERRORS) and attempt < self.max_retries:
|
|
224
|
+
await _async_sleep(_retry_wait(attempt))
|
|
225
|
+
attempt += 1
|
|
226
|
+
continue
|
|
227
|
+
raise APIConnectionError(f"Could not reach OpenScour at {self.base_url}: {e}") from e
|
|
228
|
+
if attempt < self.max_retries and (wait := _retry_wait(attempt, resp)) is not None:
|
|
229
|
+
await _async_sleep(wait)
|
|
230
|
+
attempt += 1
|
|
231
|
+
continue
|
|
232
|
+
_raise_for_status(resp)
|
|
233
|
+
return resp.json()
|
|
234
|
+
|
|
235
|
+
async def search(
|
|
236
|
+
self,
|
|
237
|
+
query: str,
|
|
238
|
+
*,
|
|
239
|
+
search_depth: SearchDepth = "basic",
|
|
240
|
+
topic: Topic = "general",
|
|
241
|
+
max_results: int = 5,
|
|
242
|
+
include_answer: bool = False,
|
|
243
|
+
include_raw_content: bool = False,
|
|
244
|
+
include_domains: list[str] | None = None,
|
|
245
|
+
exclude_domains: list[str] | None = None,
|
|
246
|
+
time_range: TimeRange | None = None,
|
|
247
|
+
) -> SearchResponse:
|
|
248
|
+
body = _search_body(
|
|
249
|
+
query, search_depth, topic, max_results, include_answer,
|
|
250
|
+
include_raw_content, include_domains, exclude_domains, time_range,
|
|
251
|
+
) # fmt: skip
|
|
252
|
+
return SearchResponse.from_dict(await self._post("/search", body))
|
|
253
|
+
|
|
254
|
+
async def extract(
|
|
255
|
+
self, urls: str | list[str], *, query: str | None = None, chunks_per_source: int = 3
|
|
256
|
+
) -> ExtractResponse:
|
|
257
|
+
"""Clean text of each page; with ``query``, only its most relevant passages."""
|
|
258
|
+
return ExtractResponse.from_dict(await self._post("/extract", _extract_body(urls, query, chunks_per_source)))
|
|
259
|
+
|
|
260
|
+
async def close(self) -> None:
|
|
261
|
+
await self._client.aclose()
|
|
262
|
+
|
|
263
|
+
async def __aenter__(self) -> AsyncOpenScour:
|
|
264
|
+
return self
|
|
265
|
+
|
|
266
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
267
|
+
await self.close()
|
openscour/errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
class OpenScourError(Exception):
|
|
2
|
+
"""Base class for all errors raised by the OpenScour client."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class APIConnectionError(OpenScourError):
|
|
6
|
+
"""The OpenScour server could not be reached."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class APIStatusError(OpenScourError):
|
|
10
|
+
"""The server returned an error response."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, status_code: int, body: object = None):
|
|
13
|
+
super().__init__(f"{status_code}: {message}")
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self.body = body
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthenticationError(APIStatusError):
|
|
19
|
+
"""The API key is missing or invalid (HTTP 401)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidRequestError(APIStatusError):
|
|
23
|
+
"""The request was rejected, e.g. an empty query (HTTP 422)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NoResultsError(APIStatusError):
|
|
27
|
+
"""No search backend returned results (HTTP 502)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RateLimitError(APIStatusError):
|
|
31
|
+
"""Too many requests (HTTP 429). ``retry_after`` is the seconds the server asked to wait, if it said."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, status_code: int, body: object = None, retry_after: float | None = None):
|
|
34
|
+
super().__init__(message, status_code, body)
|
|
35
|
+
self.retry_after = retry_after
|
openscour/mcp_server.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""MCP server that gives AI agents OpenScour's search and extract as tools.
|
|
2
|
+
|
|
3
|
+
Runs over stdio and talks to an OpenScour server through the Python client,
|
|
4
|
+
configured with OPENSCOUR_BASE_URL and OPENSCOUR_API_KEY.
|
|
5
|
+
|
|
6
|
+
claude mcp add openscour -- openscour-mcp
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections.abc import AsyncIterator
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from mcp.server.mcpserver import MCPServer
|
|
17
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
18
|
+
from mcp.types import ToolAnnotations
|
|
19
|
+
|
|
20
|
+
from openscour.client import AsyncOpenScour
|
|
21
|
+
from openscour.errors import APIConnectionError, OpenScourError
|
|
22
|
+
from openscour.types import ExtractResponse, SearchResponse
|
|
23
|
+
|
|
24
|
+
INSTRUCTIONS = (
|
|
25
|
+
"OpenScour searches the web and returns ranked passages from real pages. "
|
|
26
|
+
"Use `search` for current events, recent releases, facts you are unsure of, "
|
|
27
|
+
"or anything after your training data. Use `extract` to read the full text "
|
|
28
|
+
"of specific URLs. Cite the URLs of the passages you rely on."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Upper bound on extracted page text per URL, so one long page can't flood the context.
|
|
32
|
+
MAX_EXTRACT_CHARS = 20_000
|
|
33
|
+
|
|
34
|
+
server = MCPServer(name="openscour", instructions=INSTRUCTIONS)
|
|
35
|
+
|
|
36
|
+
_READ_ONLY_WEB = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# One client for the session, so tool calls reuse open connections. Tied to the
|
|
40
|
+
# event loop it was made on, since an async HTTP client can't move between loops.
|
|
41
|
+
_shared: tuple[asyncio.AbstractEventLoop, AsyncOpenScour] | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _shared_client() -> AsyncOpenScour:
|
|
45
|
+
global _shared
|
|
46
|
+
loop = asyncio.get_running_loop()
|
|
47
|
+
if _shared is None or _shared[0] is not loop:
|
|
48
|
+
_shared = (loop, AsyncOpenScour())
|
|
49
|
+
return _shared[1]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@asynccontextmanager
|
|
53
|
+
async def _client() -> AsyncIterator[AsyncOpenScour]:
|
|
54
|
+
"""The shared OpenScour client, with errors that reach the agent as readable tool errors.
|
|
55
|
+
|
|
56
|
+
Other exceptions only show the agent "Error executing tool", which gives it
|
|
57
|
+
nothing to act on (e.g. that the OpenScour server isn't running).
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
yield _shared_client()
|
|
61
|
+
except APIConnectionError as e:
|
|
62
|
+
raise ToolError(f"{e}. Is the OpenScour server running?") from e
|
|
63
|
+
except OpenScourError as e:
|
|
64
|
+
raise ToolError(str(e)) from e
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_search(response: SearchResponse) -> str:
|
|
68
|
+
if not response.results:
|
|
69
|
+
return f"No results for {response.query!r}."
|
|
70
|
+
parts = []
|
|
71
|
+
if response.answer:
|
|
72
|
+
parts.append(f"Answer: {response.answer}")
|
|
73
|
+
for i, r in enumerate(response.results, start=1):
|
|
74
|
+
lines = [f"[{i}] {r.title}", f"URL: {r.url}"]
|
|
75
|
+
if r.published_date:
|
|
76
|
+
lines.append(f"Published: {r.published_date}")
|
|
77
|
+
lines.append(r.content)
|
|
78
|
+
if r.raw_content:
|
|
79
|
+
lines.append(f"Full text:\n{r.raw_content[:MAX_EXTRACT_CHARS]}")
|
|
80
|
+
parts.append("\n".join(lines))
|
|
81
|
+
return "\n\n".join(parts)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def format_extract(response: ExtractResponse) -> str:
|
|
85
|
+
parts = []
|
|
86
|
+
for r in response.results:
|
|
87
|
+
text = r.raw_content
|
|
88
|
+
if len(text) > MAX_EXTRACT_CHARS:
|
|
89
|
+
text = text[:MAX_EXTRACT_CHARS] + f"\n[... truncated, {len(r.raw_content)} characters in total]"
|
|
90
|
+
parts.append(f"URL: {r.url}\n{text}")
|
|
91
|
+
for f in response.failed_results:
|
|
92
|
+
parts.append(f"URL: {f.url}\nCould not extract: {f.error}")
|
|
93
|
+
return "\n\n---\n\n".join(parts) or "No URLs were extracted."
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@server.tool(
|
|
97
|
+
description=(
|
|
98
|
+
"Search the web and get the most relevant passage from each result page. "
|
|
99
|
+
"Use for current or factual information. Results are numbered with title, URL and passage."
|
|
100
|
+
),
|
|
101
|
+
annotations=_READ_ONLY_WEB,
|
|
102
|
+
)
|
|
103
|
+
async def search(
|
|
104
|
+
query: str,
|
|
105
|
+
max_results: int = 5,
|
|
106
|
+
search_depth: Literal["basic", "advanced"] = "basic",
|
|
107
|
+
topic: Literal["general", "news"] = "general",
|
|
108
|
+
time_range: Literal["day", "week", "month", "year"] | None = None,
|
|
109
|
+
include_domains: list[str] | None = None,
|
|
110
|
+
exclude_domains: list[str] | None = None,
|
|
111
|
+
) -> str:
|
|
112
|
+
"""
|
|
113
|
+
Args:
|
|
114
|
+
query: What to search for, e.g. "Latest AWS Bedrock updates".
|
|
115
|
+
max_results: Number of results, 1 to 20.
|
|
116
|
+
search_depth: "advanced" reads more pages and returns more passages per result; slower.
|
|
117
|
+
topic: "news" for recent news coverage.
|
|
118
|
+
time_range: Only results from the last day, week, month or year.
|
|
119
|
+
include_domains: Only return results from these domains.
|
|
120
|
+
exclude_domains: Never return results from these domains.
|
|
121
|
+
"""
|
|
122
|
+
async with _client() as client:
|
|
123
|
+
response = await client.search(
|
|
124
|
+
query,
|
|
125
|
+
max_results=max_results,
|
|
126
|
+
search_depth=search_depth,
|
|
127
|
+
topic=topic,
|
|
128
|
+
time_range=time_range,
|
|
129
|
+
include_domains=include_domains,
|
|
130
|
+
exclude_domains=exclude_domains,
|
|
131
|
+
)
|
|
132
|
+
return format_search(response)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@server.tool(
|
|
136
|
+
description=(
|
|
137
|
+
"Read web pages. Pass `query` with what you are looking for to get only the most "
|
|
138
|
+
"relevant passages of each page (short, recommended); without it you get the full "
|
|
139
|
+
"page text, which can be very long."
|
|
140
|
+
),
|
|
141
|
+
annotations=_READ_ONLY_WEB,
|
|
142
|
+
)
|
|
143
|
+
async def extract(urls: list[str], query: str | None = None, chunks_per_source: int = 3) -> str:
|
|
144
|
+
"""
|
|
145
|
+
Args:
|
|
146
|
+
urls: Up to 20 http(s) URLs to read.
|
|
147
|
+
query: What you need from the pages, e.g. "new Bedrock models in October 2026".
|
|
148
|
+
chunks_per_source: With a query, how many passages to return per page (1 to 10).
|
|
149
|
+
"""
|
|
150
|
+
async with _client() as client:
|
|
151
|
+
response = await client.extract(urls, query=query, chunks_per_source=chunks_per_source)
|
|
152
|
+
return format_extract(response)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main() -> None:
|
|
156
|
+
"""Entry point for the ``openscour-mcp`` command."""
|
|
157
|
+
server.run("stdio")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
main()
|
openscour/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Optional answer synthesis with Claude (Amazon Bedrock or the Claude API)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
|
|
6
|
+
import anthropic
|
|
7
|
+
|
|
8
|
+
from openscour.server.config import settings
|
|
9
|
+
from openscour.server.ranker import Ranked
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
SYSTEM = (
|
|
14
|
+
"You answer a search query using only the numbered sources provided. "
|
|
15
|
+
"Write a direct, factual answer in 1-4 sentences. Cite sources inline as [1], [2]. "
|
|
16
|
+
"If the sources do not contain the answer, say so briefly."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@lru_cache(maxsize=1)
|
|
21
|
+
def _client():
|
|
22
|
+
if settings.answer_provider == "bedrock":
|
|
23
|
+
return anthropic.AsyncAnthropicBedrockMantle(aws_region=settings.aws_region)
|
|
24
|
+
return anthropic.AsyncAnthropic()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _model() -> str:
|
|
28
|
+
m = settings.answer_model
|
|
29
|
+
if settings.answer_provider == "bedrock" and not m.startswith("anthropic."):
|
|
30
|
+
return f"anthropic.{m}"
|
|
31
|
+
return m
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def generate_answer(query: str, results: list[Ranked]) -> str | None:
|
|
35
|
+
if settings.answer_provider == "none" or not results:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
sources = "\n\n".join(
|
|
39
|
+
f"[{i}] {r.doc.title}\n{r.doc.url}\n{r.content}" for i, r in enumerate(results, start=1)
|
|
40
|
+
)
|
|
41
|
+
messages = [{"role": "user", "content": f"Sources:\n\n{sources}\n\nQuery: {query}"}]
|
|
42
|
+
try:
|
|
43
|
+
response = await _client().messages.create(
|
|
44
|
+
model=_model(),
|
|
45
|
+
max_tokens=8000,
|
|
46
|
+
system=SYSTEM,
|
|
47
|
+
output_config={"effort": "low"},
|
|
48
|
+
messages=messages,
|
|
49
|
+
)
|
|
50
|
+
except anthropic.APIError as e:
|
|
51
|
+
log.warning("answer generation failed: %s", e)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
if response.stop_reason == "refusal":
|
|
55
|
+
return None
|
|
56
|
+
return "".join(b.text for b in response.content if b.type == "text").strip() or None
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections import OrderedDict
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TTLCache:
|
|
7
|
+
"""Small in-process LRU cache with per-entry expiry.
|
|
8
|
+
|
|
9
|
+
Good enough for a single container. Swap for Redis (ElastiCache) once
|
|
10
|
+
you run more than one API task.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, ttl_seconds: int, max_items: int = 2048):
|
|
14
|
+
self.ttl = ttl_seconds
|
|
15
|
+
self.max_items = max_items
|
|
16
|
+
self._data: OrderedDict[str, tuple[float, Any]] = OrderedDict()
|
|
17
|
+
|
|
18
|
+
def get(self, key: str) -> Any | None:
|
|
19
|
+
item = self._data.get(key)
|
|
20
|
+
if item is None:
|
|
21
|
+
return None
|
|
22
|
+
expires_at, value = item
|
|
23
|
+
if expires_at < time.monotonic():
|
|
24
|
+
del self._data[key]
|
|
25
|
+
return None
|
|
26
|
+
self._data.move_to_end(key)
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
def set(self, key: str, value: Any) -> None:
|
|
30
|
+
self._data[key] = (time.monotonic() + self.ttl, value)
|
|
31
|
+
self._data.move_to_end(key)
|
|
32
|
+
while len(self._data) > self.max_items:
|
|
33
|
+
self._data.popitem(last=False)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
4
|
+
|
|
5
|
+
from openscour import __version__
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Settings(BaseSettings):
|
|
9
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
10
|
+
|
|
11
|
+
# Comma-separated list of keys clients must send as "Authorization: Bearer <key>".
|
|
12
|
+
# Leave empty to disable auth (local development).
|
|
13
|
+
api_keys: str = ""
|
|
14
|
+
|
|
15
|
+
searxng_url: str = "http://localhost:8888"
|
|
16
|
+
# Used only when SearXNG errors out or returns nothing.
|
|
17
|
+
duckduckgo_fallback: bool = True
|
|
18
|
+
|
|
19
|
+
# include_answer backend. "none" makes include_answer return null.
|
|
20
|
+
answer_provider: Literal["none", "bedrock", "anthropic"] = "none"
|
|
21
|
+
answer_model: str = "claude-opus-5-5"
|
|
22
|
+
aws_region: str = "us-east-1"
|
|
23
|
+
|
|
24
|
+
fetch_timeout: float = 8.0
|
|
25
|
+
fetch_concurrency: int = 10
|
|
26
|
+
extract_workers: int = 4
|
|
27
|
+
# Basic searches answer after this many seconds and use search snippets for
|
|
28
|
+
# pages that aren't ready; those pages keep loading in the background for the cache.
|
|
29
|
+
basic_fetch_deadline: float = 2.0
|
|
30
|
+
max_page_bytes: int = 3_000_000
|
|
31
|
+
cache_ttl_seconds: int = 900
|
|
32
|
+
# Pages being fetched at once across all requests; more wait their turn.
|
|
33
|
+
# Requests beyond max_inflight_fetches use search snippets instead.
|
|
34
|
+
max_inflight_fetches: int = 200
|
|
35
|
+
# Extraction workers are replaced after this many pages, so memory
|
|
36
|
+
# fragmentation in lxml can't build up in a long-running server.
|
|
37
|
+
extract_max_tasks_per_child: int = 500
|
|
38
|
+
|
|
39
|
+
# Identify honestly to the sites OpenScour reads, and follow their robots.txt.
|
|
40
|
+
user_agent: str = f"OpenScour/{__version__} (+https://github.com/ivipinmishra/openscour)"
|
|
41
|
+
respect_robots_txt: bool = True
|
|
42
|
+
# Pages are only fetched from public internet addresses (SSRF protection).
|
|
43
|
+
# Set to true only for a trusted, internal-only deployment that must read
|
|
44
|
+
# intranet pages; never on a server untrusted callers can reach.
|
|
45
|
+
allow_private_network_fetches: bool = False
|
|
46
|
+
|
|
47
|
+
# Requests per minute for each API key (or each client IP when auth is off),
|
|
48
|
+
# across /search and /extract. 0 turns rate limiting off.
|
|
49
|
+
rate_limit_per_minute: int = 60
|
|
50
|
+
# Take the client IP from the X-Forwarded-For header set by a load balancer.
|
|
51
|
+
# Only enable behind a proxy that sets it: clients can forge it otherwise.
|
|
52
|
+
trust_forwarded_for: bool = False
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def api_key_set(self) -> set[str]:
|
|
56
|
+
return {k.strip() for k in self.api_keys.split(",") if k.strip()}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
settings = Settings()
|