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.
- langchain_webz-0.1.0/.env.example +2 -0
- langchain_webz-0.1.0/.gitignore +17 -0
- langchain_webz-0.1.0/LICENSE +21 -0
- langchain_webz-0.1.0/PKG-INFO +83 -0
- langchain_webz-0.1.0/README.md +56 -0
- langchain_webz-0.1.0/examples/run_news_search.py +39 -0
- langchain_webz-0.1.0/langchain_webz/__init__.py +18 -0
- langchain_webz-0.1.0/langchain_webz/client.py +171 -0
- langchain_webz-0.1.0/langchain_webz/consts.py +6 -0
- langchain_webz-0.1.0/pyproject.toml +49 -0
- langchain_webz-0.1.0/tests/test_client.py +225 -0
- langchain_webz-0.1.0/tests/test_live.py +29 -0
|
@@ -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,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()
|