Skip to main content
Glama
companies-house-mcp

companies-house-mcp

companies-house-mcp

CI

An MCP server that gives a model read-only access to the UK Companies House register: every company in England, Wales, Scotland and Northern Ireland, live or dissolved. With it, a model can answer questions like "is this supplier still trading?", "who actually owns this company?" and "has anyone lent money against it?" from the register itself, not from memory.

Seven tools, all reads. Python, the official MCP SDK (mcp 2.x), httpx, pydantic. Tested offline without an API key, and checked against the live register.

search_companies                       find a company by name, get its number
get_company_profile                    status, type, office, activities, deadlines, red flags
list_officers                          directors, secretaries, LLP members
list_filings                           what it has filed, in words
list_charges                           secured lending against it
get_registered_office_history          where it has been registered, and when it moved
list_persons_with_significant_control  who owns or controls it

Most of this README is about why the server looks the way it does. The decisions matter more than the code: wrapping an API takes an afternoon; choosing what a model should see is the actual work.


Design decisions

Seven tools, not thirty

The Companies House API has around thirty read endpoints. A server that mirrors them one to one is easy to write and worse to use. Every tool's name, description and schema sit in the model's context for the whole conversation, whether it is used or not. And every extra tool is one more wrong choice on offer: tools that overlap ("search" and "advanced search", "profile" and "registered office") are where models pick badly.

So the tools follow the questions people ask about a company, not the endpoints:

Question

Tool

Upstream requests

Which company do you mean?

search_companies

1

What is it, and is it healthy?

get_company_profile

1

Who runs it?

list_officers

1 (2 if the list is empty)

What has it filed?

list_filings

1 (2 if the list is empty)

Who has security over it?

list_charges

1 (2 if the list is empty)

Where has it been based?

get_registered_office_history

2

Who owns it?

list_persons_with_significant_control

1 (2 if the list is empty)

One tool, get_registered_office_history, has no endpoint of its own. Companies House serves the current address and, separately, the filings that changed it. The model wants the history, so the tool joins the two.

The whole tool surface (names, descriptions, input schemas) is under 8,000 characters, roughly 2,000 tokens. tests/test_surface.py fails the build if it passes 12,000, so any growth is a decision rather than drift.

Every tool is a read, and says so

The public data API is read-only anyway; filing uses a separate API with per-company authentication codes. But the server also tells the host so: each tool carries readOnlyHint, idempotentHint and openWorldHint, and destructiveHint: false. That matters in practice. A host can let the model run these tools without asking the user each time, which a tool that could change the register would never earn. Mixing reads and writes in one server makes the host treat all of it as the riskier kind.

What is deliberately not exposed

  • Officer search and officer appointment history. /search/officers and /officers/{id}/appointments turn a company tool into a person tool: "every company this person has ever been involved in". Due diligence has real uses for that, but it is a different risk profile, and it belongs in a separately reviewed server a host opts into, not bundled here.

  • Home and service addresses of officers and PSCs. The API returns them. The models here do not. No company question needs a director's correspondence address.

  • Advanced search (by activity code, incorporation date, location). It is useful for building lists of companies, but lists are not what this server is for, and it is the natural starting point for bulk harvesting.

  • Document downloads. Accounts and forms are PDFs, often scans. Fetching them is a different job (size, OCR, caching) and would sit badly inside a quick lookup. list_filings says whether a document exists.

  • Lower-frequency resources: insolvency case detail, disqualified officers, exemptions, UK establishments, statutory registers, PSC statements. Each is one small tool away when a real question needs it. The gap most likely to matter is PSC statements, so list_persons_with_significant_control says so: an empty list may mean the company filed a statement instead.

  • The streaming API. It is push, not pull, and does not fit the request and response shape of a tool call.

Results shaped for a model, not for a web page

