Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: ci

# Continuous integration: run the unit test suite on every push to main and
# every pull request, so each commit carries a trustworthy pass/fail mark.
# Network-dependent integration tests are deselected (-m "not integration")
# to keep this signal stable — upstream rate-limiting or transient network
# errors must never turn a correct commit red. Run those locally with a
# bare `python -m pytest` when you need to exercise the live pipeline.
#
# Cross-platform coverage is handled by release.yml, which builds and smoke
# tests the PyInstaller binary on windows/ubuntu/macos per release tag.

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
name: Run unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: pyproject.toml

- name: Install package and test deps
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest pytest-asyncio

- name: Run unit tests (skip network integration tests)
run: python -m pytest -m "not integration"
9 changes: 7 additions & 2 deletions agent-web-search.spec
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# Build: pyinstaller agent-web-search.spec --noconfirm
# Verified working: ddgs search returns results through the bundled exe.

from PyInstaller.utils.hooks import collect_submodules
from PyInstaller.utils.hooks import collect_submodules, copy_metadata

block_cipher = None

Expand Down Expand Up @@ -35,6 +35,11 @@ hiddenimports += [
"sse_starlette",
]

# Bundle our own dist-info so importlib.metadata.version() resolves at runtime.
# __init__.py reads __version__ from package metadata; without this the frozen
# binary would hit PackageNotFoundError and silently fall back to "0.0.0+dev".
datas = copy_metadata("agent-web-search")

# Excluded — only things known-safe to drop. (mcp.cli sys.exits on import;
# the rest are unused heavy stdlib. Do NOT exclude xml/email — pkg_resources
# and plistlib depend on them.)
Expand All @@ -48,7 +53,7 @@ a = Analysis(
["src/agent_web_search/__main__.py"],
pathex=["src"],
binaries=[],
datas=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
runtime_hooks=[],
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "agent-web-search"
version = "0.1.0"
version = "0.2.1"
description = "A free, unlimited, stable web-search MCP tool for coding agents"
readme = "README.md"
license = { text = "MIT" }
Expand Down Expand Up @@ -32,3 +32,6 @@ dev = [
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
markers = [
"integration: tests that hit the real network (skipped in CI by default; run locally with a bare `python -m pytest`)",
]
20 changes: 18 additions & 2 deletions scripts/smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,28 @@ def main() -> int:
return 1

resp = json.loads(line)
name = resp.get("result", {}).get("serverInfo", {}).get("name", "")
server_info = resp.get("result", {}).get("serverInfo", {})
name = server_info.get("name", "")
if "agent-web-search" not in name:
print(f"FAIL: bad server name: {name}", file=sys.stderr)
return 1

print(f"smoke test OK: {time.monotonic() - t0:.2f}s startup, server={name}")
# Assert the version propagated. __init__.py reads it from package
# metadata via importlib.metadata; the spec bundles that metadata via
# copy_metadata(). If either link breaks, the binary silently falls
# back to "0.0.0+dev" — catch that here so a broken release never
# ships with a garbage version.
version = server_info.get("version", "")
if not version or version.startswith("0.0.0+dev"):
print(
f"FAIL: bad/missing server version: {version!r} "
"(expected a real release version — the binary likely fell back "
"to the dev placeholder, meaning package metadata isn't bundled)",
file=sys.stderr,
)
return 1

print(f"smoke test OK: {time.monotonic() - t0:.2f}s startup, server={name} v{version}")
return 0
finally:
proc.terminate()
Expand Down
14 changes: 13 additions & 1 deletion src/agent_web_search/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
"""agent-web-search: a free, unlimited, stable web-search MCP tool."""

__version__ = "0.1.0"
from importlib.metadata import PackageNotFoundError, version

try:
# Single source of truth: pyproject.toml's [project] version. Reading it
# via importlib.metadata means changing pyproject.toml is the only edit
# needed at release — __version__, the MCP serverInfo.version, and the
# PyInstaller-built binary all follow automatically once installed.
__version__ = version("agent-web-search")
except PackageNotFoundError:
# Not installed (e.g. running from a source checkout without `pip install
# -e .`). Fall back rather than crash — tests and dev workflows that don't
# care about the version still work.
__version__ = "0.0.0+dev"
10 changes: 9 additions & 1 deletion tests/test_mcp_stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ def test_stdout_is_clean_json_rpc_only(server_env: dict[str, str]) -> None:
import time

time.sleep(0.3)
proc.stdin and proc.stdin.close()
# communicate() closes stdin itself; closing it manually first makes
# communicate()'s internal stdin.flush() raise "flush of closed file"
# on Linux (Windows tolerates it). Don't pre-close.
out, _ = proc.communicate(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
Expand All @@ -142,6 +144,7 @@ def test_stdout_is_clean_json_rpc_only(server_env: dict[str, str]) -> None:
assert parsed >= 2, f"expected >=2 JSON-RPC responses, got {parsed}"


@pytest.mark.integration
def test_tools_call_returns_json_shaped_response(
server_env: dict[str, str]
) -> None:
Expand All @@ -150,6 +153,11 @@ def test_tools_call_returns_json_shaped_response(
We only assert the server returns a well-formed JSON-RPC response whose
content is valid JSON (a results list or an error object), tolerant of
live rate-limiting. Live results aren't guaranteed.

Marked ``integration`` because it hits the live ddgs/DuckDuckGo pipeline
and is therefore weather-dependent. CI deselects it (``-m "not
integration"``); run it locally with a bare ``python -m pytest`` to
exercise the real network path.
"""
proc = _spawn(server_env)
try:
Expand Down
Loading