tooluniverse 1.0.4__py3-none-any.whl → 1.0.5__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.
Potentially problematic release.
This version of tooluniverse might be problematic. Click here for more details.
- tooluniverse/__init__.py +17 -5
- tooluniverse/agentic_tool.py +8 -2
- tooluniverse/data/agentic_tools.json +2 -2
- tooluniverse/data/odphp_tools.json +354 -0
- tooluniverse/default_config.py +1 -0
- tooluniverse/llm_clients.py +201 -0
- tooluniverse/mcp_tool_registry.py +3 -3
- tooluniverse/odphp_tool.py +226 -0
- tooluniverse/remote/boltz/boltz_mcp_server.py +2 -2
- tooluniverse/remote/uspto_downloader/uspto_downloader_mcp_server.py +2 -2
- tooluniverse/smcp.py +204 -112
- tooluniverse/smcp_server.py +4 -7
- tooluniverse/test/test_claude_sdk.py +86 -0
- tooluniverse/test/test_odphp_tool.py +166 -0
- tooluniverse/test/test_openrouter_client.py +288 -0
- tooluniverse/test/test_stdio_hooks.py +1 -1
- tooluniverse/test/test_tool_finder.py +1 -1
- {tooluniverse-1.0.4.dist-info → tooluniverse-1.0.5.dist-info}/METADATA +100 -74
- {tooluniverse-1.0.4.dist-info → tooluniverse-1.0.5.dist-info}/RECORD +23 -18
- tooluniverse-1.0.5.dist-info/licenses/LICENSE +201 -0
- tooluniverse-1.0.4.dist-info/licenses/LICENSE +0 -21
- {tooluniverse-1.0.4.dist-info → tooluniverse-1.0.5.dist-info}/WHEEL +0 -0
- {tooluniverse-1.0.4.dist-info → tooluniverse-1.0.5.dist-info}/entry_points.txt +0 -0
- {tooluniverse-1.0.4.dist-info → tooluniverse-1.0.5.dist-info}/top_level.txt +0 -0
tooluniverse/llm_clients.py
CHANGED
|
@@ -367,3 +367,204 @@ class GeminiClient(BaseLLMClient):
|
|
|
367
367
|
retries += 1
|
|
368
368
|
time.sleep(retry_delay * retries)
|
|
369
369
|
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class OpenRouterClient(BaseLLMClient):
|
|
373
|
+
"""
|
|
374
|
+
OpenRouter client using OpenAI SDK with custom base URL.
|
|
375
|
+
Supports models from OpenAI, Anthropic, Google, Qwen, and many other providers.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
# Default model limits based on latest OpenRouter offerings
|
|
379
|
+
DEFAULT_MODEL_LIMITS: Dict[str, Dict[str, int]] = {
|
|
380
|
+
"openai/gpt-5": {"max_output": 128_000, "context_window": 400_000},
|
|
381
|
+
"openai/gpt-5-codex": {"max_output": 128_000, "context_window": 400_000},
|
|
382
|
+
"google/gemini-2.5-flash": {"max_output": 65_536, "context_window": 1_000_000},
|
|
383
|
+
"google/gemini-2.5-pro": {"max_output": 65_536, "context_window": 1_000_000},
|
|
384
|
+
"anthropic/claude-sonnet-4.5": {"max_output": 16_384, "context_window": 1_000_000},
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
def __init__(self, model_id: str, logger):
|
|
388
|
+
try:
|
|
389
|
+
from openai import OpenAI as _OpenAI # type: ignore
|
|
390
|
+
import openai as _openai # type: ignore
|
|
391
|
+
except Exception as e: # pragma: no cover
|
|
392
|
+
raise RuntimeError("openai client is not available") from e
|
|
393
|
+
|
|
394
|
+
self._OpenAI = _OpenAI
|
|
395
|
+
self._openai = _openai
|
|
396
|
+
self.model_name = model_id
|
|
397
|
+
self.logger = logger
|
|
398
|
+
|
|
399
|
+
api_key = os.getenv("OPENROUTER_API_KEY")
|
|
400
|
+
if not api_key:
|
|
401
|
+
raise ValueError("OPENROUTER_API_KEY not set")
|
|
402
|
+
|
|
403
|
+
# Optional headers for OpenRouter
|
|
404
|
+
default_headers = {}
|
|
405
|
+
if site_url := os.getenv("OPENROUTER_SITE_URL"):
|
|
406
|
+
default_headers["HTTP-Referer"] = site_url
|
|
407
|
+
if site_name := os.getenv("OPENROUTER_SITE_NAME"):
|
|
408
|
+
default_headers["X-Title"] = site_name
|
|
409
|
+
|
|
410
|
+
self.client = self._OpenAI(
|
|
411
|
+
base_url="https://openrouter.ai/api/v1",
|
|
412
|
+
api_key=api_key,
|
|
413
|
+
default_headers=default_headers if default_headers else None,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Load env overrides for model limits
|
|
417
|
+
env_limits_raw = os.getenv("OPENROUTER_DEFAULT_MODEL_LIMITS")
|
|
418
|
+
self._default_limits: Dict[str, Dict[str, int]] = (
|
|
419
|
+
self.DEFAULT_MODEL_LIMITS.copy()
|
|
420
|
+
)
|
|
421
|
+
if env_limits_raw:
|
|
422
|
+
try:
|
|
423
|
+
env_limits = _json.loads(env_limits_raw)
|
|
424
|
+
for k, v in env_limits.items():
|
|
425
|
+
if isinstance(v, dict):
|
|
426
|
+
base = self._default_limits.get(k, {}).copy()
|
|
427
|
+
base.update(
|
|
428
|
+
{
|
|
429
|
+
kk: int(vv)
|
|
430
|
+
for kk, vv in v.items()
|
|
431
|
+
if isinstance(vv, (int, float, str))
|
|
432
|
+
}
|
|
433
|
+
)
|
|
434
|
+
self._default_limits[k] = base
|
|
435
|
+
except Exception:
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
def _resolve_default_max_tokens(self, model_id: str) -> Optional[int]:
|
|
439
|
+
"""Resolve default max tokens for a model."""
|
|
440
|
+
# Highest priority: explicit env per-model tokens mapping
|
|
441
|
+
mapping_raw = os.getenv("OPENROUTER_MAX_TOKENS_BY_MODEL")
|
|
442
|
+
mapping: Dict[str, Any] = {}
|
|
443
|
+
if mapping_raw:
|
|
444
|
+
try:
|
|
445
|
+
mapping = _json.loads(mapping_raw)
|
|
446
|
+
except Exception:
|
|
447
|
+
mapping = {}
|
|
448
|
+
|
|
449
|
+
if model_id in mapping:
|
|
450
|
+
try:
|
|
451
|
+
return int(mapping[model_id])
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
# Check for prefix match
|
|
456
|
+
for k, v in mapping.items():
|
|
457
|
+
try:
|
|
458
|
+
if model_id.startswith(k):
|
|
459
|
+
return int(v)
|
|
460
|
+
except Exception:
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
# Next: built-in/default-limits map
|
|
464
|
+
if model_id in self._default_limits:
|
|
465
|
+
return int(self._default_limits[model_id].get("max_output", 0)) or None
|
|
466
|
+
|
|
467
|
+
# Check for prefix match in default limits
|
|
468
|
+
for k, v in self._default_limits.items():
|
|
469
|
+
try:
|
|
470
|
+
if model_id.startswith(k):
|
|
471
|
+
return int(v.get("max_output", 0)) or None
|
|
472
|
+
except Exception:
|
|
473
|
+
continue
|
|
474
|
+
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
def test_api(self) -> None:
|
|
478
|
+
"""Test API connectivity with minimal token usage."""
|
|
479
|
+
test_messages = [{"role": "user", "content": "ping"}]
|
|
480
|
+
token_attempts = [1, 4, 16, 32]
|
|
481
|
+
last_error: Optional[Exception] = None
|
|
482
|
+
|
|
483
|
+
for tok in token_attempts:
|
|
484
|
+
try:
|
|
485
|
+
self.client.chat.completions.create(
|
|
486
|
+
model=self.model_name,
|
|
487
|
+
messages=test_messages,
|
|
488
|
+
max_tokens=tok,
|
|
489
|
+
temperature=0,
|
|
490
|
+
)
|
|
491
|
+
return
|
|
492
|
+
except Exception as e: # noqa: BLE001
|
|
493
|
+
last_error = e
|
|
494
|
+
msg = str(e).lower()
|
|
495
|
+
if (
|
|
496
|
+
"max_tokens" in msg
|
|
497
|
+
or "model output limit" in msg
|
|
498
|
+
or "finish the message" in msg
|
|
499
|
+
) and tok != token_attempts[-1]:
|
|
500
|
+
continue
|
|
501
|
+
break
|
|
502
|
+
|
|
503
|
+
if last_error:
|
|
504
|
+
raise ValueError(f"OpenRouter API test failed: {last_error}")
|
|
505
|
+
raise ValueError("OpenRouter API test failed: unknown error")
|
|
506
|
+
|
|
507
|
+
def infer(
|
|
508
|
+
self,
|
|
509
|
+
messages: List[Dict[str, str]],
|
|
510
|
+
temperature: Optional[float],
|
|
511
|
+
max_tokens: Optional[int],
|
|
512
|
+
return_json: bool,
|
|
513
|
+
custom_format: Any = None,
|
|
514
|
+
max_retries: int = 5,
|
|
515
|
+
retry_delay: int = 5,
|
|
516
|
+
) -> Optional[str]:
|
|
517
|
+
"""Execute inference using OpenRouter."""
|
|
518
|
+
retries = 0
|
|
519
|
+
call_fn = (
|
|
520
|
+
self.client.chat.completions.parse
|
|
521
|
+
if custom_format is not None
|
|
522
|
+
else self.client.chat.completions.create
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
response_format = (
|
|
526
|
+
custom_format
|
|
527
|
+
if custom_format is not None
|
|
528
|
+
else ({"type": "json_object"} if return_json else None)
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
eff_max = (
|
|
532
|
+
max_tokens
|
|
533
|
+
if max_tokens is not None
|
|
534
|
+
else self._resolve_default_max_tokens(self.model_name)
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
while retries < max_retries:
|
|
538
|
+
try:
|
|
539
|
+
kwargs: Dict[str, Any] = {
|
|
540
|
+
"model": self.model_name,
|
|
541
|
+
"messages": messages,
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if response_format is not None:
|
|
545
|
+
kwargs["response_format"] = response_format
|
|
546
|
+
if temperature is not None:
|
|
547
|
+
kwargs["temperature"] = temperature
|
|
548
|
+
if eff_max is not None:
|
|
549
|
+
kwargs["max_tokens"] = eff_max
|
|
550
|
+
|
|
551
|
+
resp = call_fn(**kwargs)
|
|
552
|
+
|
|
553
|
+
if custom_format is not None:
|
|
554
|
+
return resp.choices[0].message.parsed.model_dump()
|
|
555
|
+
return resp.choices[0].message.content
|
|
556
|
+
|
|
557
|
+
except self._openai.RateLimitError: # type: ignore[attr-defined]
|
|
558
|
+
self.logger.warning(
|
|
559
|
+
f"Rate limit exceeded. Retrying in {retry_delay} seconds..."
|
|
560
|
+
)
|
|
561
|
+
retries += 1
|
|
562
|
+
time.sleep(retry_delay * retries)
|
|
563
|
+
except Exception as e: # noqa: BLE001
|
|
564
|
+
self.logger.error(f"OpenRouter error: {e}")
|
|
565
|
+
import traceback
|
|
566
|
+
traceback.print_exc()
|
|
567
|
+
break
|
|
568
|
+
|
|
569
|
+
self.logger.error("Max retries exceeded. Unable to complete the request.")
|
|
570
|
+
return None
|
|
@@ -327,13 +327,12 @@ def _start_server_for_port(port: int, **kwargs):
|
|
|
327
327
|
|
|
328
328
|
print(f"🚀 Starting MCP server on port {port} with {len(tools)} tools...")
|
|
329
329
|
|
|
330
|
-
# Create SMCP server
|
|
330
|
+
# Create SMCP server for compatibility
|
|
331
331
|
server = _get_smcp()(
|
|
332
332
|
name=config["server_name"],
|
|
333
333
|
auto_expose_tools=False, # We'll add tools manually
|
|
334
334
|
search_enabled=True,
|
|
335
335
|
max_workers=config.get("max_workers", 5),
|
|
336
|
-
stateless_http=True, # Enable stateless mode for MCPAutoLoaderTool compatibility
|
|
337
336
|
**kwargs,
|
|
338
337
|
)
|
|
339
338
|
|
|
@@ -347,8 +346,9 @@ def _start_server_for_port(port: int, **kwargs):
|
|
|
347
346
|
# Start server in background thread
|
|
348
347
|
def run_server():
|
|
349
348
|
try:
|
|
349
|
+
# Enable stateless mode for MCPAutoLoaderTool compatibility
|
|
350
350
|
server.run_simple(
|
|
351
|
-
transport=config["transport"], host=config["host"], port=port
|
|
351
|
+
transport=config["transport"], host=config["host"], port=port, stateless_http=True
|
|
352
352
|
)
|
|
353
353
|
except Exception as e:
|
|
354
354
|
print(f"❌ Error running MCP server on port {port}: {e}")
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import requests
|
|
3
|
+
from typing import Dict, Any, Optional, List
|
|
4
|
+
from .base_tool import BaseTool
|
|
5
|
+
from .tool_registry import register_tool
|
|
6
|
+
|
|
7
|
+
# Optional but recommended: text extraction for HTML
|
|
8
|
+
try:
|
|
9
|
+
from bs4 import BeautifulSoup # pip install beautifulsoup4
|
|
10
|
+
except ImportError:
|
|
11
|
+
BeautifulSoup = None # We’ll guard uses so the tool still loads
|
|
12
|
+
|
|
13
|
+
ODPHP_BASE_URL = "https://odphp.health.gov/myhealthfinder/api/v4"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ODPHPRESTTool(BaseTool):
|
|
17
|
+
"""Base class for ODPHP (MyHealthfinder) REST API tools."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, tool_config):
|
|
20
|
+
super().__init__(tool_config)
|
|
21
|
+
self.endpoint = tool_config["fields"]["endpoint"]
|
|
22
|
+
|
|
23
|
+
def _make_request(self, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
24
|
+
url = f"{ODPHP_BASE_URL}{self.endpoint}"
|
|
25
|
+
try:
|
|
26
|
+
resp = requests.get(url, params=params, timeout=30)
|
|
27
|
+
resp.raise_for_status()
|
|
28
|
+
data = resp.json()
|
|
29
|
+
return {
|
|
30
|
+
"data": data.get("Result"),
|
|
31
|
+
"metadata": {
|
|
32
|
+
"source": "ODPHP MyHealthfinder",
|
|
33
|
+
"endpoint": url,
|
|
34
|
+
"query": params,
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
except requests.exceptions.RequestException as e:
|
|
38
|
+
return {"error": f"Request failed: {str(e)}"}
|
|
39
|
+
except ValueError as e:
|
|
40
|
+
return {"error": f"Failed to parse JSON: {str(e)}"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _sections_array(resource: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
44
|
+
"""
|
|
45
|
+
Tolerant accessor for the sections array.
|
|
46
|
+
Data sometimes uses Sections.Section (capital S) and sometimes Sections.section (lowercase).
|
|
47
|
+
"""
|
|
48
|
+
sect = resource.get("Sections") or {}
|
|
49
|
+
arr = sect.get("Section")
|
|
50
|
+
if not isinstance(arr, list):
|
|
51
|
+
arr = sect.get("section")
|
|
52
|
+
return arr if isinstance(arr, list) else []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _strip_html_to_text(html: str) -> str:
|
|
56
|
+
if not html:
|
|
57
|
+
return ""
|
|
58
|
+
if BeautifulSoup is None:
|
|
59
|
+
# fallback: very light tag remover
|
|
60
|
+
text = re.sub(r"<[^>]+>", " ", html)
|
|
61
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
62
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
63
|
+
# remove scripts/styles
|
|
64
|
+
for t in soup(["script", "style", "noscript"]):
|
|
65
|
+
t.decompose()
|
|
66
|
+
text = soup.get_text("\n", strip=True)
|
|
67
|
+
text = re.sub(r"\n{2,}", "\n\n", text)
|
|
68
|
+
return text.strip()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@register_tool("ODPHPMyHealthfinder")
|
|
72
|
+
class ODPHPMyHealthfinder(ODPHPRESTTool):
|
|
73
|
+
"""Search for demographic-specific health recommendations (MyHealthfinder)."""
|
|
74
|
+
|
|
75
|
+
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
76
|
+
params: Dict[str, Any] = {}
|
|
77
|
+
if "lang" in arguments:
|
|
78
|
+
params["lang"] = arguments["lang"]
|
|
79
|
+
if "age" in arguments:
|
|
80
|
+
params["age"] = arguments["age"]
|
|
81
|
+
if "sex" in arguments:
|
|
82
|
+
params["sex"] = arguments["sex"]
|
|
83
|
+
if "pregnant" in arguments:
|
|
84
|
+
params["pregnant"] = arguments["pregnant"]
|
|
85
|
+
|
|
86
|
+
res = self._make_request(params)
|
|
87
|
+
|
|
88
|
+
# Optional: attach PlainSections if requested
|
|
89
|
+
if isinstance(res, dict) and not res.get("error") and arguments.get("strip_html"):
|
|
90
|
+
data = res.get("data") or {}
|
|
91
|
+
resources = (((data.get("Resources") or {}).get("All") or {}).get("Resource")) or []
|
|
92
|
+
if isinstance(resources, list):
|
|
93
|
+
for r in resources:
|
|
94
|
+
plain = []
|
|
95
|
+
for sec in _sections_array(r):
|
|
96
|
+
plain.append({
|
|
97
|
+
"Title": sec.get("Title", ""),
|
|
98
|
+
"PlainContent": _strip_html_to_text(sec.get("Content", "")),
|
|
99
|
+
})
|
|
100
|
+
if plain:
|
|
101
|
+
r["PlainSections"] = plain
|
|
102
|
+
return res
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@register_tool("ODPHPItemList")
|
|
106
|
+
class ODPHPItemList(ODPHPRESTTool):
|
|
107
|
+
"""Retrieve list of topics or categories."""
|
|
108
|
+
|
|
109
|
+
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
110
|
+
params: Dict[str, Any] = {}
|
|
111
|
+
if "lang" in arguments:
|
|
112
|
+
params["lang"] = arguments["lang"]
|
|
113
|
+
if "type" in arguments:
|
|
114
|
+
params["type"] = arguments["type"]
|
|
115
|
+
return self._make_request(params)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@register_tool("ODPHPTopicSearch")
|
|
119
|
+
class ODPHPTopicSearch(ODPHPRESTTool):
|
|
120
|
+
"""Search for health topics by ID, category, or keyword."""
|
|
121
|
+
|
|
122
|
+
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
123
|
+
params: Dict[str, Any] = {}
|
|
124
|
+
if "lang" in arguments:
|
|
125
|
+
params["lang"] = arguments["lang"]
|
|
126
|
+
if "topicId" in arguments:
|
|
127
|
+
params["topicId"] = arguments["topicId"]
|
|
128
|
+
if "categoryId" in arguments:
|
|
129
|
+
params["categoryId"] = arguments["categoryId"]
|
|
130
|
+
if "keyword" in arguments:
|
|
131
|
+
params["keyword"] = arguments["keyword"]
|
|
132
|
+
|
|
133
|
+
res = self._make_request(params)
|
|
134
|
+
|
|
135
|
+
# Optional: attach PlainSections if requested
|
|
136
|
+
if isinstance(res, dict) and not res.get("error") and arguments.get("strip_html"):
|
|
137
|
+
data = res.get("data") or {}
|
|
138
|
+
resources = ((data.get("Resources") or {}).get("Resource")) or []
|
|
139
|
+
if isinstance(resources, list):
|
|
140
|
+
for r in resources:
|
|
141
|
+
plain = []
|
|
142
|
+
for sec in _sections_array(r):
|
|
143
|
+
plain.append({
|
|
144
|
+
"Title": sec.get("Title", ""),
|
|
145
|
+
"PlainContent": _strip_html_to_text(sec.get("Content", "")),
|
|
146
|
+
})
|
|
147
|
+
if plain:
|
|
148
|
+
r["PlainSections"] = plain
|
|
149
|
+
return res
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@register_tool("ODPHPOutlinkFetch")
|
|
153
|
+
class ODPHPOutlinkFetch(BaseTool):
|
|
154
|
+
"""
|
|
155
|
+
Fetch article pages referenced by AccessibleVersion / RelatedItems.Url and return readable text.
|
|
156
|
+
- HTML: extracts main/article/body text; strips nav/aside/footer/script/style.
|
|
157
|
+
- PDF or non-HTML: returns metadata + URL so the agent can surface it.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(self, tool_config):
|
|
161
|
+
super().__init__(tool_config)
|
|
162
|
+
self.timeout = 30
|
|
163
|
+
|
|
164
|
+
def _extract_text(self, html: str) -> Dict[str, str]:
|
|
165
|
+
if BeautifulSoup is None:
|
|
166
|
+
# fallback: crude extraction
|
|
167
|
+
title = ""
|
|
168
|
+
# attempt to find <title>
|
|
169
|
+
m = re.search(r"<title[^>]*>(.*?)</title>", html, flags=re.I | re.S)
|
|
170
|
+
if m:
|
|
171
|
+
title = re.sub(r"\s+", " ", m.group(1)).strip()
|
|
172
|
+
text = re.sub(r"<[^>]+>", " ", html)
|
|
173
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
174
|
+
return {"title": title, "text": text}
|
|
175
|
+
|
|
176
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
177
|
+
# remove non-content
|
|
178
|
+
for tag in soup(["script", "style", "noscript", "footer", "nav", "aside"]):
|
|
179
|
+
tag.decompose()
|
|
180
|
+
|
|
181
|
+
candidate = soup.find("main") or soup.find("article") or soup.body or soup
|
|
182
|
+
title = ""
|
|
183
|
+
# prefer main/article heading, else <title>
|
|
184
|
+
h = candidate.find(["h1", "h2"]) if candidate else None
|
|
185
|
+
if h:
|
|
186
|
+
title = h.get_text(" ", strip=True)
|
|
187
|
+
elif soup.title and soup.title.string:
|
|
188
|
+
title = soup.title.string.strip()
|
|
189
|
+
|
|
190
|
+
text = candidate.get_text("\n", strip=True) if candidate else soup.get_text("\n", strip=True)
|
|
191
|
+
text = re.sub(r"\n{2,}", "\n\n", text)
|
|
192
|
+
return {"title": title, "text": text}
|
|
193
|
+
|
|
194
|
+
def run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
195
|
+
urls: List[str] = arguments.get("urls", [])
|
|
196
|
+
max_chars: Optional[int] = arguments.get("max_chars")
|
|
197
|
+
return_html: bool = bool(arguments.get("return_html", False))
|
|
198
|
+
|
|
199
|
+
if not urls or not isinstance(urls, list):
|
|
200
|
+
return {"error": "Missing required parameter 'urls' (array of 1–3 URLs)."}
|
|
201
|
+
|
|
202
|
+
out: List[Dict[str, Any]] = []
|
|
203
|
+
for u in urls[:3]:
|
|
204
|
+
try:
|
|
205
|
+
resp = requests.get(u, timeout=self.timeout, allow_redirects=True)
|
|
206
|
+
ct = resp.headers.get("Content-Type", "")
|
|
207
|
+
item: Dict[str, Any] = {"url": u, "status": resp.status_code, "content_type": ct}
|
|
208
|
+
|
|
209
|
+
if "text/html" in ct or (not ct and resp.text.startswith("<!")):
|
|
210
|
+
ex = self._extract_text(resp.text)
|
|
211
|
+
if isinstance(max_chars, int) and max_chars > 0:
|
|
212
|
+
ex["text"] = ex["text"][:max_chars]
|
|
213
|
+
item.update(ex)
|
|
214
|
+
if return_html:
|
|
215
|
+
item["html"] = resp.text
|
|
216
|
+
elif "pdf" in ct or u.lower().endswith(".pdf"):
|
|
217
|
+
item["title"] = "(PDF Document)"
|
|
218
|
+
item["text"] = f"[PDF file: {u}]"
|
|
219
|
+
else:
|
|
220
|
+
item["title"] = ""
|
|
221
|
+
item["text"] = ""
|
|
222
|
+
out.append(item)
|
|
223
|
+
except requests.exceptions.RequestException as e:
|
|
224
|
+
out.append({"url": u, "status": 0, "content_type": "", "title": "", "text": "", "error": str(e)})
|
|
225
|
+
|
|
226
|
+
return {"results": out, "metadata": {"source": "ODPHP OutlinkFetch"}}
|
|
@@ -17,7 +17,7 @@ except FileNotFoundError as e:
|
|
|
17
17
|
)
|
|
18
18
|
sys.exit(1)
|
|
19
19
|
|
|
20
|
-
server = FastMCP("Your MCP Server"
|
|
20
|
+
server = FastMCP("Your MCP Server")
|
|
21
21
|
agents = {}
|
|
22
22
|
for tool_config in boltz_tools:
|
|
23
23
|
agents[tool_config["name"]] = Boltz2DockingTool(tool_config=tool_config)
|
|
@@ -47,4 +47,4 @@ def run_boltz2(query: dict):
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
if __name__ == "__main__":
|
|
50
|
-
server.run(transport="streamable-http", host="0.0.0.0", port=8080)
|
|
50
|
+
server.run(transport="streamable-http", host="0.0.0.0", port=8080, stateless_http=True)
|
|
@@ -18,7 +18,7 @@ except FileNotFoundError as e:
|
|
|
18
18
|
)
|
|
19
19
|
sys.exit(1)
|
|
20
20
|
|
|
21
|
-
server = FastMCP("Your MCP Server"
|
|
21
|
+
server = FastMCP("Your MCP Server")
|
|
22
22
|
agents = {}
|
|
23
23
|
for tool_config in uspto_downloader_tools:
|
|
24
24
|
agents[tool_config["name"]] = USPTOPatentDocumentDownloader(tool_config=tool_config)
|
|
@@ -58,4 +58,4 @@ def download_full_text(query: dict):
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
if __name__ == "__main__":
|
|
61
|
-
server.run(transport="streamable-http", host="0.0.0.0", port=8081)
|
|
61
|
+
server.run(transport="streamable-http", host="0.0.0.0", port=8081, stateless_http=True)
|