Temporal search and comparison for official Luxembourg and reviewed EU law, with provenance.
io.github.SFHAJJI/lex — Model Context Protocol (MCP) Server
This MCP server provides temporal search and comparison over official Luxembourg law and reviewed EU law, including provenance. It exposes 10 tools and is published under Apache-2.0. Live results are available via law.soufien.lu, with documentation at law.soufien.lu/developers.
🛠️ Key Features
Temporal search and comparison
Targets official Luxembourg law
Targets reviewed EU law
Includes provenance
MCP server with 10 tools
🚀 Use Cases
Comparing versions of legal sources over time
Searching Luxembourg and EU law with source provenance
Using read-only law data through an MCP integration
⚡ Developer Benefits
10 MCP tools for programmatic access
Topics include legal-tech, regtech, and temporal-data for context
Live law integration via law.soufien.lu
⚠️ Limitations
Read-only tooling is indicated by the “MCP-read-only tools” label
No additional tool behaviors are described in the provided excerpt
The state of one document as it stood on one date. Pure lookup, no ranking. mode=outline lists provisions without text and may be narrowed with anchors; mode=select returns only the named anchors' text; mode=full (default) returns the whole text. Every provision carries its own permalink and hash.
Every publisher state a document has been in: timeline intervals, version keys, and explicit timeline_semantics. Legilux intervals describe applicability; EUR-Lex intervals describe official consolidated wording states, not entry into force.
Compatibility name for publisher states covering a date, computed from timeline intervals and deduplicated by work. Legilux states describe applicability; EUR-Lex states are official consolidated wording states and must not be called entry into force. Every envelope carries timeline_semantics and the result carries a mandatory population disclosure.
Parameters12
date
string
required
ISO date
publisher
string
optional
optional publisher id, e.g. lu-legilux
jurisdiction
string
optional
optional jurisdiction code from index metadata, e.g. LU or EU
document_type
string
optional
backward-compatible source document class filter
source_class
string
optional
optional source document class
hierarchy
string
optional
optional normalized legal hierarchy
act_form
string
optional
optional legal act form
binding_status
string
optional
optional binding status
domain
string
optional
legacy domain filter; current v4 indexes leave it empty
What changed between two dates for one work: which publisher versions cover the selected dates and, where both texts are held, retrieve them via as_of to compare. An optional held article anchor scopes the typed comparison workspace. Read timeline_semantics before describing legal applicability.
optional opaque key returned by timeline or an ambiguous_version choice
to_version_key
string
optional
optional opaque key returned by timeline or an ambiguous_version choice
publisher
string
optional
publisher id; required when work is not publisher-qualified
language
string
optional
language code
anchor
string
optional
optional held provision anchor returned by search, e.g. art_92
Raw schema
{
"type": "object",
"properties": {
"work": {
"type": "string",
"minLength": 1,
"maxLength": 1000,
"description": "Work-level lex_id (publisher:workkey), version-level lex_id (version segment ignored), or verbatim publisher identifier with publisher supplied. Unknown document -> call search first."
},
"from_date": {
"type": "string",
"minLength": 1,
"maxLength": 10,
"pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$",
"description": "ISO date"
},
"to_date": {
"type": "string",
"minLength": 1,
"maxLength": 10,
"pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$",
"description": "ISO date"
},
"from_version_key": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "optional opaque key returned by timeline or an ambiguous_version choice"
},
"to_version_key": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"description": "optional opaque key returned by timeline or an ambiguous_version choice"
},
"publisher": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": "publisher id; required when work is not publisher-qualified"
},
"language": {
"type": "string",
"minLength": 1,
"maxLength": 16,
"description": "language code"
},
"anchor": {
"type": "string",
"minLength": 1,
"maxLength": 512,
"description": "optional held provision anchor returned by search, e.g. art_92"
}
},
"required": [
"work",
"from_date",
"to_date"
],
"additionalProperties": false
}
search
Filtered legal search. keyword is deterministic FTS5/BM25; hybrid adds the pinned local encoder and fixed RRF when verified vectors are mounted. No generative model participates. The response query_plan separates resolved work constraints, article and role intent, and residual provision terms. Returns hits WITHOUT body text; full state via as_of.
Parameters17
query
string
required
search terms
publisher
string
optional
optional publisher id
jurisdiction
string
optional
optional jurisdiction code from index metadata, e.g. LU or EU
document_type
string
optional
backward-compatible document type filter
source_class
string
optional
optional source document class
hierarchy
string
optional
optional normalized legal hierarchy
act_form
string
optional
optional legal act form
binding_status
string
optional
optional binding status
domain
string
optional
legacy domain id filter; current v4 indexes leave it empty; use publisher_metadata_identifier for official classifications
publisher_metadata_identifier
string
optional
optional exact official publisher-metadata URI returned by a search hit
language
string
optional
optional language code
retrieval_mode
string
optional
keyword or hybrid; default keyword until activation
time_scope
string
optional
all_versions or as_of
as_of
string
optional
ISO date required when time_scope=as_of
fuzzy
string
optional
auto or off; visible fallback only
works
string
optional
optional comma-separated work ids: restrict search to these works
Every distinct text ONE provision (article/annex) has had on its publisher timeline, plus lifecycle events (inserted/removed/renumbered, renumbering detected mechanically by identical text hash). Read timeline_semantics before calling an interval legal applicability. Answers "what did Article X say over its life / when did it change".
publisher id; required when work is not publisher-qualified
anchor
string
required
provision anchor, e.g. art_1er (find it via search or as_of mode=outline)
language
string
optional
optional language code; defaults to the work's primary derived language
from_date
string
optional
optional ISO date: keep only the states in force at or after it
to_date
string
optional
optional ISO date: keep only the states that began on or before it
Raw schema
{
"type": "object",
"properties": {
"work": {
"type": "string",
"minLength": 1,
"maxLength": 1000,
"description": "Work-level lex_id (publisher:workkey), version-level lex_id (version segment ignored), or verbatim publisher identifier with publisher supplied. Unknown document -> call search first."
},
"publisher": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": "publisher id; required when work is not publisher-qualified"
},
"anchor": {
"type": "string",
"minLength": 1,
"maxLength": 512,
"description": "provision anchor, e.g. art_1er (find it via search or as_of mode=outline)"
},
"language": {
"type": "string",
"minLength": 1,
"maxLength": 16,
"description": "optional language code; defaults to the work's primary derived language"
},
"from_date": {
"type": "string",
"minLength": 1,
"maxLength": 10,
"pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$",
"description": "optional ISO date: keep only the states in force at or after it"
},
"to_date": {
"type": "string",
"minLength": 1,
"maxLength": 10,
"pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$",
"description": "optional ISO date: keep only the states that began on or before it"
}
},
"required": [
"work",
"anchor"
],
"additionalProperties": false
}
provenance
Proof chain for one lex_id: source URI, retrieval time, record/body hashes, event chain, corpus commit, index build, stamp signature.
Which held provision versions contain captured publisher cross-references to this work. This reverse lookup reports the publisher references Lex captured; it does not classify their relationship type or assess current legal effect.
Parameters2
work
string
required
the law being cited, e.g. lu-legilux:loi-2020-06-04-a476
ACROSS the corpus: which works gained new versions between two dates, how many each, and when — the aggregate counterpart of diff/timeline (which cover ONE work). Use for "what changed between 2025 and 2026", "which laws changed most during the pandemic", "what moved last month". order=by_churn ranks by number of new versions; by_date (default) lists most recently changed first.
Parameters14
from_date
string
required
ISO date, start of window (inclusive)
to_date
string
required
ISO date, end of window (inclusive)
publisher
string
optional
optional publisher id
jurisdiction
string
optional
optional jurisdiction code from index metadata, e.g. LU or EU
document_type
string
optional
optional source document class(es), comma-separated; prefix with ! to exclude, e.g. !RECUEIL,!CODE_RECUEIL for instruments only
source_class
string
optional
optional source document class; alias of document_type
hierarchy
string
optional
optional normalized legal hierarchy
act_form
string
optional
optional legal act form
binding_status
string
optional
optional binding status
domain
string
optional
legacy domain filter; current v4 indexes leave it empty
Point-in-time retrieval of regulatory text. Regulators publish the current
rule; every audit, investigation and dispute is about a past date. Lex keeps
every version it has seen and answers "what did this say on 15 March 2022?"
with the exact validity interval, the timeline, the instrument that changed it,
and a hashed provenance record, and an honest, machine-readable refusal when it
cannot know.
For a client that only accepts local stdio servers, bridge to the same hosted
endpoint with a pinned version of the third-party mcp-remote adapter (Node.js 18+):
The hosted endpoint is canonical: no legal corpus, vector files or Azure
credentials are downloaded to the client. Lex also publishes its remote-server
metadata to the official MCP Registry from GitHub Actions using OIDC. Lex
intentionally does not publish an npm package: the command above is a
compatibility bridge for older clients, not a second implementation.
The following is a historical V2 example. The public assistant is temporarily
unavailable while Lex installs its deterministic V3 answer path; search, direct
law pages and MCP remain available. Historical answer, verbatim:
Q: What did CRR Article 92 require as capital ratios on 1 March 2020, and has that text changed since?
Quoted verbatim (Article 92(1)) from the CRR version in force on that date:
"Subject to Articles 93 and 94, institutions shall at all times satisfy the
following own funds requirements: (a) a Common Equity Tier 1 capital ratio of
4,5 %; (b) a Tier 1 capital ratio of 6 %; (c) a total capital ratio of 8 %."
, eu-eurlex:32013r0575:2019-12-25 (valid 2019-12-25 → 2020-06-26),
permalink.
Article 92 has had seven distinct texts: 2013-06-26 → 2013-06-27,
2013-06-28 → 2019-12-24, 2019-12-25 → 2020-06-26, 2020-06-27 → 2021-06-28,
2021-06-29 → 2022-12-31, 2023-01-01 → 2024-12-31 and 2025-01-01 onward, each
with its own permalink and sha256.
Every claim in that historical answer came from a deterministic tool call (the
trace was shown under each reply); the model did not answer from its own memory.
Do not take this file's word for it, the numbers above are checkable in one call,
and if they ever drift from the live system, that is a bug worth reporting:
A compliance officer checking which text of an obligation was in force on
the date of the facts, with a permalink and a hash for the file.
A legal-tech developer building RAG over law that must not hallucinate
versions: per-article chunks with valid_from/valid_to to filter before
similarity (dataset).
An external AI agent using the public MCP tools directly. The site's own
assistant is unavailable during the V3 answer-path replacement.
A researcher tracking how one article's text evolved across amendments
(article_history: every distinct text state, dated).
What it never does
Lex answers what the rule was. It does not decide "were we compliant?", "does
this apply to me?", or "what does this mean?", those are professional opinions.
The evidence, index and MCP layers never generate or interpret legal text
(fitness rule F10). The reviewed V3 assistant design may later explain retrieved
evidence, but that path is currently contained and is not part of the record.
One corpus repo per publisher; the corpus is human-readable JSON + git. The tree carries the legislative history, git log carries the ingest history, and the two are deliberately not the same (why).
Bitemporal: valid time is the publisher's; transaction time is ours, as
append-only observation chains inside hashed content. Nothing is ever
overwritten; publisher corrections become visible events.
Two layers: verbatim publisher bytes (evidence) → deterministic
per-article extraction (consumption). Every derived article hash-chains to
the exact bytes the state published; lex verify derive re-derives and
byte-compares.
Legal and temporal eligibility before fusion and final ranking, enforced by
a non-optional FilterSet on the only query entry point. Hybrid may use a
bounded binary-vector preselection for speed, but an ineligible candidate can
never enter the fused result set.
Signed whole-artifact manifests (ECDSA P-256): a trust root pinned in the
application verifies indexes, vectors, embedding assets, scope, benchmark
and source commits before any file is mounted. The embedded index stamp
remains public provenance, not its own trust root.
Release-gated assistant behavior: 25 frozen scenarios validate the typed
plan, arguments, outcomes, UI effects, answer and latency against an immutable
zero-traffic candidate. The catalog records author Lex release engineering;
Evaluation reviewer Soufien Hajji uses a separate signing authority. This
project-owner review is not a third-party audit. Candidate and release-grader tokens are budgeted
separately. The current maximum reservation is EUR 0.5362647 under an outer
EUR 10 preflight and measured-use ceiling, not a live billing cutoff. The
release dossier shows the CI/CD flow and
evaluation mechanics.
Honest refusals: no_version_for_date, anchor_not_in_version,
outside_observed_window, text_not_available, text_withheld, a flagged wrong answer is still
a wrong answer, so Lex refuses instead.
Current coverage
Luxembourg (Legilux, Tier A): every work and dated version currently mounted
from the publisher's Consolidation catalogue. Nothing in that collection is
filtered out by legal form. Counts, dates, corpus commit and extraction-profile
mix are read from the index on the
live coverage page, rather than copied into
product prose that becomes stale after the next publisher run.
The consolidation catalogue is not all Luxembourg law. The same official
endpoint exposes 150,187 resources classified as Act, including laws,
grand-ducal regulations, ministerial regulations and orders that may never have
received a consolidation record. That broad number also contains notices and
other material that should not all enter lawyer-facing search. The measured
boundary and the proposed normative-act increment are documented in
Luxembourg scope.
Where official XML exists, text is retained as verbatim Akoma Ntoso. The
deterministic pdf-lu/1 fallback handles eligible born-digital consolidated
PDFs and records that article boundaries came from typography rather than
publisher markup. Narrow pdf-memorial-lu/2 recovery first verifies the requested
act inside an official-gazette issue, then exposes only a strongly identified
section and visibly labels its inferred boundaries in the reader. Thematic
folders, unverified gazette matches and fileless records remain metadata-only;
Lex does not trade provenance for a larger text count. Exact text availability
and extraction-profile mix are reported from the mounted artifact on the
coverage page.
EU (EUR-Lex/Cellar, Tier A): a reviewed Luxembourg-facing scope spanning
financial services, AML, corporate, competition, tax, employment, consumer,
procurement, environmental, judicial-cooperation, intellectual-property, data,
digital, cyber and energy law, plus bounded legal-history relationships. The mounted index and
live coverage page are the source of truth for
work and version counts. Full text comes from the Publications Office's
Formex 4 structural XML where served, including large consolidations the
XHTML channel cannot carry. The present EU limit is scope, not format.
The derived dataset publishes its current counts and source commits in its
release catalog.
The broader Luxembourg original-act catalogue and approved EU scope are tracked
by the temporal expansion program. Communal regulations are deliberately out of scope: 17,232
exist as published acts, none is ever consolidated, so there is no point-in-time
history to hold. The fallback ladder for XML-less versions is spec D49.
Run it
code
LEX_CODE_COMMIT=$(git rev-parse HEAD)
LEX_ARTICLES_COMMIT=$(git -C ../lex-articles rev-parse HEAD)
LEX_LU_CORPUS_COMMIT=$(git -C ../lex-corpus-lu-legilux rev-parse HEAD)
LEX_EU_CORPUS_COMMIT=$(git -C ../lex-corpus-eu-eurlex rev-parse HEAD)
# One exact completed-enumeration identity. Reuse it only when retrying that same run.
LEX_INGEST_RUN_ID=manual-example-001
# ingest (paced, sequential; official open-data channels only)
dotnet run --project src/Lex.Ingest -- ingest --publisher lu-legilux \
--corpus ../lex-corpus-lu-legilux --code-commit "$LEX_CODE_COMMIT" \
--run-id "$LEX_INGEST_RUN_ID"
# derive the per-article layer, build the signed index
dotnet run --project src/Lex.Ingest -- derive --publisher lu-legilux --corpus ../lex-corpus-lu-legilux --out ../lex-articles
dotnet run --project src/Lex.Ingest -- index --corpus ../lex-corpus-lu-legilux --articles ../lex-articles \
--out indexes/index-lu-legilux.db --keyfile signing-key.pem \
--capability-policy deploy/capability-policy.json \
--code-commit "$LEX_CODE_COMMIT" --articles-commit "$LEX_ARTICLES_COMMIT" \
--corpus-commit "$LEX_LU_CORPUS_COMMIT"
# resumable large semantic backfill on a reviewed Windows DirectML adapter
dotnet build src/Lex.Ingest -c Release -p:UseDirectML=true
src/Lex.Ingest/bin/Release/net10.0/Lex.Ingest index \
--corpus ../lex-corpus-eu-eurlex --articles ../lex-articles \
--out indexes/index-eu-eurlex.db --embedding-model model \
--vectors indexes/index-eu-eurlex.vectors \
--embedding-directml-device 1 --embedding-batch-size 256 \
--embedding-max-batch-tokens 32768 \
--embedding-cache build-cache/eu-eurlex-embeddings.db \
--capability-policy deploy/capability-policy.json \
--code-commit "$LEX_CODE_COMMIT" --articles-commit "$LEX_ARTICLES_COMMIT" \
--corpus-commit "$LEX_EU_CORPUS_COMMIT"
# The chunker fixes legal-text boundaries before the GPU groups immutable chunks
# into 32/64/128/256/512-token inference buckets. A fixed padded-token budget reduces
# the item count for long buckets so one reviewed batch size cannot exhaust the GPU.
# Masked padding is never stored.
# web demo + MCP (stdio) locally
LEX_INDEX_DIR=indexes dotnet run --project src/Lex.Web
LEX_INDEX_DIR=indexes dotnet run --project src/Lex.Mcp.Stdio
Lex.Mcp contains the legal tools and official SDK bridge, not a deployment entry point.
The standalone stdio executable is isolated in Lex.Mcp.Stdio; production composes the same
library into Lex.Web for Streamable HTTP. Co-hosting is deliberate while site and MCP traffic
share one immutable index set and one scale/SLA boundary. D67 records the measured triggers for
extracting an independently deployed MCP service rather than adding a second runtime for optics.
The generated key above is for local development only. Production publication
uses GitHub OIDC to ask the non-exportable Azure Key Vault key to sign the
whole-artifact manifest, then deploys a zero-traffic candidate revision.
The same read-only tools cover Luxembourg and EU material. Search spans every
mounted publisher by default and can filter jurisdiction, date, hierarchy,
legal form, binding status, domain and language. Keyword retrieval is
deterministic FTS5/BM25. Hybrid adds the pinned local encoder and fixed rank
fusion when verified vectors are mounted; no generative model participates in
retrieval. A unique segment of an official publisher short title such as RGPD,
GDPR, DORA, or AI Act resolves deterministically; collisions require
clarification. Official publisher subjects, EuroVoc relations, and directory
coordinates support weak discovery but never become legal-text evidence or work
identity. No manually curated legal aliases are loaded. Model-derived weak discovery is
not active, and keyword remains the production default because the signed hybrid
holdout gate has not passed. The reviewed target V3 assistant uses the same tools,
claim-typed composition and a separately gated grounding step. It is not active until
the complete answer-dossier path passes review and promotion. Application code retains
work resolution, tool authorization, citation and gap authority. coverage exists to say what Lex does not have, because a system
that cannot state its own gaps cannot be trusted with a completeness question.
Contributing
Issues and PRs welcome, the highest-leverage areas:
A new publisher adapter (ISourceAdapter, ~200 lines): any jurisdiction
with an official machine-readable channel. The seam is publisher-pure by
fitness test; adapters never touch files or git.
Assistant release cases (evals/assistant-cases-v3.json):
frozen typed-operation judgments, digest-attested by a project-owner reviewer identity distinct
from the catalog author and run with the strict
release evaluator. The gate has no keyword or grader fallback;
cases specify the exact operation contract or refusal boundary expected from a natural question.
Extraction improvements: profiles are immutable; improvements ship as a
new profile beside the old (see fmx4-eu/1 beside xhtml-eu/1).
Contributions are accepted under the Developer Certificate of Origin
(git commit -s).
Licence
Code: Apache-2.0 (LICENSE). The code licence does not extend
to corpus data or index artefacts, see each corpus repository's NOTICE
(three layers: official acts outside copyright / Lex's compilation rights /
code licence inapplicable). Derived dataset: CC-BY-4.0 (LU) and EU
reuse-with-attribution, licence inline in every file.
Support
This is free and open, and it stays that way whatever you decide. It is also not free to run:
the live site, the nightly jobs and the storage sit on Azure infrastructure I pay for out of
pocket, and I maintain it on my own time.
If it saved you an afternoon, you can buy me a coffee ☕
and put it towards the hosting bill. Starring the repo helps just as much, and costs nothing.