Compare commits
2 commits
f593dd060b
...
7088f45f06
| Author | SHA1 | Date | |
|---|---|---|---|
| 7088f45f06 | |||
|
|
5d894d9e10 |
3 changed files with 251 additions and 0 deletions
8
researchers/web/__main__.py
Normal file
8
researchers/web/__main__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""Allow running the web researcher MCP server as a module.
|
||||||
|
|
||||||
|
Usage: python -m researchers.web
|
||||||
|
"""
|
||||||
|
|
||||||
|
from researchers.web.server import main
|
||||||
|
|
||||||
|
main()
|
||||||
91
researchers/web/server.py
Normal file
91
researchers/web/server.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""MCP server for the web researcher.
|
||||||
|
|
||||||
|
Exposes a single tool `research` that delegates to WebResearcher.
|
||||||
|
Run with: python -m researchers.web.server
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from researchers.web.agent import WebResearcher
|
||||||
|
from researchers.web.models import ResearchConstraints
|
||||||
|
|
||||||
|
mcp = FastMCP(
|
||||||
|
name="marchwarden-web-researcher",
|
||||||
|
instructions=(
|
||||||
|
"A Marchwarden web research specialist. "
|
||||||
|
"Call the research tool with a question to get a grounded, "
|
||||||
|
"evidence-based answer with citations, gaps, open questions, "
|
||||||
|
"and confidence scoring."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_secret(key: str) -> str:
|
||||||
|
"""Read a secret from ~/secrets file."""
|
||||||
|
secrets_path = os.path.expanduser("~/secrets")
|
||||||
|
with open(secrets_path) as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith(f"{key}="):
|
||||||
|
return line.split("=", 1)[1].strip()
|
||||||
|
raise ValueError(f"Key {key} not found in {secrets_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_researcher() -> WebResearcher:
|
||||||
|
"""Create a WebResearcher with keys from ~/secrets."""
|
||||||
|
return WebResearcher(
|
||||||
|
anthropic_api_key=_read_secret("ANTHROPIC_API_KEY"),
|
||||||
|
tavily_api_key=_read_secret("TAVILY_API_KEY"),
|
||||||
|
model_id=os.environ.get("MARCHWARDEN_MODEL", "claude-sonnet-4-5-20250514"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def research(
|
||||||
|
question: str,
|
||||||
|
context: Optional[str] = None,
|
||||||
|
depth: str = "balanced",
|
||||||
|
max_iterations: int = 5,
|
||||||
|
token_budget: int = 20000,
|
||||||
|
) -> str:
|
||||||
|
"""Research a question using web search and return a structured answer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question: The question to investigate.
|
||||||
|
context: What the caller already knows (optional).
|
||||||
|
depth: Research depth — "shallow", "balanced", or "deep".
|
||||||
|
max_iterations: Maximum number of search/fetch iterations (1-20).
|
||||||
|
token_budget: Maximum tokens to spend (1000-100000).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string containing the full ResearchResult with answer,
|
||||||
|
citations, gaps, discovery_events, open_questions, confidence,
|
||||||
|
and cost_metadata.
|
||||||
|
"""
|
||||||
|
researcher = _get_researcher()
|
||||||
|
constraints = ResearchConstraints(
|
||||||
|
max_iterations=max_iterations,
|
||||||
|
token_budget=token_budget,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await researcher.research(
|
||||||
|
question=question,
|
||||||
|
context=context,
|
||||||
|
depth=depth,
|
||||||
|
constraints=constraints,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.model_dump_json(indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run the MCP server on stdio."""
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
152
tests/test_server.py
Normal file
152
tests/test_server.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
"""Tests for the MCP server."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from researchers.web.server import _read_secret, research
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _read_secret
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadSecret:
|
||||||
|
def test_reads_key(self, tmp_path):
|
||||||
|
secrets = tmp_path / "secrets"
|
||||||
|
secrets.write_text("FOO=bar\nBAZ=qux\n")
|
||||||
|
with patch("researchers.web.server.os.path.expanduser", return_value=str(secrets)):
|
||||||
|
assert _read_secret("FOO") == "bar"
|
||||||
|
assert _read_secret("BAZ") == "qux"
|
||||||
|
|
||||||
|
def test_missing_key_raises(self, tmp_path):
|
||||||
|
secrets = tmp_path / "secrets"
|
||||||
|
secrets.write_text("FOO=bar\n")
|
||||||
|
with patch("researchers.web.server.os.path.expanduser", return_value=str(secrets)):
|
||||||
|
with pytest.raises(ValueError, match="MISSING"):
|
||||||
|
_read_secret("MISSING")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# research tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResearchTool:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_valid_json(self):
|
||||||
|
"""The research tool should return a JSON string with all contract fields."""
|
||||||
|
from researchers.web.models import (
|
||||||
|
ResearchResult,
|
||||||
|
ConfidenceFactors,
|
||||||
|
CostMetadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_result = ResearchResult(
|
||||||
|
answer="Test answer.",
|
||||||
|
citations=[],
|
||||||
|
gaps=[],
|
||||||
|
discovery_events=[],
|
||||||
|
open_questions=[],
|
||||||
|
confidence=0.8,
|
||||||
|
confidence_factors=ConfidenceFactors(
|
||||||
|
num_corroborating_sources=1,
|
||||||
|
source_authority="medium",
|
||||||
|
contradiction_detected=False,
|
||||||
|
query_specificity_match=0.7,
|
||||||
|
budget_exhausted=False,
|
||||||
|
recency="current",
|
||||||
|
),
|
||||||
|
cost_metadata=CostMetadata(
|
||||||
|
tokens_used=500,
|
||||||
|
iterations_run=1,
|
||||||
|
wall_time_sec=5.0,
|
||||||
|
budget_exhausted=False,
|
||||||
|
model_id="claude-test",
|
||||||
|
),
|
||||||
|
trace_id="test-trace-id",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("researchers.web.server._get_researcher") as mock_get:
|
||||||
|
mock_researcher = AsyncMock()
|
||||||
|
mock_researcher.research.return_value = mock_result
|
||||||
|
mock_get.return_value = mock_researcher
|
||||||
|
|
||||||
|
result_json = await research(
|
||||||
|
question="test question",
|
||||||
|
context="some context",
|
||||||
|
depth="shallow",
|
||||||
|
max_iterations=2,
|
||||||
|
token_budget=5000,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = json.loads(result_json)
|
||||||
|
assert data["answer"] == "Test answer."
|
||||||
|
assert data["confidence"] == 0.8
|
||||||
|
assert data["trace_id"] == "test-trace-id"
|
||||||
|
assert "citations" in data
|
||||||
|
assert "gaps" in data
|
||||||
|
assert "discovery_events" in data
|
||||||
|
assert "open_questions" in data
|
||||||
|
assert "confidence_factors" in data
|
||||||
|
assert "cost_metadata" in data
|
||||||
|
|
||||||
|
# Verify researcher was called with correct args
|
||||||
|
mock_researcher.research.assert_called_once()
|
||||||
|
call_kwargs = mock_researcher.research.call_args[1]
|
||||||
|
assert call_kwargs["question"] == "test question"
|
||||||
|
assert call_kwargs["context"] == "some context"
|
||||||
|
assert call_kwargs["depth"] == "shallow"
|
||||||
|
assert call_kwargs["constraints"].max_iterations == 2
|
||||||
|
assert call_kwargs["constraints"].token_budget == 5000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_defaults(self):
|
||||||
|
"""Test that defaults work when optional args are omitted."""
|
||||||
|
from researchers.web.models import (
|
||||||
|
ResearchResult,
|
||||||
|
ConfidenceFactors,
|
||||||
|
CostMetadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_result = ResearchResult(
|
||||||
|
answer="Default test.",
|
||||||
|
citations=[],
|
||||||
|
gaps=[],
|
||||||
|
discovery_events=[],
|
||||||
|
open_questions=[],
|
||||||
|
confidence=0.5,
|
||||||
|
confidence_factors=ConfidenceFactors(
|
||||||
|
num_corroborating_sources=0,
|
||||||
|
source_authority="low",
|
||||||
|
contradiction_detected=False,
|
||||||
|
query_specificity_match=0.5,
|
||||||
|
budget_exhausted=False,
|
||||||
|
),
|
||||||
|
cost_metadata=CostMetadata(
|
||||||
|
tokens_used=100,
|
||||||
|
iterations_run=1,
|
||||||
|
wall_time_sec=1.0,
|
||||||
|
budget_exhausted=False,
|
||||||
|
model_id="claude-test",
|
||||||
|
),
|
||||||
|
trace_id="test-id",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("researchers.web.server._get_researcher") as mock_get:
|
||||||
|
mock_researcher = AsyncMock()
|
||||||
|
mock_researcher.research.return_value = mock_result
|
||||||
|
mock_get.return_value = mock_researcher
|
||||||
|
|
||||||
|
result_json = await research(question="just a question")
|
||||||
|
|
||||||
|
data = json.loads(result_json)
|
||||||
|
assert data["answer"] == "Default test."
|
||||||
|
|
||||||
|
call_kwargs = mock_researcher.research.call_args[1]
|
||||||
|
assert call_kwargs["context"] is None
|
||||||
|
assert call_kwargs["depth"] == "balanced"
|
||||||
|
assert call_kwargs["constraints"].max_iterations == 5
|
||||||
|
assert call_kwargs["constraints"].token_budget == 20000
|
||||||
Loading…
Reference in a new issue