Model Context Protocol · Streamable HTTP
The LexLint MCP server
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:
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.
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.
codex mcp add lexlint --url https://mcp.lexlint.org/mcp
Register the server with the CLI. Codex writes the entry into
~/.codex/config.toml for you.
Reading this from the web app at
https://chatgpt.com/codex? That is the one Codex
surface with nowhere to put this: it exposes no server configuration at all, so there
is no version of these steps that can be done there. Set LexLint up in the CLI, the IDE
extension, or the desktop app. Those three share the same
~/.codex/config.toml, so doing it once in any of them configures all three.
That gets the transport, not the key: mcp add has no flag for a custom
header, and --bearer-token-env-var sends an Authorization
header, which is not what this server reads. So add two header lines by hand, to the
entry the command just wrote. Name the environment variable rather than pasting the
key, and it stays out of the file:
The second value is the variable's name, not its contents. Codex reads
UNGOVR_API_KEY out of the environment at connect time and sends it as the
header. The first line is what lets the server answer before you have a key: with the
variable unset Codex sends no header at all, and this server reads no header as a
sign-in it cannot offer, so no tools appear. An empty header is read as no key, the
tools appear, and the variable's value replaces it the moment it is set. Either way
codex mcp get lexlint reads the
entry back, and an older build without mcp add --url takes the block above
written by hand.
The tools are the whole interface here, so the order of use is on you: call
check_access first, and declare the app profile rather than letting the
agent infer one from the code. The same procedure the Claude Code skill follows ships
as an AGENTS.md in the LexLint plugin bundle, for any agent that reads
one. The worked run shows the whole loop
end to end.
https://mcp.lexlint.org/mcp
Any client that speaks streamable HTTP works: the server is stateless and holds no session of its own, so nothing has to be kept between calls. The block above is the common core, and Cursor takes it exactly as written.
It is a core rather than a universal config because two fields move between clients, and between them they account for nearly every failed setup: what the URL field is called, and what the transport is called. Neither is guessable and a client that dislikes your answer rarely says so. Find your client in the table before you paste.
| Client | Root key | URL field | Transport |
|---|---|---|---|
| Cursor | mcpServers | url | omit it |
| Zed | context_servers | url | omit it |
| Antigravity | mcpServers | serverUrl | omit it |
| Devin Desktop | mcpServers | serverUrl | omit it |
| Copilot in VS Code | servers | url | http |
| Tabnine | mcpServers | url | http, or omit |
| Cline | mcpServers | url | streamableHttp |
| Kiro | mcpServers | url | streamable-http |
| IBM Bob | mcpServers | url | streamable-http |
| Kilo Code, the extension | mcpServers | url | streamable-http |
| Kilo Code, the CLI | mcp | url | remote |
| Continue | mcpServers, a YAML list | url | streamable-http |
| Codex | mcp_servers, a TOML table | url | see the Codex tab |
Two of those are worth saying out loud, because they fail silently rather than
loudly. VS Code reads servers, so a file that says
mcpServers parses and registers nothing. What you see is a client with
no LexLint tools, and nothing anywhere naming the key. Antigravity and Devin
Desktop want serverUrl and reject a plain url, which is the
same failure wearing a different field name. In both cases the natural conclusion is
that this server is down, and it is not.
The transport is the other half, and the spellings are not interchangeable:
streamableHttp is camel-cased for Cline, streamable-http is
hyphenated for Kiro, Kilo and Continue, and http is VS Code's alone.
Give Cline the hyphenated form and it falls back to the older SSE transport and
answers 405. Where the table says to omit it, omit it: those clients infer the
transport from the presence of a URL, and an unexpected value is one more thing that
can be rejected.
Then add your key
Nothing above carries a key, which is deliberate: check_access answers
without one, reporting key_present: false beside a sign-in link and
the steps, so you can prove the connection before you have anything to paste.
claim_trial_key answers keyless too, and mints one. The
other six refuse until you have a key, carrying the same link in the refusal.
When you do have one it goes in a headers object, which every client in
the table accepts under that name. Only X-API-Key is read, so an
Authorization header is ignored however your client likes to send one.
Mind the comma the URL line gains:
That is a second edit to the same file, and on this tab nothing will make it for
you. Claude Code has /lexlint-key to write the key where the next
session will read it, and Codex has its own documented step; a client outside those
two has neither.
${UNGOVR_API_KEY} is the plain-environment form, and expansion is the
client's job. Three of the clients above do it their own way, and a placeholder a
client does not expand is sent to us as literal text, which reads back as a rejected
key rather than as a config error. Cursor wants
${env:UNGOVR_API_KEY}. Continue templates secrets as
${{ secrets.UNGOVR_API_KEY }}. VS Code expands neither, and wants an
input declared and referenced, which is the better shape anyway because VS Code
prompts once and stores the value itself rather than leaving it in a file you might
commit:
Continue is the one whose whole shape differs: a list rather than an object, and
headers under requestOptions rather than at the top level.
Anywhere your client expands nothing, paste the key literally and treat the file as the secret it then contains.
If your client only speaks stdio
Some clients, and some older builds of clients that have since added HTTP, will only
launch a local process. mcp-remote bridges the two: it speaks stdio to
your client and streamable HTTP to this server, and it needs nothing installed
beforehand.
There is no space after that colon, and putting one back breaks it on several
clients: Cursor, the Codex CLI and Claude Desktop on Windows do not escape spaces
inside args when they invoke npx, so the value arrives
cut in half. Note also that anything written into args literally is
readable in the process list by anyone else on the machine, which is a second
reason to leave the key in the environment and let
${UNGOVR_API_KEY} stand.
On a managed or on-premises client
IBM Bob, Tabnine, Kiro and Copilot on GitHub Enterprise Server each read a file of
your own, and all four are in the table above: Bob takes
~/.bob/mcp.json, or .bob/mcp.json committed with the
project, and Tabnine takes ~/.tabnine/agent/settings.json. What
changes on their governed deployments is who is allowed to write one. An
administrator registers servers centrally and user-level additions may be refused,
and that registration usually needs two things past the URL.
The first is reachability. mcp.lexlint.org is the only
host LexLint asks for, so on an egress-filtered network it is one allowlist entry
and no more, and on one that routes through an approved proxy it is one rule there.
A genuinely air-gapped install is the exception and the answer is plain: LexLint is
a hosted service and there is no offline build of it, so it cannot run in a network
with no route out. Several of the clients above are chosen precisely for air-gapped
work, and on those deployments this is not something an allowlist fixes.
The second is the key. Admin consoles rarely expand environment variables, so it goes in as a literal and the registration becomes a secret to be handled as one. A key minted for the team rather than for a person is the usual answer, since the upstream meter counts against whoever the key belongs to.
The worked run shows what a full lint looks like from the agent's side, whichever of these got you connected.
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.
Once it is connected
The tools answer a browser and an agent identically, so you can run one by hand to see what your agent will get back.
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.
| Argument | Type | Meaning |
|---|---|---|
activities | string[] | 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 |
jurisdictions | string[] | 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
| Severity | Meaning |
|---|---|
| WARN | Something 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). |
| INFO | Context, 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. |
| Kind | Meaning |
|---|---|
obligation | A specific instrument binds the declared profile now. |
coverage | A 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. |
posture | How 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. |
pending | An 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.