mcp.lexlint.org beta the LexLint MCP server, and the docs for it lexlint.org →

Model Context Protocol · Streamable HTTP

The LexLint MCP server

The session this install produces, ending in this server's live tool list
your terminal ⇆ mcp.lexlint.org

Lints what your app does against AI, scraping, privacy, cybersecurity, age-gating, and news-aggregation law, with citations. One command on Claude Code, or eight tools directly over MCP for any other agent. Pick your client below. This page is the same server, answering your browser.

Run it before you install anything

The first run needs no install, no account and no key. It happens inside a session you already have open: paste this line into it.

run https://lexlint.org/first-run

Your agent fetches the procedure, works out what your app does by reading the repository, takes a 30-day trial key with no sign-in, and reports what the lint found. It closes by uploading the run: you see the payload first, and the reply carries a link that opens the full run with no sign-in. What goes is the lint's own output, the two lists you declared and the repository's name; none of your code, schema or data is sent, and nothing in your working tree changes.

It runs in a session you drive, which is the point: your client asks before each command, and you see every endpoint it touches. There is deliberately no one-line unattended version. The procedure arrives over the network, and a command that pre-approves file access and outbound requests before anyone has read it would give that fetched text more trust than a first run has earned.

claude plugin marketplace add ungovr/lexlint && claude plugin install lexlint@lexlint

Install the plugin. It carries the whole client: the lexlint skill that runs the loop, the /lexlint, /lexlint-key and /lexlint-feedback commands, and this server already configured. The bundle holds no executables: one skill, three commands, and the schema, all of them readable before you run anything.

Restart your session afterwards. Plugins load at process start, and a /clear is not a restart. Then run /lexlint in any repository.

No key yet? Run /lexlint-key: it hands you a sign-in link for a free one, takes the key you paste back, and puts it where your next session will read it: on Claude Code that is the env block of your user settings file, chmod 600, and never a file that gets committed. The plugin reads the value from UNGOVR_API_KEY at process start rather than holding a copy of its own, so the key is never written into the server registration here, and a key exported into an already-running session is read by nothing.

On Claude Code for the web, install from the repository

A cloud session at https://claude.ai/code has no terminal to type the command into, and /plugin is one of the commands that only runs in the terminal. So the repository carries the install instead. Commit this as .claude/settings.json and every cloud session on that repository starts with LexLint already installed:

{ "extraKnownMarketplaces": { "lexlint": { "source": { "source": "github", "repo": "ungovr/lexlint" } } }, "enabledPlugins": { "lexlint@lexlint": true } }

Then open the cloud environment's network access and add mcp.lexlint.org to its allowed domains. This is the step to get right, because skipping it fails late rather than loudly: the default access tier reaches GitHub, which is why the marketplace fetch above succeeds, but it does not reach us, so the plugin installs cleanly and then every tool call cannot connect. A custom allowlist with this one host added is enough; opening the environment to any domain is not necessary.

Set UNGOVR_API_KEY as an environment variable on that same cloud environment. Each session copies those values into ordinary environment variables at startup, which is where the plugin's server configuration reads the key from. If it is missing, nothing breaks silently: check_access still answers and reports the key as absent, and every other tool refuses with the sign-in link and the steps attached to the refusal.

Committing a project .mcp.json works too, and registers the server on its own without the skill or the commands. Worth knowing that a cloud session cannot show the trust prompt a terminal session shows for project servers, so it loads them without asking. The same three settings apply to a session started from the desktop app or an IDE extension, which otherwise behave like the CLI.

Or the eight tools on their own, without the plugin

This registers the server and nothing else: no skill, no commands, and no lexlint.yml loop. Keep the single quotes: they leave ${UNGOVR_API_KEY:-} for Claude Code to fill in each time it connects, so the key is read from your environment and never written into the config. With no key yet the header goes out empty, which is enough to connect, and check_access then names the ways to get one.

claude mcp add --transport http -s user lexlint https://mcp.lexlint.org/mcp \ --header 'X-API-Key: ${UNGOVR_API_KEY:-}'

