@cyanheads/canada-parliament-mcp-server
Track Canadian federal bills, MPs, House votes, and Hansard speeches via MCP. STDIO or Streamable HTTP.
8 Tools
Overview
Canadian federal parliamentary records from LEGISinfo, the House of Commons, and OpenParliament. Follow House and Senate bills through their stages, find current and former MPs, inspect recorded House divisions, and read indexed House Hansard speeches. Public sources require no API key.
Tools
Capability reference
canparl_search_bills tool
titleContains (matching ignores case, accents, dash and quote styles, and repeated whitespace), billCode, originatingChamber, billType, status, and sponsor, combined with AND; limit 1–100 (default 10; values up to 10,000 clamp to 100 with a notice) with offset pagination
- Bilingual titles, status, latest major stage, and royal assent date, with the exact filtered
total and pagination.nextOffset
- Whole bill rows stop early at the 24,000-byte response budget; follow
pagination.nextOffset with identical filters. An oversized single row returns whole with a notice
sponsor takes an OpenParliament slug, /politicians/ path, or profile URL (member.slug from canparl_get_member, or an includeFormer search row), or a current MP's personId. A digit-only value equal to a current personId, or its /politicians/<digits>/ path or URL, resolves through the same verified crosswalk as canparl_get_member; appliedFilters.sponsorName names the profile used
- Sponsor results cover only OpenParliament's index (
sponsorCoverage: "openparliament_index"), which excludes Senators; a scan past ten 100-row pages fails as sponsor_scan_incomplete
canparl_get_bill tool
billCode and its session from canparl_search_bills; returns the sponsor and their affiliation title, House/Senate/royal-assent stages, House divisions (dateTime is the LEGISinfo sitting date-time, not when the division was recorded), Senate vote records, publications, and statute
- Also the Library of Parliament legislative
summary and dossier notes as plain text (each note link kept as text (href)), the six reading milestones as UTC instants converted from LEGISinfo's Ottawa-time clocks, and related webReferences (press releases, background documents, costing notes) whose URLs are relayed as published, never fetched
- Above a 24,000-byte response budget, returns
kind: "outline" with sponsor, statute, and milestones inline, listing the sections sponsor, stages.house, stages.senate, stages.royalAssent, divisions, senateVoteDetails, publications, statute, summary.en, summary.fr, notes.en, notes.fr, milestones, and webReferences; re-call with section, paging collections by offset and limit (1–100, default 20; values up to 10,000 clamp to 100 with a notice) through pagination.nextOffset
- Pages hold whole records; an indivisible record or language section over the budget returns whole with
disclosure, and null, empty, and past-end sections stay distinct
canparl_search_members tool
- Current roster:
name and riding substrings (matching ignores case, accents, dash and quote styles, and repeated whitespace), province (postal code or full name), and party; limit 1–100 (default 25; values up to 10,000 clamp to 100 with a notice) with the exact filtered total
parliament (36th onward) searches that Parliament's official roster with the same filters: everyone who sat in it, one row each with the caucus and riding last held there, the membership from and to dates, and current. An unoffered Parliament fails listing the ones offered; it is rejected with includeFormer
includeFormer: true searches OpenParliament's historical name index instead: one token matches a surname, several match a full recorded name. Parliament, geographic, and party filters are rejected there, rows may carry only a name and slug, and total is null
- Pass
detailLookup unchanged to canparl_get_member: official roster rows carry an official personId, historical rows an OpenParliament slug
- Whole member rows stop early at the 24,000-byte response budget; follow
pagination.nextOffset with identical filters. An oversized single row returns whole with a notice
canparl_get_member tool
- Exactly one
personId or slug from detailLookup; returns the profile and dated memberships. A digit-only slug equal to a current personId, or its /politicians/<digits>/ path or URL, is read as that personId. A former member's personId (36th Parliament onward, as on canparl_get_vote ballots) resolves on the all-Parliaments roster with current: false, carrying their latest Parliament's caucus and riding; slug inputs stay current-roster only
personId crosswalks verify the official ID and a recorded name or published alias; a bounded surname lookup after an empty exact-name page accommodates accents and middle initials without guessing a slug
sponsoredBills (0–5, default 5) and recentVotes (0–10, default 10) page independently through billsOffset and votesOffset; 0 skips a section, and values up to 10,000 clamp to the cap with a notice
- With one official
personId, ballots come newest first from the official member-votes feed in one request, for the member's latest session or votesSession, optionally narrowed by votesBillCode. ballot reads Yes/No/Paired, officialBallot keeps Yea/Nay/Paired, and source names the feed; other members keep OpenParliament ballots
- Each activity section reports
available, unavailable, or not_requested, with failure metadata; unavailable history is never reported as empty
- Profile
links are never fetched: an absolute http or https URL is relayed as published, and a relative value is resolved against https://openparliament.ca
canparl_search_votes tool
billCode, result (Agreed To, Negatived, Tie), documentType, inclusive Ottawa-date dateFrom and dateTo, and subjectContains (matching ignores case, accents, dash and quote styles, and repeated whitespace); limit 1–100 (default 25; values up to 10,000 clamp to 100 with a notice) with offset pagination
- Newest first, with official tallies and the exact filtered
total; pass session and number to canparl_get_vote
- Whole division rows stop early at the 24,000-byte response budget; follow
pagination.nextOffset with identical filters. An oversized single row returns whole with a notice
canparl_get_vote tool
number and its session; ballot (Yea, Nay, Paired; Yes/No accepted), personId, party, and province filter only the ballot page; limit 1–400 (default 30; values up to 10,000 clamp to 400 with a notice) with offset pagination
tally, byParty, and ballotsTotal cover the whole division and ballotsMatched the filtered set; tallyMatchesBallots flags a disagreement without changing either count
- Ballots are
Yea, Nay, or Paired; nonvoters are not listed, so a personId with no ballot returns an empty page
- A page stops at a 24,000-byte response budget with
truncated and a notice; follow nextOffset for the rest
canparl_search_speeches tool
- Needs a scope:
politician, billDebated or mentionedBill ({ session, code }), dateFrom, or dateTo; contains is a literal substring of the selected language (en default, or fr; matching ignores case, accents, dash and quote styles, and repeated whitespace) and never a scope on its own. limit 1–10 (default 5; values up to 10,000 clamp to 10 with a notice), and a page stops at a 24,000-byte response budget, returning at least one speech whole
politician takes the same slug, path, URL, or current personId values as canparl_search_bills sponsor, resolved the same way; appliedFilters.politicianName names the profile used
- Scans at most 100 raw candidates per call: follow
nextOffset with identical filters until exhausted before concluding there is no match, and even then the answer covers OpenParliament's index only
- Complete
content, speaker, h1, and h2 in the selected language (null where that language has none, never the other language's text; older records carry no French), with speech-date party and riding in English (null when unavailable); substantiveOnly (default true) drops procedural remarks, and committee records are excluded and counted in excludedCommittees
canparl_list_reference tool
topic: sessions, bill_types, bill_statuses, vote_results, vote_document_types, or parties; returns the complete set without pagination
sessions takes no session and reports basic or full bill coverage and House division coverage per session; other topics take a session and mark each value observed in its feed; parties takes only the current session
Features
Built on @cyanheads/mcp-ts-core: stdio and Streamable HTTP transports, pluggable auth (none / jwt / oauth), swappable storage (in-memory, filesystem, Supabase, Cloudflare KV/R2/D1), structured logging with optional OpenTelemetry tracing.
Canadian Parliament-specific:
- Official bill coverage from session
35-1 (1994), full dossiers from 37-1 (2001), and House divisions from 38-1 (2004); OpenParliament's historical name index reaches 1994
- Bills cover both chambers; member, division, and speech tools cover the House of Commons. Missing historical fields stay
null
- Sessions accept
"45-1" or { "parliament": 45, "session": 1 }; an omitted session resolves from the current official bill feed, except in speech bill scopes
- Shared source pacing, short-lived in-memory caches, retries, and one 50-second tool deadline. OpenParliament requests are serialized with a two-second start gap, so sponsored bills and OpenParliament ballots can take tens of seconds
- Official-source HTTP 403 refusals fail with
upstream_blocked, the source name, and guidance to retry that source later; response HTML is excluded, and official data is never replaced with another source
Agent-friendly output:
- Provenance:
source, url, original retrievedAt, and cached on every successful retrieval
- Explicit coverage: normalized
appliedFilters, continuation fields, notices, and typed failure reasons separate no matches from unavailable or incomplete data
- Advertised input formats: dates, sessions, person IDs, bill codes, and bill sections carry a
pattern in the tool schema that accepts what the tool accepts, padded values included. A value outside the format fails at argument validation (-32602, invalid_arguments) naming the field and the expected form; impossible dates, reversed bounds, out-of-range session numbers, and unknown vocabulary values keep their typed tool reasons
- Both surfaces: every
structuredContent value, complete source text included, also renders in Markdown content[]
The Canadian Parliament research guide connects bill, division, member, and Hansard records and explains the evidence needed for historical conclusions. It ships with plugin installs, npm, Docker, and the desktop bundle, and is available over MCP as skill://canada-parliament-guide/SKILL.md.
Getting started
Connect to the hosted server in Claude Code:
Or run it locally:
For other clients, add this to the MCP config file:
No API key is required. Optionally set CANPARL_USER_AGENT to identify your deployment to the upstream sources. For Docker or a source build, see Running the server.
Configuration
See .env.example for every server setting and the common framework overrides.
Running the server
Local development
Requires Bun v1.4.0 or higher (or Node.js v24+), and Internet access to parl.ca, ourcommons.ca, and openparliament.ca.
Docker
The image defaults to HTTP transport, stateless sessions, and logs in /var/log/canada-parliament-mcp-server. To run it over stdio from an MCP client, use "command": "docker" with "args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/canada-parliament-mcp-server:latest"]. Build locally with docker build -t canada-parliament-mcp-server .; OpenTelemetry peers are installed by default, or omitted with --build-arg OTEL_ENABLED=false.
Project structure
See docs/tree.md for the full tree.
Development guide
See CLAUDE.md for development guidelines and architectural rules. The short version:
- Handlers throw, framework catches; catch only to translate a known source failure or keep optional enrichment
- Use
ctx.log for logging and ctx.enrich for applied filters, totals, and notices
- Register new tools in the
createApp() arrays in src/index.ts
- Wrap external API calls: validate raw → normalize to domain type → return output schema; never fabricate missing fields
Contributing
Issues are welcome. Run checks and tests before submitting:
License
Code is licensed under Apache 2.0. Parliamentary data is an unofficial reproduction credited to Parliament and, where used, OpenParliament; Parliament retains copyright. Reuse is governed separately by the Parliament notices, the Speaker's Permission, the Senate terms, and the OpenParliament API terms. This free, noncommercial relay does not imply government endorsement or an open-data licence.