The raw API is built for the Companies House website: nested address objects, enumeration keys where words should be, links, etags, and many fields no answer needs. A model pays for each of those tokens and then has to work out what they mean. So every tool returns its own pydantic model, published as an MCP output schema, with:

  • Words, not codes. "ltd" becomes "Private limited company"; a filing's "appoint-person-director-company-with-name-date" plus its values becomes "Appointment of Mr Tomasz Brandt as a director on 2019-11-04". The text comes from Companies House's own lookup tables (api-enumerations), vendored as JSON by scripts/sync_enumerations.py, so it matches their website exactly. An unknown key degrades to readable words instead of disappearing.

  • Red flags derived, not left to inference. get_company_profile returns a warnings list: overdue accounts, an overdue confirmation statement, liquidation, proposal to strike off, insolvency history, a disputed or undeliverable registered office. Without it, a model has to notice that one boolean among twenty is true. Its schema also says what an empty list means: none found, not "vetted".

  • Defaults that answer the usual question. "Who are the directors?" means current ones, so resigned officers and ceased PSCs are left out unless asked for. The result still carries the whole-company counts and how many rows were dropped, so the model knows what it isn't seeing.

  • Paging the model can't get wrong. Every list returns next_start_index, which is null at the end and never points past an empty page, so there is no loop to get stuck in.

  • Chains the model can follow. A corporate owner comes back with its registration number, and the description says to pass it to get_company_profile. That is how "who ultimately owns this?" gets answered, one hop at a time.

  • Limits in the schema. Page sizes are capped at 100 and filing categories are an enum, so a bad call fails validation before it costs a request against the key's quota.

Example: the profile of a company in trouble (from the test fixtures):

{
  "company_number": "SC654321",
  "name": "NORTHGATE PLANT HIRE LTD",
  "status": "Liquidation",
  "registered_office": "c/o Firth & Mowat Recovery LLP, 3 Castle Wynd, Inverness, IV2 3EQ, Scotland",
  "warnings": [
    "Company status is Liquidation.",
    "Status detail: Active proposal to strike off.",
    "Accounts are overdue, due by 2025-12-02.",
    "Confirmation statement is overdue, due by 2025-03-15.",
    "The company has an insolvency history on the register.",
    "The registered office address is in dispute.",
    "Mail to the registered office has been returned as undeliverable."
  ]
}

Inputs forgiving where it is safe, strict where it isn't

Models write company numbers the way people do: 445790, sc 123456, Company No. 04 12 34 56. All of those normalise to the canonical eight characters. What cannot be a company number (TESCO, nine digits) is refused before any request, with a message saying what a valid number looks like and to call search_companies if all you have is a name. Sending it upstream would come back as a 404, and a model reads a 404 as "this company does not exist". That is a different and wrong answer.

Errors written for the model that has to handle them

Every expected failure reaches the model as a message that says what happened and what to do next:

Situation

What the model is told

Malformed number

what a valid one looks like; use search_companies

No such company

the number isn't on the register; search by name

Rate limited

wait about N seconds; don't retry straight away

API down or timing out

the fault is upstream; try again in a minute, don't guess

No key / bad key

server misconfigured; retrying won't help

Unexpected exceptions are not dressed up: the SDK reports them as a generic tool failure and nothing internal leaks into the conversation.

One case needs special handling, and the live API is what showed it (see below). Ask Companies House for the charges, officers, filings or PSCs of a company that does not exist and it answers 200 with an empty list. To a model that reads as "this company has no charges", which is the wrong answer to a question about a company that isn't there. So an empty first page is checked against the company profile, which does 404 for a missing company: if the company exists the empty list stands; if not, the model gets CompanyNotFound. The extra request is spent only when the answer is empty.

Rate limiting that neither hangs nor hammers

The API allows 600 requests per five minutes per key, and a model fanning out across twenty search results can hit that in seconds. The limiter keeps a rolling window of its own requests. A short wait (up to 5 seconds) is absorbed quietly. A long wait is refused at once with the real number of seconds, because a tool call that hangs for four minutes is worse for an agent than an error it can plan around. The limiter also reads the server's X-Ratelimit-Remain / X-Ratelimit-Reset headers, so it stays honest when another process shares the key. Transient failures (502, 503, 504, connection errors, timeouts) get exactly one retry; a 500 does not, because a request that broke the server once will break it again.

Built against the spec, then run against the real thing

The first version was written and tested against the published specification only, before an API key existed. Then it was run against the live register: five companies recorded (Tesco PLC, Deloitte LLP, Woolworths Limited, the old Woolworths company now in liquidation, and a dissolved Scottish company), 35 responses in all. Here is what the live API contradicted, and what it confirmed.

Assumption from the spec

What the live API does

Consequence, and the fix

A company that doesn't exist gets a 404

Only on the profile. Its charges, officers, filings and PSCs come back 200 with an empty list

The server would have told a model a non-existent company "has no charges". Empty lists are now confirmed against the profile

The rate-limit header is X-Ratelimit-Remaining

It is X-Ratelimit-Remain (with -Limit: 600, -Window: 5m, -Reset)

The limiter never saw the server's count. It reads the real header now, and the test fixtures use it