The header is the part that matters. Registered with no X-API-Key header at all, the server answers with a sign-in challenge, and sign-in is not open to new clients yet, so /mcp shows the server as needing authentication and lists no tools. Remove that entry and add it again with the header.

Every tool runs on your own UnGovr Open Data key, which is free. LexLint holds no keys: yours is passed through to the data API on your behalf. check_access answers without a key, so you can test the connection before you have one, and claim_trial_key answers without one because it mints one; the other six refuse until you do, and say where to get one.

LexLint stores nothing unless you explicitly ask it to: your key is passed through to the UnGovr Open Data API on every call, and the upstream free tier (currently 50 requests per day) is the only meter. The one exception is upload_lint_run, covered under Tool reference below: a run reaches the portal only when you upload it yourself from the CLI, after LexLint has shown you exactly what it contains and you have said yes, and it is then kept against your UnGovr account rather than against this stateless worker. Rate-limit and authentication errors come back from upstream unchanged. The lint itself is deterministic and runs in one place, this stateless worker. Behind it, three things happen on a schedule: the legal news wire is swept every day across every jurisdiction in the corpus, and a story that outruns what we hold opens a research request; the corpus is rebuilt and republished every day; and every law carries the date it was last checked against its sources, which check_access reports as a range rather than a single flattering figure. Research itself is done by people, in waves, so a law last checked four months ago says so.

Tool reference

Eight tools. Five of them read; submit_feedback, upload_lint_run and claim_trial_key are the three that write.

check_access

The preflight, and the first call to make. It reports whether a key reached LexLint, whether it is valid, how much of today's free allowance is left and when it resets, and how current the corpus is. Optional argument: jurisdictions, a list of the slugs you intend to declare, which comes back with what the corpus holds for each.

Call it first because a missing key otherwise surfaces several steps later, as a rejected tool call in the middle of a lint rather than as one line before anything was asked. It costs one upstream request, always, whether or not jurisdictions is set: the coverage preview that argument returns is read from the same fetch the preflight already makes, not a second one.

It also answers which models LexLint's procedure is exercised against, in tested_model_families and model_notice. Your agent compares its own model against that list and tells you if it is not on it, so you can switch before spending a run or carry on knowing. The comparison happens in your session: check_access has no argument that carries a model, and LexLint is never told which one you are running. It is an advisory rather than a gate, and the lint is the same lint either way.

claim_trial_key

The no-account route to a key, from inside the session. It mints a 30-day trial key good for 50 requests over its whole life and returns it in the result, with no account, no sign-in and no email. Optional argument: client_version, which is how the answer can tell you a newer plugin exists.

It is the other tool that answers without a key, beside check_access, and the only one of the pair that writes anything. Your agent runs it after you pick the no-account route over signing in, never on its own, since check_access reports both routes and you choose. It is not idempotent, so every successful call mints another key and spends one of the five an address may take each day, and it refuses outright when the session already holds a key or a sign-in, because a trial is for a developer who has neither.

A key returned into a session is recorded in that session's transcript, which the result says out loud. An account key is the one without an expiry or a lifetime total, so the trial is a taste of the full run rather than a replacement for one. Persist what comes back as UNGOVR_API_KEY and start a new session: the value is read at process start, so exporting it into a running session does nothing.

set_profile

Step 1 of the lint. Declare what the app does and where it operates; get back the validated, canonical profile plus what the corpus holds for each jurisdiction, so you know the depth of a run before spending it. Write the returned profile into the project's lexlint.yml and pass it to run_lint. Arguments are the same two run_lint takes, below.

It stores nothing. This server has no session, and splitting the lint in two did not give it one: the profile lives in your repository, not here. An unknown activity or a malformed slug is refused at this step rather than quietly weakening a lint later, and a malformed slug is refused outright, by name, before any request is spent. A profile that lints clean over fewer places than you declared is worse than an error, so this step will not hand you one.

