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/smcp.py CHANGED
@@ -1389,7 +1389,7 @@ class SMCP(FastMCP):
1389
1389
  self.tool_finder_available = True
1390
1390
  self.tool_finder_type = "Tool_Finder_LLM"
1391
1391
  self.logger.info(
1392
- "✅ Tool_Finder_LLM (cost-optimized) available for advanced search"
1392
+ "✅ Tool_Finder_LLM available for advanced search"
1393
1393
  )
1394
1394
  return
1395
1395
 
@@ -1879,6 +1879,91 @@ class SMCP(FastMCP):
1879
1879
  except Exception:
1880
1880
  pass
1881
1881
 
1882
+ def _print_tooluniverse_banner(self):
1883
+ """Print ToolUniverse branding banner after FastMCP banner with dynamic information."""
1884
+ # Get transport info if available
1885
+ transport_display = getattr(self, '_transport_type', 'Unknown')
1886
+ server_url = getattr(self, '_server_url', 'N/A')
1887
+ tools_count = len(self._exposed_tools)
1888
+
1889
+ # Map transport types to display names
1890
+ transport_map = {
1891
+ 'stdio': 'STDIO',
1892
+ 'streamable-http': 'Streamable-HTTP',
1893
+ 'http': 'HTTP',
1894
+ 'sse': 'SSE'
1895
+ }
1896
+ transport_name = transport_map.get(transport_display, transport_display)
1897
+
1898
+ # Format lines with proper alignment (matching FastMCP style)
1899
+ # Each line should be exactly 75 characters (emoji takes 2 display widths but counts as 1 in len())
1900
+ transport_line = f" 📦 Transport: {transport_name}"
1901
+ server_line = f" 🔗 Server URL: {server_url}"
1902
+ tools_line = f" 🧰 Loaded Tools: {tools_count}"
1903
+
1904
+ # Pad to exactly 75 characters (emoji counts as 1 in len() but displays as 2)
1905
+ transport_line = transport_line + " " * (75 - len(transport_line))
1906
+ server_line = server_line + " " * (75 - len(server_line))
1907
+ tools_line = tools_line + " " * (75 - len(tools_line))
1908
+
1909
+ banner = f"""
1910
+ ╭────────────────────────────────────────────────────────────────────────────╮
1911
+ │ │
1912
+ │ 🧬 ToolUniverse SMCP Server 🧬 │
1913
+ │ │
1914
+ │ Bridging AI Agents with Scientific Computing Tools │
1915
+ │ │
1916
+ │{transport_line}│
1917
+ │{server_line}│
1918
+ │{tools_line}│
1919
+ │ │
1920
+ │ 🌐 Website: https://aiscientist.tools/ │
1921
+ │ 💻 GitHub: https://github.com/mims-harvard/ToolUniverse │
1922
+ │ │
1923
+ ╰────────────────────────────────────────────────────────────────────────────╯
1924
+ """
1925
+ print(banner)
1926
+
1927
+ def run(self, *args, **kwargs):
1928
+ """
1929
+ Override run method to display ToolUniverse banner after FastMCP banner.
1930
+
1931
+ This method intercepts the parent's run() call to inject our custom banner
1932
+ immediately after FastMCP displays its startup banner.
1933
+ """
1934
+ # Save transport information for banner display
1935
+ transport = kwargs.get('transport', args[0] if args else 'unknown')
1936
+ host = kwargs.get('host', '0.0.0.0')
1937
+ port = kwargs.get('port', 7000)
1938
+
1939
+ self._transport_type = transport
1940
+
1941
+ # Build server URL based on transport
1942
+ if transport == 'streamable-http' or transport == 'http':
1943
+ self._server_url = f"http://{host}:{port}/mcp"
1944
+ elif transport == 'sse':
1945
+ self._server_url = f"http://{host}:{port}"
1946
+ else:
1947
+ self._server_url = "N/A (stdio mode)"
1948
+
1949
+ # Use threading to print our banner shortly after FastMCP's banner
1950
+ import threading
1951
+ import time
1952
+
1953
+ def delayed_banner():
1954
+ """Print ToolUniverse banner with a small delay to appear after FastMCP banner."""
1955
+ time.sleep(1.0) # Delay to ensure FastMCP banner displays first
1956
+ self._print_tooluniverse_banner()
1957
+
1958
+ # Start banner thread only on first run
1959
+ if not hasattr(self, '_tooluniverse_banner_shown'):
1960
+ self._tooluniverse_banner_shown = True
1961
+ banner_thread = threading.Thread(target=delayed_banner, daemon=True)
1962
+ banner_thread.start()
1963
+
1964
+ # Call parent's run method (blocking call)
1965
+ return super().run(*args, **kwargs)
1966
+
1882
1967
  def run_simple(
1883
1968
  self,
1884
1969
  transport: Literal["stdio", "http", "sse"] = "http",
@@ -2085,120 +2170,127 @@ class SMCP(FastMCP):
2085
2170
  func_params = []
2086
2171
  param_annotations = {}
2087
2172
 
2088
- for param_name, param_info in properties.items():
2089
- param_type = param_info.get("type", "string")
2090
- param_description = param_info.get(
2091
- "description", f"{param_name} parameter"
2092
- )
2093
- is_required = param_name in required_params
2094
-
2095
- # Map JSON schema types to Python types and create appropriate Field
2096
- field_kwargs = {"description": param_description}
2097
-
2098
- if param_type == "string":
2099
- python_type = str
2100
- # For string type, don't add json_schema_extra - let Pydantic handle it
2101
- elif param_type == "integer":
2102
- python_type = int
2103
- # For integer type, don't add json_schema_extra - let Pydantic handle it
2104
- elif param_type == "number":
2105
- python_type = float
2106
- # For number type, don't add json_schema_extra - let Pydantic handle it
2107
- elif param_type == "boolean":
2108
- python_type = bool
2109
- # For boolean type, don't add json_schema_extra - let Pydantic handle it
2110
- elif param_type == "array":
2111
- python_type = list
2112
- # Add array-specific schema information only for complex cases
2113
- items_info = param_info.get("items", {})
2114
- if items_info:
2115
- # Clean up items definition - remove invalid fields
2116
- cleaned_items = items_info.copy()
2117
-
2118
- # Remove 'required' field from items (not valid in JSON Schema for array items)
2119
- if "required" in cleaned_items:
2120
- cleaned_items.pop("required")
2121
-
2122
- field_kwargs["json_schema_extra"] = {
2123
- "type": "array",
2124
- "items": cleaned_items,
2125
- }
2173
+ # Process parameters in two phases: required first, then optional
2174
+ # This ensures Python function signature validity (no default args before non-default)
2175
+ for is_required_phase in [True, False]:
2176
+ for param_name, param_info in properties.items():
2177
+ param_type = param_info.get("type", "string")
2178
+ param_description = param_info.get(
2179
+ "description", f"{param_name} parameter"
2180
+ )
2181
+ is_required = param_name in required_params
2182
+
2183
+ # Skip if not in current phase
2184
+ if is_required != is_required_phase:
2185
+ continue
2186
+
2187
+ # Map JSON schema types to Python types and create appropriate Field
2188
+ field_kwargs = {"description": param_description}
2189
+
2190
+ if param_type == "string":
2191
+ python_type = str
2192
+ # For string type, don't add json_schema_extra - let Pydantic handle it
2193
+ elif param_type == "integer":
2194
+ python_type = int
2195
+ # For integer type, don't add json_schema_extra - let Pydantic handle it
2196
+ elif param_type == "number":
2197
+ python_type = float
2198
+ # For number type, don't add json_schema_extra - let Pydantic handle it
2199
+ elif param_type == "boolean":
2200
+ python_type = bool
2201
+ # For boolean type, don't add json_schema_extra - let Pydantic handle it
2202
+ elif param_type == "array":
2203
+ python_type = list
2204
+ # Add array-specific schema information only for complex cases
2205
+ items_info = param_info.get("items", {})
2206
+ if items_info:
2207
+ # Clean up items definition - remove invalid fields
2208
+ cleaned_items = items_info.copy()
2209
+
2210
+ # Remove 'required' field from items (not valid in JSON Schema for array items)
2211
+ if "required" in cleaned_items:
2212
+ cleaned_items.pop("required")
2213
+
2214
+ field_kwargs["json_schema_extra"] = {
2215
+ "type": "array",
2216
+ "items": cleaned_items,
2217
+ }
2218
+ else:
2219
+ # If no items specified, default to string items
2220
+ field_kwargs["json_schema_extra"] = {
2221
+ "type": "array",
2222
+ "items": {"type": "string"},
2223
+ }
2224
+ elif param_type == "object":
2225
+ python_type = dict
2226
+ # Add object-specific schema information
2227
+ object_props = param_info.get("properties", {})
2228
+ if object_props:
2229
+ # Clean up the nested object properties - fix common schema issues
2230
+ cleaned_props = {}
2231
+ nested_required = []
2232
+
2233
+ for prop_name, prop_info in object_props.items():
2234
+ cleaned_prop = prop_info.copy()
2235
+
2236
+ # Fix string "True"/"False" in required field (common ToolUniverse issue)
2237
+ if "required" in cleaned_prop:
2238
+ req_value = cleaned_prop.pop("required")
2239
+ if req_value in ["True", "true", True]:
2240
+ nested_required.append(prop_name)
2241
+ # Remove the individual required field as it should be at object level
2242
+
2243
+ cleaned_props[prop_name] = cleaned_prop
2244
+
2245
+ # Create proper JSON schema for nested object
2246
+ object_schema = {"type": "object", "properties": cleaned_props}
2247
+
2248
+ # Add required array at object level if there are required fields
2249
+ if nested_required:
2250
+ object_schema["required"] = nested_required
2251
+
2252
+ field_kwargs["json_schema_extra"] = object_schema
2126
2253
  else:
2127
- # If no items specified, default to string items
2128
- field_kwargs["json_schema_extra"] = {
2129
- "type": "array",
2130
- "items": {"type": "string"},
2131
- }
2132
- elif param_type == "object":
2133
- python_type = dict
2134
- # Add object-specific schema information
2135
- object_props = param_info.get("properties", {})
2136
- if object_props:
2137
- # Clean up the nested object properties - fix common schema issues
2138
- cleaned_props = {}
2139
- nested_required = []
2140
-
2141
- for prop_name, prop_info in object_props.items():
2142
- cleaned_prop = prop_info.copy()
2143
-
2144
- # Fix string "True"/"False" in required field (common ToolUniverse issue)
2145
- if "required" in cleaned_prop:
2146
- req_value = cleaned_prop.pop("required")
2147
- if req_value in ["True", "true", True]:
2148
- nested_required.append(prop_name)
2149
- # Remove the individual required field as it should be at object level
2150
-
2151
- cleaned_props[prop_name] = cleaned_prop
2152
-
2153
- # Create proper JSON schema for nested object
2154
- object_schema = {"type": "object", "properties": cleaned_props}
2155
-
2156
- # Add required array at object level if there are required fields
2157
- if nested_required:
2158
- object_schema["required"] = nested_required
2159
-
2160
- field_kwargs["json_schema_extra"] = object_schema
2161
- else:
2162
- # For unknown types, default to string and only add type info if it's truly unknown
2163
- python_type = str
2164
- if param_type not in [
2165
- "string",
2166
- "integer",
2167
- "number",
2168
- "boolean",
2169
- "array",
2170
- "object",
2171
- ]:
2172
- field_kwargs["json_schema_extra"] = {"type": param_type}
2173
-
2174
- # Create Pydantic Field with enhanced schema information
2175
- pydantic_field = Field(**field_kwargs)
2176
-
2177
- if is_required:
2178
- # Required parameter with description and schema info
2179
- annotated_type = Annotated[python_type, pydantic_field]
2180
- param_annotations[param_name] = annotated_type
2181
- func_params.append(
2182
- inspect.Parameter(
2183
- param_name,
2184
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
2185
- annotation=annotated_type,
2254
+ # For unknown types, default to string and only add type info if it's truly unknown
2255
+ python_type = str
2256
+ if param_type not in [
2257
+ "string",
2258
+ "integer",
2259
+ "number",
2260
+ "boolean",
2261
+ "array",
2262
+ "object",
2263
+ ]:
2264
+ field_kwargs["json_schema_extra"] = {"type": param_type}
2265
+
2266
+ # Create Pydantic Field with enhanced schema information
2267
+ pydantic_field = Field(**field_kwargs)
2268
+
2269
+ if is_required:
2270
+ # Required parameter with description and schema info
2271
+ annotated_type = Annotated[python_type, pydantic_field]
2272
+ param_annotations[param_name] = annotated_type
2273
+ func_params.append(
2274
+ inspect.Parameter(
2275
+ param_name,
2276
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
2277
+ annotation=annotated_type,
2278
+ )
2186
2279
  )
2187
- )
2188
- else:
2189
- # Optional parameter with description, schema info and default value
2190
- annotated_type = Annotated[
2191
- Union[python_type, type(None)], pydantic_field
2192
- ]
2193
- param_annotations[param_name] = annotated_type
2194
- func_params.append(
2195
- inspect.Parameter(
2196
- param_name,
2197
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
2198
- default=None,
2199
- annotation=annotated_type,
2280
+ else:
2281
+ # Optional parameter with description, schema info and default value
2282
+ annotated_type = Annotated[
2283
+ Union[python_type, type(None)], pydantic_field
2284
+ ]
2285
+ param_annotations[param_name] = annotated_type
2286
+ func_params.append(
2287
+ inspect.Parameter(
2288
+ param_name,
2289
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
2290
+ default=None,
2291
+ annotation=annotated_type,
2292
+ )
2200
2293
  )
2201
- )
2202
2294
 
2203
2295
  # Create the async function with dynamic signature
2204
2296
  if not properties:
@@ -111,7 +111,6 @@ Examples:
111
111
  auto_expose_tools=True,
112
112
  search_enabled=True,
113
113
  max_workers=5,
114
- stateless_http=True, # Enable stateless mode for MCPAutoLoaderTool compatibility
115
114
  hooks_enabled=hooks_enabled,
116
115
  hook_config=hook_config,
117
116
  hook_type=args.hook_type,
@@ -254,8 +253,8 @@ Examples:
254
253
  # Server configuration (stdio-specific)
255
254
  parser.add_argument(
256
255
  "--name",
257
- default="SMCP ToolUniverse Server",
258
- help="Server name (default: SMCP ToolUniverse Server)",
256
+ default="ToolUniverse SMCP Server",
257
+ help="Server name (default: ToolUniverse SMCP Server)",
259
258
  )
260
259
  parser.add_argument(
261
260
  "--no-search",
@@ -528,7 +527,6 @@ Examples:
528
527
  exclude_tool_types=exclude_tool_types,
529
528
  search_enabled=not args.no_search,
530
529
  max_workers=args.max_workers,
531
- stateless_http=True, # Enable stateless mode for MCPAutoLoaderTool compatibility
532
530
  hooks_enabled=hooks_enabled,
533
531
  hook_config=hook_config,
534
532
  hook_type=hook_type,
@@ -685,8 +683,8 @@ Examples:
685
683
  )
686
684
  parser.add_argument(
687
685
  "--name",
688
- default="SMCP ToolUniverse Server",
689
- help="Server name (default: SMCP ToolUniverse Server)",
686
+ default="ToolUniverse SMCP Server",
687
+ help="Server name (default: ToolUniverse SMCP Server)",
690
688
  )
691
689
  parser.add_argument(
692
690
  "--no-search",
@@ -956,7 +954,6 @@ Examples:
956
954
  exclude_tool_types=exclude_tool_types,
957
955
  search_enabled=not args.no_search,
958
956
  max_workers=args.max_workers,
959
- stateless_http=True, # Enable stateless mode for MCPAutoLoaderTool compatibility
960
957
  hooks_enabled=hooks_enabled,
961
958
  hook_config=hook_config,
962
959
  hook_type=args.hook_type,
@@ -0,0 +1,86 @@
1
+ import asyncio
2
+
3
+ from claude_agent_sdk import ClaudeAgentOptions, query
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+
9
+ async def delegate_task(
10
+ prompt: str,
11
+ append_system_prompt: str,
12
+ ):
13
+ """Delegate a task to an expert
14
+
15
+ Args:
16
+ prompt: The prompt describing the task to delegate
17
+ append_system_prompt: The system prompt describing the expert
18
+ Returns:
19
+ The result of the delegation
20
+ """
21
+ import os
22
+ cwd = os.getcwd()
23
+
24
+ async for message in query(
25
+ prompt=prompt,
26
+ options=ClaudeAgentOptions(
27
+ system_prompt={
28
+ "type": "preset",
29
+ "preset": "claude_code",
30
+ "append": append_system_prompt,
31
+ }, # Use the preset
32
+ cwd=cwd,
33
+ permission_mode="bypassPermissions",
34
+ mcp_servers={
35
+ "tooluniverse": {
36
+ "type": "stdio",
37
+ "command": "tooluniverse-smcp-stdio",
38
+ "args": [],
39
+ "env": {},
40
+ },
41
+ },
42
+ ),
43
+ ):
44
+ # Print all message types to see tool usage
45
+ message_type = type(message).__name__
46
+ print(f"\n--- Message Type: {message_type} ---")
47
+
48
+ # Check for tool use messages
49
+ if message_type == "ToolUseMessage":
50
+ print(f"Tool Name: {message.name}")
51
+ print(f"Tool Input: {message.input}")
52
+ elif message_type == "ToolResultMessage":
53
+ print(f"Tool: {message.tool_use_id}")
54
+ result_preview = (
55
+ str(message.content)[:200]
56
+ if hasattr(message, "content")
57
+ else str(message)[:200]
58
+ )
59
+ print(f"Result: {result_preview}...")
60
+ elif message_type == "TextMessage":
61
+ text_preview = (
62
+ str(message.text)[:200]
63
+ if hasattr(message, "text")
64
+ else str(message)[:200]
65
+ )
66
+ print(f"Text: {text_preview}...")
67
+ elif message_type == "ResultMessage":
68
+ return {
69
+ "status": "success",
70
+ "result": message.result,
71
+ }
72
+ else:
73
+ # Print any other message types for debugging
74
+ print(f"Message: {message}")
75
+
76
+
77
+ if __name__ == "__main__":
78
+ # result = asyncio.run(conduct_research("What is the capital of France?"))
79
+ # print(result)
80
+ result = asyncio.run(
81
+ delegate_task(
82
+ "What tools do you have available? Return a list of all available tool names",
83
+ "You are a helpful assistant",
84
+ )
85
+ )
86
+ print(result)
@@ -0,0 +1,166 @@
1
+ import json
2
+ import os
3
+ from tooluniverse import ToolUniverse
4
+ import pytest
5
+
6
+ schema_path = os.path.join(os.path.dirname(__file__), "..", "data", "odphp_tools.json")
7
+ with open(schema_path) as f:
8
+ schemas = {tool["name"]: tool["return_schema"] for tool in json.load(f)}
9
+
10
+ tooluni = ToolUniverse()
11
+ tooluni.load_tools()
12
+
13
+
14
+ def summarize_result(tool_name, res):
15
+ if isinstance(res, str):
16
+ return f"{tool_name}: INVALID Raw string response: {res[:200]}..."
17
+ if isinstance(res, dict):
18
+ if "error" in res:
19
+ return f"{tool_name}: ERROR {res['error']}"
20
+ data = res.get("data", {})
21
+ total = data.get("Total") if isinstance(data, dict) else None
22
+ msg = f"{tool_name}: SUCCESS"
23
+ if isinstance(total, int):
24
+ msg += f" | Total={total}"
25
+
26
+ if tool_name == "odphp_myhealthfinder":
27
+ heading = data.get("MyHFHeading", "")
28
+ resources = (data.get("Resources", {}).get("All", {}).get("Resource", [])) or []
29
+ first_title = resources[0].get("Title") if resources else None
30
+ msg += f" | Heading='{heading[:60]}...'"
31
+ if first_title:
32
+ msg += f" | FirstResource='{first_title}'"
33
+ callouts = (data.get("Callouts", {}).get("All", {}).get("Resource", [])) or []
34
+ if callouts and callouts[0].get("MyHFTitle"):
35
+ msg += f" | FirstCallout='{callouts[0].get('MyHFTitle')}'"
36
+
37
+ elif tool_name == "odphp_itemlist":
38
+ items = data.get("Items", {}).get("Item", []) or []
39
+ titles = [i.get("Title") for i in items[:3]]
40
+ if titles:
41
+ msg += f" | ExampleItems={titles}"
42
+
43
+ elif tool_name == "odphp_topicsearch":
44
+ resources = data.get("Resources", {}).get("Resource", []) or []
45
+ titles = [r.get("Title") for r in resources[:3]]
46
+ if titles:
47
+ msg += f" | ExampleTopics={titles}"
48
+
49
+ elif tool_name.startswith("odphp_outlink_fetch"):
50
+ results = res.get("results") or []
51
+ if results:
52
+ first = results[0]
53
+ msg += f" | url={first.get('url')} status={first.get('status')} type={first.get('content_type')}"
54
+ if first.get("title"):
55
+ msg += f" | Title='{first['title'][:50]}...'"
56
+ if first.get("text"):
57
+ snippet = first["text"][:80].replace("\n", " ")
58
+ msg += f" | TextSnippet='{snippet}...'"
59
+
60
+ expected_keys = schemas.get(tool_name, {}).get("properties", {}).keys()
61
+ missing = [k for k in expected_keys if k not in data and k not in res]
62
+ if missing:
63
+ msg += f" | WARNING: Missing keys {missing}"
64
+ else:
65
+ msg += " | Schema OK"
66
+ return msg
67
+ return f"{tool_name}: INVALID Unexpected type {type(res)}"
68
+
69
+
70
+ def test_01_myhealthfinder_valid():
71
+ res = tooluni.run({"name": "odphp_myhealthfinder",
72
+ "arguments": {"age": 35, "sex": "female", "pregnant": "no", "lang": "en"}})
73
+ print(summarize_result("odphp_myhealthfinder", res))
74
+ assert isinstance(res, dict) and not res.get("error")
75
+
76
+
77
+ def test_02_itemlist_valid():
78
+ res = tooluni.run({"name": "odphp_itemlist", "arguments": {"type": "topic", "lang": "en"}})
79
+ print(summarize_result("odphp_itemlist", res))
80
+ assert isinstance(res, dict) and not res.get("error")
81
+
82
+
83
+ def test_03_topicsearch_keyword_valid():
84
+ res = tooluni.run({"name": "odphp_topicsearch", "arguments": {"keyword": "cancer", "lang": "en"}})
85
+ print(summarize_result("odphp_topicsearch", res))
86
+ assert isinstance(res, dict) and not res.get("error")
87
+
88
+
89
+ def test_04_invalid_types_fail_fast():
90
+ r1 = tooluni.run({"name": "odphp_myhealthfinder", "arguments": {"age": "banana"}})
91
+ r2 = tooluni.run({"name": "odphp_topicsearch", "arguments": {"topicId": 123}})
92
+ print("Expected type errors:", r1, r2)
93
+ assert isinstance(r1, str) and "Type mismatches" in r1
94
+ assert isinstance(r2, str) and "Type mismatches" in r2
95
+
96
+
97
+ def test_05_sections_case_and_strip_html():
98
+ res = tooluni.run({"name": "odphp_topicsearch",
99
+ "arguments": {"keyword": "Keep Your Heart Healthy", "lang": "en", "strip_html": True}})
100
+ print(summarize_result("odphp_topicsearch", res))
101
+ assert isinstance(res, dict) and not res.get("error")
102
+ data = res.get("data") or {}
103
+ resources = (data.get("Resources") or {}).get("Resource") or []
104
+ if resources:
105
+ s_any = resources[0].get("Sections", {})
106
+ arr = s_any.get("Section") or s_any.get("section") or []
107
+ assert isinstance(arr, list)
108
+ assert "PlainSections" in resources[0]
109
+
110
+
111
+ def test_06_outlink_fetch_accessible_version():
112
+ url = "https://odphp.health.gov/myhealthfinder/health-conditions/heart-health/keep-your-heart-healthy"
113
+ res = tooluni.run({"name": "odphp_outlink_fetch",
114
+ "arguments": {"urls": [url], "max_chars": 4000}})
115
+ print(summarize_result("odphp_outlink_fetch", res))
116
+ assert isinstance(res, dict) and not res.get("error")
117
+ results = res.get("results") or []
118
+ assert results and results[0].get("status") in (200, 301, 302)
119
+ if "text/html" in (results[0].get("content_type") or ""):
120
+ assert len(results[0].get("text", "")) > 100
121
+
122
+
123
+ def test_07_itemlist_spanish():
124
+ res = tooluni.run({"name": "odphp_itemlist", "arguments": {"type": "topic", "lang": "es"}})
125
+ print(summarize_result("odphp_itemlist", res))
126
+ assert isinstance(res, dict) and not res.get("error")
127
+
128
+
129
+ def test_08_topicsearch_by_category():
130
+ cats = tooluni.run({"name": "odphp_itemlist", "arguments": {"type": "category", "lang": "en"}})
131
+ first_cat = (cats.get("data", {}).get("Items", {}).get("Item") or [])[0]
132
+ cid = first_cat.get("Id")
133
+ res = tooluni.run({"name": "odphp_topicsearch", "arguments": {"categoryId": cid, "lang": "en"}})
134
+ print(summarize_result("odphp_topicsearch", res))
135
+ assert isinstance(res, dict) and not res.get("error")
136
+
137
+ def test_09_outlink_fetch_pdf():
138
+ url = "https://odphp.health.gov/sites/default/files/2021-12/DGA_Pregnancy_FactSheet-508.pdf"
139
+ res = tooluni.run({"name": "odphp_outlink_fetch",
140
+ "arguments": {"urls": [url], "max_chars": 1000}})
141
+ print(summarize_result("odphp_outlink_fetch_pdf", res))
142
+
143
+ assert isinstance(res, dict) and not res.get("error")
144
+ results = res.get("results") or []
145
+ assert results, "No results returned for PDF URL"
146
+
147
+ ctype = results[0].get("content_type", "")
148
+ assert ctype.startswith("application/pdf"), f"Expected PDF but got {ctype}"
149
+
150
+ # Ensure text extraction worked at least partially
151
+ text = results[0].get("text", "")
152
+ assert text and len(text) > 50, "Extracted PDF text too short"
153
+
154
+
155
+ if __name__ == "__main__":
156
+ print("\nRunning ODPHP tool tests...\n")
157
+ test_01_myhealthfinder_valid()
158
+ test_02_itemlist_valid()
159
+ test_03_topicsearch_keyword_valid()
160
+ test_04_invalid_types_fail_fast()
161
+ test_05_sections_case_and_strip_html()
162
+ test_06_outlink_fetch_accessible_version()
163
+ test_07_itemlist_spanish()
164
+ test_08_topicsearch_by_category()
165
+ test_09_outlink_fetch_pdf()
166
+ print("\nAll ODPHP tests executed.\n")