Skip to content

Commit 7216e92

Browse files
Add default headers to the Oxylabs API requests (#13)
* Add default headers to the Oxylabs API requests * Updated URL params
1 parent 9997e02 commit 7216e92

File tree

8 files changed

+130
-19
lines changed

8 files changed

+130
-19
lines changed

.github/workflows/lint_and_test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,4 @@ jobs:
3737
3838
- name: Run tests
3939
run: |
40-
uv run pytest --cov=src --cov-report xml --cov-report term --cov-fail-under=80 ./tests
40+
uv run pytest --cov=src --cov-report xml --cov-report term --cov-fail-under=90 ./tests

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ format: $(virtualenv_dir)
2222

2323
.PHONY: test
2424
test: install_deps
25-
uv run pytest --cov=src --cov-report xml --cov-report term --cov-fail-under=80 ./tests
25+
uv run pytest --cov=src --cov-report xml --cov-report term --cov-fail-under=90 ./tests
2626

2727
.PHONY: run
2828
run: install_deps

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "oxylabs-mcp"
3-
version = "0.1.5"
3+
version = "0.1.6"
44
description = "Oxylabs MCP server"
55
authors = [
66
{name="Augis Braziunas", email="[email protected]"},

src/oxylabs_mcp/server.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any
22

3-
from mcp.server.fastmcp import FastMCP
3+
from mcp.server.fastmcp import Context, FastMCP
44

55
from oxylabs_mcp import url_params
66
from oxylabs_mcp.config import settings
@@ -21,13 +21,14 @@
2121
description="Scrape url using Oxylabs Web API with universal scraper",
2222
)
2323
async def scrape_universal_url(
24+
ctx: Context, # type: ignore[type-arg]
2425
url: url_params.URL_PARAM,
2526
parse: url_params.PARSE_PARAM = True, # noqa: FBT002
2627
render: url_params.RENDER_PARAM = "",
2728
) -> str:
2829
"""Scrape url using Oxylabs Web API with universal scraper."""
2930
try:
30-
async with oxylabs_client(with_auth=True) as client:
31+
async with oxylabs_client(ctx, with_auth=True) as client:
3132
payload: dict[str, Any] = {"url": url}
3233
if parse:
3334
payload["parse"] = parse
@@ -48,6 +49,7 @@ async def scrape_universal_url(
4849
description="Scrape url using Oxylabs Web Unblocker",
4950
)
5051
async def scrape_with_web_unblocker(
52+
ctx: Context, # type: ignore[type-arg]
5153
url: url_params.URL_PARAM,
5254
render: url_params.RENDER_PARAM = "",
5355
) -> str:
@@ -61,7 +63,7 @@ async def scrape_with_web_unblocker(
6163
headers["X-Oxylabs-Render"] = render
6264

6365
try:
64-
async with oxylabs_client(with_proxy=True, verify=False, headers=headers) as client:
66+
async with oxylabs_client(ctx, with_proxy=True, verify=False, headers=headers) as client:
6567
response = await client.get(url)
6668

6769
response.raise_for_status()
@@ -76,6 +78,7 @@ async def scrape_with_web_unblocker(
7678
description="Scrape Google Search results using Oxylabs Web API",
7779
)
7880
async def scrape_google_search(
81+
ctx: Context, # type: ignore[type-arg]
7982
query: url_params.GOOGLE_QUERY_PARAM,
8083
parse: url_params.PARSE_PARAM = True, # noqa: FBT002
8184
render: url_params.RENDER_PARAM = "",
@@ -90,7 +93,7 @@ async def scrape_google_search(
9093
) -> str:
9194
"""Scrape Google Search results using Oxylabs Web API."""
9295
try:
93-
async with oxylabs_client(with_auth=True) as client:
96+
async with oxylabs_client(ctx, with_auth=True) as client:
9497
payload: dict[str, Any] = {"query": query}
9598

9699
if ad_mode:
@@ -131,6 +134,7 @@ async def scrape_google_search(
131134
description="Scrape Amazon Search results using Oxylabs Web API",
132135
)
133136
async def scrape_amazon_search(
137+
ctx: Context, # type: ignore[type-arg]
134138
query: url_params.AMAZON_SEARCH_QUERY_PARAM,
135139
category_id: url_params.CATEGORY_ID_CONTEXT_PARAM = "",
136140
merchant_id: url_params.MERCHANT_ID_CONTEXT_PARAM = "",
@@ -146,7 +150,7 @@ async def scrape_amazon_search(
146150
) -> str:
147151
"""Scrape Amazon Search results using Oxylabs Web API."""
148152
try:
149-
async with oxylabs_client(with_auth=True) as client:
153+
async with oxylabs_client(ctx, with_auth=True) as client:
150154
payload: dict[str, Any] = {"source": "amazon_search", "query": query}
151155

152156
context = []
@@ -190,6 +194,7 @@ async def scrape_amazon_search(
190194
description="Scrape Amazon Products using Oxylabs Web API",
191195
)
192196
async def scrape_amazon_products(
197+
ctx: Context, # type: ignore[type-arg]
193198
query: url_params.AMAZON_SEARCH_QUERY_PARAM,
194199
autoselect_variant: url_params.AUTOSELECT_VARIANT_CONTEXT_PARAM = False, # noqa: FBT002
195200
currency: url_params.CURRENCY_CONTEXT_PARAM = "",
@@ -202,7 +207,7 @@ async def scrape_amazon_products(
202207
) -> str:
203208
"""Scrape Amazon Products using Oxylabs Web API."""
204209
try:
205-
async with oxylabs_client(with_auth=True) as client:
210+
async with oxylabs_client(ctx, with_auth=True) as client:
206211
payload: dict[str, Any] = {"source": "amazon_product", "query": query}
207212

208213
context = []

src/oxylabs_mcp/url_params.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,15 @@
5151
]
5252
START_PAGE_PARAM = Annotated[
5353
int,
54-
Field(ge=1, description="Starting page number."),
54+
Field(description="Starting page number."),
5555
]
5656
PAGES_PARAM = Annotated[
5757
int,
58-
Field(ge=1, description="Number of pages to retrieve."),
58+
Field(description="Number of pages to retrieve."),
5959
]
6060
LIMIT_PARAM = Annotated[
6161
int,
62-
Field(ge=1, description="Number of results to retrieve in each page."),
62+
Field(description="Number of results to retrieve in each page."),
6363
]
6464
DOMAIN_PARAM = Annotated[
6565
str,

src/oxylabs_mcp/utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import os
33
import re
44
from contextlib import asynccontextmanager
5+
from importlib.metadata import version
6+
from platform import architecture, python_version
57
from typing import AsyncIterator
68

79
from httpx import (
@@ -15,6 +17,8 @@
1517
from lxml.html import defs, fromstring, tostring
1618
from lxml.html.clean import Cleaner
1719
from markdownify import markdownify as md
20+
from mcp.server.fastmcp import Context
21+
from mcp.shared.context import RequestContext
1822

1923
from oxylabs_mcp.config import settings
2024
from oxylabs_mcp.exceptions import MCPServerError
@@ -110,8 +114,33 @@ def convert_html_to_md(html: str) -> str:
110114
return md(html) # type: ignore[no-any-return]
111115

112116

117+
def _get_request_context(ctx: Context) -> RequestContext | None: # type: ignore[type-arg]
118+
try:
119+
return ctx.request_context
120+
except ValueError:
121+
return None
122+
123+
124+
def _update_with_default_headers(
125+
ctx: Context, headers: dict[str, str] # type: ignore[type-arg]
126+
) -> None:
127+
if request_context := _get_request_context(ctx):
128+
if client_params := request_context.session.client_params:
129+
client = f"oxylabs-mcp-{client_params.clientInfo.name}"
130+
else:
131+
client = "oxylabs-mcp"
132+
else:
133+
client = "oxylabs-mcp"
134+
135+
bits, _ = architecture()
136+
sdk_type = f"{client}/{version('oxylabs-mcp')} ({python_version()}; {bits})"
137+
138+
headers["x-oxylabs-sdk"] = sdk_type
139+
140+
113141
@asynccontextmanager
114142
async def oxylabs_client(
143+
ctx: Context, # type: ignore[type-arg]
115144
headers: dict[str, str] | None = None,
116145
*,
117146
with_proxy: bool = False,
@@ -122,6 +151,8 @@ async def oxylabs_client(
122151
if headers is None:
123152
headers = {}
124153

154+
_update_with_default_headers(ctx, headers)
155+
125156
username, password = get_auth_from_env()
126157

127158
if with_proxy:

tests/conftest.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from contextlib import asynccontextmanager
2-
from unittest.mock import AsyncMock, patch
2+
from unittest.mock import AsyncMock, MagicMock, patch
33

44
import pytest
5+
from mcp.server.lowlevel.server import request_ctx
6+
from mcp.shared.context import RequestContext
57

68

79
@pytest.fixture
@@ -10,7 +12,28 @@ def oxylabs_client():
1012

1113
@asynccontextmanager
1214
async def wrapper(*args, **kwargs):
15+
client_mock.context_manager_call_args = args
16+
client_mock.context_manager_call_kwargs = kwargs
17+
1318
yield client_mock
1419

1520
with patch("oxylabs_mcp.utils.AsyncClient", new=wrapper):
1621
yield client_mock
22+
23+
24+
@pytest.fixture
25+
def request_session():
26+
request_session_mock = MagicMock()
27+
28+
token = request_ctx.set(
29+
RequestContext(
30+
42,
31+
None,
32+
request_session_mock,
33+
None,
34+
)
35+
)
36+
37+
yield request_session_mock
38+
39+
request_ctx.reset(token)

tests/integration/test_server.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import json
2+
import re
23
from contextlib import nullcontext as does_not_raise
34
from typing import Any
4-
from unittest.mock import AsyncMock, patch
5+
from unittest.mock import AsyncMock, MagicMock, patch
56

67
import pytest
78
from httpx import Request, Response
@@ -246,11 +247,8 @@ async def test_oxylabs_web_unblocker_results(
246247
*params.USER_AGENTS,
247248
params.INVALID_USER_AGENT,
248249
params.START_PAGE_SPECIFIED,
249-
params.START_PAGE_INVALID,
250250
params.PAGES_SPECIFIED,
251-
params.PAGES_INVALID,
252251
params.LIMIT_SPECIFIED,
253-
params.LIMIT_INVALID,
254252
params.DOMAIN_SPECIFIED,
255253
params.GEO_LOCATION_SPECIFIED,
256254
params.LOCALE_SPECIFIED,
@@ -312,9 +310,7 @@ async def test_oxylabs_google_search_ad_mode_argument(
312310
*params.USER_AGENTS,
313311
params.INVALID_USER_AGENT,
314312
params.START_PAGE_SPECIFIED,
315-
params.START_PAGE_INVALID,
316313
params.PAGES_SPECIFIED,
317-
params.PAGES_INVALID,
318314
params.DOMAIN_SPECIFIED,
319315
params.GEO_LOCATION_SPECIFIED,
320316
params.LOCALE_SPECIFIED,
@@ -381,3 +377,59 @@ async def test_oxylabs_amazon_product_scraper_arguments(
381377
):
382378
result = await mcp.call_tool("oxylabs_amazon_product_scraper", arguments=arguments)
383379
assert result == [TextContent(type="text", text=expected_result)]
380+
381+
@pytest.mark.asyncio
382+
@pytest.mark.parametrize(
383+
("tool", "arguments"),
384+
[
385+
pytest.param(
386+
"oxylabs_universal_scraper", {"url": "test_url"}, id="oxylabs_universal_scraper"
387+
),
388+
pytest.param("oxylabs_web_unblocker", {"url": "test_url"}, id="oxylabs_web_unblocker"),
389+
pytest.param(
390+
"oxylabs_google_search_scraper",
391+
{"query": "Generic query"},
392+
id="oxylabs_google_search_scraper",
393+
),
394+
pytest.param(
395+
"oxylabs_amazon_search_scraper",
396+
{"query": "Generic query"},
397+
id="oxylabs_amazon_search_scraper",
398+
),
399+
pytest.param(
400+
"oxylabs_amazon_product_scraper",
401+
{"query": "Generic query"},
402+
id="oxylabs_amazon_product_scraper",
403+
),
404+
],
405+
)
406+
async def test_default_headers_are_set(
407+
self,
408+
mcp: FastMCP,
409+
request_data: Request,
410+
oxylabs_client: AsyncMock,
411+
request_session: MagicMock,
412+
tool: str,
413+
arguments: dict,
414+
):
415+
request_session.client_params.clientInfo.name = "fake_cursor"
416+
417+
mock_response = Response(
418+
200,
419+
content=json.dumps({"results": [{"content": "Mocked content"}]}),
420+
request=request_data,
421+
)
422+
423+
oxylabs_client.post.return_value = mock_response
424+
oxylabs_client.get.return_value = mock_response
425+
426+
with patch("os.environ", new=ENV_VARIABLES):
427+
await mcp.call_tool(tool, arguments=arguments)
428+
429+
assert "x-oxylabs-sdk" in oxylabs_client.context_manager_call_kwargs["headers"]
430+
431+
oxylabs_sdk_header = oxylabs_client.context_manager_call_kwargs["headers"]["x-oxylabs-sdk"]
432+
client_info, _ = oxylabs_sdk_header.split(maxsplit=1)
433+
434+
client_info_pattern = re.compile(r"oxylabs-mcp-fake_cursor/(\d+)\.(\d+)\.(\d+)$")
435+
assert re.match(client_info_pattern, client_info)

0 commit comments

Comments
 (0)