It costs one upstream request for any number of jurisdictions. That is a separate request from check_access's own, but it answers the same coverage question, so asking both gets you that answer twice for the two calls a normal run already makes, not for a third one. If you are calling check_access a second time later, for example to revalidate a local cache, ask it or set_profile for coverage, never both, since a second check_access call really is a second request.

run_lint

Step 2, and the one that returns findings. Pass the activities and jurisdictions set_profile validated.

It costs three upstream requests for any number of declared jurisdictions: the corpus manifest and its two published NDJSON files, filtered to your declaration in memory once all three have arrived, rather than fetched one jurisdiction at a time. On a key whose corpus has not moved since an earlier lint, the manifest request revalidates as an unmetered 304 and the other two are served from that key's own cache, so a repeat lint against an unchanged corpus can cost as few as zero metered requests.

Put together, one full lint from a cold corpus, check_access, set_profile, run_lint, costs five upstream requests, however many jurisdictions you declare: at the free tier's 50 requests a day, that is ten full lints a day even before a warm corpus makes some of them cheaper.

ArgumentTypeMeaning
activitiesstring[]What the app does. Allowed values: crawls_web, trains_models, generates_content, deploys_chatbot, processes_voice, processes_biometrics, automated_outreach, high_risk_decisions, publishes_adult_content, operates_social_platform, serves_minors, operates_app_store, ships_mobile_app, aggregates_content, distributes_software_product, and five roles a sector-gated statute reaches the project through: handles_health_records, provides_financial_services, operates_essential_service, is_listed_company, provides_telecom_services
jurisdictionsstring[]Jurisdiction slugs, lowercase: us, us/ca, eu, de, kr, and so on

Not sure which slug to use? Search by name at https://ungovr.org/atlas and read the slug off the entity's URL, or translate between a URN, a slug, and other government identifiers at https://ungovr.org/urn.

Each finding carries severity, jurisdiction, summary, citation, as_of_date, stale, and confidence. Where LexLint holds a page on the instrument, the finding also carries note_url: show the citation as a link to it. It is null when there is no such page, and it is never something to construct, since the address is a stored code rather than a form of the citation. A finding about a specific instrument also carries lifecycle: what its status and dates mean read as of now, as one of new, recent, in_force, imminent, proposed, blocked or unknown, with a detail line such as in force in 47 days. It is computed per call, not stored, so a countdown is correct on the day you read it. An instrument that has been repealed, superseded or withdrawn carries no band, because it is not reported at all: it binds nobody, and there is nothing for your code to do about it. Unknown argument names and unknown activity values are refused with an error rather than silently ignored, so a typo cannot produce a falsely clean run.

get_law

One jurisdiction's law, across all six topics LexLint covers: AI law, scraping and access legality, personal-data and biometric privacy, product-security and cyber-resilience law, age-gating and age verification, and news-aggregation law. Returns the instruments (citation, status, behavior categories, as_of_date) together with the jurisdiction's crawl posture: an overall crawl-posture reading, a per-scenario access matrix, robots.txt legal weight, and TDM opt-out status. Argument: jurisdiction (slug). Resolution walks to the most specific jurisdiction with data and reports the walk.

It is one call for all six questions, because it is one payload. It replaced get_ai_law and get_scraping_law, which returned that same payload under two names and left privacy with no name at all. Those two names are gone, not deprecated: a configuration still naming either gets an unknown-tool error, and the fix is to name get_law.

resolve_domain_jurisdiction

Maps a hostname or URL to the operator's home jurisdiction, one input signal for the other tools, for feeding into the other tools. Argument: domain. IP addresses are refused: a server's location is not the legal signal, the operator's establishment is.

It costs one to four upstream requests, not a fixed number: it tries the exact host, then up to three parent domains, stopping at the first match, and a fall-back to top-level-domain inference costs nothing further. It is not part of the five-request lint total above; call it only to derive a jurisdiction rather than declaring one.

submit_feedback

The one tool that writes. It sends your feedback on LexLint to the people who build it, recorded against your UnGovr account so we can write back. Arguments: comments (required), usage_summary, client, plugin_version.

