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.
- bowmark_mcp-0.1.0/.gitignore +5 -0
- bowmark_mcp-0.1.0/PKG-INFO +103 -0
- bowmark_mcp-0.1.0/README.md +88 -0
- bowmark_mcp-0.1.0/bowmark_mcp/__init__.py +117 -0
- bowmark_mcp-0.1.0/bowmark_mcp/__main__.py +3 -0
- bowmark_mcp-0.1.0/pyproject.toml +30 -0
- bowmark_mcp-0.1.0/tests/test_bridge.py +96 -0
- bowmark_mcp-0.1.0/uv.lock +888 -0
|
@@ -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,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"
|