Filings in the address category are registered-office moves

The category also holds register inspection address changes (AD02), register moves (AD03) and register-of-members locations (353)

Tesco's history showed inspection-address changes as office moves. Office moves are now matched by description key, from the register's own tables

Office changes are AD01 forms

Companies House can move an office itself, to its default address in Cardiff (RP05), when the real one is shown to be wrong

That is a red flag, so it is flagged in the history, and the profile warns when the current office is the default address

Address values are clean text

They arrive as , Tesco House, Delamare Road,, Cheshunt,, Herts

Tidied before a model sees them

Old changes have old and new addresses

Paper-era forms (287, LLP287) carry only free text

Kept, with the text in description and the address fields left null

Confirmed as assumed: charge particulars, classification and secured_details arrive as objects, not the arrays the spec describes (the shaping already accepted both); every filing description key across the five histories was found in the vendored tables; an empty search is 200 with total_results: 0.

Last, a live run through a real host. Claude Code, with only this server connected, was asked who owns the active Woolworths Limited and whether company 00000001 has any charges. It called search_companies, then list_persons_with_significant_control and list_charges in parallel, then get_company_profile. It answered that Woolworths Limited is controlled by Littlewoods Limited (active, 00262152) and that 00000001 is not on the register. Before the fix above, it would have said 00000001 had no charges.


Related MCP server: companies-house-mcp

Using it

You need Python 3.11+ and a free API key from the Companies House developer hub (create an application, then a REST key).

git clone <this repo> && cd companies-house-mcp
python -m venv .venv && . .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -e .

Claude Code

claude mcp add companies-house -e COMPANIES_HOUSE_API_KEY=your-key -- companies-house-mcp

Claude Desktop (or any host that takes this config shape)

{
  "mcpServers": {
    "companies-house": {
      "command": "/path/to/.venv/bin/companies-house-mcp",
      "env": { "COMPANIES_HOUSE_API_KEY": "your-key" }
    }
  }
}

It runs over stdio by default. companies-house-mcp --transport streamable-http --port 8000 serves it over HTTP instead. Without a key the server still starts and lists its tools, and every call explains that the key is missing.

Variable

Default

COMPANIES_HOUSE_API_KEY

none

required for calls

COMPANIES_HOUSE_BASE_URL

the live API

e.g. a sandbox

COMPANIES_HOUSE_TIMEOUT

10

seconds per request

Tests

pip install -e ".[dev]"
pytest                    # 127 tests, no network, no key
ruff check . && ruff format --check .
mypy                      # --strict over src/, per pyproject.toml

127 tests, all offline. GitHub Actions runs exactly those three commands on Python 3.11 and 3.12 for every push and pull request; the badge above is that workflow. mypy is configured strict with files = ["src"], so the strict guarantee covers the package, not the tests and scripts.

The suite fakes Companies House at the HTTP layer (httpx.MockTransport), so the real client, limiter, retry and error mapping run in every test. Tool tests talk to the server through the SDK's in-process MCP client, the same path a host takes, and one test starts the server as a subprocess over stdio. That one catches packaging mistakes and stray output that would corrupt the protocol stream. test_surface.py checks the contract itself: exactly these seven tools, all annotated read-only, every input documented, page sizes capped, and the context budget.