An agent should call it only when you ask it to, and send only what you have explicitly approved. It should draft the usage summary from what it actually did, show you the exact payload, and send that and nothing else. It should never read your session transcript to build one: if you have pasted a key into a session, it is recorded there. Running it is your decision every time. One other thing is sent automatically and it is the whole of the rest of the list: which plugin version you are running, forwarded with the check_access preflight and stored on its own, in a place that holds nothing but version numbers. It tells us whether anyone is still on a bundle old enough that retiring a tool name would break them. A version string and nothing else, with no record of what you linted. Nothing else about your sessions is collected automatically by LexLint. Your key is an UnGovr Open Data key, and that API keeps a request log of its own, set out at https://www.ungovr.org/open-data/api-keys and holding which collection was asked for and a one-way hash of the key, never which jurisdiction you looked up.

It is deliberately not runnable from the console. A Run button on a page you are poking at would file real feedback against your account as a side effect of exploring, and the whole point of the tool is that you approved the text first.

upload_lint_run

The other tool that writes. It stores one complete lint run on the LexLint portal, against the UnGovr account your key belongs to, and hands back the portal URL. Argument: payload (required), the versioned upload record the skill builds: the manifest, findings, work items and run envelope, and never the API key, source files, prompts, transcripts or git identity.

An agent should run it only after you have seen the preview of exactly what will leave the repository and said yes explicitly, this session. It should never volunteer it, never run it because the plugin is installed, and never run it in a headless or CI session where nobody can approve anything. Uploads are idempotent: re-sending the same run answers with the same record rather than storing a duplicate, so a retry after a dropped connection is always safe.

It is deliberately not runnable from the console, for the same reason as submit_feedback: the whole point of the tool is that you approved the exact payload first, and a playground exploring it cannot supply that approval.

The severity model

SeverityMeaning
WARNSomething to act on. Three different things arrive this way, and kind tells them apart: a live obligation applies to your declared profile (obligation); a jurisdiction-wide crawl-law attribute LexLint flags as worth acting on, such as an unsettled or restrictive posture (posture); or LexLint lacks current data for a jurisdiction you declared (coverage).
INFOContext, not a live duty on you. Three different things arrive this way, and kind tells them apart: an instrument LexLint cannot say is currently binding (pending); a jurisdiction-wide statement of how the local law treats crawling as a whole (posture), which cites nothing and binds nobody on its own; and a note about what was not reported (coverage), such as instruments that exist but no longer bind.
KindMeaning
obligationA specific instrument binds the declared profile now.
coverageA note about what was not reported, never a pass: LexLint could not read something, holds no data for a declared jurisdiction, cannot map what it holds to a declared activity, or holds an instrument that no longer binds.
postureHow this jurisdiction's law treats crawling as a whole: whether browsewrap binds, what weight robots.txt carries, whether a public page is outside computer-crime law. Jurisdiction-wide attributes rather than instruments, so they bind nobody on their own and cite nothing. Appear only when crawls_web is declared.
pendingAn instrument LexLint cannot say is currently binding: proposed or in committee, enacted with a future effective date, enacted with no commencement date on record, enjoined by a court, or carrying a status LexLint has no policy for. An injunction can be lifted and a missing commencement date does not mean the law never took effect, so treat this as a duty to watch, not one to ignore.

A posture finding whose value is unsettled is a warning, not a pass. "The law here is silent, untested, or in flux" is among the most actionable things a crawler author can be told.

LexLint never reports an ERROR severity. A lint cannot be sure an activity is prohibited rather than merely regulated, and it will not assert unlawfulness on the strength of a matched instrument. Treat a live WARN obligation as the thing to act on.

Three rules bound what the output can claim. A jurisdiction LexLint has no current data for is a WARN, never a silent pass. Every finding carries its as-of date and a staleness flag, because laws change faster than corpora. And the passing state is no basic issues found: the word "compliant" does not appear in LexLint output, because a lint cannot know that.