Search UK Legislation
legislation_searchSearch UK legislation by title or full text, filter by type or year, and get ranked results with metadata from legislation.gov.uk.
Instructions
Search UK legislation on legislation.gov.uk.
Returns ranked results: title, type, year, number, and legislation.gov.uk URL. Use legislation_get_toc to explore structure, then legislation_get_section for provisions.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| params | Yes | LegislationSearchInput with query, optional type filter, optional year. |
Output Schema
| Name | Required | Description | Default |
|---|---|---|---|
| results | Yes | Matching legislation items | |
| total | Yes | Total number of matches |
Implementation Reference
- src/modules/legislation/tools.py:170-216 (handler)The actual tool handler function that executes legislation_search logic. Searches UK legislation via legislation.gov.uk Atom feed, parses results into LegislationResult objects, and returns a LegislationSearchResult.
async def legislation_search(params: LegislationSearchInput, ctx: Context) -> LegislationSearchResult: """Search UK legislation on legislation.gov.uk. Returns ranked results: title, type, year, number, and legislation.gov.uk URL. Use legislation_get_toc to explore structure, then legislation_get_section for provisions. Args: params: LegislationSearchInput with query, optional type filter, optional year. """ client = ctx.lifespan_context["legislation_http"] path = f"/{params.type}" if params.type else "/search" # Title search by default — best ranking for "find me Act X". `fulltext` # opens up content search across every Act/SI, useful for concept queries. qp: dict = {"results-count": 20} qp["text" if params.fulltext else "title"] = params.query if params.year: qp["year"] = params.year resp = await client.get(f"{LEGISLATION_BASE}{path}", params=qp) resp.raise_for_status() root = etree.fromstring(resp.content) total_el = root.findtext(".//os:totalResults", namespaces=ATOM_NS) total = int(total_el) if total_el else 0 results = [] for entry in root.findall(".//a:entry", namespaces=ATOM_NS): title = entry.findtext("a:title", namespaces=ATOM_NS) or "Unknown" entry_id = entry.findtext("a:id", namespaces=ATOM_NS) or "" m = _ID_RE.search(entry_id) if m: leg_type, yr, num = m.group(1), int(m.group(2)), int(m.group(3)) else: leg_type, yr, num = "unknown", 0, 0 results.append(LegislationResult( title=title, type=leg_type, year=yr, number=num, score=None, url=f"{LEGISLATION_BASE}/{leg_type}/{yr}/{num}", next_steps=({ "toc": f"legislation://{leg_type}/{yr}/{num}/toc", "section_template": f"legislation://{leg_type}/{yr}/{num}/section/{{section}}", "point_in_time_hint": "Append ?date=YYYY-MM-DD to either URI for historical research", } if leg_type != "unknown" else {}), )) return LegislationSearchResult(results=results, total=total or len(results)) - Input schema for the legislation_search tool with fields: query, type, year, fulltext.
class LegislationSearchInput(BaseModel): model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") query: str = Field(..., description="Search query, e.g. 'Housing Act 1988' or 'data protection personal data'", min_length=1, max_length=500) type: str | None = Field(None, description="Filter by type: 'ukpga' (Acts), 'uksi' (SIs), 'asp' (Scottish Acts), 'nia' (NI Acts)") year: int | None = Field(None, description="Filter by year of enactment", ge=1800, le=2100) fulltext: bool = Field( False, description=( "Default false → searches Act/SI titles only (best for finding a named Act, " "e.g. 'Housing Act 1988' returns ukpga/1988/50 first). Set true to search " "the full text of every Act/SI for the query (returns SIs and regulations " "that cite the term — e.g. 'rental deposits' would return many implementing " "instruments)." ), ) - Output model for legislation_search results, containing a list of LegislationResult items and total count.
class LegislationSearchResult(BaseModel): """Container for legislation search results.""" results: list[LegislationResult] = Field(..., description="Matching legislation items") total: int = Field(..., description="Total number of matches") - src/modules/legislation/__init__.py:22-23 (registration)Registration path: register_tools is called in __init__.py, which triggers the @mcp.tool decorator in tools.py. The tool is mounted on the gateway at line 185 of gateway.py with namespace 'legislation', making the full tool name 'legislation_search'.
register_tools(legislation_mcp) register_prompts(legislation_mcp) - src/deps.py:146-170 (helper)Lifespan factory providing the 'legislation_http' client (curl_cffi with Chrome impersonation) used by the handler to call legislation.gov.uk.
async def http_lifespan(server: FastMCP): """Provide shared async HTTP clients for the lifespan of the server.""" async with httpx.AsyncClient( timeout=30.0, headers=SHARED_HEADERS, follow_redirects=True, ) as http, httpx.AsyncClient( timeout=30.0, headers=XML_HEADERS, follow_redirects=True, ) as xml_http, CurlAsyncSession( impersonate="chrome", timeout=30.0, allow_redirects=True, # Without an explicit Accept, legislation.gov.uk's /search endpoint # returns the HTML search page, not the Atom feed our parser needs. # Real-world Codex test caught this — legislation_search returned 0 hits # for "Housing Act 1988". See PR comment on issue #4. headers={"Accept": "application/atom+xml, application/xml, text/xml"}, ) as legislation_session: yield { "http": http, "xml_http": xml_http, "legislation_http": LegislationClient(legislation_session), }