mesharc 0.3.2__tar.gz → 0.3.4__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.
- {mesharc-0.3.2 → mesharc-0.3.4}/CHANGELOG.md +31 -0
- {mesharc-0.3.2/mesharc.egg-info → mesharc-0.3.4}/PKG-INFO +4 -4
- {mesharc-0.3.2 → mesharc-0.3.4}/README.md +3 -3
- {mesharc-0.3.2 → mesharc-0.3.4}/mesharc/__init__.py +7 -1
- {mesharc-0.3.2 → mesharc-0.3.4}/mesharc/mcp.py +319 -127
- {mesharc-0.3.2 → mesharc-0.3.4/mesharc.egg-info}/PKG-INFO +4 -4
- {mesharc-0.3.2 → mesharc-0.3.4}/pyproject.toml +1 -1
- {mesharc-0.3.2 → mesharc-0.3.4}/tests/test_behaviour.py +1 -1
- {mesharc-0.3.2 → mesharc-0.3.4}/tests/test_client.py +6 -0
- mesharc-0.3.4/tests/test_definitions.py +192 -0
- {mesharc-0.3.2 → mesharc-0.3.4}/tests/test_mcp_http.py +6 -6
- mesharc-0.3.2/tests/test_definitions.py +0 -67
- {mesharc-0.3.2 → mesharc-0.3.4}/LICENSE +0 -0
- {mesharc-0.3.2 → mesharc-0.3.4}/MANIFEST.in +0 -0
- {mesharc-0.3.2 → mesharc-0.3.4}/SECURITY.md +0 -0
- {mesharc-0.3.2 → mesharc-0.3.4}/mesharc/py.typed +0 -0
- {mesharc-0.3.2 → mesharc-0.3.4}/mesharc.egg-info/SOURCES.txt +0 -0
- {mesharc-0.3.2 → mesharc-0.3.4}/mesharc.egg-info/dependency_links.txt +0 -0
- {mesharc-0.3.2 → mesharc-0.3.4}/mesharc.egg-info/entry_points.txt +0 -0
- {mesharc-0.3.2 → mesharc-0.3.4}/mesharc.egg-info/requires.txt +0 -0
- {mesharc-0.3.2 → mesharc-0.3.4}/mesharc.egg-info/top_level.txt +0 -0
- {mesharc-0.3.2 → mesharc-0.3.4}/setup.cfg +0 -0
- {mesharc-0.3.2 → mesharc-0.3.4}/tests/test_results.py +0 -0
|
@@ -6,6 +6,37 @@ All notable changes to this package are recorded here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.3.4 - 2026-10-07
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`cancel_job(kind, id, project_id=None)`**, an eighteenth MCP tool: it stops a crawl, run or batch that is still going. It's the counterpart to `get_job`.
|
|
14
|
+
- Until now an assistant had no way out of a job that should not finish. `start_run` and `recrawl_pages` answer 409 while a run is going, and nothing could stop that run.
|
|
15
|
+
- It uses the API's existing cancels: `POST /projects/{id}/runs/{run_id}/cancel`, `DELETE /crawl/{id}` and `DELETE /scrape/{id}`.
|
|
16
|
+
- Queued work is dropped at once, and a page being read finishes first. Pages already read stay readable, and nothing is deleted.
|
|
17
|
+
- A job that already finished is left as it is and reported with its status, so asking twice is harmless.
|
|
18
|
+
- It needs write access and is marked destructive (a stop cannot be resumed).
|
|
19
|
+
- **`list_runs(project_id, limit=25)`**, a nineteenth MCP tool. It lists a project's runs newest first, so an assistant can find a run id for `list_pages`, `get_page`, `get_changes` or `search_pages`, see whether a run is still going before `start_run`, or pick the one to stop. Until now only `get_project`'s last run and `start_run`'s answer gave a run id. It's read-only, over the existing `GET /projects/{id}/runs`, and keeps only the fields that pick a run (no config, link graph or engine profile).
|
|
20
|
+
- **`MeshArc.cancel_batch(batch_id)`**: stops a scrape batch, or a one-URL scrape still going. It's the client's counterpart to `Crawl.cancel()` and `runs.cancel()`.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- `start_run`, `recrawl_pages` and `get_job` say that `cancel_job` stops a running job; `cancel_job` and the `run_id` parameters point to `list_runs`. The server's instructions and the README name them too.
|
|
25
|
+
|
|
26
|
+
## 0.3.3 - 2026-10-07
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **Tool descriptions are written in short lines, one fact each.** Glama's grader scored 0.3.2's tools A (4.6/5). The points it took off were for clauses chained by semicolons, and for descriptions that added nothing about the inputs beyond the schema. Each description now keeps its purpose and when-to-use lines, and adds:
|
|
31
|
+
- **Inputs:** how the inputs work together. Examples: `formats` only picks which stored bodies come back; path globs match the path, never the host; a `get_page` url must match apart from scheme and trailing slash; `recrawl_pages` resolves '/pricing' against the project's site.
|
|
32
|
+
- **Access:** whether a read-only connection may call it, and that errors come back as `{error, status}`.
|
|
33
|
+
- **Returns and limits:** the answer's shape and its limits. Examples: `list_pages` returns up to 500 rows, shallowest first; `list_projects` returns everything, oldest first; `get_changes` answers 409 for a run still going; `get_job` answers 404 once a crawl has expired.
|
|
34
|
+
- Apart from the fix below, behaviour is unchanged. Only the text an assistant reads differs.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- `list_projects` returns each project's `status` (draft, crawling, healthy or failing). It asked the API for `health`, which no project carries, so every row said `health: null`.
|
|
39
|
+
|
|
9
40
|
## 0.3.2 - 2026-10-06
|
|
10
41
|
|
|
11
42
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mesharc
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Python client for the MeshArc API: a URL in, clean content out, and a record of what changed.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://mesharc.dev
|
|
@@ -279,13 +279,13 @@ MESHARC_API_KEY=mesharc_... mesharc-mcp # serves over stdio
|
|
|
279
279
|
claude mcp add mesharc -e MESHARC_API_KEY=mesharc_... -- mesharc-mcp
|
|
280
280
|
```
|
|
281
281
|
|
|
282
|
-
Tools: `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `list_projects`, `describe_project_config`, `get_project`, `create_project`, `update_project`, `start_run`, `list_pages`, `get_page`, `get_changes`, `search_pages`, `recrawl_pages`, `get_job`. Every tool is a call through this client.
|
|
282
|
+
Tools: `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `list_projects`, `describe_project_config`, `get_project`, `create_project`, `update_project`, `start_run`, `list_runs`, `list_pages`, `get_page`, `get_changes`, `search_pages`, `recrawl_pages`, `get_job`, `cancel_job`. Every tool is a call through this client.
|
|
283
283
|
|
|
284
284
|
A result with many pages in it is a map, not the territory: a crawl answers with an index of the pages read (up to 500, fewer if the budget needs the room), an excerpt of as many as a 60,000-character budget pays for (fifty at most), and a count of each page's links rather than the links. Ask for the one page you want on its own with `get_job(kind="crawl", id=…, url=…)`, or carry on through the crawl with `cursor=`. A multi-URL scrape past its budget counts the rest by status and names any that did not come back ok. One page asked for on its own -- `extract_url`, `get_page`, `scrape_urls` with a single url -- comes back whole, capped at 12,000 characters.
|
|
285
285
|
|
|
286
|
-
Hosted, a connection can be approved read-only, and read-only cuts in a place worth knowing: it reads the whole workspace, but it cannot reach the site. Anything that fetches is a write, because it leaves the workspace and usually spends its credits -- `map_site` is the one that fetches and costs nothing, and it is gated with the rest. `list_projects`, `get_project`, `describe_project_config`, `list_pages`, `get_page`, `get_changes`, `search_pages` and `get_job` work; the
|
|
286
|
+
Hosted, a connection can be approved read-only, and read-only cuts in a place worth knowing: it reads the whole workspace, but it cannot reach the site. Anything that fetches is a write, because it leaves the workspace and usually spends its credits -- `map_site` is the one that fetches and costs nothing, and it is gated with the rest. `list_projects`, `get_project`, `describe_project_config`, `list_runs`, `list_pages`, `get_page`, `get_changes`, `search_pages` and `get_job` work; the ten that fetch or write -- `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `create_project`, `update_project`, `start_run`, `recrawl_pages`, `cancel_job` -- answer `{"code": "read_only"}` with what to do about it, until the app is reconnected with write access.
|
|
287
287
|
|
|
288
|
-
Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side
|
|
288
|
+
Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side; `get_job` picks it up and `cancel_job` stops it. Run locally, those tools block as they always have.
|
|
289
289
|
|
|
290
290
|
### Hosting it yourself
|
|
291
291
|
|
|
@@ -243,13 +243,13 @@ MESHARC_API_KEY=mesharc_... mesharc-mcp # serves over stdio
|
|
|
243
243
|
claude mcp add mesharc -e MESHARC_API_KEY=mesharc_... -- mesharc-mcp
|
|
244
244
|
```
|
|
245
245
|
|
|
246
|
-
Tools: `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `list_projects`, `describe_project_config`, `get_project`, `create_project`, `update_project`, `start_run`, `list_pages`, `get_page`, `get_changes`, `search_pages`, `recrawl_pages`, `get_job`. Every tool is a call through this client.
|
|
246
|
+
Tools: `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `list_projects`, `describe_project_config`, `get_project`, `create_project`, `update_project`, `start_run`, `list_runs`, `list_pages`, `get_page`, `get_changes`, `search_pages`, `recrawl_pages`, `get_job`, `cancel_job`. Every tool is a call through this client.
|
|
247
247
|
|
|
248
248
|
A result with many pages in it is a map, not the territory: a crawl answers with an index of the pages read (up to 500, fewer if the budget needs the room), an excerpt of as many as a 60,000-character budget pays for (fifty at most), and a count of each page's links rather than the links. Ask for the one page you want on its own with `get_job(kind="crawl", id=…, url=…)`, or carry on through the crawl with `cursor=`. A multi-URL scrape past its budget counts the rest by status and names any that did not come back ok. One page asked for on its own -- `extract_url`, `get_page`, `scrape_urls` with a single url -- comes back whole, capped at 12,000 characters.
|
|
249
249
|
|
|
250
|
-
Hosted, a connection can be approved read-only, and read-only cuts in a place worth knowing: it reads the whole workspace, but it cannot reach the site. Anything that fetches is a write, because it leaves the workspace and usually spends its credits -- `map_site` is the one that fetches and costs nothing, and it is gated with the rest. `list_projects`, `get_project`, `describe_project_config`, `list_pages`, `get_page`, `get_changes`, `search_pages` and `get_job` work; the
|
|
250
|
+
Hosted, a connection can be approved read-only, and read-only cuts in a place worth knowing: it reads the whole workspace, but it cannot reach the site. Anything that fetches is a write, because it leaves the workspace and usually spends its credits -- `map_site` is the one that fetches and costs nothing, and it is gated with the rest. `list_projects`, `get_project`, `describe_project_config`, `list_runs`, `list_pages`, `get_page`, `get_changes`, `search_pages` and `get_job` work; the ten that fetch or write -- `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `create_project`, `update_project`, `start_run`, `recrawl_pages`, `cancel_job` -- answer `{"code": "read_only"}` with what to do about it, until the app is reconnected with write access.
|
|
251
251
|
|
|
252
|
-
Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side
|
|
252
|
+
Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side; `get_job` picks it up and `cancel_job` stops it. Run locally, those tools block as they always have.
|
|
253
253
|
|
|
254
254
|
### Hosting it yourself
|
|
255
255
|
|
|
@@ -31,7 +31,7 @@ from urllib.parse import parse_qs, urlparse
|
|
|
31
31
|
|
|
32
32
|
import httpx
|
|
33
33
|
|
|
34
|
-
__version__ = "0.3.
|
|
34
|
+
__version__ = "0.3.4"
|
|
35
35
|
__all__ = ["MeshArc", "MeshArcError", "MeshArcTimeoutError", "Crawl"]
|
|
36
36
|
|
|
37
37
|
DEFAULT_BASE = "https://api.mesharc.dev"
|
|
@@ -432,6 +432,12 @@ class MeshArc:
|
|
|
432
432
|
time.sleep(poll)
|
|
433
433
|
self._h.pace()
|
|
434
434
|
|
|
435
|
+
def cancel_batch(self, batch_id: str) -> None:
|
|
436
|
+
"""Stop a scrape: every run of a batch, or a one-URL scrape still going.
|
|
437
|
+
Queued pages are dropped; a page being read finishes first, and pages
|
|
438
|
+
already read stay readable."""
|
|
439
|
+
self._h("DELETE", f"/scrape/{batch_id}")
|
|
440
|
+
|
|
435
441
|
def crawl(self, url: str, wait: bool = False, poll: float = 3.0, timeout: float = 3600,
|
|
436
442
|
idempotency_key: Optional[str] = None, **opts: Any) -> Crawl:
|
|
437
443
|
"""Crawl a site once, with no project to set up first.
|
|
@@ -83,7 +83,8 @@ INSTRUCTIONS = (
|
|
|
83
83
|
"crawl_site to crawl a whole site once without setting a project up first "
|
|
84
84
|
"(keep_crawl_as_project turns one of those into a watched project afterwards). "
|
|
85
85
|
"The project tools are for a site watched over time: its pages, its change record, "
|
|
86
|
-
"and search inside a run.
|
|
86
|
+
"and search inside a run. A long tool may hand back a job: get_job follows it and cancel_job stops it. "
|
|
87
|
+
"Blocked pages are reported as blocked, never as missing."
|
|
87
88
|
)
|
|
88
89
|
|
|
89
90
|
# The tools, in the order they are declared, with how each was registered.
|
|
@@ -92,7 +93,7 @@ INSTRUCTIONS = (
|
|
|
92
93
|
# on a second server later, built with auth in its constructor. The registry is
|
|
93
94
|
# what carries the descriptions across: a description is the only thing an
|
|
94
95
|
# assistant has to pick a tool by, and re-registering without them would leave
|
|
95
|
-
#
|
|
96
|
+
# nineteen nameless verbs.
|
|
96
97
|
_TOOLS: "list[tuple]" = []
|
|
97
98
|
|
|
98
99
|
# What each tool does to the world, in the hints MCP clients read. A tool that
|
|
@@ -111,7 +112,7 @@ def _acts(*, destructive=False, idempotent=False, open_world=True):
|
|
|
111
112
|
# The parameters most tools share, described once.
|
|
112
113
|
ProjectId = Annotated[str, Field(description="The project's id, as list_projects or create_project returns it.")]
|
|
113
114
|
RunId = Annotated[str | None, Field(
|
|
114
|
-
description="A run's id, from start_run or the runId a list_pages answer carries; "
|
|
115
|
+
description="A run's id, from list_runs, start_run or the runId a list_pages answer carries; "
|
|
115
116
|
"leave empty for the last finished run.")]
|
|
116
117
|
Schedule = Literal["manual", "hourly", "daily", "weekly"]
|
|
117
118
|
FetchConfig = Annotated[dict | None, Field(
|
|
@@ -533,7 +534,7 @@ def _scopes():
|
|
|
533
534
|
return list(getattr(at, "scopes", None) or []) if at else []
|
|
534
535
|
|
|
535
536
|
|
|
536
|
-
#
|
|
537
|
+
# Ten of the nineteen tools need write, including every one that fetches a
|
|
537
538
|
# page: fetching spends the workspace's credits, so it is not a read however
|
|
538
539
|
# it reads to an assistant asking for one URL. The API answers "this needs the
|
|
539
540
|
# member role", which is true and tells an assistant nothing it can act on --
|
|
@@ -591,14 +592,20 @@ def _safe(fn):
|
|
|
591
592
|
|
|
592
593
|
@tool(title="Scrape a list of URLs",
|
|
593
594
|
annotations=_acts(idempotent=True),
|
|
594
|
-
description="Fetch known URLs (1 to 500) once and return each page's content
|
|
595
|
-
"
|
|
596
|
-
"following links
|
|
597
|
-
"
|
|
598
|
-
"
|
|
599
|
-
"
|
|
600
|
-
"
|
|
601
|
-
"
|
|
595
|
+
description="Fetch known URLs (1 to 500) once and return each page's content. No project is created.\n"
|
|
596
|
+
"Use extract_url for one page with browser steps or structured fields, crawl_site to discover "
|
|
597
|
+
"pages by following links, and map_site to list a site's URLs without fetching them.\n"
|
|
598
|
+
"Inputs: `formats` only picks which stored bodies come back (e.g. 'markdown,text'); how a page is "
|
|
599
|
+
"fetched and extracted is `config`. A PDF, Word or spreadsheet URL is read as text unless "
|
|
600
|
+
"parse_documents is false.\n"
|
|
601
|
+
"Cost: the credits of the engine that read each page (1 plain fetch, 4 browser render). A page "
|
|
602
|
+
"the site refuses is free.\n"
|
|
603
|
+
"Returns: one URL answers in the same request. A list waits for the batch, and a large batch is "
|
|
604
|
+
"an index plus excerpts inside 60,000 characters. Hosted, a batch still going after the time "
|
|
605
|
+
"budget comes back as a job for get_job. Repeating the same call returns that job instead of "
|
|
606
|
+
"starting another.\n"
|
|
607
|
+
"Needs write access; a read-only connection is refused with code 'read_only'. Errors return "
|
|
608
|
+
"{error, status}.")
|
|
602
609
|
def scrape_urls(urls: Annotated[list[str], Field(
|
|
603
610
|
description="The pages to fetch: 1 to 500 absolute http(s) URLs.")],
|
|
604
611
|
config: FetchConfig = None,
|
|
@@ -633,16 +640,21 @@ def scrape_urls(urls: Annotated[list[str], Field(
|
|
|
633
640
|
|
|
634
641
|
@tool(title="Extract one page in full",
|
|
635
642
|
annotations=_acts(),
|
|
636
|
-
description="Fetch one URL exactly as a project with the given settings would, and return the whole page: "
|
|
637
|
-
"
|
|
638
|
-
"
|
|
639
|
-
"for
|
|
640
|
-
"create_project
|
|
641
|
-
"
|
|
642
|
-
"
|
|
643
|
-
"
|
|
644
|
-
"
|
|
645
|
-
"
|
|
643
|
+
description="Fetch one URL exactly as a project with the given settings would, and return the whole page: the "
|
|
644
|
+
"bodies config's formats ask for plus the raw html (each capped at 12,000 characters), head and "
|
|
645
|
+
"extracted fields, its images, and which engine read it. The link list is left out.\n"
|
|
646
|
+
"Use it for one page that needs browser steps, structured fields, or a settings trial before "
|
|
647
|
+
"create_project. For plain content of known URLs use scrape_urls. To read a page a project "
|
|
648
|
+
"already stored, without fetching, use get_page.\n"
|
|
649
|
+
"Inputs: browser `actions` in config (click, type, select, press, wait, scroll; repeat "
|
|
650
|
+
"'until_gone' for Load-more buttons; `each` to act on every match) run before the page is read. A "
|
|
651
|
+
"parse_documents key in config wins over the parse_documents argument.\n"
|
|
652
|
+
"Behaviour: it climbs the fetch ladder, plain http first and a browser only when the page needs "
|
|
653
|
+
"one or render_js is 'always'. It costs the credits of the rung that read it (1 to 4 for most "
|
|
654
|
+
"pages, +1 when formats ask for a screenshot). A page the site refuses comes back marked blocked, "
|
|
655
|
+
"at no cost, rather than as an error.\n"
|
|
656
|
+
"Needs write access; a read-only connection is refused with code 'read_only'. Errors return "
|
|
657
|
+
"{error, status}.")
|
|
646
658
|
def extract_url(url: Annotated[str, Field(description="The absolute http(s) URL of the page to read.")],
|
|
647
659
|
config: FetchConfig = None,
|
|
648
660
|
parse_documents: ParseDocuments = True) -> dict:
|
|
@@ -658,12 +670,18 @@ def extract_url(url: Annotated[str, Field(description="The absolute http(s) URL
|
|
|
658
670
|
|
|
659
671
|
@tool(title="Map a site's declared URLs",
|
|
660
672
|
annotations=_acts(),
|
|
661
|
-
description="List every URL a site declares in its sitemaps
|
|
662
|
-
"paths, each index file walked to its children
|
|
663
|
-
"first, to see how big a site is and what sections it has, before crawl_site or
|
|
664
|
-
"
|
|
665
|
-
"
|
|
666
|
-
"
|
|
673
|
+
description="List every URL a site declares in its sitemaps (found through robots.txt and the well-known "
|
|
674
|
+
"paths, each index file walked to its children) without fetching any of the pages.\n"
|
|
675
|
+
"Use it first, to see how big a site is and what sections it has, before crawl_site or "
|
|
676
|
+
"create_project. It cannot read page content (scrape_urls or crawl_site do), and it misses pages "
|
|
677
|
+
"a site links to but does not declare.\n"
|
|
678
|
+
"Inputs: `url` can be any page of the site, since discovery starts at the site's root. `search` "
|
|
679
|
+
"is a case-insensitive substring of the full URL, with no wildcards, applied before `limit`. "
|
|
680
|
+
"`totals` still count every declared URL.\n"
|
|
681
|
+
"Cost: 1 credit per sitemap file read (usually 1 in total), never per URL.\n"
|
|
682
|
+
"Returns: the discovery method, totals, up to `limit` URLs and the section names.\n"
|
|
683
|
+
"Needs write access; a read-only connection is refused with code 'read_only'. Errors return "
|
|
684
|
+
"{error, status}.")
|
|
667
685
|
def map_site(url: Annotated[str, Field(description="Any URL on the site, usually its home page.")],
|
|
668
686
|
search: Annotated[str | None, Field(
|
|
669
687
|
description="Keep only URLs containing this text, e.g. '/blog/'; omit for all.")] = None,
|
|
@@ -681,16 +699,22 @@ def map_site(url: Annotated[str, Field(description="Any URL on the site, usually
|
|
|
681
699
|
|
|
682
700
|
@tool(title="Crawl a whole site once",
|
|
683
701
|
annotations=_acts(idempotent=True),
|
|
684
|
-
description="Crawl a site once from a start URL
|
|
685
|
-
"
|
|
686
|
-
"or section whose page URLs you do not know
|
|
687
|
-
"over time use create_project
|
|
688
|
-
"
|
|
689
|
-
"
|
|
690
|
-
"
|
|
691
|
-
"
|
|
692
|
-
"
|
|
693
|
-
"
|
|
702
|
+
description="Crawl a site once from a start URL, following its links and reading its sitemap up to `limit` "
|
|
703
|
+
"pages, and return what it found. No project is set up.\n"
|
|
704
|
+
"Use it to read a site or section whose page URLs you do not know. For known URLs use "
|
|
705
|
+
"scrape_urls. To watch a site over time use create_project, or keep_crawl_as_project on this "
|
|
706
|
+
"crawl afterwards.\n"
|
|
707
|
+
"Inputs: include_paths and exclude_paths match the URL path only, never the host, and pages they "
|
|
708
|
+
"leave out do not count toward `limit`. '/blog/*' takes the blog index and everything under it. "
|
|
709
|
+
"`max_depth` counts link hops from the start URL (0 = that page only).\n"
|
|
710
|
+
"Cost: the credits of the engine that read each page (usually 1 to 4). Refused pages are free.\n"
|
|
711
|
+
"Behaviour: it waits for the crawl, minutes for a large limit. Hosted, a crawl still going after "
|
|
712
|
+
"the time budget comes back as a job for get_job, and repeating the call returns the same crawl.\n"
|
|
713
|
+
"Returns: an index of the pages read plus excerpts inside 60,000 characters. get_job with `url` "
|
|
714
|
+
"reads one page in full, and with `cursor` the next window. The crawl is kept for a day; pass its "
|
|
715
|
+
"crawl_id to keep_crawl_as_project to keep it for good.\n"
|
|
716
|
+
"Needs write access; a read-only connection is refused with code 'read_only'. Errors return "
|
|
717
|
+
"{error, status}.")
|
|
694
718
|
def crawl_site(url: Annotated[str, Field(description="The absolute http(s) URL to start from, e.g. the home page.")],
|
|
695
719
|
limit: Annotated[int, Field(
|
|
696
720
|
description="The most pages to read, 1 to 5,000 (default 50); the plan's page cap also applies.")]
|
|
@@ -740,12 +764,19 @@ def crawl_site(url: Annotated[str, Field(description="The absolute http(s) URL t
|
|
|
740
764
|
|
|
741
765
|
@tool(title="Keep a crawl as a project",
|
|
742
766
|
annotations=_acts(open_world=False),
|
|
743
|
-
description="Turn a crawl_site crawl into a project, so the site is watched over time and each later run "
|
|
744
|
-
"
|
|
745
|
-
"
|
|
746
|
-
"
|
|
747
|
-
"
|
|
748
|
-
"
|
|
767
|
+
description="Turn a crawl_site crawl into a project, so the site is watched over time and each later run is "
|
|
768
|
+
"compared with this one.\n"
|
|
769
|
+
"Use it after crawl_site when the site is worth watching. To start a watched site from scratch "
|
|
770
|
+
"use create_project.\n"
|
|
771
|
+
"Inputs: `crawl_id` must be a crawl this workspace started, kept within a day. After that the "
|
|
772
|
+
"crawl and its pages have expired and the call answers 404. A `schedule` other than 'manual' "
|
|
773
|
+
"starts runs on its own, and each run spends credits.\n"
|
|
774
|
+
"Behaviour: nothing is fetched again and it costs no credits; the crawl's pages become the "
|
|
775
|
+
"project's first run. It counts toward the plan's project limit and is refused with plan_limit "
|
|
776
|
+
"when that is full.\n"
|
|
777
|
+
"Returns: the new project, whose id the other project tools take.\n"
|
|
778
|
+
"Needs write access; a read-only connection is refused with code 'read_only'. Errors return "
|
|
779
|
+
"{error, status}.")
|
|
749
780
|
def keep_crawl_as_project(crawl_id: Annotated[str, Field(
|
|
750
781
|
description="The crawl_id a crawl_site answer (or get_job on a crawl) returned.")],
|
|
751
782
|
name: Annotated[str | None, Field(
|
|
@@ -762,13 +793,17 @@ def keep_crawl_as_project(crawl_id: Annotated[str, Field(
|
|
|
762
793
|
|
|
763
794
|
@tool(title="List all projects",
|
|
764
795
|
annotations=READS_STORED,
|
|
765
|
-
description="List every project in the workspace
|
|
766
|
-
"
|
|
767
|
-
"
|
|
768
|
-
"
|
|
769
|
-
"
|
|
796
|
+
description="List every project in the workspace (the sites it watches over time) with each one's id, name, "
|
|
797
|
+
"seed URL, host, schedule, page count, coverage, last run and status (draft, crawling, healthy or "
|
|
798
|
+
"failing).\n"
|
|
799
|
+
"Call it first to find a project_id for the other project tools, or to check whether a site is "
|
|
800
|
+
"already watched before create_project. get_project gives one project's full settings.\n"
|
|
801
|
+
"Returns: all projects in one answer, oldest first, with no paging. A key limited to some "
|
|
802
|
+
"projects sees only those.\n"
|
|
803
|
+
"Works on a read-only connection. Free: it reads stored data and never fetches. Errors return "
|
|
804
|
+
"{error, status}.")
|
|
770
805
|
def list_projects() -> list | dict:
|
|
771
|
-
return _safe(lambda: [{k: p.get(k) for k in ("id", "name", "seed", "host", "schedule", "pages", "coverage", "lastRun", "
|
|
806
|
+
return _safe(lambda: [{k: p.get(k) for k in ("id", "name", "seed", "host", "schedule", "pages", "coverage", "lastRun", "status")}
|
|
772
807
|
for p in _client().projects.list()])
|
|
773
808
|
|
|
774
809
|
|
|
@@ -821,11 +856,16 @@ CONFIG_GUIDE = {
|
|
|
821
856
|
|
|
822
857
|
@tool(title="Describe project settings",
|
|
823
858
|
annotations=READS_STORED,
|
|
824
|
-
description="List the project settings an assistant can set
|
|
825
|
-
"
|
|
826
|
-
"update_project or
|
|
827
|
-
"limit or behaviour
|
|
828
|
-
"
|
|
859
|
+
description="List the project settings an assistant can set, each with its meaning and default, plus the "
|
|
860
|
+
"valid schedules and how path globs work.\n"
|
|
861
|
+
"Read it before create_project, update_project or any `config` argument whenever the request "
|
|
862
|
+
"names a section, schedule, format, limit or behaviour. Keys are exact, and guessed names are "
|
|
863
|
+
"refused.\n"
|
|
864
|
+
"It describes settings in general. get_project shows the values one project has.\n"
|
|
865
|
+
"Returns: `settings` as [{key, meaning, default}], `other_keys` (accepted but rarely needed), "
|
|
866
|
+
"`schedules`, and a `note` on path globs.\n"
|
|
867
|
+
"Works on a read-only connection. Free: it reads stored data and never fetches. Errors return "
|
|
868
|
+
"{error, status}.")
|
|
829
869
|
def describe_project_config() -> dict:
|
|
830
870
|
def go():
|
|
831
871
|
with _client() as s:
|
|
@@ -841,24 +881,34 @@ def describe_project_config() -> dict:
|
|
|
841
881
|
|
|
842
882
|
@tool(title="Get one project",
|
|
843
883
|
annotations=READS_STORED,
|
|
844
|
-
description="Read one project in full: its settings (config), schedule, retention, coverage and last run
|
|
845
|
-
"Read it before update_project, to see the values you are about to change
|
|
846
|
-
"
|
|
847
|
-
"
|
|
848
|
-
"
|
|
884
|
+
description="Read one project in full: its settings (config), schedule, retention, coverage and last run.\n"
|
|
885
|
+
"Read it before update_project, to see the values you are about to change. list_projects is the "
|
|
886
|
+
"lighter way to find projects. list_pages and get_changes read what its runs found.\n"
|
|
887
|
+
"Inputs: `project_id` is the 32-character hex id that list_projects shows, not the project's name "
|
|
888
|
+
"or URL. An unknown id, or a project the key cannot see, answers 404.\n"
|
|
889
|
+
"Returns: the project's id, name, seed, host, schedule, config, retention, pages, coverage, "
|
|
890
|
+
"status, lastRun, nextRunAt and changeSummary. The webhook signing secret is withheld.\n"
|
|
891
|
+
"Works on a read-only connection. Free: it reads stored data and never fetches. Errors return "
|
|
892
|
+
"{error, status}.")
|
|
849
893
|
def get_project(project_id: ProjectId) -> dict:
|
|
850
894
|
return _safe(lambda: _client().projects.get(project_id))
|
|
851
895
|
|
|
852
896
|
|
|
853
897
|
@tool(title="Create a watched project",
|
|
854
898
|
annotations=_acts(),
|
|
855
|
-
description="Create a project that watches a site over time
|
|
856
|
-
"compared with the last, recording pages added, modified and removed
|
|
857
|
-
"is to monitor or track a site
|
|
858
|
-
"
|
|
859
|
-
"
|
|
860
|
-
"
|
|
861
|
-
"
|
|
899
|
+
description="Create a project that watches a site over time. It is crawled on its schedule and each run is "
|
|
900
|
+
"compared with the last, recording pages added, modified and removed.\n"
|
|
901
|
+
"Use it when the request is to monitor or track a site. For a one-off read use crawl_site (and "
|
|
902
|
+
"keep_crawl_as_project later if it is worth keeping). Check list_projects first so the site is "
|
|
903
|
+
"not added twice.\n"
|
|
904
|
+
"Inputs: `seed` fixes the project's host for good; no later update can move it to another site. A "
|
|
905
|
+
"`schedule` other than 'manual' starts runs on its own. Unknown `config` keys are refused, and "
|
|
906
|
+
"values above the plan's caps (such as max_pages) are lowered to them.\n"
|
|
907
|
+
"Behaviour: creating it reads the site's robots.txt and sitemaps but fetches no pages. Every run "
|
|
908
|
+
"then spends credits per page. It is refused when the plan's project limit is reached.\n"
|
|
909
|
+
"Returns: the new project, in get_project's shape; start_run takes its id to crawl it now.\n"
|
|
910
|
+
"Needs write access; a read-only connection is refused with code 'read_only'. Errors return "
|
|
911
|
+
"{error, status}.")
|
|
862
912
|
def create_project(seed: Annotated[str, Field(
|
|
863
913
|
description="Where the crawl starts: a public site's URL or bare domain, e.g. "
|
|
864
914
|
"'https://example.com/blog/' or 'example.com'. The project covers that host.")],
|
|
@@ -877,12 +927,16 @@ def create_project(seed: Annotated[str, Field(
|
|
|
877
927
|
|
|
878
928
|
@tool(title="Update a project's settings",
|
|
879
929
|
annotations=_acts(destructive=True, idempotent=True, open_world=False),
|
|
880
|
-
description="Change an existing project's name, schedule or settings
|
|
881
|
-
"
|
|
882
|
-
"
|
|
883
|
-
"
|
|
884
|
-
"
|
|
885
|
-
"
|
|
930
|
+
description="Change an existing project's name, schedule or settings.\n"
|
|
931
|
+
"Call get_project first to see the current values, and describe_project_config for valid keys. To "
|
|
932
|
+
"watch a different site use create_project instead.\n"
|
|
933
|
+
"Inputs: only what you pass changes. In `config` only the keys given are replaced and the rest "
|
|
934
|
+
"stay. A replaced value is gone, not versioned.\n"
|
|
935
|
+
"Behaviour: nothing is fetched and no credits are spent now. The next run uses the new settings. "
|
|
936
|
+
"A change that alters how pages are read starts a fresh comparison baseline.\n"
|
|
937
|
+
"Returns: the updated project, in get_project's shape.\n"
|
|
938
|
+
"Needs write access; a read-only connection is refused with code 'read_only'. Errors return "
|
|
939
|
+
"{error, status}.")
|
|
886
940
|
def update_project(project_id: ProjectId,
|
|
887
941
|
name: Annotated[str | None, Field(description="A new name; omit to leave it.")] = None,
|
|
888
942
|
schedule: Annotated[Schedule | None, Field(
|
|
@@ -907,16 +961,19 @@ def update_project(project_id: ProjectId,
|
|
|
907
961
|
|
|
908
962
|
@tool(title="Start a project run now",
|
|
909
963
|
annotations=_acts(),
|
|
910
|
-
description="Crawl a project's site now with its saved settings, outside its schedule
|
|
911
|
-
"compared with the last one
|
|
912
|
-
"
|
|
913
|
-
"
|
|
914
|
-
"
|
|
915
|
-
"
|
|
916
|
-
"
|
|
917
|
-
"
|
|
918
|
-
"
|
|
919
|
-
"
|
|
964
|
+
description="Crawl a project's site now with its saved settings, outside its schedule. The run is then "
|
|
965
|
+
"compared with the last one.\n"
|
|
966
|
+
"Use it after create_project or update_project, or when fresh results are wanted. To re-read only "
|
|
967
|
+
"a few pages use recrawl_pages. For a site with no project use crawl_site.\n"
|
|
968
|
+
"Cost: every page read costs credits (usually 1 to 4). A run stops when the credit budget runs "
|
|
969
|
+
"out and keeps what it read.\n"
|
|
970
|
+
"Refusals: 409 while a run of the project is already queued or running (cancel_job stops it), and "
|
|
971
|
+
"402 when no credits are left.\n"
|
|
972
|
+
"Returns: without wait, the queued run at once. With wait, the finished run, except hosted, where "
|
|
973
|
+
"a run still going after the time budget comes back as a job for get_job. Read the results with "
|
|
974
|
+
"list_pages and get_changes.\n"
|
|
975
|
+
"Needs write access; a read-only connection is refused with code 'read_only'. Errors return "
|
|
976
|
+
"{error, status}.")
|
|
920
977
|
def start_run(project_id: ProjectId,
|
|
921
978
|
wait: Annotated[bool, Field(
|
|
922
979
|
description="true waits for the run to finish (minutes for a large site); false (default) "
|
|
@@ -934,14 +991,49 @@ def start_run(project_id: ProjectId,
|
|
|
934
991
|
return _safe(go)
|
|
935
992
|
|
|
936
993
|
|
|
994
|
+
# A run as list_runs shows it: what an assistant needs to pick one, without
|
|
995
|
+
# the config, link graph and engine profile a run record also carries.
|
|
996
|
+
RUN_FIELDS = ("id", "status", "trigger", "scope", "pagesN", "changedN", "counts", "coverage", "stop",
|
|
997
|
+
"startedAt", "elapsed", "baseline", "rebaselined")
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
@tool(title="List a project's runs",
|
|
1001
|
+
annotations=READS_STORED,
|
|
1002
|
+
description="List a project's runs, newest first, with each run's id, status, trigger, pages read, pages "
|
|
1003
|
+
"changed, coverage, stop reason and start time.\n"
|
|
1004
|
+
"Use it to find a run_id for list_pages, get_page, get_changes or search_pages, to see whether a "
|
|
1005
|
+
"run is still going before start_run, or to pick the run to stop with cancel_job. get_project "
|
|
1006
|
+
"shows only the last run.\n"
|
|
1007
|
+
"Inputs: `limit` caps how many come back (1 to 100, default 25). Older runs are not paged here.\n"
|
|
1008
|
+
"Returns: {runs: [{id, status, trigger, scope, pagesN, changedN, counts, coverage, stop, "
|
|
1009
|
+
"startedAt, elapsed, baseline, rebaselined}]}. trigger is manual, scheduled, api or recrawl; "
|
|
1010
|
+
"scope is how many URLs a recrawl_pages run listed (0 for a full run); changedN is null before a "
|
|
1011
|
+
"run has a change record.\n"
|
|
1012
|
+
"Works on a read-only connection. Free: it reads stored data and never fetches. Errors return "
|
|
1013
|
+
"{error, status}.")
|
|
1014
|
+
def list_runs(project_id: ProjectId,
|
|
1015
|
+
limit: Annotated[int, Field(
|
|
1016
|
+
description="How many runs to return, newest first: 1 to 100 (default 25).")] = 25) -> dict:
|
|
1017
|
+
"""The runs of one project, so a run id can be found without guessing:
|
|
1018
|
+
the tools that read a run take one, and get_project shows only the last."""
|
|
1019
|
+
def go():
|
|
1020
|
+
rows = _client().runs.list(project_id, limit=max(1, min(limit, 100)))
|
|
1021
|
+
return {"runs": [{k: r.get(k) for k in RUN_FIELDS} for r in rows]}
|
|
1022
|
+
return _safe(go)
|
|
1023
|
+
|
|
1024
|
+
|
|
937
1025
|
@tool(title="List a run's pages",
|
|
938
1026
|
annotations=READS_STORED,
|
|
939
|
-
description="List the pages one project run stored
|
|
940
|
-
"
|
|
941
|
-
"
|
|
942
|
-
"
|
|
943
|
-
"
|
|
944
|
-
"finished run answers with no pages
|
|
1027
|
+
description="List the pages one project run stored, with each page's url, status, depth, word count and when "
|
|
1028
|
+
"it last changed, plus the run's link-graph summary and its runId.\n"
|
|
1029
|
+
"Use it to see what a crawl covered or to pick a URL. get_page reads one page in full, "
|
|
1030
|
+
"search_pages finds pages by their text, and get_changes lists only what changed.\n"
|
|
1031
|
+
"Inputs: `run_id` is a run's id, not a number or a date. Leave it empty for the latest finished "
|
|
1032
|
+
"run; a project with no finished run answers with no pages.\n"
|
|
1033
|
+
"Returns: up to 500 rows, shallowest pages first. A larger run is cut at 500, so use search_pages "
|
|
1034
|
+
"to find pages beyond that.\n"
|
|
1035
|
+
"Works on a read-only connection. Free: it reads stored data and never fetches. Errors return "
|
|
1036
|
+
"{error, status}.")
|
|
945
1037
|
def list_pages(project_id: ProjectId, run_id: RunId = None) -> dict:
|
|
946
1038
|
def go():
|
|
947
1039
|
r = _client().pages(project_id, run_id)
|
|
@@ -953,31 +1045,40 @@ def list_pages(project_id: ProjectId, run_id: RunId = None) -> dict:
|
|
|
953
1045
|
@tool(title="Get one stored page",
|
|
954
1046
|
annotations=READS_STORED,
|
|
955
1047
|
description="Read one page a project run stored, in full: its markdown and other bodies (each capped at "
|
|
956
|
-
"12,000 characters), head fields (title, description, canonical, h1), extracted fields, and "
|
|
957
|
-
"
|
|
958
|
-
"
|
|
959
|
-
"
|
|
960
|
-
"
|
|
1048
|
+
"12,000 characters), head fields (title, description, canonical, h1), extracted fields, and its "
|
|
1049
|
+
"versions across runs.\n"
|
|
1050
|
+
"Use it when you know the page's URL; list_pages or search_pages finds it first. For a page from "
|
|
1051
|
+
"a crawl_site crawl use get_job with url instead. For a live copy use extract_url.\n"
|
|
1052
|
+
"Inputs: `url` is an address, not a search term. The scheme and a trailing slash may differ, but "
|
|
1053
|
+
"the rest of the URL, query string included, must match exactly. `run_id` pins that run's copy; "
|
|
1054
|
+
"without it, the newest copy across the project's last 50 runs.\n"
|
|
1055
|
+
"A URL the run did not store answers 404.\n"
|
|
1056
|
+
"Works on a read-only connection. Free: it reads stored data and never fetches. Errors return "
|
|
1057
|
+
"{error, status}.")
|
|
961
1058
|
def get_page(project_id: ProjectId,
|
|
962
1059
|
url: Annotated[str, Field(
|
|
963
1060
|
description="The page's full URL as list_pages or search_pages shows it; the other scheme or "
|
|
964
1061
|
"trailing slash is matched too.")],
|
|
965
1062
|
run_id: Annotated[str | None, Field(
|
|
966
|
-
description="A run's id, from
|
|
1063
|
+
description="A run's id, from list_runs or start_run, to read that run's copy; leave "
|
|
967
1064
|
"empty for the newest copy across the project's recent runs.")] = None) -> dict:
|
|
968
1065
|
return _safe(lambda: _trim_page(_client().page(project_id, url, run_id)))
|
|
969
1066
|
|
|
970
1067
|
|
|
971
1068
|
@tool(title="Get what changed in a run",
|
|
972
1069
|
annotations=READS_STORED,
|
|
973
|
-
description="Report what changed in a project run against the run before it
|
|
974
|
-
"
|
|
975
|
-
"
|
|
976
|
-
"
|
|
977
|
-
"
|
|
978
|
-
"
|
|
979
|
-
"
|
|
980
|
-
"is
|
|
1070
|
+
description="Report what changed in a project run against the run before it: pages added, modified and "
|
|
1071
|
+
"removed, head-field changes (title, description, canonical), and the run's coverage.\n"
|
|
1072
|
+
"Use it to answer 'what changed on the site'. list_pages shows every page whatever changed, and "
|
|
1073
|
+
"get_page shows one page's text and versions.\n"
|
|
1074
|
+
"Inputs: `run_id` picks the run whose record to read, and that run is always compared with the "
|
|
1075
|
+
"run just before it. Leave it empty for the latest finished run. A run still going answers 409.\n"
|
|
1076
|
+
"Behaviour: removals are withheld when the crawl reached under 90% of the site, so a blocked "
|
|
1077
|
+
"crawl never reports the site as gone. A project's first run is a baseline with nothing to "
|
|
1078
|
+
"compare.\n"
|
|
1079
|
+
"Returns: up to 200 entries per list, plus the runs a record exists for.\n"
|
|
1080
|
+
"Works on a read-only connection. Free: it reads stored data and never fetches. Errors return "
|
|
1081
|
+
"{error, status}.")
|
|
981
1082
|
def get_changes(project_id: ProjectId, run_id: RunId = None) -> dict:
|
|
982
1083
|
def go():
|
|
983
1084
|
r = _client().changes(project_id, run_id)
|
|
@@ -991,12 +1092,18 @@ def get_changes(project_id: ProjectId, run_id: RunId = None) -> dict:
|
|
|
991
1092
|
|
|
992
1093
|
@tool(title="Search a run's stored pages",
|
|
993
1094
|
annotations=READS_STORED,
|
|
994
|
-
description="Find which pages of a project run contain some text or an element
|
|
995
|
-
"
|
|
996
|
-
"it to locate pages by what they say or contain
|
|
997
|
-
"then reads one in full. It
|
|
998
|
-
"
|
|
999
|
-
"
|
|
1095
|
+
description="Find which pages of a project run contain some text or an element, and return the matching URLs "
|
|
1096
|
+
"with how many pages were scanned.\n"
|
|
1097
|
+
"Use it to locate pages by what they say or contain. list_pages lists every page, and get_page "
|
|
1098
|
+
"then reads one in full. It cannot search a crawl_site crawl or the live site: use get_job or "
|
|
1099
|
+
"extract_url for those.\n"
|
|
1100
|
+
"Inputs: a selector search needs the run to have kept html (the rawHtml format). When none was "
|
|
1101
|
+
"kept, the answer says so rather than reporting no matches. Leave run_id empty for the latest "
|
|
1102
|
+
"finished run.\n"
|
|
1103
|
+
"Returns: up to 500 hits, each {url, count, snippet}, with `scanned`, `noBody` and `truncated` "
|
|
1104
|
+
"(true when more pages matched). It is as fresh as the run.\n"
|
|
1105
|
+
"Works on a read-only connection. Free: it reads stored data and never fetches. Errors return "
|
|
1106
|
+
"{error, status}.")
|
|
1000
1107
|
def search_pages(project_id: ProjectId,
|
|
1001
1108
|
q: Annotated[str, Field(
|
|
1002
1109
|
description="What to look for, 1 to 200 characters. In content mode every word must "
|
|
@@ -1011,29 +1118,43 @@ def search_pages(project_id: ProjectId,
|
|
|
1011
1118
|
|
|
1012
1119
|
@tool(title="Re-crawl chosen pages",
|
|
1013
1120
|
annotations=_acts(),
|
|
1014
|
-
description="Fetch specific pages of a project again now, as a small run of their own whose record "
|
|
1015
|
-
"
|
|
1016
|
-
"changed without crawling the whole site
|
|
1017
|
-
"reads a page outside any project
|
|
1018
|
-
"
|
|
1019
|
-
"
|
|
1020
|
-
"
|
|
1121
|
+
description="Fetch specific pages of a project again now, as a small run of their own whose record compares "
|
|
1122
|
+
"just those pages with the last full run.\n"
|
|
1123
|
+
"Use it to check a few pages you expect changed without crawling the whole site. start_run "
|
|
1124
|
+
"re-crawls everything, and extract_url reads a page outside any project.\n"
|
|
1125
|
+
"Inputs: a path such as '/pricing' is resolved against the project's site. A URL on another site "
|
|
1126
|
+
"is refused with 400 naming it, before anything runs.\n"
|
|
1127
|
+
"Cost: each page costs credits (usually 1 to 4). A page that fails is recorded in the run with "
|
|
1128
|
+
"its status, not dropped.\n"
|
|
1129
|
+
"Refusals: 409 while another run of the project is queued or running; cancel_job stops it.\n"
|
|
1130
|
+
"Returns: the queued run. Follow it with get_job (kind 'run') and read the results with "
|
|
1131
|
+
"get_changes.\n"
|
|
1132
|
+
"Needs write access; a read-only connection is refused with code 'read_only'. Errors return "
|
|
1133
|
+
"{error, status}.")
|
|
1021
1134
|
def recrawl_pages(project_id: ProjectId,
|
|
1022
1135
|
urls: Annotated[list[str], Field(
|
|
1023
|
-
description="1 to 500
|
|
1136
|
+
description="1 to 500 pages on the project's site to fetch again: full URLs, or paths "
|
|
1137
|
+
"such as '/pricing'.")]) -> dict:
|
|
1024
1138
|
return _safe(lambda: _client().recrawl(project_id, urls))
|
|
1025
1139
|
|
|
1026
1140
|
|
|
1027
1141
|
@tool(title="Follow a long-running job",
|
|
1028
1142
|
annotations=READS_STORED,
|
|
1029
|
-
description="Check on a job a long tool handed back
|
|
1030
|
-
"recrawl_pages, or a batch from scrape_urls
|
|
1031
|
-
"
|
|
1032
|
-
"
|
|
1033
|
-
"
|
|
1034
|
-
"
|
|
1035
|
-
"
|
|
1036
|
-
"
|
|
1143
|
+
description="Check on a job a long tool handed back (a crawl from crawl_site, a run from start_run or "
|
|
1144
|
+
"recrawl_pages, or a batch from scrape_urls) and return its result once it has finished.\n"
|
|
1145
|
+
"Call it when a tool answered with status 'running' and a job. Calling the original tool again is "
|
|
1146
|
+
"not needed and would not start a second job. For a project's stored pages use get_page instead. "
|
|
1147
|
+
"To stop a job use cancel_job.\n"
|
|
1148
|
+
"Inputs: for a crawl, `url` returns one page in full (even mid-crawl) and `cursor` the next "
|
|
1149
|
+
"window of pages.\n"
|
|
1150
|
+
"Returns: while it runs, {status: 'running', job, counts}. Once finished, a crawl gives "
|
|
1151
|
+
"crawl_site's answer (crawl_id, status, pages, index, counts, stop, note, and cursor when there "
|
|
1152
|
+
"is more), a batch gives scrape_urls' answer, and a run gives the run record (id, status, "
|
|
1153
|
+
"counts).\n"
|
|
1154
|
+
"Behaviour: a crawl expires a day after it started unless it was kept, and then the call answers "
|
|
1155
|
+
"404.\n"
|
|
1156
|
+
"Works on a read-only connection. Free: it reads stored data and never fetches. Errors return "
|
|
1157
|
+
"{error, status}.")
|
|
1037
1158
|
def get_job(kind: Annotated[Literal["crawl", "run", "batch"], Field(
|
|
1038
1159
|
description="What the job is: 'crawl' (crawl_site), 'run' (start_run, recrawl_pages) or "
|
|
1039
1160
|
"'batch' (scrape_urls); the 'job' object a tool returned names it.")],
|
|
@@ -1093,7 +1214,78 @@ def get_job(kind: Annotated[Literal["crawl", "run", "batch"], Field(
|
|
|
1093
1214
|
return _safe(go)
|
|
1094
1215
|
|
|
1095
1216
|
|
|
1096
|
-
#
|
|
1217
|
+
# What a stop means, said once for every kind of job.
|
|
1218
|
+
CANCEL_NOTE = ("queued work is dropped at once; a page being read finishes first, then the job "
|
|
1219
|
+
"stops. Pages already read stay readable, and nothing is deleted.")
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
@tool(title="Cancel a running job",
|
|
1223
|
+
annotations=_acts(destructive=True, idempotent=True, open_world=False),
|
|
1224
|
+
description="Stop a crawl, run or batch that is still going: a crawl started too wide, a run that blocks the "
|
|
1225
|
+
"next one, or a batch of the wrong URLs.\n"
|
|
1226
|
+
"Use it when the work should not finish. To check on a job without stopping it use get_job. "
|
|
1227
|
+
"start_run and recrawl_pages answer 409 while a run is going, so stop that run here first.\n"
|
|
1228
|
+
"Inputs: `kind` and `id` come from the 'job' object a long tool returned, or from crawl_site's "
|
|
1229
|
+
"crawl_id. A run also needs its project_id; list_runs shows which of a project's runs is still "
|
|
1230
|
+
"going.\n"
|
|
1231
|
+
"Behaviour: queued work is dropped at once, and a page being read finishes first. Pages already "
|
|
1232
|
+
"read stay readable and nothing is deleted. It cannot be undone: run the work again to resume. A "
|
|
1233
|
+
"job that already finished is left as it is.\n"
|
|
1234
|
+
"Returns: {kind, id, outcome, note}. outcome is 'cancelled' (it never started), 'cancelling' (it "
|
|
1235
|
+
"stops after the page in hand), or the status of a job that had already ended.\n"
|
|
1236
|
+
"Needs write access; a read-only connection is refused with code 'read_only'. Errors return "
|
|
1237
|
+
"{error, status}.")
|
|
1238
|
+
def cancel_job(kind: Annotated[Literal["crawl", "run", "batch"], Field(
|
|
1239
|
+
description="What the job is: 'crawl' (crawl_site), 'run' (start_run, recrawl_pages) or "
|
|
1240
|
+
"'batch' (scrape_urls); the 'job' object a tool returned names it.")],
|
|
1241
|
+
id: Annotated[str, Field(
|
|
1242
|
+
description="The job's id, from that 'job' object, or a crawl_site answer's crawl_id.")],
|
|
1243
|
+
project_id: Annotated[str | None, Field(
|
|
1244
|
+
description="Required for a run: the project it belongs to (the 'job' object carries "
|
|
1245
|
+
"it). Ignored for a crawl or a batch.")] = None) -> dict:
|
|
1246
|
+
"""The way out of a job that should not finish: a crawl started too wide, a
|
|
1247
|
+
run that blocks the next one (start_run and recrawl_pages answer 409 while
|
|
1248
|
+
one is going), a batch of the wrong URLs. Every kind stops the same way,
|
|
1249
|
+
through the API's own cancel; a job already finished is left as it is and
|
|
1250
|
+
reported with its status (a crawl or batch is checked first and nothing is
|
|
1251
|
+
sent; a run's cancel goes to the API, which changes nothing on a finished
|
|
1252
|
+
run), so asking twice is harmless."""
|
|
1253
|
+
def go():
|
|
1254
|
+
s = _client()
|
|
1255
|
+
if kind == "run":
|
|
1256
|
+
if not project_id:
|
|
1257
|
+
return {"error": "a run needs its project_id", "code": "validation"}
|
|
1258
|
+
r = s.runs.cancel(project_id, id) or {}
|
|
1259
|
+
# The API says what it did: cancelled (it never started), cancelling
|
|
1260
|
+
# (it stops after the page in hand), or the status of a run that had
|
|
1261
|
+
# already ended.
|
|
1262
|
+
return {"kind": "run", "id": id, "project_id": project_id,
|
|
1263
|
+
"outcome": r.get("outcome") or "no job", "note": CANCEL_NOTE}
|
|
1264
|
+
if kind == "crawl":
|
|
1265
|
+
job = s.get_crawl(id)
|
|
1266
|
+
status = job.envelope.get("status")
|
|
1267
|
+
if not _running(status):
|
|
1268
|
+
return {"kind": "crawl", "id": id, "outcome": status,
|
|
1269
|
+
"note": "the crawl had already finished; nothing to stop"}
|
|
1270
|
+
job.cancel()
|
|
1271
|
+
return {"kind": "crawl", "id": id,
|
|
1272
|
+
"outcome": "cancelled" if status == "queued" else "cancelling", "note": CANCEL_NOTE}
|
|
1273
|
+
if kind == "batch":
|
|
1274
|
+
# Only the status is wanted: a format the API does not keep a body
|
|
1275
|
+
# for returns the rows without markdown, where the default would
|
|
1276
|
+
# carry every page of a finished 500-url batch just to read one word.
|
|
1277
|
+
status = s.batch(id, formats="none").get("status")
|
|
1278
|
+
if not _running(status):
|
|
1279
|
+
return {"kind": "batch", "id": id, "outcome": status,
|
|
1280
|
+
"note": "the batch had already finished; nothing to stop"}
|
|
1281
|
+
s.cancel_batch(id)
|
|
1282
|
+
return {"kind": "batch", "id": id,
|
|
1283
|
+
"outcome": "cancelled" if status == "queued" else "cancelling", "note": CANCEL_NOTE}
|
|
1284
|
+
return {"error": f"kind must be crawl, run or batch, not {kind!r}", "code": "validation"}
|
|
1285
|
+
return _safe(go)
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
# Stdio's server, built now that the nineteen are declared. Hosted mode
|
|
1097
1289
|
# builds its own in `authorize`, because auth is set in the constructor.
|
|
1098
1290
|
server = _build()
|
|
1099
1291
|
|
|
@@ -1220,7 +1412,7 @@ def _transport_security(public_url):
|
|
|
1220
1412
|
|
|
1221
1413
|
|
|
1222
1414
|
def authorize(issuer, public, secret):
|
|
1223
|
-
"""The hosted server: the same
|
|
1415
|
+
"""The hosted server: the same nineteen tools, with OAuth attached.
|
|
1224
1416
|
|
|
1225
1417
|
A server of its own rather than the stdio one with auth bolted on after
|
|
1226
1418
|
the fact. `token_verifier` and `auth` are constructor arguments, and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mesharc
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Python client for the MeshArc API: a URL in, clean content out, and a record of what changed.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://mesharc.dev
|
|
@@ -279,13 +279,13 @@ MESHARC_API_KEY=mesharc_... mesharc-mcp # serves over stdio
|
|
|
279
279
|
claude mcp add mesharc -e MESHARC_API_KEY=mesharc_... -- mesharc-mcp
|
|
280
280
|
```
|
|
281
281
|
|
|
282
|
-
Tools: `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `list_projects`, `describe_project_config`, `get_project`, `create_project`, `update_project`, `start_run`, `list_pages`, `get_page`, `get_changes`, `search_pages`, `recrawl_pages`, `get_job`. Every tool is a call through this client.
|
|
282
|
+
Tools: `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `list_projects`, `describe_project_config`, `get_project`, `create_project`, `update_project`, `start_run`, `list_runs`, `list_pages`, `get_page`, `get_changes`, `search_pages`, `recrawl_pages`, `get_job`, `cancel_job`. Every tool is a call through this client.
|
|
283
283
|
|
|
284
284
|
A result with many pages in it is a map, not the territory: a crawl answers with an index of the pages read (up to 500, fewer if the budget needs the room), an excerpt of as many as a 60,000-character budget pays for (fifty at most), and a count of each page's links rather than the links. Ask for the one page you want on its own with `get_job(kind="crawl", id=…, url=…)`, or carry on through the crawl with `cursor=`. A multi-URL scrape past its budget counts the rest by status and names any that did not come back ok. One page asked for on its own -- `extract_url`, `get_page`, `scrape_urls` with a single url -- comes back whole, capped at 12,000 characters.
|
|
285
285
|
|
|
286
|
-
Hosted, a connection can be approved read-only, and read-only cuts in a place worth knowing: it reads the whole workspace, but it cannot reach the site. Anything that fetches is a write, because it leaves the workspace and usually spends its credits -- `map_site` is the one that fetches and costs nothing, and it is gated with the rest. `list_projects`, `get_project`, `describe_project_config`, `list_pages`, `get_page`, `get_changes`, `search_pages` and `get_job` work; the
|
|
286
|
+
Hosted, a connection can be approved read-only, and read-only cuts in a place worth knowing: it reads the whole workspace, but it cannot reach the site. Anything that fetches is a write, because it leaves the workspace and usually spends its credits -- `map_site` is the one that fetches and costs nothing, and it is gated with the rest. `list_projects`, `get_project`, `describe_project_config`, `list_runs`, `list_pages`, `get_page`, `get_changes`, `search_pages` and `get_job` work; the ten that fetch or write -- `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `create_project`, `update_project`, `start_run`, `recrawl_pages`, `cancel_job` -- answer `{"code": "read_only"}` with what to do about it, until the app is reconnected with write access.
|
|
287
287
|
|
|
288
|
-
Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side
|
|
288
|
+
Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape — hands back a job after `MESHARC_MCP_WAIT` seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side; `get_job` picks it up and `cancel_job` stops it. Run locally, those tools block as they always have.
|
|
289
289
|
|
|
290
290
|
### Hosting it yourself
|
|
291
291
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mesharc"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "Python client for the MeshArc API: a URL in, clean content out, and a record of what changed."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -128,6 +128,6 @@ def test_pace_waits_out_the_window_only_when_nearly_out(monkeypatch):
|
|
|
128
128
|
def test_the_mcp_server_offers_the_verbs_and_the_config_tools():
|
|
129
129
|
source = (pathlib.Path(mesharc.__file__).parent / "mcp.py").read_text(encoding="utf-8")
|
|
130
130
|
for name in ("scrape_urls", "extract_url", "map_site", "crawl_site", "keep_crawl_as_project",
|
|
131
|
-
"list_projects", "get_changes", "search_pages", "recrawl_pages", "get_job",
|
|
131
|
+
"list_projects", "get_changes", "search_pages", "recrawl_pages", "get_job", "cancel_job", "list_runs",
|
|
132
132
|
"describe_project_config", "get_project", "create_project", "update_project"):
|
|
133
133
|
assert f"def {name}(" in source, name
|
|
@@ -120,3 +120,9 @@ def test_export_streams_to_a_file(tmp_path):
|
|
|
120
120
|
def test_a_204_returns_none():
|
|
121
121
|
arc = scripted((204, None))
|
|
122
122
|
assert arc.revoke_key("k1") is None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_cancel_batch_deletes_the_scrape():
|
|
126
|
+
arc = scripted((204, None))
|
|
127
|
+
assert arc.cancel_batch("b1") is None
|
|
128
|
+
assert (arc.calls[0].method, arc.calls[0].url.path) == ("DELETE", "/api/v1/scrape/b1")
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""What an assistant reads before it calls a tool.
|
|
2
|
+
|
|
3
|
+
A tool is chosen and filled in from its definition alone: the description, the
|
|
4
|
+
parameters' descriptions and the hints on what it does to the world. Every
|
|
5
|
+
parameter went undescribed until 0.3.2, and directories that grade
|
|
6
|
+
definitions marked the whole server down for it. These keep it from
|
|
7
|
+
drifting back.
|
|
8
|
+
"""
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
mcp_mod = pytest.importorskip("mesharc.mcp", reason="needs the mcp extra")
|
|
14
|
+
|
|
15
|
+
# The tools that only read what the workspace has stored. Everything else
|
|
16
|
+
# reaches a site or changes the workspace, and a read-only connection is
|
|
17
|
+
# refused it -- the same nine the read_only answer is about.
|
|
18
|
+
READ_ONLY = {"list_projects", "describe_project_config", "get_project", "list_runs", "list_pages",
|
|
19
|
+
"get_page", "get_changes", "search_pages", "get_job"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _tools():
|
|
23
|
+
return {t.name: t for t in asyncio.run(mcp_mod.server.list_tools())}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_all_nineteen_are_listed():
|
|
27
|
+
assert len(_tools()) == 19
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_every_parameter_is_described():
|
|
31
|
+
for name, t in _tools().items():
|
|
32
|
+
for param, schema in (t.input_schema.get("properties") or {}).items():
|
|
33
|
+
assert (schema.get("description") or "").strip(), f"{name}.{param} has no description"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_every_tool_has_a_title_and_a_description():
|
|
37
|
+
for name, t in _tools().items():
|
|
38
|
+
assert t.title and t.title != name and len(t.title) > len(name), name
|
|
39
|
+
assert len((t.description or "").split()) >= 20, name
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_the_hints_say_which_tools_only_read():
|
|
43
|
+
for name, t in _tools().items():
|
|
44
|
+
a = t.annotations
|
|
45
|
+
assert a is not None, name
|
|
46
|
+
assert a.read_only_hint is (name in READ_ONLY), name
|
|
47
|
+
if name in READ_ONLY:
|
|
48
|
+
assert a.destructive_hint is False and a.open_world_hint is False, name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_only_the_tools_that_overwrite_or_stop_are_destructive():
|
|
52
|
+
# update_project replaces values; cancel_job stops work that cannot be
|
|
53
|
+
# resumed where it was. Nothing else changes what is already there.
|
|
54
|
+
destructive = {n for n, t in _tools().items() if t.annotations.destructive_hint}
|
|
55
|
+
assert destructive == {"update_project", "cancel_job"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_fixed_choices_are_listed():
|
|
59
|
+
tools = _tools()
|
|
60
|
+
|
|
61
|
+
def enum(tool, param):
|
|
62
|
+
schema = tools[tool].input_schema["properties"][param]
|
|
63
|
+
options = [schema] + list(schema.get("anyOf") or [])
|
|
64
|
+
return next(o["enum"] for o in options if "enum" in o)
|
|
65
|
+
|
|
66
|
+
assert enum("get_job", "kind") == ["crawl", "run", "batch"]
|
|
67
|
+
assert enum("cancel_job", "kind") == ["crawl", "run", "batch"]
|
|
68
|
+
assert enum("search_pages", "mode") == ["content", "selector"]
|
|
69
|
+
for tool in ("create_project", "keep_crawl_as_project", "update_project"):
|
|
70
|
+
assert enum(tool, "schedule") == ["manual", "hourly", "daily", "weekly"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_each_description_states_the_access_its_hints_declare():
|
|
74
|
+
# The description and the hints must never disagree: graders mark a
|
|
75
|
+
# contradiction down to 1, and an assistant told a tool only reads would
|
|
76
|
+
# call it on a read-only connection and be refused.
|
|
77
|
+
for name, t in _tools().items():
|
|
78
|
+
says_read_only = "Works on a read-only connection" in t.description
|
|
79
|
+
says_write = "Needs write access" in t.description
|
|
80
|
+
assert says_read_only == (name in READ_ONLY), name
|
|
81
|
+
assert says_write == (name not in READ_ONLY), name
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_list_projects_passes_on_each_projects_status(monkeypatch):
|
|
85
|
+
# It asked the API for `health`, which no project has ever carried, so
|
|
86
|
+
# every row said health: null. The project's state is `status`.
|
|
87
|
+
class _Projects:
|
|
88
|
+
@staticmethod
|
|
89
|
+
def list():
|
|
90
|
+
return [{"id": "p1", "name": "Docs", "status": "failing", "webhookSecret": "whsec_x"}]
|
|
91
|
+
|
|
92
|
+
class _Client:
|
|
93
|
+
projects = _Projects()
|
|
94
|
+
|
|
95
|
+
monkeypatch.setattr(mcp_mod, "_client", lambda: _Client())
|
|
96
|
+
rows = mcp_mod.list_projects()
|
|
97
|
+
assert rows[0]["status"] == "failing" and "health" not in rows[0]
|
|
98
|
+
assert "webhookSecret" not in rows[0]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _Crawl:
|
|
102
|
+
def __init__(self, status):
|
|
103
|
+
self.envelope = {"status": status}
|
|
104
|
+
self.cancelled = False
|
|
105
|
+
|
|
106
|
+
def cancel(self):
|
|
107
|
+
self.cancelled = True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _Cancels:
|
|
111
|
+
"""A client that records what cancel_job asked it to stop."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, crawl_status="running", batch_status="running", run_outcome="cancelling"):
|
|
114
|
+
self.crawl = _Crawl(crawl_status)
|
|
115
|
+
self._batch_status = batch_status
|
|
116
|
+
self.stopped_batches = []
|
|
117
|
+
outcome = run_outcome
|
|
118
|
+
|
|
119
|
+
class _Runs:
|
|
120
|
+
asked = []
|
|
121
|
+
|
|
122
|
+
def cancel(self, project_id, run_id):
|
|
123
|
+
self.asked.append((project_id, run_id))
|
|
124
|
+
return {"id": run_id, "outcome": outcome}
|
|
125
|
+
|
|
126
|
+
self.runs = _Runs()
|
|
127
|
+
|
|
128
|
+
def get_crawl(self, crawl_id):
|
|
129
|
+
return self.crawl
|
|
130
|
+
|
|
131
|
+
def batch(self, batch_id, formats="markdown"):
|
|
132
|
+
self.batch_formats = formats
|
|
133
|
+
return {"id": batch_id, "status": self._batch_status}
|
|
134
|
+
|
|
135
|
+
def cancel_batch(self, batch_id):
|
|
136
|
+
self.stopped_batches.append(batch_id)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_cancel_job_stops_a_running_crawl_and_says_how(monkeypatch):
|
|
140
|
+
c = _Cancels(crawl_status="running")
|
|
141
|
+
monkeypatch.setattr(mcp_mod, "_client", lambda: c)
|
|
142
|
+
out = mcp_mod.cancel_job("crawl", "c1")
|
|
143
|
+
assert c.crawl.cancelled and out["outcome"] == "cancelling" and "nothing is deleted" in out["note"]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_cancel_job_leaves_a_finished_crawl_alone(monkeypatch):
|
|
147
|
+
c = _Cancels(crawl_status="done")
|
|
148
|
+
monkeypatch.setattr(mcp_mod, "_client", lambda: c)
|
|
149
|
+
out = mcp_mod.cancel_job("crawl", "c1")
|
|
150
|
+
assert not c.crawl.cancelled and out["outcome"] == "done"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_cancel_job_stops_a_queued_batch_outright(monkeypatch):
|
|
154
|
+
c = _Cancels(batch_status="queued")
|
|
155
|
+
monkeypatch.setattr(mcp_mod, "_client", lambda: c)
|
|
156
|
+
out = mcp_mod.cancel_job("batch", "b1")
|
|
157
|
+
assert c.stopped_batches == ["b1"] and out["outcome"] == "cancelled"
|
|
158
|
+
# The status is read without the pages' bodies.
|
|
159
|
+
assert c.batch_formats == "none"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_cancel_job_passes_on_the_runs_outcome(monkeypatch):
|
|
163
|
+
c = _Cancels(run_outcome="done")
|
|
164
|
+
monkeypatch.setattr(mcp_mod, "_client", lambda: c)
|
|
165
|
+
out = mcp_mod.cancel_job("run", "r1", project_id="p1")
|
|
166
|
+
assert c.runs.asked[-1] == ("p1", "r1") and out["outcome"] == "done"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_cancel_job_needs_a_runs_project(monkeypatch):
|
|
170
|
+
c = _Cancels()
|
|
171
|
+
monkeypatch.setattr(mcp_mod, "_client", lambda: c)
|
|
172
|
+
assert mcp_mod.cancel_job("run", "r1")["code"] == "validation"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_list_runs_keeps_what_picks_a_run_and_drops_the_bulk(monkeypatch):
|
|
176
|
+
asked = []
|
|
177
|
+
|
|
178
|
+
class _Runs:
|
|
179
|
+
def list(self, project_id, limit=25):
|
|
180
|
+
asked.append((project_id, limit))
|
|
181
|
+
return [{"id": "r2", "status": "running", "trigger": "manual", "counts": {"ok": 3},
|
|
182
|
+
"linkGraph": {"orphans": 9}, "cfg": "v2", "engine": "http"}]
|
|
183
|
+
|
|
184
|
+
class _Client:
|
|
185
|
+
runs = _Runs()
|
|
186
|
+
|
|
187
|
+
monkeypatch.setattr(mcp_mod, "_client", lambda: _Client())
|
|
188
|
+
out = mcp_mod.list_runs("p1", limit=500)
|
|
189
|
+
assert asked == [("p1", 100)]
|
|
190
|
+
row = out["runs"][0]
|
|
191
|
+
assert row["id"] == "r2" and row["status"] == "running" and row["counts"] == {"ok": 3}
|
|
192
|
+
assert "linkGraph" not in row and "cfg" not in row and "engine" not in row
|
|
@@ -237,14 +237,14 @@ def test_an_unreachable_issuer_stops_the_server(monkeypatch):
|
|
|
237
237
|
|
|
238
238
|
|
|
239
239
|
def test_get_job_is_offered_alongside_the_rest():
|
|
240
|
-
"""
|
|
240
|
+
"""Nineteen declared, and nineteen on the server that was built from
|
|
241
241
|
them. Counting `@server.tool` in the source said nothing about what the
|
|
242
242
|
server ended up carrying."""
|
|
243
243
|
import anyio
|
|
244
244
|
|
|
245
|
-
assert len(mcp_mod._TOOLS) ==
|
|
245
|
+
assert len(mcp_mod._TOOLS) == 19, "sixteen verbs plus get_job, cancel_job and list_runs"
|
|
246
246
|
served = [t.name for t in anyio.run(mcp_mod.server.list_tools)]
|
|
247
|
-
assert len(served) ==
|
|
247
|
+
assert len(served) == 19
|
|
248
248
|
assert "get_job" in served
|
|
249
249
|
assert callable(mcp_mod.get_job)
|
|
250
250
|
|
|
@@ -426,7 +426,7 @@ def test_the_server_answers_a_real_call_as_the_caller_who_sent_it(served):
|
|
|
426
426
|
r, listed = _rpc(served, "tools/list", session=sid)
|
|
427
427
|
assert r.status_code == 200, r.text
|
|
428
428
|
names = [t["name"] for t in listed["result"]["tools"]]
|
|
429
|
-
assert len(names) ==
|
|
429
|
+
assert len(names) == 19, names
|
|
430
430
|
assert "get_job" in names
|
|
431
431
|
|
|
432
432
|
r, out = _rpc(served, "tools/call", {"name": "list_projects", "arguments": {}}, session=sid)
|
|
@@ -450,8 +450,8 @@ def test_auth_is_attached_through_the_constructor(served):
|
|
|
450
450
|
assert str(mcp_mod.server.settings.auth.resource_server_url).rstrip("/") == RESOURCE.rstrip("/")
|
|
451
451
|
assert mcp_mod.server.settings.auth.validate_token_resource is True
|
|
452
452
|
|
|
453
|
-
# Both servers carry the same
|
|
454
|
-
assert len(mcp_mod._TOOLS) ==
|
|
453
|
+
# Both servers carry the same nineteen tools, declared once.
|
|
454
|
+
assert len(mcp_mod._TOOLS) == 19
|
|
455
455
|
names = {fn.__name__ for fn, _how in mcp_mod._TOOLS}
|
|
456
456
|
assert "get_job" in names
|
|
457
457
|
assert all(how.get("description") for _fn, how in mcp_mod._TOOLS), "a tool with no description is a verb an assistant cannot choose"
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
"""What an assistant reads before it calls a tool.
|
|
2
|
-
|
|
3
|
-
A tool is chosen and filled in from its definition alone: the description, the
|
|
4
|
-
parameters' descriptions and the hints on what it does to the world. Every
|
|
5
|
-
parameter went undescribed until 0.3.2, and directories that grade
|
|
6
|
-
definitions marked the whole server down for it. These keep it from
|
|
7
|
-
drifting back.
|
|
8
|
-
"""
|
|
9
|
-
import asyncio
|
|
10
|
-
|
|
11
|
-
import pytest
|
|
12
|
-
|
|
13
|
-
mcp_mod = pytest.importorskip("mesharc.mcp", reason="needs the mcp extra")
|
|
14
|
-
|
|
15
|
-
# The tools that only read what the workspace has stored. Everything else
|
|
16
|
-
# reaches a site or changes the workspace, and a read-only connection is
|
|
17
|
-
# refused it -- the same nine the read_only answer is about.
|
|
18
|
-
READ_ONLY = {"list_projects", "describe_project_config", "get_project", "list_pages",
|
|
19
|
-
"get_page", "get_changes", "search_pages", "get_job"}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _tools():
|
|
23
|
-
return {t.name: t for t in asyncio.run(mcp_mod.server.list_tools())}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_all_seventeen_are_listed():
|
|
27
|
-
assert len(_tools()) == 17
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_every_parameter_is_described():
|
|
31
|
-
for name, t in _tools().items():
|
|
32
|
-
for param, schema in (t.input_schema.get("properties") or {}).items():
|
|
33
|
-
assert (schema.get("description") or "").strip(), f"{name}.{param} has no description"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_every_tool_has_a_title_and_a_description():
|
|
37
|
-
for name, t in _tools().items():
|
|
38
|
-
assert t.title and t.title != name and len(t.title) > len(name), name
|
|
39
|
-
assert len((t.description or "").split()) >= 20, name
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_the_hints_say_which_tools_only_read():
|
|
43
|
-
for name, t in _tools().items():
|
|
44
|
-
a = t.annotations
|
|
45
|
-
assert a is not None, name
|
|
46
|
-
assert a.read_only_hint is (name in READ_ONLY), name
|
|
47
|
-
if name in READ_ONLY:
|
|
48
|
-
assert a.destructive_hint is False and a.open_world_hint is False, name
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_only_update_project_overwrites():
|
|
52
|
-
destructive = {n for n, t in _tools().items() if t.annotations.destructive_hint}
|
|
53
|
-
assert destructive == {"update_project"}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_fixed_choices_are_listed():
|
|
57
|
-
tools = _tools()
|
|
58
|
-
|
|
59
|
-
def enum(tool, param):
|
|
60
|
-
schema = tools[tool].input_schema["properties"][param]
|
|
61
|
-
options = [schema] + list(schema.get("anyOf") or [])
|
|
62
|
-
return next(o["enum"] for o in options if "enum" in o)
|
|
63
|
-
|
|
64
|
-
assert enum("get_job", "kind") == ["crawl", "run", "batch"]
|
|
65
|
-
assert enum("search_pages", "mode") == ["content", "selector"]
|
|
66
|
-
for tool in ("create_project", "keep_crawl_as_project", "update_project"):
|
|
67
|
-
assert enum(tool, "schedule") == ["manual", "hourly", "daily", "weekly"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|