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.

Files changed (45) hide show
  1. tooluniverse/__init__.py +39 -0
  2. tooluniverse/agentic_tool.py +82 -12
  3. tooluniverse/arxiv_tool.py +113 -0
  4. tooluniverse/biorxiv_tool.py +97 -0
  5. tooluniverse/core_tool.py +153 -0
  6. tooluniverse/crossref_tool.py +73 -0
  7. tooluniverse/data/arxiv_tools.json +87 -0
  8. tooluniverse/data/biorxiv_tools.json +70 -0
  9. tooluniverse/data/core_tools.json +105 -0
  10. tooluniverse/data/crossref_tools.json +70 -0
  11. tooluniverse/data/dblp_tools.json +73 -0
  12. tooluniverse/data/doaj_tools.json +94 -0
  13. tooluniverse/data/fatcat_tools.json +72 -0
  14. tooluniverse/data/hal_tools.json +70 -0
  15. tooluniverse/data/medrxiv_tools.json +70 -0
  16. tooluniverse/data/openaire_tools.json +85 -0
  17. tooluniverse/data/osf_preprints_tools.json +77 -0
  18. tooluniverse/data/pmc_tools.json +109 -0
  19. tooluniverse/data/pubmed_tools.json +65 -0
  20. tooluniverse/data/unpaywall_tools.json +86 -0
  21. tooluniverse/data/wikidata_sparql_tools.json +42 -0
  22. tooluniverse/data/zenodo_tools.json +82 -0
  23. tooluniverse/dblp_tool.py +62 -0
  24. tooluniverse/default_config.py +17 -0
  25. tooluniverse/doaj_tool.py +124 -0
  26. tooluniverse/execute_function.py +70 -9
  27. tooluniverse/fatcat_tool.py +66 -0
  28. tooluniverse/hal_tool.py +77 -0
  29. tooluniverse/llm_clients.py +286 -0
  30. tooluniverse/medrxiv_tool.py +97 -0
  31. tooluniverse/openaire_tool.py +145 -0
  32. tooluniverse/osf_preprints_tool.py +67 -0
  33. tooluniverse/pmc_tool.py +181 -0
  34. tooluniverse/pubmed_tool.py +110 -0
  35. tooluniverse/smcp.py +109 -79
  36. tooluniverse/test/test_claude_sdk.py +11 -4
  37. tooluniverse/unpaywall_tool.py +63 -0
  38. tooluniverse/wikidata_sparql_tool.py +61 -0
  39. tooluniverse/zenodo_tool.py +74 -0
  40. {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/METADATA +2 -1
  41. {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/RECORD +45 -13
  42. {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/entry_points.txt +1 -0
  43. {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/WHEEL +0 -0
  44. {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/licenses/LICENSE +0 -0
  45. {tooluniverse-1.0.5.dist-info → tooluniverse-1.0.6.dist-info}/top_level.txt +0 -0
@@ -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
- # Create the async function with dynamic signature
2296
- if not properties:
2297
- # Tool has no parameters - create simple function
2298
- async def dynamic_tool_function() -> str:
2299
- """Execute ToolUniverse tool with no arguments."""
2300
- try:
2301
- # Prepare function call with empty arguments
2302
- function_call = {"name": tool_name, "arguments": {}}
2303
-
2304
- # Execute in thread pool to avoid blocking
2305
- loop = asyncio.get_event_loop()
2306
- result = await loop.run_in_executor(
2307
- self.executor,
2308
- self.tooluniverse.run_one_function,
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
- except Exception as e:
2319
- error_msg = f"Error executing {tool_name}: {str(e)}"
2320
- self.logger.error(error_msg)
2321
- return json.dumps({"error": error_msg}, indent=2)
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
- # Set function metadata
2324
- dynamic_tool_function.__name__ = tool_name
2325
- dynamic_tool_function.__signature__ = inspect.Signature([])
2326
- dynamic_tool_function.__annotations__ = {"return": str}
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
- else:
2329
- # Tool has parameters - create function with dynamic signature
2330
- async def dynamic_tool_function(**kwargs) -> str:
2331
- """Execute ToolUniverse tool with provided arguments."""
2332
- try:
2333
- # Filter out None values for optional parameters
2334
- args_dict = {k: v for k, v in kwargs.items() if v is not None}
2335
-
2336
- # Validate required parameters
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
- # Execute in thread pool to avoid blocking
2354
- loop = asyncio.get_event_loop()
2355
- result = await loop.run_in_executor(
2356
- self.executor,
2357
- self.tooluniverse.run_one_function,
2358
- function_call,
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
- # Format the result
2362
- if isinstance(result, str):
2363
- return result
2364
- else:
2365
- return json.dumps(result, indent=2, default=str)
2366
-
2367
- except Exception as e:
2368
- error_msg = f"Error executing {tool_name}: {str(e)}"
2369
- self.logger.error(error_msg)
2370
- return json.dumps({"error": error_msg}, indent=2)
2371
-
2372
- # Set function metadata
2373
- dynamic_tool_function.__name__ = tool_name
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
- # Set function signature dynamically for tools with parameters
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
- # Set annotations for type hints
2380
- dynamic_tool_function.__annotations__ = param_annotations.copy()
2381
- dynamic_tool_function.__annotations__["return"] = str
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
- import os
22
- cwd = os.getcwd()
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": "tooluniverse-smcp-stdio",
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
+