There are two kinds of fixture. The synthetic ones (invented companies, shaped on the published spec, with the live API's behaviour folded back in) drive the behaviour tests. The recorded ones in tests/fixtures/recorded/ are real responses from the live register, made by scripts/record_fixtures.py, and tests/test_recorded.py runs the shaping layer over every one, so drift in the live API shows up as a failing test. Before a recording is written, natural persons are redacted: individual officers' and PSCs' names, birth dates, addresses and officer ids, and officer names inside filing text. The server never returns officers' addresses, and its fixtures don't publish them either. Companies and corporate officers are kept as recorded.

Layout

src/companies_house_mcp/
  server.py          tools, descriptions, argument schemas, entry point
  client.py          HTTP: auth, retries, status codes to errors, the empty-list check
  ratelimit.py       sliding-window limiter that also obeys the server's headers
  models.py          what the tools return (the output schemas)
  shaping.py         raw API JSON to those models; total, never throws on odd records
  company_number.py  normalisation and validation
  errors.py          every expected failure, with its model-facing message
  enumerations.py    Companies House lookup tables (data/enumerations.json)
scripts/             refresh the lookup tables; record live fixtures
tests/               fake API, tool tests over MCP, stdio test, surface contract

Notes

  • The register holds what companies filed. It is authoritative for what was filed and when, not proof that the contents are true. The server's instructions tell the model as much.

  • Register data comes from Companies House and is subject to their terms. The vendored lookup tables come from companieshouse/api-enumerations.

Licence

MIT. See LICENSE.

Available Tools

7 tools
get_company_profileA
Read-onlyIdempotent

Get a company's register entry: name, status, type, incorporation date, registered office, business activities (SIC codes), previous names, and filing deadlines.

Read warnings first: it lists overdue accounts or confirmation statements, insolvency history, proposals to strike off, and a disputed or undeliverable registered office. has_charges says whether list_charges will find anything. One upstream request.

Inputs: company_number (required). Returns: name, type, status, accounts, +12 more

get_registered_office_historyA
Read-onlyIdempotent

Get a company's current registered office and every recorded change to it, newest first, with old and new addresses where the register has them.

Frequent moves, or a move shortly before insolvency, are worth pointing out. Two upstream requests.

Inputs: company_number (required). Returns: changes, complete, company_number, current_address

list_chargesA
Read-onlyIdempotent

List charges (secured lending, such as mortgages and debentures) registered against a company: who holds them, when created, whether satisfied, and whether a floating charge covers everything the company owns.

A company with no charges returns an empty list, not an error.

Inputs: company_number (required), limit, start_index, outstanding_only. Returns: total, charges, satisfied, outstanding, +2 more

list_filingsA
Read-onlyIdempotent

List what a company has filed at Companies House, newest first: accounts, confirmation statements, officer changes, charges, resolutions and so on, each with its form code and a readable description.

Use category rather than paging through everything. Document contents are not returned, only whether a copy exists.

Inputs: company_number (required), limit, category, start_index. Returns: total, filings, category, company_number, +1 more

list_officersA
Read-onlyIdempotent

List a company's directors, secretaries and LLP members, with appointment dates, occupation, nationality and month and year of birth.

active_count and resigned_count cover the whole company even when one page does not. Addresses are deliberately left out.

Inputs: company_number (required), limit, start_index, include_resigned. Returns: officers, active_count, company_number, resigned_count, +2 more

list_persons_with_significant_controlA
Read-onlyIdempotent

List who owns or controls a company (its persons with significant control): the people and companies holding over 25% of shares or votes, or the right to appoint the board, and how.

A corporate owner comes with its registration number; if it is a UK company, pass that to get_company_profile to follow the chain upwards. An empty list can mean the company filed a statement that it has no PSC, which this tool does not read.

Inputs: company_number (required), limit, start_index, include_ceased. Returns: people, active_count, ceased_count, ceased_omitted, +2 more

search_companiesA
Read-onlyIdempotent

Find companies on the UK register by name, returning their company numbers.

Covers live and dissolved companies alike, ranked by relevance; check status before assuming a hit is trading. Use the returned company_number with every other tool. It searches company names only, so it cannot find a company from a director's name.

Inputs: query (required), limit, start_index. Returns: query, results, total_results, next_start_index

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.3/5.0

    Scored across 7 tools

    Disambiguation5/5

    Each tool addresses a distinct Companies House resource: companies, profiles, filings, officers, charges, registered office history, and PSC. There is no meaningful overlap or ambiguity between them.

    Naming Consistency5/5

    Tool names follow a clear verb_noun pattern: list_* for collections and get_* for singular resources, with search_companies as the only search exception, which is still a common and predictable convention. Naming is uniform in style and case.

    Tool Count5/5

    Seven tools is well-scoped for the Companies House domain, covering the major public register endpoints without unnecessary bloat or fragmentation. Each tool corresponds to a meaningful API operation and earns its place.

    Completeness4/5

    The core Companies House surface is covered: search, company profile, filings, officers, charges, registered office history, and PSC. Minor gaps remain, such as not providing filing document contents or PSC statement details, but these do not significantly impair typical workflows.

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      A
      quality
      D
      maintenance
      Enables AI assistants to search and retrieve UK Companies House data including company profiles, officers, and filing history via the official API.
      4
      196 npm
      1
      MIT
    • F
      license
      B
      quality
      D
      maintenance
      Provides access to UK Companies House public data, enabling search and retrieval of company profiles, officers, filing history, and more through natural language queries.
      12
      -
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables querying UK company data including search, profiles, officers, filings, and persons with significant control via Companies House API.
      196 npm
      MIT