langchain-webz 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,2 @@
1
+ WEBZ_API_TOKEN=
2
+ WEBZ_MCP_URL=https://news-search-mcp.webz.io/mcp
@@ -0,0 +1,17 @@
1
+ .venv/
2
+ venv/
3
+ .idea/
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+ langchain_tests/
16
+ node_modules/
17
+ *.db
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Webz.io
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-webz
3
+ Version: 0.1.0
4
+ Summary: LangChain / LangGraph tools for Webz.io News Search via the hosted MCP server
5
+ Project-URL: Homepage, https://news-search-mcp.webz.io
6
+ Project-URL: Documentation, https://news-search-mcp.webz.io
7
+ Project-URL: Issues, https://github.com/Webhose/webz-news-search/issues
8
+ Author-email: "Webz.io" <support@webz.io>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: langchain,langgraph,mcp,news,search,webz
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: langchain-core>=0.3.0
22
+ Requires-Dist: langchain-mcp-adapters>=0.1.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # langchain-webz
29
+
30
+ LangChain and LangGraph tools for [Webz.io News Search](https://news-search-mcp.webz.io).
31
+
32
+ This package does not hardcode search filters. It connects to the hosted MCP server and loads the live tool schema with `tools/list`. When the MCP adds a filter, agents see it on the next connection.
33
+
34
+ ```bash
35
+ pip install langchain-webz
36
+ export WEBZ_API_TOKEN="your-webz-api-token"
37
+ ```
38
+
39
+ ## Direct tool call
40
+
41
+ ```python
42
+ from langchain_webz import WebzNewsSearch
43
+
44
+ # env: WEBZ_API_TOKEN
45
+ tool = WebzNewsSearch()
46
+
47
+ # or pass the token in code
48
+ tool = WebzNewsSearch(api_token="your-webz-api-token")
49
+
50
+ print(tool.invoke({"query": "AI regulation Europe"}))
51
+ ```
52
+
53
+ ## Agent / LangGraph
54
+
55
+ ```python
56
+ from langchain.agents import create_agent
57
+ from langchain_webz import get_webz_tools
58
+
59
+ tools = get_webz_tools()
60
+ agent = create_agent("openai:gpt-4.1-mini", tools)
61
+ result = agent.invoke({"messages": [{"role": "user", "content": "What is the latest EU AI news?"}]})
62
+ print(result["messages"][-1].content)
63
+ ```
64
+
65
+ Inside an already running event loop, use the async helpers:
66
+
67
+ ```python
68
+ from langchain_webz import aget_webz_tools, awebz_news_search
69
+
70
+ tools = await aget_webz_tools()
71
+ tool = await awebz_news_search()
72
+ ```
73
+
74
+ ## Config
75
+
76
+ | Name | Default | Purpose |
77
+ | --- | --- | --- |
78
+ | `WEBZ_API_TOKEN` | required | Webz API token from the dashboard |
79
+ | `WEBZ_MCP_URL` | `https://news-search-mcp.webz.io/mcp` | override for local MCP |
80
+
81
+ You can also pass `api_token=` and `mcp_url=` to the helpers.
82
+
83
+ Get a token: https://webz.io
@@ -0,0 +1,56 @@
1
+ # langchain-webz
2
+
3
+ LangChain and LangGraph tools for [Webz.io News Search](https://news-search-mcp.webz.io).
4
+
5
+ This package does not hardcode search filters. It connects to the hosted MCP server and loads the live tool schema with `tools/list`. When the MCP adds a filter, agents see it on the next connection.
6
+
7
+ ```bash
8
+ pip install langchain-webz
9
+ export WEBZ_API_TOKEN="your-webz-api-token"
10
+ ```
11
+
12
+ ## Direct tool call
13
+
14
+ ```python
15
+ from langchain_webz import WebzNewsSearch
16
+
17
+ # env: WEBZ_API_TOKEN
18
+ tool = WebzNewsSearch()
19
+
20
+ # or pass the token in code
21
+ tool = WebzNewsSearch(api_token="your-webz-api-token")
22
+
23
+ print(tool.invoke({"query": "AI regulation Europe"}))
24
+ ```
25
+
26
+ ## Agent / LangGraph
27
+
28
+ ```python
29
+ from langchain.agents import create_agent
30
+ from langchain_webz import get_webz_tools
31
+
32
+ tools = get_webz_tools()
33
+ agent = create_agent("openai:gpt-4.1-mini", tools)
34
+ result = agent.invoke({"messages": [{"role": "user", "content": "What is the latest EU AI news?"}]})
35
+ print(result["messages"][-1].content)
36
+ ```
37
+
38
+ Inside an already running event loop, use the async helpers:
39
+
40
+ ```python
41
+ from langchain_webz import aget_webz_tools, awebz_news_search
42
+
43
+ tools = await aget_webz_tools()
44
+ tool = await awebz_news_search()
45
+ ```
46
+
47
+ ## Config
48
+
49
+ | Name | Default | Purpose |
50
+ | --- | --- | --- |
51
+ | `WEBZ_API_TOKEN` | required | Webz API token from the dashboard |
52
+ | `WEBZ_MCP_URL` | `https://news-search-mcp.webz.io/mcp` | override for local MCP |
53
+
54
+ You can also pass `api_token=` and `mcp_url=` to the helpers.
55
+
56
+ Get a token: https://webz.io
@@ -0,0 +1,39 @@
1
+ """
2
+ run a single News Search call through the LangChain wrapper.
3
+
4
+ usage:
5
+ export WEBZ_API_TOKEN="your-token"
6
+ python examples/run_news_search.py
7
+
8
+ or pass the token in code with WebzNewsSearch(api_token="...").
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+
16
+ from langchain_webz import WebzNewsSearch
17
+
18
+ QUERY = "How many goals does Cristiano have and how many left to 1000?"
19
+
20
+
21
+ def main() -> None:
22
+ # preferred: WEBZ_API_TOKEN in the environment
23
+ # also valid: WebzNewsSearch(api_token="paste-token-here")
24
+ token = os.getenv("WEBZ_API_TOKEN")
25
+ tool = WebzNewsSearch(api_token=token) if token else WebzNewsSearch()
26
+
27
+ print("tool name:", tool.name)
28
+ print("live args from MCP tools/list:", sorted(tool.args))
29
+ print("---")
30
+ print(tool.invoke({"query": QUERY, "k": 3}), end="\n\n")
31
+ print("SUCCESS ! End of tool")
32
+
33
+
34
+ if __name__ == "__main__":
35
+ try:
36
+ main()
37
+ except Exception as exc:
38
+ print(f"search failed: {exc}", file=sys.stderr)
39
+ raise SystemExit(1)
@@ -0,0 +1,18 @@
1
+ from langchain_webz.client import (
2
+ WebzConfigError,
3
+ WebzNewsSearch,
4
+ aget_webz_tools,
5
+ awebz_news_search,
6
+ get_webz_tools,
7
+ )
8
+ from langchain_webz.consts import DEFAULT_MCP_URL, TOKEN_ENV_NAME
9
+
10
+ __all__ = [
11
+ "DEFAULT_MCP_URL",
12
+ "TOKEN_ENV_NAME",
13
+ "WebzConfigError",
14
+ "WebzNewsSearch",
15
+ "aget_webz_tools",
16
+ "awebz_news_search",
17
+ "get_webz_tools",
18
+ ]
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from collections.abc import Coroutine
6
+ from typing import Any, TypeVar
7
+
8
+ from langchain_core.tools import BaseTool, StructuredTool
9
+ from langchain_mcp_adapters.client import MultiServerMCPClient
10
+
11
+ from langchain_webz.consts import (
12
+ DEFAULT_MCP_URL,
13
+ MCP_SERVER_NAME,
14
+ MCP_TRANSPORT,
15
+ MCP_URL_ENV_NAME,
16
+ PREFERRED_TOOL_NAME,
17
+ TOKEN_ENV_NAME,
18
+ )
19
+
20
+ T = TypeVar("T")
21
+
22
+
23
+ class WebzConfigError(ValueError):
24
+ """raised when the Webz MCP client cannot be configured or loaded."""
25
+
26
+
27
+ def resolve_api_token(api_token: str | None = None) -> str:
28
+ token = (api_token or os.getenv(TOKEN_ENV_NAME) or "").strip()
29
+ if not token:
30
+ raise WebzConfigError(
31
+ f"missing Webz API token. set {TOKEN_ENV_NAME} or pass api_token."
32
+ )
33
+ return token
34
+
35
+
36
+ def resolve_mcp_url(mcp_url: str | None = None) -> str:
37
+ url = (mcp_url or os.getenv(MCP_URL_ENV_NAME) or DEFAULT_MCP_URL).strip()
38
+ if not url:
39
+ raise WebzConfigError("missing MCP url.")
40
+ return url.rstrip("/")
41
+
42
+
43
+ def build_mcp_connection(api_token: str, mcp_url: str) -> dict[str, dict[str, Any]]:
44
+ return {
45
+ MCP_SERVER_NAME: {
46
+ "transport": MCP_TRANSPORT,
47
+ "url": mcp_url,
48
+ "headers": {"Authorization": f"Bearer {api_token}"},
49
+ }
50
+ }
51
+
52
+
53
+ def pick_news_search_tool(tools: list[BaseTool]) -> BaseTool:
54
+ if not tools:
55
+ raise WebzConfigError("MCP server returned no tools.")
56
+ for tool in tools:
57
+ if tool.name == PREFERRED_TOOL_NAME:
58
+ return tool
59
+ if len(tools) == 1:
60
+ return tools[0]
61
+ names = ", ".join(tool.name for tool in tools)
62
+ raise WebzConfigError(
63
+ f"MCP server did not expose {PREFERRED_TOOL_NAME}. available tools: {names}"
64
+ )
65
+
66
+
67
+ def flatten_tool_result(result: Any) -> str:
68
+ """turns MCP/LangChain content blocks into the article text string."""
69
+ if isinstance(result, tuple) and result:
70
+ result = result[0]
71
+ if isinstance(result, str):
72
+ return result
73
+ if isinstance(result, list):
74
+ parts: list[str] = []
75
+ for item in result:
76
+ if isinstance(item, str):
77
+ parts.append(item)
78
+ elif isinstance(item, dict) and item.get("text"):
79
+ parts.append(str(item["text"]))
80
+ return "\n".join(parts)
81
+ return str(result)
82
+
83
+
84
+ def enable_sync_invoke(tool: BaseTool) -> BaseTool:
85
+ """adds sync invoke and flattens MCP content blocks to text."""
86
+ if not isinstance(tool, StructuredTool) or tool.coroutine is None:
87
+ return tool
88
+ coroutine = tool.coroutine
89
+
90
+ async def run_async(*args: Any, **kwargs: Any) -> str:
91
+ return flatten_tool_result(await coroutine(*args, **kwargs))
92
+
93
+ def run_sync(*args: Any, **kwargs: Any) -> str:
94
+ return run_coroutine_sync(run_async(*args, **kwargs))
95
+
96
+ return tool.model_copy(
97
+ update={
98
+ "func": run_sync,
99
+ "coroutine": run_async,
100
+ "response_format": "content",
101
+ }
102
+ )
103
+
104
+
105
+ def run_coroutine_sync(coro: Coroutine[Any, Any, T]) -> T:
106
+ try:
107
+ asyncio.get_running_loop()
108
+ except RuntimeError:
109
+ return asyncio.run(coro)
110
+ raise WebzConfigError(
111
+ "cannot call the sync helper from a running event loop. "
112
+ "use await aget_webz_tools() or await awebz_news_search()."
113
+ )
114
+
115
+
116
+ async def aget_webz_tools(
117
+ api_token: str | None = None,
118
+ mcp_url: str | None = None,
119
+ ) -> list[BaseTool]:
120
+ """
121
+ loads every tool from the hosted Webz MCP server.
122
+
123
+ params:
124
+ - api_token: webz api token. defaults to WEBZ_API_TOKEN.
125
+ - mcp_url: mcp endpoint. defaults to production.
126
+
127
+ returns:
128
+ - langchain tools whose schemas come from tools/list.
129
+ """
130
+ token = resolve_api_token(api_token)
131
+ url = resolve_mcp_url(mcp_url)
132
+ client = MultiServerMCPClient(build_mcp_connection(token, url))
133
+ # ponytail: schemas come from MCP tools/list; new filters ship with the server, not this package
134
+ tools = await client.get_tools()
135
+ if not tools:
136
+ raise WebzConfigError(f"MCP server at {url} returned no tools.")
137
+ return [enable_sync_invoke(tool) for tool in tools]
138
+
139
+
140
+ def get_webz_tools(
141
+ api_token: str | None = None,
142
+ mcp_url: str | None = None,
143
+ ) -> list[BaseTool]:
144
+ return run_coroutine_sync(aget_webz_tools(api_token=api_token, mcp_url=mcp_url))
145
+
146
+
147
+ async def awebz_news_search(
148
+ api_token: str | None = None,
149
+ mcp_url: str | None = None,
150
+ ) -> BaseTool:
151
+ tools = await aget_webz_tools(api_token=api_token, mcp_url=mcp_url)
152
+ return pick_news_search_tool(tools)
153
+
154
+
155
+ def WebzNewsSearch(
156
+ api_token: str | None = None,
157
+ mcp_url: str | None = None,
158
+ ) -> BaseTool:
159
+ """
160
+ returns the live news search tool from the hosted MCP server.
161
+
162
+ params:
163
+ - api_token: webz api token. defaults to WEBZ_API_TOKEN.
164
+ - mcp_url: mcp endpoint. defaults to production.
165
+
166
+ returns:
167
+ - langchain tool with the current MCP input schema.
168
+ """
169
+ return run_coroutine_sync(
170
+ awebz_news_search(api_token=api_token, mcp_url=mcp_url)
171
+ )
@@ -0,0 +1,6 @@
1
+ DEFAULT_MCP_URL = "https://news-search-mcp.webz.io/mcp"
2
+ TOKEN_ENV_NAME = "WEBZ_API_TOKEN"
3
+ MCP_URL_ENV_NAME = "WEBZ_MCP_URL"
4
+ MCP_SERVER_NAME = "webz-news-search"
5
+ PREFERRED_TOOL_NAME = "news_search_by_webz"
6
+ MCP_TRANSPORT = "http"
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-webz"
7
+ version = "0.1.0"
8
+ description = "LangChain / LangGraph tools for Webz.io News Search via the hosted MCP server"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Webz.io", email = "support@webz.io" },
15
+ ]
16
+ keywords = ["langchain", "langgraph", "webz", "news", "mcp", "search"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
26
+ ]
27
+ dependencies = [
28
+ "langchain-core>=0.3.0",
29
+ "langchain-mcp-adapters>=0.1.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=8.0",
35
+ "pytest-asyncio>=0.24",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://news-search-mcp.webz.io"
40
+ Documentation = "https://news-search-mcp.webz.io"
41
+ Issues = "https://github.com/Webhose/webz-news-search/issues"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["langchain_webz"]
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "auto"
48
+ testpaths = ["tests"]
49
+ pythonpath = ["."]
@@ -0,0 +1,225 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+ from langchain_core.tools import BaseTool, StructuredTool, tool
9
+
10
+ from langchain_webz.client import (
11
+ WebzConfigError,
12
+ WebzNewsSearch,
13
+ aget_webz_tools,
14
+ build_mcp_connection,
15
+ enable_sync_invoke,
16
+ get_webz_tools,
17
+ pick_news_search_tool,
18
+ resolve_api_token,
19
+ resolve_mcp_url,
20
+ )
21
+ from langchain_webz.consts import (
22
+ DEFAULT_MCP_URL,
23
+ MCP_SERVER_NAME,
24
+ MCP_TRANSPORT,
25
+ PREFERRED_TOOL_NAME,
26
+ TOKEN_ENV_NAME,
27
+ )
28
+
29
+ PACKAGE_ROOT = Path(__file__).resolve().parents[1]
30
+ CLIENT_SOURCE = (PACKAGE_ROOT / "langchain_webz" / "client.py").read_text(encoding="utf-8")
31
+ CONSTS_SOURCE = (PACKAGE_ROOT / "langchain_webz" / "consts.py").read_text(encoding="utf-8")
32
+
33
+ FILTER_NAMES_OWNED_BY_MCP = (
34
+ "allow_all_dates",
35
+ "exclude_domain",
36
+ "domain_rank_gte",
37
+ "domain_rank_lte",
38
+ "trust_category",
39
+ "political_bias",
40
+ "min_similarity",
41
+ "allow_multiple_chunks_per_article",
42
+ )
43
+
44
+
45
+ @tool(PREFERRED_TOOL_NAME)
46
+ def fake_news_search(query: str, extra_filter: str = "x") -> str:
47
+ """fake news search tool used in unit tests."""
48
+ return f"{query}:{extra_filter}"
49
+
50
+
51
+ @tool
52
+ def fake_other_tool(value: str) -> str:
53
+ """second fake tool to prove all MCP tools are forwarded."""
54
+ return value
55
+
56
+
57
+ class FakeMcpClient:
58
+ def __init__(self, connections: dict) -> None:
59
+ self.connections = connections
60
+
61
+ async def get_tools(self) -> list[BaseTool]:
62
+ return [fake_news_search, fake_other_tool]
63
+
64
+
65
+ class EmptyMcpClient(FakeMcpClient):
66
+ async def get_tools(self) -> list[BaseTool]:
67
+ return []
68
+
69
+
70
+ def test_resolve_api_token_requires_value(monkeypatch: pytest.MonkeyPatch) -> None:
71
+ monkeypatch.delenv(TOKEN_ENV_NAME, raising=False)
72
+ with pytest.raises(WebzConfigError, match="missing Webz API token"):
73
+ resolve_api_token()
74
+
75
+
76
+ def test_resolve_api_token_prefers_argument(monkeypatch: pytest.MonkeyPatch) -> None:
77
+ monkeypatch.setenv(TOKEN_ENV_NAME, "from-env")
78
+ assert resolve_api_token(" from-arg ") == "from-arg"
79
+
80
+
81
+ def test_resolve_mcp_url_default_and_override(monkeypatch: pytest.MonkeyPatch) -> None:
82
+ monkeypatch.delenv("WEBZ_MCP_URL", raising=False)
83
+ assert resolve_mcp_url() == DEFAULT_MCP_URL
84
+ assert resolve_mcp_url("https://localhost:8765/mcp/") == "https://localhost:8765/mcp"
85
+
86
+
87
+ def test_build_mcp_connection_uses_bearer_header() -> None:
88
+ connection = build_mcp_connection("secret-token", DEFAULT_MCP_URL)
89
+ server = connection[MCP_SERVER_NAME]
90
+ assert server["transport"] == MCP_TRANSPORT
91
+ assert server["url"] == DEFAULT_MCP_URL
92
+ assert server["headers"]["Authorization"] == "Bearer secret-token"
93
+
94
+
95
+ def test_pick_prefers_named_news_tool() -> None:
96
+ preferred = SimpleNamespace(name=PREFERRED_TOOL_NAME)
97
+ other = SimpleNamespace(name="other_tool")
98
+ assert pick_news_search_tool([other, preferred]) is preferred
99
+
100
+
101
+ def test_pick_falls_back_to_single_tool() -> None:
102
+ only = SimpleNamespace(name="whatever_the_server_exposes")
103
+ assert pick_news_search_tool([only]) is only
104
+
105
+
106
+ def test_pick_errors_when_preferred_missing_among_many() -> None:
107
+ with pytest.raises(WebzConfigError, match="did not expose"):
108
+ pick_news_search_tool(
109
+ [SimpleNamespace(name="alpha"), SimpleNamespace(name="beta")]
110
+ )
111
+
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_aget_webz_tools_loads_whatever_mcp_returns(
115
+ monkeypatch: pytest.MonkeyPatch,
116
+ ) -> None:
117
+ captured: dict = {}
118
+
119
+ def fake_client_ctor(connections: dict) -> FakeMcpClient:
120
+ captured["connections"] = connections
121
+ return FakeMcpClient(connections)
122
+
123
+ monkeypatch.setattr("langchain_webz.client.MultiServerMCPClient", fake_client_ctor)
124
+ tools = await aget_webz_tools(api_token="tok", mcp_url="https://example.test/mcp")
125
+ assert [item.name for item in tools] == [PREFERRED_TOOL_NAME, "fake_other_tool"]
126
+ assert "extra_filter" in inspect.signature(fake_news_search.func).parameters
127
+ server = captured["connections"][MCP_SERVER_NAME]
128
+ assert server["url"] == "https://example.test/mcp"
129
+ assert server["headers"]["Authorization"] == "Bearer tok"
130
+
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_aget_webz_tools_rejects_empty_server(
134
+ monkeypatch: pytest.MonkeyPatch,
135
+ ) -> None:
136
+ monkeypatch.setattr("langchain_webz.client.MultiServerMCPClient", EmptyMcpClient)
137
+ with pytest.raises(WebzConfigError, match="returned no tools"):
138
+ await aget_webz_tools(api_token="tok")
139
+
140
+
141
+ def test_sync_helpers_use_loaded_tools(monkeypatch: pytest.MonkeyPatch) -> None:
142
+ monkeypatch.setattr("langchain_webz.client.MultiServerMCPClient", FakeMcpClient)
143
+ tools = get_webz_tools(api_token="tok")
144
+ news_tool = WebzNewsSearch(api_token="tok")
145
+ assert [item.name for item in tools] == [PREFERRED_TOOL_NAME, "fake_other_tool"]
146
+ assert news_tool.name == PREFERRED_TOOL_NAME
147
+ assert "extra_filter" in news_tool.args
148
+ assert "query" in news_tool.args
149
+
150
+
151
+ def test_wrapper_source_does_not_hardcode_mcp_filters() -> None:
152
+ combined = CLIENT_SOURCE + CONSTS_SOURCE
153
+ for name in FILTER_NAMES_OWNED_BY_MCP:
154
+ assert name not in combined, f"wrapper must not hardcode MCP filter {name}"
155
+
156
+
157
+ def test_public_exports() -> None:
158
+ import langchain_webz
159
+
160
+ for name in (
161
+ "WebzNewsSearch",
162
+ "get_webz_tools",
163
+ "aget_webz_tools",
164
+ "awebz_news_search",
165
+ "WebzConfigError",
166
+ "DEFAULT_MCP_URL",
167
+ "TOKEN_ENV_NAME",
168
+ ):
169
+ assert hasattr(langchain_webz, name)
170
+
171
+
172
+ def test_enable_sync_invoke_allows_tool_invoke() -> None:
173
+ async def call_tool(*, query: str) -> str:
174
+ return f"got {query}"
175
+
176
+ async_only = StructuredTool(
177
+ name=PREFERRED_TOOL_NAME,
178
+ description="async-only mcp-style tool",
179
+ args_schema={
180
+ "type": "object",
181
+ "properties": {"query": {"type": "string"}},
182
+ "required": ["query"],
183
+ },
184
+ coroutine=call_tool,
185
+ )
186
+ with pytest.raises(NotImplementedError, match="sync invocation"):
187
+ async_only.invoke({"query": "hi"})
188
+
189
+ wrapped = enable_sync_invoke(async_only)
190
+ assert wrapped.invoke({"query": "hi"}) == "got hi"
191
+
192
+
193
+ def test_enable_sync_invoke_flattens_content_and_artifact() -> None:
194
+ async def call_tool(*, query: str) -> list[dict[str, str]]:
195
+ return [{"type": "text", "text": f"Query: {query}", "id": "lc_test"}]
196
+
197
+ mcp_style = StructuredTool(
198
+ name=PREFERRED_TOOL_NAME,
199
+ description="mcp content_and_artifact tool",
200
+ args_schema={
201
+ "type": "object",
202
+ "properties": {"query": {"type": "string"}},
203
+ "required": ["query"],
204
+ },
205
+ coroutine=call_tool,
206
+ response_format="content_and_artifact",
207
+ )
208
+ wrapped = enable_sync_invoke(mcp_style)
209
+ assert wrapped.response_format == "content"
210
+ assert wrapped.invoke({"query": "AI"}) == "Query: AI"
211
+
212
+
213
+ def test_flatten_tool_result_unwraps_content_blocks() -> None:
214
+ from langchain_webz.client import flatten_tool_result
215
+
216
+ blocks = [
217
+ {
218
+ "type": "text",
219
+ "text": "Query: AI regulation Europe\nTitle: Example",
220
+ "id": "lc_test",
221
+ }
222
+ ]
223
+ assert flatten_tool_result(blocks) == "Query: AI regulation Europe\nTitle: Example"
224
+ assert flatten_tool_result((blocks, None)) == "Query: AI regulation Europe\nTitle: Example"
225
+ assert flatten_tool_result("already text") == "already text"
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import pytest
6
+
7
+ from langchain_webz import WebzNewsSearch, get_webz_tools
8
+ from langchain_webz.consts import PREFERRED_TOOL_NAME
9
+
10
+ pytestmark = pytest.mark.skipif(
11
+ not os.getenv("WEBZ_API_TOKEN"),
12
+ reason="WEBZ_API_TOKEN is not set",
13
+ )
14
+
15
+
16
+ def test_live_tools_come_from_hosted_mcp() -> None:
17
+ tools = get_webz_tools()
18
+ names = [tool.name for tool in tools]
19
+ assert PREFERRED_TOOL_NAME in names
20
+ news_tool = next(tool for tool in tools if tool.name == PREFERRED_TOOL_NAME)
21
+ assert "query" in news_tool.args
22
+
23
+
24
+ def test_live_news_search_invoke() -> None:
25
+ tool = WebzNewsSearch()
26
+ result = tool.invoke({"query": "AI regulation Europe", "k": 1})
27
+ assert isinstance(result, str)
28
+ assert "Query:" in result
29
+ assert result.strip()