tooluniverse 1.0.5__py3-none-any.whl → 1.0.6__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 +39 -0
- tooluniverse/agentic_tool.py +82 -12
- tooluniverse/arxiv_tool.py +113 -0
- tooluniverse/biorxiv_tool.py +97 -0
- tooluniverse/core_tool.py +153 -0
- tooluniverse/crossref_tool.py +73 -0
- tooluniverse/data/arxiv_tools.json +87 -0
- tooluniverse/data/biorxiv_tools.json +70 -0
- tooluniverse/data/core_tools.json +105 -0
- tooluniverse/data/crossref_tools.json +70 -0
- tooluniverse/data/dblp_tools.json +73 -0
- tooluniverse/data/doaj_tools.json +94 -0
- tooluniverse/data/fatcat_tools.json +72 -0
- tooluniverse/data/hal_tools.json +70 -0
- tooluniverse/data/medrxiv_tools.json +70 -0
- tooluniverse/data/openaire_tools.json +85 -0
- tooluniverse/data/osf_preprints_tools.json +77 -0
- tooluniverse/data/pmc_tools.json +109 -0
- tooluniverse/data/pubmed_tools.json +65 -0
- tooluniverse/data/unpaywall_tools.json +86 -0
- tooluniverse/data/wikidata_sparql_tools.json +42 -0
- tooluniverse/data/zenodo_tools.json +82 -0
- tooluniverse/dblp_tool.py +62 -0
- tooluniverse/default_config.py +17 -0
- tooluniverse/doaj_tool.py +124 -0
- tooluniverse/execute_function.py +70 -9
- tooluniverse/fatcat_tool.py +66 -0
- tooluniverse/hal_tool.py +77 -0
- tooluniverse/llm_clients.py +286 -0
- tooluniverse/medrxiv_tool.py +97 -0
- tooluniverse/openaire_tool.py +145 -0
- tooluniverse/osf_preprints_tool.py +67 -0
- tooluniverse/pmc_tool.py +181 -0
- tooluniverse/pubmed_tool.py +110 -0
- tooluniverse/smcp.py +109 -79
- tooluniverse/test/test_claude_sdk.py +11 -4
- tooluniverse/unpaywall_tool.py +63 -0
- tooluniverse/wikidata_sparql_tool.py +61 -0
- tooluniverse/zenodo_tool.py +74 -0
- {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/METADATA +2 -1
- {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/RECORD +45 -13
- {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/entry_points.txt +1 -0
- {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/WHEEL +0 -0
- {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/licenses/LICENSE +0 -0
- {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/top_level.txt +0 -0
tooluniverse/pmc_tool.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PMC (PubMed Central) Tool for searching full-text biomedical literature.
|
|
4
|
+
|
|
5
|
+
PMC is the free full-text archive of biomedical and life sciences journal
|
|
6
|
+
literature at the U.S. National Institutes of Health's National Library of
|
|
7
|
+
Medicine. This tool provides access to millions of full-text articles.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from typing import Dict, List, Any, Optional
|
|
12
|
+
from .base_tool import BaseTool
|
|
13
|
+
from .tool_registry import register_tool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@register_tool("PMCTool")
|
|
17
|
+
class PMCTool(BaseTool):
|
|
18
|
+
"""Tool for searching PMC full-text biomedical literature."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, tool_config=None):
|
|
21
|
+
super().__init__(tool_config)
|
|
22
|
+
self.base_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
|
|
23
|
+
self.session = requests.Session()
|
|
24
|
+
self.session.headers.update({
|
|
25
|
+
'User-Agent': 'ToolUniverse/1.0',
|
|
26
|
+
'Accept': 'application/json'
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
def _search(self, query: str, limit: int = 10,
|
|
30
|
+
date_from: Optional[str] = None,
|
|
31
|
+
date_to: Optional[str] = None,
|
|
32
|
+
article_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
33
|
+
"""
|
|
34
|
+
Search for papers using PMC API.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
query: Search query
|
|
38
|
+
limit: Maximum number of results
|
|
39
|
+
date_from: Start date filter (YYYY/MM/DD)
|
|
40
|
+
date_to: End date filter (YYYY/MM/DD)
|
|
41
|
+
article_type: Article type filter (e.g., 'research-article', 'review')
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
List of paper dictionaries
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
# Step 1: Search PMC for article IDs
|
|
48
|
+
search_params = {
|
|
49
|
+
'db': 'pmc',
|
|
50
|
+
'term': query,
|
|
51
|
+
'retmax': min(limit, 100), # NCBI API max limit
|
|
52
|
+
'retmode': 'json',
|
|
53
|
+
'sort': 'relevance'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Add date filters if provided
|
|
57
|
+
if date_from or date_to:
|
|
58
|
+
date_filter = []
|
|
59
|
+
if date_from:
|
|
60
|
+
date_filter.append(
|
|
61
|
+
f"({date_from}[PDAT]:{date_to or '3000/12/31'}[PDAT])"
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
date_filter.append(f"(:{date_to}[PDAT])")
|
|
65
|
+
search_params['term'] += f" AND {' '.join(date_filter)}"
|
|
66
|
+
|
|
67
|
+
# Add article type filter if provided
|
|
68
|
+
if article_type:
|
|
69
|
+
search_params['term'] += f" AND {article_type}[PT]"
|
|
70
|
+
|
|
71
|
+
# Make search request
|
|
72
|
+
search_response = self.session.get(
|
|
73
|
+
f"{self.base_url}/esearch.fcgi",
|
|
74
|
+
params=search_params,
|
|
75
|
+
timeout=30
|
|
76
|
+
)
|
|
77
|
+
search_response.raise_for_status()
|
|
78
|
+
|
|
79
|
+
search_data = search_response.json()
|
|
80
|
+
pmc_ids = search_data.get('esearchresult', {}).get('idlist', [])
|
|
81
|
+
|
|
82
|
+
if not pmc_ids:
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
# Step 2: Get detailed information for each article
|
|
86
|
+
summary_params = {
|
|
87
|
+
'db': 'pmc',
|
|
88
|
+
'id': ','.join(pmc_ids),
|
|
89
|
+
'retmode': 'json'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
summary_response = self.session.get(
|
|
93
|
+
f"{self.base_url}/esummary.fcgi",
|
|
94
|
+
params=summary_params,
|
|
95
|
+
timeout=30
|
|
96
|
+
)
|
|
97
|
+
summary_response.raise_for_status()
|
|
98
|
+
|
|
99
|
+
summary_data = summary_response.json()
|
|
100
|
+
results = []
|
|
101
|
+
|
|
102
|
+
# Parse results
|
|
103
|
+
for pmc_id in pmc_ids:
|
|
104
|
+
article_data = summary_data.get('result', {}).get(pmc_id, {})
|
|
105
|
+
|
|
106
|
+
paper = {
|
|
107
|
+
'title': article_data.get('title', 'No title'),
|
|
108
|
+
'abstract': article_data.get('abstract', 'No abstract available'),
|
|
109
|
+
'authors': self._extract_authors(article_data.get('authors', [])),
|
|
110
|
+
'year': self._extract_year(article_data.get('pubdate')),
|
|
111
|
+
'pmc_id': pmc_id,
|
|
112
|
+
'pmid': article_data.get('pmid'),
|
|
113
|
+
'doi': article_data.get('elocationid'),
|
|
114
|
+
'url': f"https://www.ncbi.nlm.nih.gov/pmc/articles/{pmc_id}/",
|
|
115
|
+
'venue': article_data.get('source'),
|
|
116
|
+
'open_access': True, # PMC only contains open access articles
|
|
117
|
+
'source': 'PMC',
|
|
118
|
+
'article_type': (article_data.get('pubtype', ['Unknown'])[0]
|
|
119
|
+
if article_data.get('pubtype') else 'Unknown'),
|
|
120
|
+
'citations': article_data.get('pmcrefcount', 0)
|
|
121
|
+
}
|
|
122
|
+
results.append(paper)
|
|
123
|
+
|
|
124
|
+
return results
|
|
125
|
+
|
|
126
|
+
except requests.exceptions.RequestException as e:
|
|
127
|
+
return [{'error': f'PMC API request failed: {str(e)}'}]
|
|
128
|
+
except Exception as e:
|
|
129
|
+
return [{'error': f'PMC API error: {str(e)}'}]
|
|
130
|
+
|
|
131
|
+
def _extract_authors(self, authors: List[Dict]) -> List[str]:
|
|
132
|
+
"""Extract author names from PMC API response."""
|
|
133
|
+
if not authors:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
author_names = []
|
|
137
|
+
for author in authors:
|
|
138
|
+
name = author.get('name', '')
|
|
139
|
+
if name:
|
|
140
|
+
author_names.append(name)
|
|
141
|
+
|
|
142
|
+
return author_names
|
|
143
|
+
|
|
144
|
+
def _extract_year(self, pubdate: str) -> str:
|
|
145
|
+
"""Extract year from publication date."""
|
|
146
|
+
if not pubdate:
|
|
147
|
+
return 'Unknown'
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
# PMC API returns dates in various formats
|
|
151
|
+
# Extract year from the beginning of the string
|
|
152
|
+
return pubdate[:4]
|
|
153
|
+
except Exception:
|
|
154
|
+
return 'Unknown'
|
|
155
|
+
|
|
156
|
+
def run(self, tool_arguments) -> List[Dict[str, Any]]:
|
|
157
|
+
"""
|
|
158
|
+
Execute the PMC search.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
tool_arguments: Dictionary containing search parameters
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
List of paper dictionaries
|
|
165
|
+
"""
|
|
166
|
+
query = tool_arguments.get('query', '')
|
|
167
|
+
if not query:
|
|
168
|
+
return [{'error': 'Query parameter is required'}]
|
|
169
|
+
|
|
170
|
+
limit = tool_arguments.get('limit', 10)
|
|
171
|
+
date_from = tool_arguments.get('date_from')
|
|
172
|
+
date_to = tool_arguments.get('date_to')
|
|
173
|
+
article_type = tool_arguments.get('article_type')
|
|
174
|
+
|
|
175
|
+
return self._search(
|
|
176
|
+
query=query,
|
|
177
|
+
limit=limit,
|
|
178
|
+
date_from=date_from,
|
|
179
|
+
date_to=date_to,
|
|
180
|
+
article_type=article_type
|
|
181
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from .base_tool import BaseTool
|
|
3
|
+
from .tool_registry import register_tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@register_tool("PubMedTool")
|
|
7
|
+
class PubMedTool(BaseTool):
|
|
8
|
+
"""
|
|
9
|
+
Search PubMed using NCBI E-utilities (esearch + esummary) and return
|
|
10
|
+
articles.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, tool_config):
|
|
14
|
+
super().__init__(tool_config)
|
|
15
|
+
self.esearch_url = (
|
|
16
|
+
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
|
|
17
|
+
)
|
|
18
|
+
self.esummary_url = (
|
|
19
|
+
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def run(self, arguments):
|
|
23
|
+
query = arguments.get("query")
|
|
24
|
+
limit = int(arguments.get("limit", 10))
|
|
25
|
+
api_key = arguments.get("api_key") # optional NCBI API key
|
|
26
|
+
if not query:
|
|
27
|
+
return {"error": "`query` parameter is required."}
|
|
28
|
+
return self._search(query, limit, api_key)
|
|
29
|
+
|
|
30
|
+
def _search(self, query, limit, api_key=None):
|
|
31
|
+
params = {
|
|
32
|
+
"db": "pubmed",
|
|
33
|
+
"term": query,
|
|
34
|
+
"retmax": max(1, min(limit, 200)),
|
|
35
|
+
"retmode": "json",
|
|
36
|
+
}
|
|
37
|
+
if api_key:
|
|
38
|
+
params["api_key"] = api_key
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
r = requests.get(self.esearch_url, params=params, timeout=20)
|
|
42
|
+
except requests.RequestException as e:
|
|
43
|
+
return {
|
|
44
|
+
"error": "Network error calling PubMed esearch",
|
|
45
|
+
"reason": str(e),
|
|
46
|
+
}
|
|
47
|
+
if r.status_code != 200:
|
|
48
|
+
return {
|
|
49
|
+
"error": f"PubMed esearch error {r.status_code}",
|
|
50
|
+
"reason": r.reason,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
data = r.json()
|
|
54
|
+
id_list = data.get("esearchresult", {}).get("idlist", [])
|
|
55
|
+
if not id_list:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
summary_params = {
|
|
59
|
+
"db": "pubmed",
|
|
60
|
+
"id": ",".join(id_list),
|
|
61
|
+
"retmode": "json",
|
|
62
|
+
}
|
|
63
|
+
if api_key:
|
|
64
|
+
summary_params["api_key"] = api_key
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
s = requests.get(
|
|
68
|
+
self.esummary_url,
|
|
69
|
+
params=summary_params,
|
|
70
|
+
timeout=20,
|
|
71
|
+
)
|
|
72
|
+
except requests.RequestException as e:
|
|
73
|
+
return {
|
|
74
|
+
"error": "Network error calling PubMed esummary",
|
|
75
|
+
"reason": str(e),
|
|
76
|
+
}
|
|
77
|
+
if s.status_code != 200:
|
|
78
|
+
return {
|
|
79
|
+
"error": f"PubMed esummary error {s.status_code}",
|
|
80
|
+
"reason": s.reason,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
result = s.json().get("result", {})
|
|
84
|
+
uids = result.get("uids", [])
|
|
85
|
+
articles = []
|
|
86
|
+
for uid in uids:
|
|
87
|
+
rec = result.get(uid, {})
|
|
88
|
+
title = rec.get("title")
|
|
89
|
+
journal = rec.get("fulljournalname") or rec.get("source")
|
|
90
|
+
year = None
|
|
91
|
+
pubdate = rec.get("pubdate")
|
|
92
|
+
if pubdate and len(pubdate) >= 4 and pubdate[:4].isdigit():
|
|
93
|
+
year = int(pubdate[:4])
|
|
94
|
+
doi = None
|
|
95
|
+
for id_obj in rec.get("articleids", []):
|
|
96
|
+
if id_obj.get("idtype") == "doi":
|
|
97
|
+
doi = id_obj.get("value")
|
|
98
|
+
break
|
|
99
|
+
url = f"https://pubmed.ncbi.nlm.nih.gov/{uid}/"
|
|
100
|
+
articles.append(
|
|
101
|
+
{
|
|
102
|
+
"title": title,
|
|
103
|
+
"journal": journal,
|
|
104
|
+
"year": year,
|
|
105
|
+
"doi": doi,
|
|
106
|
+
"url": url,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return articles
|
tooluniverse/smcp.py
CHANGED
|
@@ -92,6 +92,7 @@ AI Agent Interface:
|
|
|
92
92
|
"""
|
|
93
93
|
|
|
94
94
|
import asyncio
|
|
95
|
+
import functools
|
|
95
96
|
import json
|
|
96
97
|
from concurrent.futures import ThreadPoolExecutor
|
|
97
98
|
from typing import Any, Dict, List, Optional, Union, Callable, Literal
|
|
@@ -2292,93 +2293,122 @@ class SMCP(FastMCP):
|
|
|
2292
2293
|
)
|
|
2293
2294
|
)
|
|
2294
2295
|
|
|
2295
|
-
#
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
function_call,
|
|
2310
|
-
)
|
|
2311
|
-
|
|
2312
|
-
# Format the result
|
|
2313
|
-
if isinstance(result, str):
|
|
2314
|
-
return result
|
|
2315
|
-
else:
|
|
2316
|
-
return json.dumps(result, indent=2, default=str)
|
|
2296
|
+
# Add optional streaming parameter to signature
|
|
2297
|
+
stream_field = Field(
|
|
2298
|
+
description="Set to true to receive incremental streaming output (experimental)."
|
|
2299
|
+
)
|
|
2300
|
+
stream_annotation = Annotated[Union[bool, type(None)], stream_field]
|
|
2301
|
+
param_annotations["_tooluniverse_stream"] = stream_annotation
|
|
2302
|
+
func_params.append(
|
|
2303
|
+
inspect.Parameter(
|
|
2304
|
+
"_tooluniverse_stream",
|
|
2305
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
2306
|
+
default=None,
|
|
2307
|
+
annotation=stream_annotation,
|
|
2308
|
+
)
|
|
2309
|
+
)
|
|
2317
2310
|
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2311
|
+
# Optional FastMCP context injection for streaming callbacks
|
|
2312
|
+
try:
|
|
2313
|
+
from fastmcp.server.context import Context as MCPContext # type: ignore
|
|
2314
|
+
except Exception: # pragma: no cover - context unavailable
|
|
2315
|
+
MCPContext = None # type: ignore
|
|
2316
|
+
|
|
2317
|
+
if MCPContext is not None:
|
|
2318
|
+
func_params.append(
|
|
2319
|
+
inspect.Parameter(
|
|
2320
|
+
"ctx",
|
|
2321
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
2322
|
+
default=None,
|
|
2323
|
+
annotation=MCPContext,
|
|
2324
|
+
)
|
|
2325
|
+
)
|
|
2322
2326
|
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
+
async def dynamic_tool_function(**kwargs) -> str:
|
|
2328
|
+
"""Execute ToolUniverse tool with provided arguments."""
|
|
2329
|
+
try:
|
|
2330
|
+
ctx = kwargs.pop("ctx", None)
|
|
2331
|
+
stream_flag = bool(kwargs.get("_tooluniverse_stream"))
|
|
2327
2332
|
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
missing_required = [
|
|
2338
|
-
param for param in required_params if param not in args_dict
|
|
2339
|
-
]
|
|
2340
|
-
if missing_required:
|
|
2341
|
-
return json.dumps(
|
|
2342
|
-
{
|
|
2343
|
-
"error": f"Missing required parameters: {missing_required}",
|
|
2344
|
-
"required": required_params,
|
|
2345
|
-
"provided": list(args_dict.keys()),
|
|
2346
|
-
},
|
|
2347
|
-
indent=2,
|
|
2348
|
-
)
|
|
2349
|
-
|
|
2350
|
-
# Prepare function call
|
|
2351
|
-
function_call = {"name": tool_name, "arguments": args_dict}
|
|
2333
|
+
# Filter out None values for optional parameters (preserve streaming flag)
|
|
2334
|
+
args_dict = {
|
|
2335
|
+
k: v for k, v in kwargs.items() if v is not None
|
|
2336
|
+
}
|
|
2337
|
+
filtered_args = {
|
|
2338
|
+
k: v
|
|
2339
|
+
for k, v in args_dict.items()
|
|
2340
|
+
if k != "_tooluniverse_stream"
|
|
2341
|
+
}
|
|
2352
2342
|
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2343
|
+
# Validate required parameters
|
|
2344
|
+
missing_required = [
|
|
2345
|
+
param for param in required_params if param not in filtered_args
|
|
2346
|
+
]
|
|
2347
|
+
if missing_required:
|
|
2348
|
+
return json.dumps(
|
|
2349
|
+
{
|
|
2350
|
+
"error": f"Missing required parameters: {missing_required}",
|
|
2351
|
+
"required": required_params,
|
|
2352
|
+
"provided": list(filtered_args.keys()),
|
|
2353
|
+
},
|
|
2354
|
+
indent=2,
|
|
2359
2355
|
)
|
|
2360
2356
|
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2357
|
+
function_call = {"name": tool_name, "arguments": args_dict}
|
|
2358
|
+
|
|
2359
|
+
loop = asyncio.get_event_loop()
|
|
2360
|
+
stream_callback = None
|
|
2361
|
+
|
|
2362
|
+
if stream_flag and ctx is not None and MCPContext is not None:
|
|
2363
|
+
def stream_callback(chunk: str) -> None:
|
|
2364
|
+
if not chunk:
|
|
2365
|
+
return
|
|
2366
|
+
try:
|
|
2367
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
2368
|
+
ctx.info(chunk), loop
|
|
2369
|
+
)
|
|
2370
|
+
|
|
2371
|
+
def _log_future_result(fut) -> None:
|
|
2372
|
+
exc = fut.exception()
|
|
2373
|
+
if exc:
|
|
2374
|
+
self.logger.debug(
|
|
2375
|
+
f"Streaming callback error for {tool_name}: {exc}"
|
|
2376
|
+
)
|
|
2377
|
+
|
|
2378
|
+
future.add_done_callback(_log_future_result)
|
|
2379
|
+
except Exception as cb_error: # noqa: BLE001
|
|
2380
|
+
self.logger.debug(
|
|
2381
|
+
f"Failed to dispatch stream chunk for {tool_name}: {cb_error}"
|
|
2382
|
+
)
|
|
2383
|
+
|
|
2384
|
+
# Ensure downstream tools see the streaming flag
|
|
2385
|
+
if "_tooluniverse_stream" not in args_dict:
|
|
2386
|
+
args_dict["_tooluniverse_stream"] = True
|
|
2387
|
+
|
|
2388
|
+
run_callable = functools.partial(
|
|
2389
|
+
self.tooluniverse.run_one_function,
|
|
2390
|
+
function_call,
|
|
2391
|
+
stream_callback=stream_callback,
|
|
2392
|
+
)
|
|
2374
2393
|
|
|
2375
|
-
|
|
2376
|
-
if func_params:
|
|
2377
|
-
dynamic_tool_function.__signature__ = inspect.Signature(func_params)
|
|
2394
|
+
result = await loop.run_in_executor(self.executor, run_callable)
|
|
2378
2395
|
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2396
|
+
if isinstance(result, str):
|
|
2397
|
+
return result
|
|
2398
|
+
else:
|
|
2399
|
+
return json.dumps(result, indent=2, default=str)
|
|
2400
|
+
|
|
2401
|
+
except Exception as e:
|
|
2402
|
+
error_msg = f"Error executing {tool_name}: {str(e)}"
|
|
2403
|
+
self.logger.error(error_msg)
|
|
2404
|
+
return json.dumps({"error": error_msg}, indent=2)
|
|
2405
|
+
|
|
2406
|
+
# Set function metadata
|
|
2407
|
+
dynamic_tool_function.__name__ = tool_name
|
|
2408
|
+
dynamic_tool_function.__signature__ = inspect.Signature(func_params)
|
|
2409
|
+
annotations = param_annotations.copy()
|
|
2410
|
+
annotations["return"] = str
|
|
2411
|
+
dynamic_tool_function.__annotations__ = annotations
|
|
2382
2412
|
|
|
2383
2413
|
# Create detailed docstring for internal use, but use clean description for FastMCP
|
|
2384
2414
|
param_docs = []
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
2
4
|
|
|
3
5
|
from claude_agent_sdk import ClaudeAgentOptions, query
|
|
4
6
|
from dotenv import load_dotenv
|
|
@@ -18,8 +20,10 @@ async def delegate_task(
|
|
|
18
20
|
Returns:
|
|
19
21
|
The result of the delegation
|
|
20
22
|
"""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
# Create sandbox directory if it doesn't exist
|
|
24
|
+
sandbox_dir = Path(__file__).parent / "sandbox"
|
|
25
|
+
sandbox_dir.mkdir(exist_ok=True)
|
|
26
|
+
cwd = str(sandbox_dir)
|
|
23
27
|
|
|
24
28
|
async for message in query(
|
|
25
29
|
prompt=prompt,
|
|
@@ -34,8 +38,11 @@ async def delegate_task(
|
|
|
34
38
|
mcp_servers={
|
|
35
39
|
"tooluniverse": {
|
|
36
40
|
"type": "stdio",
|
|
37
|
-
"command": "
|
|
38
|
-
"args": [
|
|
41
|
+
"command": "uv",
|
|
42
|
+
"args": [
|
|
43
|
+
"run",
|
|
44
|
+
"tooluniverse-smcp-stdio",
|
|
45
|
+
],
|
|
39
46
|
"env": {},
|
|
40
47
|
},
|
|
41
48
|
},
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from .base_tool import BaseTool
|
|
3
|
+
from .tool_registry import register_tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@register_tool("UnpaywallTool")
|
|
7
|
+
class UnpaywallTool(BaseTool):
|
|
8
|
+
"""
|
|
9
|
+
Query Unpaywall by DOI to check open-access status and OA locations.
|
|
10
|
+
Requires a contact email.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, tool_config, base_url="https://api.unpaywall.org/v2/"):
|
|
14
|
+
super().__init__(tool_config)
|
|
15
|
+
self.base_url = base_url.rstrip("/") + "/"
|
|
16
|
+
|
|
17
|
+
def run(self, arguments):
|
|
18
|
+
doi = arguments.get("doi")
|
|
19
|
+
email = arguments.get("email") # required by Unpaywall
|
|
20
|
+
if not doi:
|
|
21
|
+
return {"error": "`doi` parameter is required."}
|
|
22
|
+
if not email:
|
|
23
|
+
return {"error": "`email` parameter is required for Unpaywall."}
|
|
24
|
+
return self._lookup(doi, email)
|
|
25
|
+
|
|
26
|
+
def _lookup(self, doi, email):
|
|
27
|
+
url = f"{self.base_url}{doi}"
|
|
28
|
+
params = {"email": email}
|
|
29
|
+
try:
|
|
30
|
+
response = requests.get(
|
|
31
|
+
url,
|
|
32
|
+
params=params,
|
|
33
|
+
timeout=20,
|
|
34
|
+
)
|
|
35
|
+
except requests.RequestException as e:
|
|
36
|
+
return {
|
|
37
|
+
"error": "Network error calling Unpaywall API",
|
|
38
|
+
"reason": str(e),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if response.status_code != 200:
|
|
42
|
+
return {
|
|
43
|
+
"error": f"Unpaywall API error {response.status_code}",
|
|
44
|
+
"reason": response.reason,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
data = response.json()
|
|
48
|
+
result = {
|
|
49
|
+
"is_oa": data.get("is_oa"),
|
|
50
|
+
"oa_status": data.get("oa_status"),
|
|
51
|
+
"best_oa_location": data.get("best_oa_location"),
|
|
52
|
+
"oa_locations": data.get("oa_locations"),
|
|
53
|
+
"journal_is_oa": data.get("journal_is_oa"),
|
|
54
|
+
"journal_issn_l": data.get("journal_issn_l"),
|
|
55
|
+
"journal_issns": data.get("journal_issns"),
|
|
56
|
+
"doi": data.get("doi"),
|
|
57
|
+
"title": data.get("title"),
|
|
58
|
+
"year": data.get("year"),
|
|
59
|
+
"publisher": data.get("publisher"),
|
|
60
|
+
"url": data.get("url"),
|
|
61
|
+
}
|
|
62
|
+
return result
|
|
63
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from .base_tool import BaseTool
|
|
3
|
+
from .tool_registry import register_tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@register_tool("WikidataSPARQLTool")
|
|
7
|
+
class WikidataSPARQLTool(BaseTool):
|
|
8
|
+
"""
|
|
9
|
+
Run SPARQL queries against Wikidata (powering Scholia views).
|
|
10
|
+
|
|
11
|
+
Parameters (arguments):
|
|
12
|
+
sparql (str): SPARQL query string
|
|
13
|
+
max_results (int): Optional result limit override
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, tool_config):
|
|
17
|
+
super().__init__(tool_config)
|
|
18
|
+
self.endpoint = "https://query.wikidata.org/sparql"
|
|
19
|
+
|
|
20
|
+
def run(self, arguments=None):
|
|
21
|
+
arguments = arguments or {}
|
|
22
|
+
sparql = arguments.get("sparql")
|
|
23
|
+
max_results = arguments.get("max_results")
|
|
24
|
+
if not sparql:
|
|
25
|
+
return {"error": "`sparql` parameter is required."}
|
|
26
|
+
if max_results:
|
|
27
|
+
# naive limit appending if not present
|
|
28
|
+
if "limit" not in sparql.lower():
|
|
29
|
+
sparql = f"{sparql}\nLIMIT {int(max_results)}"
|
|
30
|
+
|
|
31
|
+
headers = {
|
|
32
|
+
"Accept": "application/sparql-results+json",
|
|
33
|
+
"User-Agent": "ToolUniverse/1.0 (https://github.com)"
|
|
34
|
+
}
|
|
35
|
+
try:
|
|
36
|
+
resp = requests.get(
|
|
37
|
+
self.endpoint,
|
|
38
|
+
params={"query": sparql, "format": "json"},
|
|
39
|
+
headers=headers,
|
|
40
|
+
timeout=30,
|
|
41
|
+
)
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
data = resp.json()
|
|
44
|
+
except requests.RequestException as e:
|
|
45
|
+
return {
|
|
46
|
+
"error": "Network/API error calling Wikidata SPARQL",
|
|
47
|
+
"reason": str(e),
|
|
48
|
+
}
|
|
49
|
+
except ValueError:
|
|
50
|
+
return {"error": "Failed to decode SPARQL response as JSON"}
|
|
51
|
+
|
|
52
|
+
bindings = data.get("results", {}).get("bindings", [])
|
|
53
|
+
# Normalize by unwrapping "value" fields
|
|
54
|
+
normalized = []
|
|
55
|
+
for b in bindings:
|
|
56
|
+
row = {}
|
|
57
|
+
for k, v in b.items():
|
|
58
|
+
row[k] = v.get("value")
|
|
59
|
+
normalized.append(row)
|
|
60
|
+
return normalized
|
|
61
|
+
|