bowmark-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: bowmark-mcp
3
+ Version: 0.1.0
4
+ Summary: Bowmark MCP over stdio — a thin bridge to the hosted Bowmark MCP at https://api.bowmark.ai/mcp
5
+ Project-URL: Homepage, https://bowmark.ai
6
+ Project-URL: Documentation, https://bowmark.ai/install
7
+ Author-email: Bowmark AI <christopher@bowmark.ai>
8
+ License: MIT
9
+ Keywords: ai-agents,bowmark,browser-automation,mcp,model-context-protocol
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: mcp<2,>=1.16
14
+ Description-Content-Type: text/markdown
15
+
16
+ # bowmark-mcp — Bowmark MCP over stdio
17
+
18
+ Bowmark gives agents pre-computed navigation recipes for public websites (skip
19
+ explore-and-discover). The canonical server is hosted, streamable HTTP, no auth
20
+ required: `https://api.bowmark.ai/mcp`.
21
+
22
+ This package is a **thin stdio bridge** to that hosted server, for MCP hosts
23
+ whose client only speaks stdio (for example browser-use's `MCPClient`). Tool
24
+ schemas, descriptions, and results pass through verbatim — the hosted server
25
+ stays the single source of truth; nothing is reimplemented here.
26
+
27
+ `mcp-name: ai.bowmark/bowmark`
28
+
29
+ ## Use
30
+
31
+ ```sh
32
+ uvx bowmark-mcp # or: pipx run bowmark-mcp / python -m bowmark_mcp
33
+ ```
34
+
35
+ Example — browser-use:
36
+
37
+ ```python
38
+ from browser_use.mcp.client import MCPClient
39
+
40
+ bowmark = MCPClient(server_name="bowmark", command="uvx", args=["bowmark-mcp"])
41
+ ```
42
+
43
+ Example — any MCP host config:
44
+
45
+ ```json
46
+ { "mcpServers": { "bowmark": { "command": "uvx", "args": ["bowmark-mcp"] } } }
47
+ ```
48
+
49
+ If your host speaks streamable HTTP, skip this bridge and connect directly to
50
+ `https://api.bowmark.ai/mcp`.
51
+
52
+ ## Environment
53
+
54
+ | Var | Meaning |
55
+ |---|---|
56
+ | `BOWMARK_MCP_URL` | Target MCP URL. Default `https://api.bowmark.ai/mcp?s=p` (`?s=p` attributes the install to the PyPI bridge). Point at `http://localhost:3001/mcp` for a local Bowmark API. |
57
+ | `BOWMARK_API_KEY` | Optional. Forwarded as `X-Bowmark-Key`; a free key (bowmark.ai dashboard) raises the anonymous per-IP daily synthesis cap to your plan budget. |
58
+
59
+ ## Design notes (repo-internal)
60
+
61
+ - **One remote session per request, retried once.** `streamablehttp_client` is
62
+ an anyio-scoped context manager; holding one session across handler tasks
63
+ trips "exit cancel scope in a different task". The hosted MCP is stateless,
64
+ so a fresh session per request is semantically identical and costs one
65
+ initialize round-trip — noise next to an `ask` synthesis.
66
+ - **Pass-through only.** No tool logic lives here; the agent-surfaces sync rule
67
+ in the root CLAUDE.md is unaffected because descriptions/schemas ride through
68
+ from `apps/api/src/routes/mcp.ts`.
69
+ - **`?s=p` source code** is registered in `apps/api/src/mcp-sources.ts` +
70
+ `mcp-registry/sources.json` (PyPI stdio bridge channel).
71
+ - **The `mcp-name: ai.bowmark/bowmark` line above is load-bearing**: the
72
+ official MCP Registry validates PyPI package ownership by finding that
73
+ marker in the package README. Don't remove it.
74
+ - **Versioning is manual** (this is a thin bridge, not the api): bump
75
+ `pyproject.toml` when it changes. Not wired into release-please.
76
+
77
+ ## Tests
78
+
79
+ ```sh
80
+ cd packages/mcp-stdio
81
+ uv run --with pytest --with-editable . pytest -q
82
+ ```
83
+
84
+ Network-free (the remote hop is monkeypatched). A live smoke against prod:
85
+
86
+ ```sh
87
+ uv run --with-editable . python - <<'EOF'
88
+ import asyncio, bowmark_mcp
89
+ async def main():
90
+ tools = await bowmark_mcp.list_tools_impl()
91
+ print([t.name for t in tools])
92
+ asyncio.run(main())
93
+ EOF
94
+ ```
95
+
96
+ ## Publishing to PyPI (manual, Christopher)
97
+
98
+ 1. `cd packages/mcp-stdio && uv build`
99
+ 2. `uv publish` (needs the PyPI token; mirror it to 1Password when minted).
100
+ 3. After the package is live, add a `packages` entry (registryType `pypi`,
101
+ identifier `bowmark-mcp`, transport `stdio`) to `mcp-registry/server.json`
102
+ and republish — the registry validates ownership via the `mcp-name` marker
103
+ above, so it must come AFTER the PyPI publish. See `mcp-registry/README.md`.
@@ -0,0 +1,88 @@
1
+ # bowmark-mcp — Bowmark MCP over stdio
2
+
3
+ Bowmark gives agents pre-computed navigation recipes for public websites (skip
4
+ explore-and-discover). The canonical server is hosted, streamable HTTP, no auth
5
+ required: `https://api.bowmark.ai/mcp`.
6
+
7
+ This package is a **thin stdio bridge** to that hosted server, for MCP hosts
8
+ whose client only speaks stdio (for example browser-use's `MCPClient`). Tool
9
+ schemas, descriptions, and results pass through verbatim — the hosted server
10
+ stays the single source of truth; nothing is reimplemented here.
11
+
12
+ `mcp-name: ai.bowmark/bowmark`
13
+
14
+ ## Use
15
+
16
+ ```sh
17
+ uvx bowmark-mcp # or: pipx run bowmark-mcp / python -m bowmark_mcp
18
+ ```
19
+
20
+ Example — browser-use:
21
+
22
+ ```python
23
+ from browser_use.mcp.client import MCPClient
24
+
25
+ bowmark = MCPClient(server_name="bowmark", command="uvx", args=["bowmark-mcp"])
26
+ ```
27
+
28
+ Example — any MCP host config:
29
+
30
+ ```json
31
+ { "mcpServers": { "bowmark": { "command": "uvx", "args": ["bowmark-mcp"] } } }
32
+ ```
33
+
34
+ If your host speaks streamable HTTP, skip this bridge and connect directly to
35
+ `https://api.bowmark.ai/mcp`.
36
+
37
+ ## Environment
38
+
39
+ | Var | Meaning |
40
+ |---|---|
41
+ | `BOWMARK_MCP_URL` | Target MCP URL. Default `https://api.bowmark.ai/mcp?s=p` (`?s=p` attributes the install to the PyPI bridge). Point at `http://localhost:3001/mcp` for a local Bowmark API. |
42
+ | `BOWMARK_API_KEY` | Optional. Forwarded as `X-Bowmark-Key`; a free key (bowmark.ai dashboard) raises the anonymous per-IP daily synthesis cap to your plan budget. |
43
+
44
+ ## Design notes (repo-internal)
45
+
46
+ - **One remote session per request, retried once.** `streamablehttp_client` is
47
+ an anyio-scoped context manager; holding one session across handler tasks
48
+ trips "exit cancel scope in a different task". The hosted MCP is stateless,
49
+ so a fresh session per request is semantically identical and costs one
50
+ initialize round-trip — noise next to an `ask` synthesis.
51
+ - **Pass-through only.** No tool logic lives here; the agent-surfaces sync rule
52
+ in the root CLAUDE.md is unaffected because descriptions/schemas ride through
53
+ from `apps/api/src/routes/mcp.ts`.
54
+ - **`?s=p` source code** is registered in `apps/api/src/mcp-sources.ts` +
55
+ `mcp-registry/sources.json` (PyPI stdio bridge channel).
56
+ - **The `mcp-name: ai.bowmark/bowmark` line above is load-bearing**: the
57
+ official MCP Registry validates PyPI package ownership by finding that
58
+ marker in the package README. Don't remove it.
59
+ - **Versioning is manual** (this is a thin bridge, not the api): bump
60
+ `pyproject.toml` when it changes. Not wired into release-please.
61
+
62
+ ## Tests
63
+
64
+ ```sh
65
+ cd packages/mcp-stdio
66
+ uv run --with pytest --with-editable . pytest -q
67
+ ```
68
+
69
+ Network-free (the remote hop is monkeypatched). A live smoke against prod:
70
+
71
+ ```sh
72
+ uv run --with-editable . python - <<'EOF'
73
+ import asyncio, bowmark_mcp
74
+ async def main():
75
+ tools = await bowmark_mcp.list_tools_impl()
76
+ print([t.name for t in tools])
77
+ asyncio.run(main())
78
+ EOF
79
+ ```
80
+
81
+ ## Publishing to PyPI (manual, Christopher)
82
+
83
+ 1. `cd packages/mcp-stdio && uv build`
84
+ 2. `uv publish` (needs the PyPI token; mirror it to 1Password when minted).
85
+ 3. After the package is live, add a `packages` entry (registryType `pypi`,
86
+ identifier `bowmark-mcp`, transport `stdio`) to `mcp-registry/server.json`
87
+ and republish — the registry validates ownership via the `mcp-name` marker
88
+ above, so it must come AFTER the PyPI publish. See `mcp-registry/README.md`.
@@ -0,0 +1,117 @@
1
+ """Bowmark MCP stdio bridge.
2
+
3
+ A thin stdio MCP server that proxies every request to the hosted Bowmark MCP
4
+ (streamable HTTP, https://api.bowmark.ai/mcp). Exists for MCP hosts whose
5
+ client only speaks stdio (e.g. browser-use's ``MCPClient``): they run
6
+ ``uvx bowmark-mcp`` and get the exact hosted tools — schemas, descriptions,
7
+ and envelopes pass through verbatim, so the hosted server stays the single
8
+ source of truth.
9
+
10
+ Env:
11
+ BOWMARK_MCP_URL Target MCP URL. Default ``https://api.bowmark.ai/mcp?s=p``
12
+ (the ``?s=p`` tags the install source as the PyPI bridge;
13
+ point at ``http://localhost:3001/mcp`` for a local API).
14
+ BOWMARK_API_KEY Optional Bowmark API key, forwarded as ``X-Bowmark-Key``.
15
+ Omit for the anonymous tier.
16
+
17
+ Design note — one remote session PER REQUEST, not one held for the process
18
+ lifetime. The MCP Python SDK's ``streamablehttp_client`` is an anyio-scoped
19
+ context manager: entering it in one handler task and closing it from another
20
+ raises "attempted to exit cancel scope in a different task". Since the hosted
21
+ Bowmark MCP is stateless and auth-free, a fresh session per request is
22
+ semantically identical, and its cost (one initialize round-trip) is noise next
23
+ to an ``ask`` synthesis. Each request still retries once on a transport
24
+ failure before surfacing the error.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import os
31
+ import sys
32
+ from collections.abc import Awaitable, Callable
33
+ from typing import Any, TypeVar
34
+
35
+ import mcp.types as types
36
+ from mcp.client.session import ClientSession
37
+ from mcp.client.streamable_http import streamablehttp_client
38
+ from mcp.server.lowlevel import Server
39
+ from mcp.server.stdio import stdio_server
40
+
41
+ DEFAULT_URL = "https://api.bowmark.ai/mcp?s=p"
42
+
43
+ T = TypeVar("T")
44
+
45
+
46
+ def target_url() -> str:
47
+ return os.environ.get("BOWMARK_MCP_URL", "").strip() or DEFAULT_URL
48
+
49
+
50
+ def auth_headers() -> dict[str, str] | None:
51
+ """Optional API key -> X-Bowmark-Key header (the hosted MCP also accepts
52
+ Authorization: Bearer; one header is enough)."""
53
+ key = os.environ.get("BOWMARK_API_KEY", "").strip()
54
+ return {"X-Bowmark-Key": key} if key else None
55
+
56
+
57
+ async def _with_remote(fn: Callable[[ClientSession], Awaitable[T]]) -> T:
58
+ async with streamablehttp_client(target_url(), headers=auth_headers()) as (
59
+ read,
60
+ write,
61
+ _,
62
+ ):
63
+ async with ClientSession(read, write) as session:
64
+ await session.initialize()
65
+ return await fn(session)
66
+
67
+
68
+ async def call_remote(fn: Callable[[ClientSession], Awaitable[T]]) -> T:
69
+ """One retry on any failure: the remote is stateless, so a fresh session is
70
+ equivalent, and a transient network blip shouldn't fail the host's call."""
71
+ try:
72
+ return await _with_remote(fn)
73
+ except Exception as first:
74
+ print(f"bowmark-mcp: retrying after: {first}", file=sys.stderr)
75
+ return await _with_remote(fn)
76
+
77
+
78
+ def error_text(result: types.CallToolResult, name: str) -> str:
79
+ """Flatten an isError result's text content for re-raising locally."""
80
+ texts = [c.text for c in result.content if isinstance(c, types.TextContent)]
81
+ return "; ".join(t for t in texts if t) or f"{name} failed upstream"
82
+
83
+
84
+ async def list_tools_impl() -> list[types.Tool]:
85
+ res = await call_remote(lambda s: s.list_tools())
86
+ return res.tools
87
+
88
+
89
+ async def call_tool_impl(name: str, arguments: dict[str, Any] | None) -> list[Any]:
90
+ result = await call_remote(lambda s: s.call_tool(name, arguments or {}))
91
+ # The lowlevel server wraps a raised exception as an isError result with
92
+ # the exception text, so upstream errors round-trip with their message.
93
+ if result.isError:
94
+ raise RuntimeError(error_text(result, name))
95
+ return list(result.content)
96
+
97
+
98
+ def build_server() -> Server:
99
+ server = Server("bowmark")
100
+ server.list_tools()(list_tools_impl)
101
+ server.call_tool()(call_tool_impl)
102
+ return server
103
+
104
+
105
+ async def _serve() -> None:
106
+ server = build_server()
107
+ async with stdio_server() as (read, write):
108
+ await server.run(read, write, server.create_initialization_options())
109
+
110
+
111
+ def main() -> None:
112
+ # stdout belongs to the protocol; anything human goes to stderr.
113
+ asyncio.run(_serve())
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
@@ -0,0 +1,3 @@
1
+ from . import main
2
+
3
+ main()
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "bowmark-mcp"
3
+ version = "0.1.0"
4
+ description = "Bowmark MCP over stdio — a thin bridge to the hosted Bowmark MCP at https://api.bowmark.ai/mcp"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Bowmark AI", email = "christopher@bowmark.ai" }]
9
+ keywords = ["mcp", "model-context-protocol", "bowmark", "browser-automation", "ai-agents"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Operating System :: OS Independent",
13
+ ]
14
+ # The official MCP Python SDK is the only dependency — it brings the
15
+ # streamable-HTTP client (httpx) with it. <2 guards against a future major.
16
+ dependencies = ["mcp>=1.16,<2"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://bowmark.ai"
20
+ Documentation = "https://bowmark.ai/install"
21
+
22
+ [project.scripts]
23
+ bowmark-mcp = "bowmark_mcp:main"
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["bowmark_mcp"]
@@ -0,0 +1,96 @@
1
+ # Network-free unit tests for the stdio bridge. The remote hop is
2
+ # monkeypatched at the call_remote seam; nothing here opens a socket.
3
+ #
4
+ # Run: cd packages/mcp-stdio && uv run --with pytest --with-editable . pytest -q
5
+
6
+ import asyncio
7
+
8
+ import mcp.types as types
9
+ import pytest
10
+
11
+ import bowmark_mcp
12
+
13
+
14
+ def test_target_url_default(monkeypatch):
15
+ monkeypatch.delenv("BOWMARK_MCP_URL", raising=False)
16
+ assert bowmark_mcp.target_url() == bowmark_mcp.DEFAULT_URL
17
+
18
+
19
+ def test_target_url_override_and_blank(monkeypatch):
20
+ monkeypatch.setenv("BOWMARK_MCP_URL", "http://localhost:3001/mcp")
21
+ assert bowmark_mcp.target_url() == "http://localhost:3001/mcp"
22
+ # Blank/whitespace env must not blank the target.
23
+ monkeypatch.setenv("BOWMARK_MCP_URL", " ")
24
+ assert bowmark_mcp.target_url() == bowmark_mcp.DEFAULT_URL
25
+
26
+
27
+ def test_auth_headers(monkeypatch):
28
+ monkeypatch.delenv("BOWMARK_API_KEY", raising=False)
29
+ assert bowmark_mcp.auth_headers() is None
30
+ monkeypatch.setenv("BOWMARK_API_KEY", "bmk_test123")
31
+ assert bowmark_mcp.auth_headers() == {"X-Bowmark-Key": "bmk_test123"}
32
+
33
+
34
+ def _tool_result(text: str, is_error: bool) -> types.CallToolResult:
35
+ return types.CallToolResult(
36
+ content=[types.TextContent(type="text", text=text)], isError=is_error
37
+ )
38
+
39
+
40
+ def test_call_tool_passes_content_through(monkeypatch):
41
+ async def fake_call_remote(fn):
42
+ return _tool_result('{"status":"ok"}', is_error=False)
43
+
44
+ monkeypatch.setattr(bowmark_mcp, "call_remote", fake_call_remote)
45
+ content = asyncio.run(bowmark_mcp.call_tool_impl("ask", {"site": "x.com", "task": "y"}))
46
+ assert len(content) == 1
47
+ assert content[0].text == '{"status":"ok"}'
48
+
49
+
50
+ def test_call_tool_raises_on_upstream_error(monkeypatch):
51
+ async def fake_call_remote(fn):
52
+ return _tool_result("upstream exploded", is_error=True)
53
+
54
+ monkeypatch.setattr(bowmark_mcp, "call_remote", fake_call_remote)
55
+ with pytest.raises(RuntimeError, match="upstream exploded"):
56
+ asyncio.run(bowmark_mcp.call_tool_impl("ask", {}))
57
+
58
+
59
+ def test_call_remote_retries_once_then_raises(monkeypatch):
60
+ calls = {"n": 0}
61
+
62
+ async def flaky(_fn):
63
+ calls["n"] += 1
64
+ raise ConnectionError("boom")
65
+
66
+ monkeypatch.setattr(bowmark_mcp, "_with_remote", flaky)
67
+ with pytest.raises(ConnectionError):
68
+ asyncio.run(bowmark_mcp.call_remote(lambda s: s))
69
+ assert calls["n"] == 2 # exactly one retry
70
+
71
+
72
+ def test_call_remote_retry_succeeds(monkeypatch):
73
+ calls = {"n": 0}
74
+
75
+ async def flaky_then_ok(_fn):
76
+ calls["n"] += 1
77
+ if calls["n"] == 1:
78
+ raise ConnectionError("boom")
79
+ return "ok"
80
+
81
+ monkeypatch.setattr(bowmark_mcp, "_with_remote", flaky_then_ok)
82
+ assert asyncio.run(bowmark_mcp.call_remote(lambda s: s)) == "ok"
83
+ assert calls["n"] == 2
84
+
85
+
86
+ def test_error_text_flattens_and_falls_back():
87
+ r = _tool_result("", is_error=True)
88
+ assert bowmark_mcp.error_text(r, "ask") == "ask failed upstream"
89
+ r2 = types.CallToolResult(
90
+ content=[
91
+ types.TextContent(type="text", text="a"),
92
+ types.TextContent(type="text", text="b"),
93
+ ],
94
+ isError=True,
95
+ )
96
+ assert bowmark_mcp.error_text(r2, "ask") == "a; b"