Skip to content

Commit d4e121c

Browse files
authored
fix(ai)!: keep session settings on MCP session fetches (#418)
The three MCP tools that accept a `session_id` forwarded every per-fetch parameter to the session's `fetch()`, including their own defaults when the caller supplied nothing. `validate_fetch` treats every key it receives as an override, so the branch that reads the value from the session config never ran, and the tool defaults replaced the settings the session was opened with. - Forward only the parameters the caller actually supplied - Default those parameters to `None` on `fetch`, `bulk_fetch`, `stealthy_fetch`, `bulk_stealthy_fetch` and `screenshot` - Add regression tests for the forwarded kwargs, and for the session settings surviving the validation the tools go through
1 parent 0003f64 commit d4e121c

2 files changed

Lines changed: 344 additions & 94 deletions

File tree

scrapling/core/ai.py

Lines changed: 77 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ def _page_pool_size(urls: Sequence[str]) -> int:
5353
return min(max(len(urls), 1), _MAX_POOL_PAGES)
5454

5555

56+
def _supplied_params(**params: Any) -> Dict[str, Any]:
57+
"""Keep only the parameters the caller set, so the unset ones fall back to the session/fetcher defaults."""
58+
return {name: value for name, value in params.items() if value is not None}
59+
60+
5661
_FETCH_TOOL_ANNOTATIONS = ToolAnnotations(read_only_hint=True, open_world_hint=True)
5762
_SESSION_TOOL_ANNOTATIONS = ToolAnnotations(read_only_hint=False, destructive_hint=False, open_world_hint=True)
5863
_LIST_TOOL_ANNOTATIONS = ToolAnnotations(read_only_hint=True, open_world_hint=False)
@@ -321,11 +326,11 @@ async def screenshot(
321326
image_type: ScreenshotType = "png",
322327
full_page: bool = False,
323328
quality: Optional[int] = None,
324-
wait: int | float = 0,
329+
wait: int | float | None = None,
325330
wait_selector: Optional[str] = None,
326-
wait_selector_state: SelectorWaitStates = "attached",
327-
network_idle: bool = False,
328-
timeout: int | float = 30000,
331+
wait_selector_state: Optional[SelectorWaitStates] = None,
332+
network_idle: bool | None = None,
333+
timeout: int | float | None = None,
329334
) -> List[ImageContent | TextContent]:
330335
"""Capture a screenshot of a web page using an existing browser session and return it as an image.
331336
A browser session must be opened first with `open_session` (either `dynamic` or `stealthy`); the session ID is then passed here.
@@ -335,11 +340,11 @@ async def screenshot(
335340
:param image_type: Image format. Defaults to "png". Use "jpeg" for smaller file sizes.
336341
:param full_page: When True, captures the full scrollable page instead of just the viewport. Defaults to False.
337342
:param quality: Image quality (0-100) for JPEG only. Raises if passed with `image_type="png"`.
338-
:param wait: Time in milliseconds to wait after page load before capturing. Defaults to 0.
339-
:param wait_selector: Optional CSS selector to wait for before capturing.
340-
:param wait_selector_state: State to wait for the selector. Defaults to "attached".
341-
:param network_idle: Wait for the page until there are no network connections for at least 500 ms.
342-
:param timeout: Timeout in milliseconds for page operations. Defaults to 30,000.
343+
:param wait: Time in milliseconds to wait after page load before capturing. Uses the session's setting when omitted.
344+
:param wait_selector: Optional CSS selector to wait for before capturing. Uses the session's setting when omitted.
345+
:param wait_selector_state: State to wait for the selector. Uses the session's setting when omitted.
346+
:param network_idle: Wait for the page until there are no network connections for at least 500 ms. Uses the session's setting when omitted.
347+
:param timeout: Timeout in milliseconds for page operations. Uses the session's setting when omitted.
343348
"""
344349
if quality is not None and image_type != "jpeg":
345350
raise ValueError("'quality' is only valid when 'image_type' is 'jpeg'.")
@@ -361,12 +366,14 @@ async def _capture(page: Any) -> None:
361366

362367
await entry.session.fetch(
363368
url,
364-
wait=wait,
365-
timeout=timeout,
366-
network_idle=network_idle,
367-
wait_selector=wait_selector,
368-
wait_selector_state=wait_selector_state,
369369
page_action=_capture,
370+
**_supplied_params(
371+
wait=wait,
372+
timeout=timeout,
373+
network_idle=network_idle,
374+
wait_selector=wait_selector,
375+
wait_selector_state=wait_selector_state,
376+
),
370377
)
371378

372379
if "error" in captured:
@@ -529,22 +536,22 @@ async def fetch(
529536
css_selector: Optional[str] = None,
530537
main_content_only: bool = True,
531538
headless: bool = True, # noqa: F821
532-
google_search: bool = True,
539+
google_search: bool | None = None,
533540
real_chrome: bool = False,
534-
wait: int | float = 0,
541+
wait: int | float | None = None,
535542
proxy: Optional[str | Dict[str, str]] = None,
536543
timezone_id: str | None = None,
537544
locale: str | None = None,
538545
extra_headers: Optional[Dict[str, str]] = None,
539546
useragent: Optional[str] = None,
540547
cdp_url: Optional[str] = None,
541548
executable_path: Optional[str] = None,
542-
timeout: int | float = 30000,
543-
disable_resources: bool = False,
549+
timeout: int | float | None = None,
550+
disable_resources: bool | None = None,
544551
wait_selector: Optional[str] = None,
545552
cookies: Sequence[SetCookieParam] | None = None,
546-
network_idle: bool = False,
547-
wait_selector_state: SelectorWaitStates = "attached",
553+
network_idle: bool | None = None,
554+
wait_selector_state: Optional[SelectorWaitStates] = None,
548555
session_id: Optional[str] = None,
549556
) -> ResponseModel:
550557
"""Use playwright to open a browser to fetch a URL and return a structured output of the result.
@@ -573,7 +580,7 @@ async def fetch(
573580
:param google_search: Enabled by default, Scrapling will set a Google referer header.
574581
:param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
575582
:param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only.
576-
:param session_id: Optional session ID from open_session. If provided, reuses the existing browser session instead of creating a new one.
583+
:param session_id: Optional session ID from open_session. If provided, reuses the existing browser session instead of creating a new one, and every option you leave out here keeps the value the session was opened with.
577584
"""
578585
results = await self.bulk_fetch(
579586
urls=[url],
@@ -608,22 +615,22 @@ async def bulk_fetch(
608615
css_selector: Optional[str] = None,
609616
main_content_only: bool = True,
610617
headless: bool = True, # noqa: F821
611-
google_search: bool = True,
618+
google_search: bool | None = None,
612619
real_chrome: bool = False,
613-
wait: int | float = 0,
620+
wait: int | float | None = None,
614621
proxy: Optional[str | Dict[str, str]] = None,
615622
timezone_id: str | None = None,
616623
locale: str | None = None,
617624
extra_headers: Optional[Dict[str, str]] = None,
618625
useragent: Optional[str] = None,
619626
cdp_url: Optional[str] = None,
620627
executable_path: Optional[str] = None,
621-
timeout: int | float = 30000,
622-
disable_resources: bool = False,
628+
timeout: int | float | None = None,
629+
disable_resources: bool | None = None,
623630
wait_selector: Optional[str] = None,
624631
cookies: Sequence[SetCookieParam] | None = None,
625-
network_idle: bool = False,
626-
wait_selector_state: SelectorWaitStates = "attached",
632+
network_idle: bool | None = None,
633+
wait_selector_state: Optional[SelectorWaitStates] = None,
627634
session_id: Optional[str] = None,
628635
) -> List[ResponseModel]:
629636
"""Use playwright to open a browser, then fetch a group of URLs at the same time, and for each page return a structured output of the result.
@@ -652,32 +659,26 @@ async def bulk_fetch(
652659
:param google_search: Enabled by default, Scrapling will set a Google referer header.
653660
:param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
654661
:param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only.
655-
:param session_id: Optional session ID from open_session. If provided, reuses the existing browser session instead of creating a new one.
662+
:param session_id: Optional session ID from open_session. If provided, reuses the existing browser session instead of creating a new one, and every option you leave out here keeps the value the session was opened with.
656663
"""
664+
fetch_params = _supplied_params(
665+
wait=wait,
666+
timeout=timeout,
667+
google_search=google_search,
668+
extra_headers=extra_headers,
669+
disable_resources=disable_resources,
670+
wait_selector=wait_selector,
671+
wait_selector_state=wait_selector_state,
672+
network_idle=network_idle,
673+
)
657674
if session_id:
658675
entry = self._get_session(session_id, "dynamic")
659-
tasks = [
660-
entry.session.fetch(
661-
url,
662-
wait=wait,
663-
timeout=timeout,
664-
google_search=google_search,
665-
extra_headers=extra_headers,
666-
disable_resources=disable_resources,
667-
wait_selector=wait_selector,
668-
wait_selector_state=wait_selector_state,
669-
network_idle=network_idle,
670-
proxy=proxy,
671-
)
672-
for url in urls
673-
]
676+
tasks = [entry.session.fetch(url, proxy=proxy, **fetch_params) for url in urls]
674677
responses = await gather(*tasks)
675678
else:
676679
async with AsyncDynamicSession(
677-
wait=wait,
678680
proxy=proxy,
679681
locale=locale,
680-
timeout=timeout,
681682
cookies=cookies,
682683
cdp_url=cdp_url,
683684
headless=headless,
@@ -686,13 +687,8 @@ async def bulk_fetch(
686687
useragent=useragent,
687688
timezone_id=timezone_id,
688689
real_chrome=real_chrome,
689-
network_idle=network_idle,
690-
wait_selector=wait_selector,
691-
google_search=google_search,
692-
extra_headers=extra_headers,
693690
executable_path=self._resolve_executable_path(executable_path),
694-
disable_resources=disable_resources,
695-
wait_selector_state=wait_selector_state,
691+
**fetch_params,
696692
) as session:
697693
tasks = [session.fetch(url) for url in urls]
698694
responses = await gather(*tasks)
@@ -706,9 +702,9 @@ async def stealthy_fetch(
706702
css_selector: Optional[str] = None,
707703
main_content_only: bool = True,
708704
headless: bool = True, # noqa: F821
709-
google_search: bool = True,
705+
google_search: bool | None = None,
710706
real_chrome: bool = False,
711-
wait: int | float = 0,
707+
wait: int | float | None = None,
712708
proxy: Optional[str | Dict[str, str]] = None,
713709
timezone_id: str | None = None,
714710
locale: str | None = None,
@@ -717,15 +713,15 @@ async def stealthy_fetch(
717713
hide_canvas: bool = False,
718714
cdp_url: Optional[str] = None,
719715
executable_path: Optional[str] = None,
720-
timeout: int | float = 30000,
721-
disable_resources: bool = False,
716+
timeout: int | float | None = None,
717+
disable_resources: bool | None = None,
722718
wait_selector: Optional[str] = None,
723719
cookies: Sequence[SetCookieParam] | None = None,
724-
network_idle: bool = False,
725-
wait_selector_state: SelectorWaitStates = "attached",
720+
network_idle: bool | None = None,
721+
wait_selector_state: Optional[SelectorWaitStates] = None,
726722
block_webrtc: bool = False,
727723
allow_webgl: bool = True,
728-
solve_cloudflare: bool = False,
724+
solve_cloudflare: bool | None = None,
729725
additional_args: Optional[Dict] = None,
730726
session_id: Optional[str] = None,
731727
) -> ResponseModel:
@@ -760,7 +756,7 @@ async def stealthy_fetch(
760756
:param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
761757
:param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only.
762758
:param additional_args: Additional arguments to be passed to Playwright's context as additional settings, and it takes higher priority than Scrapling's settings.
763-
:param session_id: Optional session ID from open_session. If provided, reuses the existing browser session instead of creating a new one.
759+
:param session_id: Optional session ID from open_session. If provided, reuses the existing browser session instead of creating a new one, and every option you leave out here keeps the value the session was opened with.
764760
"""
765761
results = await self.bulk_stealthy_fetch(
766762
urls=[url],
@@ -800,9 +796,9 @@ async def bulk_stealthy_fetch(
800796
css_selector: Optional[str] = None,
801797
main_content_only: bool = True,
802798
headless: bool = True, # noqa: F821
803-
google_search: bool = True,
799+
google_search: bool | None = None,
804800
real_chrome: bool = False,
805-
wait: int | float = 0,
801+
wait: int | float | None = None,
806802
proxy: Optional[str | Dict[str, str]] = None,
807803
timezone_id: str | None = None,
808804
locale: str | None = None,
@@ -811,15 +807,15 @@ async def bulk_stealthy_fetch(
811807
hide_canvas: bool = False,
812808
cdp_url: Optional[str] = None,
813809
executable_path: Optional[str] = None,
814-
timeout: int | float = 30000,
815-
disable_resources: bool = False,
810+
timeout: int | float | None = None,
811+
disable_resources: bool | None = None,
816812
wait_selector: Optional[str] = None,
817813
cookies: Sequence[SetCookieParam] | None = None,
818-
network_idle: bool = False,
819-
wait_selector_state: SelectorWaitStates = "attached",
814+
network_idle: bool | None = None,
815+
wait_selector_state: Optional[SelectorWaitStates] = None,
820816
block_webrtc: bool = False,
821817
allow_webgl: bool = True,
822-
solve_cloudflare: bool = False,
818+
solve_cloudflare: bool | None = None,
823819
additional_args: Optional[Dict] = None,
824820
session_id: Optional[str] = None,
825821
) -> List[ResponseModel]:
@@ -854,34 +850,28 @@ async def bulk_stealthy_fetch(
854850
:param extra_headers: A dictionary of extra headers to add to the request. _The referer set by `google_search` takes priority over the referer set here if used together._
855851
:param proxy: The proxy to be used with requests, it can be a string or a dictionary with the keys 'server', 'username', and 'password' only.
856852
:param additional_args: Additional arguments to be passed to Playwright's context as additional settings, and it takes higher priority than Scrapling's settings.
857-
:param session_id: Optional session ID from open_session. If provided, reuses the existing browser session instead of creating a new one.
853+
:param session_id: Optional session ID from open_session. If provided, reuses the existing browser session instead of creating a new one, and every option you leave out here keeps the value the session was opened with.
858854
"""
855+
fetch_params = _supplied_params(
856+
wait=wait,
857+
timeout=timeout,
858+
google_search=google_search,
859+
extra_headers=extra_headers,
860+
disable_resources=disable_resources,
861+
wait_selector=wait_selector,
862+
wait_selector_state=wait_selector_state,
863+
network_idle=network_idle,
864+
solve_cloudflare=solve_cloudflare,
865+
)
859866
if session_id:
860867
entry = self._get_session(session_id, "stealthy")
861-
tasks = [
862-
entry.session.fetch(
863-
url,
864-
wait=wait,
865-
timeout=timeout,
866-
google_search=google_search,
867-
extra_headers=extra_headers,
868-
disable_resources=disable_resources,
869-
wait_selector=wait_selector,
870-
wait_selector_state=wait_selector_state,
871-
network_idle=network_idle,
872-
proxy=proxy,
873-
solve_cloudflare=solve_cloudflare,
874-
)
875-
for url in urls
876-
]
868+
tasks = [entry.session.fetch(url, proxy=proxy, **fetch_params) for url in urls]
877869
responses = await gather(*tasks)
878870
else:
879871
async with AsyncStealthySession(
880-
wait=wait,
881872
proxy=proxy,
882873
locale=locale,
883874
cdp_url=cdp_url,
884-
timeout=timeout,
885875
cookies=cookies,
886876
headless=headless,
887877
block_ads=True,
@@ -891,16 +881,10 @@ async def bulk_stealthy_fetch(
891881
real_chrome=real_chrome,
892882
hide_canvas=hide_canvas,
893883
allow_webgl=allow_webgl,
894-
network_idle=network_idle,
895884
block_webrtc=block_webrtc,
896-
wait_selector=wait_selector,
897-
google_search=google_search,
898-
extra_headers=extra_headers,
899885
executable_path=self._resolve_executable_path(executable_path),
900886
additional_args=additional_args,
901-
solve_cloudflare=solve_cloudflare,
902-
disable_resources=disable_resources,
903-
wait_selector_state=wait_selector_state,
887+
**fetch_params,
904888
) as session:
905889
tasks = [session.fetch(url) for url in urls]
906890
responses = await gather(*tasks)
@@ -940,7 +924,7 @@ def _build_server(self, host: str, port: int) -> MCPServer:
940924
5. For all fetch tools, `main_content_only` is enabled by default and returns only the content inside the page's `<body>` tag. Pass `main_content_only=False` when you need the full page instead.
941925
6. If the task consists of multiple requests to the same website, open a session to be more efficient.
942926
7. For browser-based tools, if a `session_id` is provided (from open_session), the browser session will be reused instead of creating a new one.
943-
When using a session, browser-level params (headless, proxy, locale, etc.) are ignored since they were set at session creation time.
927+
When using a session, browser-level params (headless, locale, useragent, etc.) are ignored since they were set at session creation time, and any per-request param you leave out keeps the value the session was opened with.
944928
8. If you are making multiple requests, use the bulk version of the tool to be more efficient.
945929
9. If you are crawling/browsing a website, be more efficient by using the `css_selector` parameter to only access the parts you are interested in and save money/time. Example: use the `a` selector to extract the urls right away.
946930
10. The user can pass a CDP URL to connect to a remote browser session through the `open_session` tool, then use it in the rest of the tools.

0 commit comments

Comments
 (0)