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.
@@ -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.2
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 nine that fetch or write -- `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `create_project`, `update_project`, `start_run`, `recrawl_pages` -- answer `{"code": "read_only"}` with what to do about it, until the app is reconnected with write access.
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 and `get_job` picks it up. Run locally, those tools block as they always have.
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 nine that fetch or write -- `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `create_project`, `update_project`, `start_run`, `recrawl_pages` -- answer `{"code": "read_only"}` with what to do about it, until the app is reconnected with write access.
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 and `get_job` picks it up. Run locally, those tools block as they always have.
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.2"
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. Blocked pages are reported as blocked, never as missing."
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
- # seventeen nameless verbs.
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
- # Nine of the seventeen tools need write, including every one that fetches a
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, markdown by default; no project "
595
- "is created. For one page with every format or browser steps use extract_url; to find pages by "
596
- "following links use crawl_site; to list a site's URLs without fetching them use map_site. "
597
- "Each page costs the credits of the engine that read it (1 plain fetch, 4 browser render), and a "
598
- "page the site refuses is free. A single URL answers in the same request; a list waits for the "
599
- "batch, and a large one comes back as an index with excerpts inside 60,000 characters. Hosted, a "
600
- "batch still going after the time budget comes back as a job for get_job, and repeating the same "
601
- "call returns that job instead of starting another.")
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
- "the bodies config's formats ask for plus the raw html (each capped at 12,000 characters), head "
638
- "and extracted fields, its images, and which engine read it -- without its link list. Use it "
639
- "for a single page that needs browser steps, structured fields or a settings trial before "
640
- "create_project; for plain content of one or many known URLs use scrape_urls, and get_page "
641
- "reads a page a project already stored without fetching. It climbs the fetch ladder -- plain "
642
- "http first, a browser only when the page needs one or render_js is 'always' -- and costs the "
643
- "credits of the rung that read it (1 to 4 for most pages, +1 when formats ask for a "
644
- "screenshot). Browser `actions` in config (click, type, select, press, wait, scroll; repeat "
645
- "'until_gone' for Load-more buttons; `each` to act on every match) run before the page is read.")
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 -- found through robots.txt and the well-known "
662
- "paths, each index file walked to its children -- without fetching any of the pages. Use it "
663
- "first, to see how big a site is and what sections it has, before crawl_site or create_project; "
664
- "it cannot read page content (scrape_urls or crawl_site do) and misses pages a site links to but "
665
- "does not declare. Costs 1 credit per sitemap file read, usually 1 in total, never per URL. "
666
- "Returns the discovery method, totals, up to `limit` URLs and the section names.")
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 -- following its links and reading its sitemap, up to "
685
- "`limit` pages -- and return what it found, without setting up a project. Use it to read a site "
686
- "or section whose page URLs you do not know; for known URLs use scrape_urls, and to watch a site "
687
- "over time use create_project (or keep_crawl_as_project on this crawl afterwards). Each page "
688
- "costs the credits of the engine that read it (usually 1 to 4) and refused pages are free. It "
689
- "waits for the crawl, minutes for a large limit; hosted, a crawl still going after the time "
690
- "budget comes back as a job for get_job, and repeating the call returns the same crawl. The "
691
- "answer is an index of the pages read plus excerpts inside 60,000 characters; get_job with "
692
- "`url` reads one page in full and with `cursor` the next window. The crawl is kept for a day; "
693
- "pass the result's crawl_id to keep_crawl_as_project to keep it for good.")
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
- "is compared against this one. Nothing is fetched again and it costs no credits: the crawl's "
745
- "pages become the project's first run. Use it after crawl_site when the site is worth watching; "
746
- "to start a watched site from scratch use create_project. Without it a crawl and its pages "
747
- "expire after a day. It counts toward the plan's project limit (refused with plan_limit when "
748
- "full). Returns the new project, whose id the other project tools take.")
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 -- the sites it watches over time -- with each one's id, "
766
- "name, seed URL, host, schedule, page count, coverage, last run and health. Call it first to "
767
- "find a project_id for the other project tools, or to check whether a site is already watched "
768
- "before create_project; get_project gives one project's full settings. Reads only what is "
769
- "stored: free, and it fetches nothing. A key limited to some projects sees only those.")
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", "health")}
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 -- each key with its meaning and its default "
825
- "-- plus the valid schedules and how path globs work. Read it before create_project, "
826
- "update_project or a `config` argument whenever the request names a section, schedule, format, "
827
- "limit or behaviour: keys are exact and guessed names are refused. It describes settings in "
828
- "general; get_project shows the values one project has. Free, and it fetches nothing.")
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; list_projects is "
846
- "the lighter way to find projects, and list_pages or get_changes read what its runs found. "
847
- "Free, fetches nothing; an unknown or hidden project answers 404. The webhook signing secret "
848
- "is withheld from the answer.")
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: it is crawled on its schedule and each run is "
856
- "compared with the last, recording pages added, modified and removed. Use it when the request "
857
- "is to monitor or track a site; for a one-off read use crawl_site (keep_crawl_as_project can "
858
- "turn that into a project later), and check list_projects first so the site is not added "
859
- "twice. Creating it reads the site's robots.txt and sitemaps but fetches no pages; every run "
860
- "then spends credits per page. Refused when the plan's project limit is reached. Returns the "
861
- "project, whose id start_run takes to crawl it now.")
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. Only what you pass changes: in "
881
- "`config` only the keys given are replaced, the rest stay; a replaced value is not kept. Call "
882
- "get_project first to see the current values, and describe_project_config for valid keys; to "
883
- "start a different site use create_project instead. Nothing is fetched and no credits are "
884
- "spent now; the next run uses the new settings, and a settings change that alters how pages "
885
- "are read starts a fresh comparison baseline. Returns the updated project.")
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; the run is then "
911
- "compared with the last one. Use it after create_project or update_project, or when fresh "
912
- "results are wanted; to re-read only a few pages use recrawl_pages, and for a site with no "
913
- "project use crawl_site. Every page read costs credits (usually 1 to 4) and a run stops when "
914
- "the credit budget runs out, keeping what it read; it is refused with 409 while a run of the "
915
- "project is already queued or running, and 402 when no credits are left. Without wait it "
916
- "returns the queued run at once; with wait it returns the finished run, except hosted, where "
917
- "a run still going after "
918
- "the time budget comes back as a job for get_job. Results are read with list_pages and "
919
- "get_changes.")
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 -- the last finished run unless run_id is given -- "
940
- "with each page's url, status, depth, word count and when it last changed, up to 500 rows, "
941
- "plus the runId. Use it to see what a crawl covered or to pick a URL; get_page reads one of "
942
- "them in full, search_pages finds pages by their text, and get_changes lists only what "
943
- "changed. Free and reads only what is stored: it never fetches the site. A project with no "
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
- "its versions across runs. Use it when you know the page's URL; list_pages or search_pages "
958
- "finds the URL first, and for a page from a crawl_site crawl use get_job with url instead. "
959
- "Free and reads only what is stored, so it never fetches the site -- use extract_url for a "
960
- "live copy. A URL the run did not store answers 404.")
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 start_run or a list_pages answer, to read that run's copy; leave "
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 -- the last finished run "
974
- "unless run_id is given: pages added, modified and removed, head-field changes (title, "
975
- "description, canonical...), and the run's coverage, with up to 200 entries per list plus the "
976
- "runs a record exists for. Use it to answer 'what changed on the site'; list_pages shows every "
977
- "page whatever changed, and get_page shows one page's text and versions. Removals are withheld "
978
- "when the crawl reached under 90% of the site, so a blocked crawl never reports the site as "
979
- "gone. A project's first run is a baseline with nothing to compare. Free and reads only what "
980
- "is stored.")
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 -- the last finished run "
995
- "unless run_id is given -- returning the matching URLs with how many pages were scanned. Use "
996
- "it to locate pages by what they say or contain; list_pages lists every page and get_page "
997
- "then reads one in full. It scans the stored copies and never fetches the site, so it is free "
998
- "and as fresh as the run. A selector search needs the run to have kept html (the rawHtml "
999
- "format); when none was kept the answer says so rather than reporting no matches.")
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
- "compares just those pages with the last full run. Use it to check a few pages you expect "
1016
- "changed without crawling the whole site; start_run re-crawls everything, and extract_url "
1017
- "reads a page outside any project. Each page costs credits (usually 1 to 4). The URLs must be "
1018
- "on the project's site; refused with 409 while another run of the project is queued or "
1019
- "running. Returns the queued run; follow it with get_job (kind 'run') and read results with "
1020
- "get_changes.")
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 full URLs on the project's site to fetch again.")]) -> dict:
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 -- a crawl from crawl_site, a run from start_run or "
1030
- "recrawl_pages, or a batch from scrape_urls -- and, once it has finished, return the same "
1031
- "result the original tool would have given; while it is still going, its status and counts. "
1032
- "Call it when a tool answered with status 'running' and a job; calling the original tool "
1033
- "again is not needed and would not start a second job. For a crawl it is also how to read "
1034
- "further: `url` returns one page in full (even mid-crawl) and `cursor` the next window of "
1035
- "pages. For a project's stored pages use get_page instead. Free: it only reads stored "
1036
- "results and never fetches.")
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
- # Stdio's server, built now that the seventeen are declared. Hosted mode
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 seventeen tools, with OAuth attached.
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.2
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 nine that fetch or write -- `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `create_project`, `update_project`, `start_run`, `recrawl_pages` -- answer `{"code": "read_only"}` with what to do about it, until the app is reconnected with write access.
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 and `get_job` picks it up. Run locally, those tools block as they always have.
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.2"
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
- """Seventeen declared, and seventeen on the server that was built from
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) == 17, "sixteen verbs plus get_job"
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) == 17
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) == 17, 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 seventeen tools, declared once.
454
- assert len(mcp_mod._TOOLS) == 17
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