|
|
""" |
|
|
Nuthatch MCP Server |
|
|
Wraps Nuthatch API v2.3.1 as reusable MCP tools |
|
|
Runs locally with FastMCP and supports STDIO transport |
|
|
|
|
|
Features: |
|
|
- 7 core tools for bird species reference data |
|
|
- Rich media: images (Unsplash) + audio (xeno-canto.org) |
|
|
- Taxonomic search and conservation status filtering |
|
|
- JSON responses for easy integration |
|
|
- Rate limiting and error handling |
|
|
|
|
|
Difference from eBird: |
|
|
- eBird: Real-time sightings, location-based hotspots |
|
|
- Nuthatch: Species reference, images, audio, taxonomy |
|
|
- Focus: "what is this bird?" vs. "Where can I see it?" |
|
|
""" |
|
|
import os |
|
|
import sys |
|
|
import requests |
|
|
import json |
|
|
import time |
|
|
from typing import Optional, Dict, List, Any |
|
|
from fastmcp import FastMCP |
|
|
from dotenv import load_dotenv |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
load_dotenv() |
|
|
|
|
|
NUTHATCH_API_KEY = os.getenv("NUTHATCH_API_KEY") |
|
|
NUTHATCH_BASE_URL = os.getenv("NUTHATCH_BASE_URL", "https://nuthatch.lastelm.software/v2") |
|
|
DEFAULT_TIMEOUT = 15 |
|
|
|
|
|
|
|
|
|
|
|
RATE_LIMIT_DELAY = 1.0 |
|
|
|
|
|
if not NUTHATCH_API_KEY: |
|
|
|
|
|
print("[WARNING]: NUTHATCH_API_KEY not found in .env", file=sys.stderr) |
|
|
print(" Get one from: https://nuthatch.lastelm.software/", file=sys.stderr) |
|
|
|
|
|
|
|
|
ENABLED_TOOLS = { |
|
|
"search_birds": True, |
|
|
"get_bird_info": True, |
|
|
"get_bird_images": True, |
|
|
"get_bird_audio": True, |
|
|
"search_by_family": True, |
|
|
"filter_by_status": True, |
|
|
"get_all_families": True, |
|
|
} |
|
|
|
|
|
|
|
|
NUTHATCH_MCP_AUTH_KEY = os.getenv("NUTHATCH_MCP_AUTH_KEY") |
|
|
|
|
|
|
|
|
if NUTHATCH_MCP_AUTH_KEY: |
|
|
|
|
|
from fastmcp.server.auth.providers.debug import DebugTokenVerifier |
|
|
|
|
|
auth = DebugTokenVerifier( |
|
|
validate=lambda token: token == NUTHATCH_MCP_AUTH_KEY, |
|
|
client_id="nuthatch-mcp-client" |
|
|
) |
|
|
mcp = FastMCP("Nuthatch Bird Reference", auth=auth) |
|
|
else: |
|
|
|
|
|
mcp = FastMCP("Nuthatch Bird Reference") |
|
|
|
|
|
|
|
|
_last_request_time = 0 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rate_limit(): |
|
|
"""Enforce rate limiting to avoid exceeding Nuthatch's API limits (500/hour)""" |
|
|
global _last_request_time |
|
|
elapsed = time.time() - _last_request_time |
|
|
if elapsed < RATE_LIMIT_DELAY: |
|
|
time.sleep(RATE_LIMIT_DELAY - elapsed) |
|
|
_last_request_time = time.time() |
|
|
|
|
|
|
|
|
def _make_request(endpoint: str, params: Optional[Dict] = None) -> Optional[Dict]: |
|
|
""" |
|
|
Centralized request handler with error handling and rate limiting. |
|
|
|
|
|
IMPORTANT: Header name is case-sensitive! "API-Key" |
|
|
|
|
|
Args: |
|
|
endpoint: API endpoint path (e.g., "/birds") |
|
|
params: Query parameters dictionary |
|
|
|
|
|
Returns: |
|
|
JSON response data or None on error |
|
|
""" |
|
|
_rate_limit() |
|
|
try: |
|
|
headers = {"API-Key": NUTHATCH_API_KEY} |
|
|
url = f"{NUTHATCH_BASE_URL}{endpoint}" |
|
|
response = requests.get( |
|
|
url, |
|
|
headers=headers, |
|
|
params=params or {}, |
|
|
timeout=DEFAULT_TIMEOUT, |
|
|
) |
|
|
|
|
|
if response.status_code == 200: |
|
|
return response.json() |
|
|
elif response.status_code == 400: |
|
|
print(f" Bad Request ({url}): {response.text[:400]}", flush=True) |
|
|
return None |
|
|
elif response.status_code == 401: |
|
|
print(f" Unauthorized ({url}): Check your NUTHATCH_API_KEY - body={response.text[:400]}", flush=True) |
|
|
return None |
|
|
elif response.status_code == 404: |
|
|
print(f" Not found ({url}): Invalid endpoint or resource - body={response.text[:400]}", flush=True) |
|
|
return None |
|
|
else: |
|
|
print( |
|
|
f" HTTP {response.status_code} for {url} " |
|
|
f"params={params or {}} body={response.text[:400]}", |
|
|
flush=True, |
|
|
) |
|
|
return None |
|
|
|
|
|
except requests.Timeout: |
|
|
print(f" Request timeout after {DEFAULT_TIMEOUT}s for {endpoint}", flush=True) |
|
|
return None |
|
|
except requests.ConnectionError: |
|
|
print(f" Connection error calling {endpoint} - check network", flush=True) |
|
|
return None |
|
|
except Exception as e: |
|
|
print(f" Unexpected error calling {endpoint}: {str(e)}", flush=True) |
|
|
return None |
|
|
|
|
|
|
|
|
def _format_success_response(data: Any, **kwargs) -> str: |
|
|
"""Format a successful response as JSON""" |
|
|
response = {"status": "success", "data": data} |
|
|
response.update(kwargs) |
|
|
return json.dumps(response) |
|
|
|
|
|
|
|
|
def _format_error_response(error: str) -> str: |
|
|
"""Format an error response as JSON""" |
|
|
return json.dumps({"status": "error", "error": error}) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def search_birds( |
|
|
name: str = "", |
|
|
family: str = "", |
|
|
region: str = "", |
|
|
status: str = "", |
|
|
has_images: bool = True, |
|
|
page_size: int = 10 |
|
|
) -> str: |
|
|
""" |
|
|
Search for bird species using multiple filters. |
|
|
|
|
|
Comprehensive search tool that combines name, taxonomy, geography, and media filters. |
|
|
Great for exploratory queries like "show me all cardinals" or "endangered birds". |
|
|
|
|
|
Can accept: |
|
|
- User input: "cardinals", "eagles", "finches" |
|
|
- Classifier output: "Northern Cardinal" -> search for similar species |
|
|
- Taxonomic queries: family="Cardinalidae" |
|
|
|
|
|
Args: |
|
|
name: Common or scientific name (partial match) |
|
|
family: Scientific family name (e.g., "Cardinalidae", "Anatidae") |
|
|
region: Geographic region ("North America", "Western Europe") |
|
|
status: Conservation status ("Low Concern", "Endangered", etc.) |
|
|
has_images: Only returns birds with images (default: True) |
|
|
page_size: Maximum results to return (max: 100) |
|
|
|
|
|
Returns: |
|
|
JSON with matching birds and their basic info |
|
|
|
|
|
Example: |
|
|
search_birds(name="cardinal", has_images=True) |
|
|
-> Returns all cardinal species with images |
|
|
""" |
|
|
if not name and not family and not region and not status: |
|
|
return _format_error_response("At least one search filter required") |
|
|
|
|
|
if page_size > 100: |
|
|
page_size = 100 |
|
|
|
|
|
try: |
|
|
params = {"pageSize": page_size} |
|
|
|
|
|
if has_images is not None: |
|
|
params["hasImg"] = "true" if has_images else "false" |
|
|
if name: |
|
|
|
|
|
params["name"] = name.lower() |
|
|
if family: |
|
|
|
|
|
params["family"] = family.lower() |
|
|
if region: |
|
|
params["region"] = region |
|
|
if status: |
|
|
params["status"] = status |
|
|
|
|
|
data = _make_request("/birds", params) |
|
|
|
|
|
if data is None: |
|
|
return _format_error_response("Failed to fetch birds") |
|
|
|
|
|
if not data.get('entities'): |
|
|
return _format_success_response( |
|
|
[], |
|
|
count=0, |
|
|
total_count=0, |
|
|
filters={"name": name, "family": family, "region": region, "status": status} |
|
|
) |
|
|
|
|
|
|
|
|
birds = [ |
|
|
{ |
|
|
"name": bird['name'], |
|
|
"scientific_name": bird['sciName'], |
|
|
"family": bird.get('family', 'Unknown'), |
|
|
"order": bird.get('order', 'Unknown'), |
|
|
"status": bird.get('status', 'Unknown'), |
|
|
"region": bird.get('region', []), |
|
|
"image_count": len(bird.get('images', [])), |
|
|
"has_audio": len(bird.get('recordings', [])) > 0 |
|
|
} |
|
|
for bird in data['entities'] |
|
|
] |
|
|
|
|
|
return _format_success_response( |
|
|
birds, |
|
|
count=len(birds), |
|
|
total_count=data.get('totalCount', 0), |
|
|
filters={"name": name, "family": family, "region": region, "status": status} |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
return _format_error_response(f"Search failed: {str(e)}") |
|
|
|
|
|
|
|
|
|
|
|
mcp.tool()(search_birds) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_bird_info(name: str) -> str: |
|
|
""" |
|
|
Get comprehensive information about a specific bird species. |
|
|
|
|
|
Returns all available data: taxonomy, size, conservation status, |
|
|
image/audio counts, and geographic range. |
|
|
|
|
|
Can accept: |
|
|
- User input: "Northern Cardinal" |
|
|
- Classifier output: Species name from image classification |
|
|
|
|
|
Args: |
|
|
name: Common or scientific name of the bird |
|
|
|
|
|
Returns: |
|
|
JSON with complete species information |
|
|
|
|
|
Example: |
|
|
get_bird_info("Northern Cardinal") |
|
|
-> Returns full details: family, size, status, media counts, etc. |
|
|
""" |
|
|
if not name or len(name.strip()) < 2: |
|
|
return _format_error_response("Bird name required (minimum 2 characters)") |
|
|
|
|
|
try: |
|
|
|
|
|
|
|
|
params = {"name": name.lower(), "pageSize": 1} |
|
|
data = _make_request("/birds", params) |
|
|
|
|
|
if data is None or not data.get('entities'): |
|
|
return _format_error_response(f"Bird '{name}' not found in database") |
|
|
|
|
|
bird = data['entities'][0] |
|
|
|
|
|
|
|
|
info = { |
|
|
"name": bird['name'], |
|
|
"scientific_name": bird['sciName'], |
|
|
"family": bird.get('family', 'Unknown'), |
|
|
"order": bird.get('order', 'Unknown'), |
|
|
"status": bird.get('status', 'Unknown'), |
|
|
"region": bird.get('region', []), |
|
|
"length_cm": { |
|
|
"min": bird.get('lengthMin'), |
|
|
"max": bird.get('lengthMax') |
|
|
} if bird.get('lengthMin') else None, |
|
|
"wingspan_cm": { |
|
|
"min": bird.get('wingspanMin'), |
|
|
"max": bird.get('wingspanMax') |
|
|
} if bird.get('wingspanMin') else None, |
|
|
"image_count": len(bird.get('images', [])), |
|
|
"audio_count": len(bird.get('recordings', [])), |
|
|
"has_images": len(bird.get('images', [])) > 0, |
|
|
"has_audio": len(bird.get('recordings', [])) > 0 |
|
|
} |
|
|
|
|
|
return _format_success_response(info, bird_name=name) |
|
|
|
|
|
except Exception as e: |
|
|
return _format_error_response(f"Lookup failed: {str(e)}") |
|
|
|
|
|
|
|
|
mcp.tool()(get_bird_info) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_bird_images(name: str, max_images: int = 5) -> str: |
|
|
""" |
|
|
Get image URLs for a bird species. |
|
|
|
|
|
Returns high-quality reference images from Unsplash and curator photos. |
|
|
Perfect for visual comparison with user's uploaded photo. |
|
|
|
|
|
Can accept: |
|
|
- User input: "Show me pictures of a cardinal" |
|
|
- Classifier output: Species name -> fetch reference images |
|
|
|
|
|
Args: |
|
|
name: Common or scientific name of the bird |
|
|
max_images: Maximum number of image URLs to return (default: 5) |
|
|
|
|
|
Returns: |
|
|
JSON with image URLs and bird identification |
|
|
|
|
|
Example: |
|
|
get_bird_images("Northern Cardinal", max_images=3) |
|
|
-> Returns 3 image URLs for visual comparison |
|
|
""" |
|
|
if not name or len(name.strip()) < 2: |
|
|
return _format_error_response("Bird name required (minimum 2 characters)") |
|
|
|
|
|
try: |
|
|
|
|
|
params = {"name": name.lower(), "pageSize": 1, "hasImg": "true"} |
|
|
data = _make_request("/birds", params) |
|
|
|
|
|
if data is None or not data.get('entities'): |
|
|
return _format_error_response(f"Bird '{name}' not found or has no images") |
|
|
|
|
|
bird = data['entities'][0] |
|
|
images = bird.get('images', []) |
|
|
|
|
|
if not images: |
|
|
return _format_error_response(f"No images available for '{bird['name']}'") |
|
|
|
|
|
return _format_success_response( |
|
|
images[:max_images], |
|
|
bird_name=bird['name'], |
|
|
scientific_name=bird['sciName'], |
|
|
total_images=len(images), |
|
|
returned_count=min(len(images), max_images) |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
return _format_error_response(f"Image lookup failed: {str(e)}") |
|
|
|
|
|
|
|
|
mcp.tool()(get_bird_images) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_bird_audio(name: str, max_recordings: int = 5) -> str: |
|
|
""" |
|
|
Get audio recordings for a bird species. |
|
|
|
|
|
Returns recordings from xeno-canto.org with location, date, and type info. |
|
|
Great for learning bird calls and songs. |
|
|
|
|
|
Can accept: |
|
|
- User input: "What does a cardinal sound like?" |
|
|
- Classifier output: Species name -> fetch audio examples |
|
|
|
|
|
Args: |
|
|
name: Common or scientific name of the bird |
|
|
max_recordings: Maximum number of recordings to return (default: 5) |
|
|
|
|
|
Returns: |
|
|
JSON with recording metadata and download URLs |
|
|
|
|
|
Example: |
|
|
get_bird_audio("Northern Cardinal", max_recordings=3) |
|
|
-> Returns 3 audio recordings with metadata |
|
|
""" |
|
|
if not name or len(name.strip()) < 2: |
|
|
return _format_error_response("Bird name required (minimum 2 characters)") |
|
|
|
|
|
try: |
|
|
|
|
|
params = {"name": name.lower(), "pageSize": 1} |
|
|
data = _make_request("/birds", params) |
|
|
|
|
|
if data is None or not data.get('entities'): |
|
|
return _format_error_response(f"Bird '{name}' not found") |
|
|
|
|
|
bird = data['entities'][0] |
|
|
recordings = bird.get('recordings', []) |
|
|
|
|
|
if not recordings: |
|
|
return _format_error_response(f"No audio recordings available for '{bird['name']}'") |
|
|
|
|
|
|
|
|
formatted_recordings = [ |
|
|
{ |
|
|
"type": rec.get('type', 'Unknown'), |
|
|
"location": rec.get('loc', 'Unknown'), |
|
|
"country": rec.get('cnt', 'Unknown'), |
|
|
"date": rec.get('date', 'Unknown'), |
|
|
"recordist": rec.get('rec', 'Unknown'), |
|
|
"file_url": rec.get('file', ''), |
|
|
"xeno_canto_url": rec.get('url', ''), |
|
|
"quality": rec.get('q', ''), |
|
|
"length": rec.get('length', 'Unknown') |
|
|
} |
|
|
for rec in recordings[:max_recordings] |
|
|
] |
|
|
|
|
|
return _format_success_response( |
|
|
formatted_recordings, |
|
|
bird_name=bird['name'], |
|
|
scientific_name=bird['sciName'], |
|
|
total_recordings=len(recordings), |
|
|
returned_count=min(len(recordings), max_recordings) |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
return _format_error_response(f"Audio lookup failed: {str(e)}") |
|
|
|
|
|
|
|
|
mcp.tool()(get_bird_audio) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def search_by_family(family_name: str, max_results: int = 20) -> str: |
|
|
""" |
|
|
Get all bird species in a taxonomic family. |
|
|
|
|
|
Great for exploring related species after identifying a bird. |
|
|
Example: "This is a cardinal. What other cardinals exist?" |
|
|
|
|
|
Can accept: |
|
|
- User input: "Show me all finches" |
|
|
- Derived from classification: After identifying a Cardinalidae member |
|
|
|
|
|
Args: |
|
|
family_name: Scientific family name (e.g., "Cardinalidae", "Fringillidae") |
|
|
max_results: Maximum species to return (default: 20) |
|
|
|
|
|
Returns: |
|
|
JSON with all species in that family |
|
|
|
|
|
Example: |
|
|
search_by_family("Cardinalidae") |
|
|
-> Returns Northern Cardinal, Pyrrhuloxia, Rose-breasted Grosbeak, etc. |
|
|
""" |
|
|
if not family_name or len(family_name.strip()) < 2: |
|
|
return _format_error_response("Family name required (minimum 2 characters)") |
|
|
|
|
|
try: |
|
|
|
|
|
params = {"family": family_name.lower(), "pageSize": min(max_results, 100)} |
|
|
data = _make_request("/birds", params) |
|
|
|
|
|
if data is None: |
|
|
return _format_error_response("Failed to fetch family data") |
|
|
|
|
|
if not data.get('entities'): |
|
|
return _format_error_response(f"No birds found in family '{family_name}'") |
|
|
|
|
|
|
|
|
birds = [ |
|
|
{ |
|
|
"name": bird['name'], |
|
|
"scientific_name": bird['sciName'], |
|
|
"status": bird.get('status', 'Unknown'), |
|
|
"region": bird.get('region', []), |
|
|
"has_images": len(bird.get('images', [])) > 0, |
|
|
"has_audio": len(bird.get('recordings', [])) > 0 |
|
|
} |
|
|
for bird in data['entities'] |
|
|
] |
|
|
|
|
|
return _format_success_response( |
|
|
birds, |
|
|
family=family_name, |
|
|
count=len(birds), |
|
|
total_count=data.get('totalCount', 0) |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
return _format_error_response(f"Family search failed: {str(e)}") |
|
|
|
|
|
|
|
|
mcp.tool()(search_by_family) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def filter_by_status(status: str, region: str = "", max_results: int = 20) -> str: |
|
|
""" |
|
|
Find birds by conservation status. |
|
|
|
|
|
Great for conservation awareness and educational purposes. |
|
|
Common statuses: "Low Concern", "Endangered", "Threatened", "Vulnerable" |
|
|
|
|
|
Can accept: |
|
|
- User input: "Show me endangered birds" |
|
|
- Educational queries: "What birds are threatened?" |
|
|
|
|
|
Args: |
|
|
status: Conservation status to filter by |
|
|
region: Optional geographic filter ("North America", "Western Europe") |
|
|
max_results: Maximum birds to return (default: 20) |
|
|
|
|
|
Returns: |
|
|
JSON with birds matching the conservation status |
|
|
|
|
|
Example: |
|
|
filter_by_status("Endangered", region="North America") |
|
|
-> Returns endangered birds in North America |
|
|
""" |
|
|
if not status or len(status.strip()) < 2: |
|
|
return _format_error_response("Conservation status required") |
|
|
|
|
|
try: |
|
|
params = {"status": status, "pageSize": min(max_results, 100)} |
|
|
if region: |
|
|
params["region"] = region |
|
|
|
|
|
data = _make_request("/birds", params) |
|
|
|
|
|
if data is None: |
|
|
return _format_error_response("Failed to fetch status data") |
|
|
|
|
|
if not data.get('entities'): |
|
|
return _format_error_response( |
|
|
f"No birds found with status '{status}'" |
|
|
+ (f" in region '{region}'" if region else "") |
|
|
) |
|
|
|
|
|
birds = [ |
|
|
{ |
|
|
"name": bird['name'], |
|
|
"scientific_name": bird['sciName'], |
|
|
"family": bird.get('family', 'Unknown'), |
|
|
"status": bird.get('status', 'Unknown'), |
|
|
"region": bird.get('region', []), |
|
|
"has_images": len(bird.get('images', [])) > 0 |
|
|
} |
|
|
for bird in data['entities'] |
|
|
] |
|
|
|
|
|
return _format_success_response( |
|
|
birds, |
|
|
status=status, |
|
|
region=region or "All regions", |
|
|
count=len(birds), |
|
|
total_count=data.get('totalCount', 0) |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
return _format_error_response(f"Status filter failed: {str(e)}") |
|
|
|
|
|
|
|
|
mcp.tool()(filter_by_status) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_all_families(region: str = "") -> str: |
|
|
""" |
|
|
Get list of all unique bird families in the database. |
|
|
|
|
|
Useful for taxonomic exploration and understanding database coverage. |
|
|
|
|
|
Can accept: |
|
|
- User input: "What families are covered?" |
|
|
- Educational queries: "Show me all bird families" |
|
|
|
|
|
Args: |
|
|
region: Optional geographic filter ("North America", "Western Europe") |
|
|
|
|
|
Returns: |
|
|
JSON with unique family names |
|
|
|
|
|
Example: |
|
|
get_all_families(region="North America") |
|
|
-> Returns ["Anatidae", "Cardinalidae", "Fringillidae", ...] |
|
|
""" |
|
|
try: |
|
|
|
|
|
params = {"pageSize": 100} |
|
|
if region: |
|
|
params["region"] = region |
|
|
|
|
|
data = _make_request("/birds", params) |
|
|
|
|
|
if data is None: |
|
|
return _format_error_response("Failed to fetch family data") |
|
|
|
|
|
if not data.get('entities'): |
|
|
return _format_error_response("No birds found") |
|
|
|
|
|
|
|
|
families = list(set( |
|
|
bird.get('family', 'Unknown') |
|
|
for bird in data['entities'] |
|
|
if bird.get('family') |
|
|
)) |
|
|
families.sort() |
|
|
|
|
|
return _format_success_response( |
|
|
families, |
|
|
region=region or "All regions", |
|
|
count=len(families), |
|
|
note="This is a sample - database may contain more families" |
|
|
) |
|
|
|
|
|
except Exception as e: |
|
|
return _format_error_response(f"Family listing failed: {str(e)}") |
|
|
|
|
|
|
|
|
mcp.tool()(get_all_families) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(): |
|
|
"""Start the MCP server with dual transport support (STDIO or HTTP).""" |
|
|
|
|
|
is_http_mode = "--http" in sys.argv or "--streamable-http" in sys.argv |
|
|
|
|
|
|
|
|
|
|
|
output = sys.stdout if is_http_mode else sys.stderr |
|
|
|
|
|
print("\n"+"="*70, file=output) |
|
|
print("π¦ [NUTHATCH MCP SERVER] - Starting...", file=output) |
|
|
print("="*70, file=output) |
|
|
print(f"[API KEY]: {'β
Configured' if NUTHATCH_API_KEY else 'β Missing'}", file=output) |
|
|
print("\n[AVAILABLE TOOLS]:", file=output) |
|
|
|
|
|
tools_list = [ |
|
|
"1. search_birds - Multi-filter bird search", |
|
|
"2. get_bird_info - Complete species information", |
|
|
"3. get_bird_images - Reference image URLs", |
|
|
"4. get_bird_audio - Audio recordings from xeno-canto", |
|
|
"5. search_by_family - All species in taxonomic family", |
|
|
"6. filter_by_status - Birds by conservation status", |
|
|
"7. get_all_families - List all bird families" |
|
|
] |
|
|
|
|
|
for tool in tools_list: |
|
|
print(f" β {tool}", file=output) |
|
|
|
|
|
print("\n[DATA SOURCE]:", file=output) |
|
|
print(" β’ Images: Unsplash + curator photos", file=output) |
|
|
print(" β’ Audio: xeno-canto.org recordings", file=output) |
|
|
print(" β’ Coverage: 1000+ species (North America, Western Europe)", file=output) |
|
|
|
|
|
print("\n"+"="*70, file=output) |
|
|
|
|
|
if is_http_mode: |
|
|
|
|
|
port = 8001 |
|
|
host = "127.0.0.1" |
|
|
|
|
|
for i, arg in enumerate(sys.argv): |
|
|
if arg == "--port" and i + 1 < len(sys.argv): |
|
|
port = int(sys.argv[i + 1]) |
|
|
elif arg == "--host" and i + 1 < len(sys.argv): |
|
|
host = sys.argv[i + 1] |
|
|
|
|
|
|
|
|
auth_status = "π Protected (auth required)" if NUTHATCH_MCP_AUTH_KEY else "π No authentication (development mode)" |
|
|
|
|
|
print("[TRANSPORT]: Starting streamable-http MCP server", file=output) |
|
|
print(f"[HOST]: {host}", file=output) |
|
|
print(f"[PORT]: {port}", file=output) |
|
|
print(f"[URL]: http://{host}:{port}", file=output) |
|
|
print(f"[AUTH]: {auth_status}", file=output) |
|
|
print("[NOTE]: This is MCP over HTTP for web clients", file=output) |
|
|
print("="*70+"\n", file=output) |
|
|
|
|
|
|
|
|
mcp.run(transport="streamable-http", host=host, port=port) |
|
|
else: |
|
|
print("[TRANSPORT]: Running as stdio MCP server", file=output) |
|
|
print("[NOTE]: For HTTP transport, use: python nuthatch_tools.py --http", file=output) |
|
|
print("="*70+"\n", file=output) |
|
|
|
|
|
|
|
|
mcp.run(transport="stdio") |
|
|
|
|
|
if __name__ == "__main__": |
|
|
main() |
|
|
|
|
|
|