Skip to main content
Glama
README.md
# xule-mcp

An MCP server that helps write code in the **XBRL Query and Rules Language 1.0**
— the W3C-style Public Working Draft spec at
https://www.xbrl.org/Specification/xbrl-rules-3.0/PWD-2024-06-11/xbrl-rules-3.0-PWD-2024-06-11.html
— which formalizes XBRL US's **XULE** language (see the XULE 1.2.2 spec:
https://xbrl.us/wp-content/uploads/2022/11/XULEv1.2.2.pdf).

It does **not** execute XULE against real XBRL data (that requires a full processor,
e.g. Arelle's `xule` plugin) — it's a reference and pre-flight linting layer: it helps an
LLM (or you) write syntactically sound rules by exposing the object model, function
catalog, filter syntax, and boilerplate snippets as tools, plus a heuristic linter that
catches the mistakes that don't need a real taxonomy/instance loaded to catch.

## Tools

| Tool | What it does |
|---|---|
| `xule_overview` | High-level explanation of the language, query sets, and query blocks |
| `xule_search_reference` | Full-text search across keywords/objects/functions/filters |
| `xule_get_object` / `xule_list_objects` | Query Data Model objects (concept, fact, cube, taxonomy, network, ...) and their properties/functions |
| `xule_get_function` / `xule_list_functions` | Built-in property/function catalog (numeric, string, date, set, dictionary, etc.) |
| `xule_fact_query_filters` | `@Concept[...]` filter syntax reference (period, unit, entity, dimension, cube, where, covered-dims, ...) |
| `xule_query_set_structure` | Namespace/constant/function declarations + required/optional parts of an `assert`/`output` block |
| `xule_snippet` | Ready-to-adapt boilerplate (assert rule, output query, for-loop, if-else, custom function, cube navigation, ...) |
| `xule_lint` | Heuristic checker: unbalanced brackets/quotes/comments, missing `message` on `assert`, bad `severity` literals, malformed `namespace`/`constant` lines, dangling `{$tag}` message references, unmatched `if`/`else` |
| `xule_operators_and_literals` | Comparison/boolean/numeric/set operators (with precedence) and literal types |
| `xule_dqc_conventions` | Real-world production XULE conventions from XBRL US's DQC rules, including places where production keywords diverge from the XBRL International spec (e.g. `rule-focus` vs `query-focus`) |
| `xule_dqc_snippet` | Ready-to-adapt DQC rule shapes (full rule skeleton, custom-function doc convention, `navigate`/`filter` `returns` forms, `skip`-based set building) |

`xule_lint` is intentionally **not** a full grammar parser — the spec's EBNF (§12.3) isn't
fully reproduced here, so it can miss deeper semantic issues and, rarely, flag valid code
it doesn't recognize. Treat it as a fast first pass before running a real processor.

## Setup (Claude Desktop, local/stdio)

```bash
cd xule-mcp
npm install
```

Add to your Claude Desktop MCP config (the same place your other local servers, like the
filing-fee MCP, are configured):

```json
{
  "mcpServers": {
    "xule": {
      "command": "node",
      "args": ["/absolute/path/to/xule-mcp/src/index.js"]
    }
  }
}
```

Restart Claude Desktop and the `xule_*` tools will be available.

## Running in GitHub Codespaces

This repo includes a [`.devcontainer/devcontainer.json`](.devcontainer/devcontainer.json),
so opening it as a Codespace (**Code → Codespaces → Create codespace on main**) gives you a
ready-to-go Node.js environment with `npm install` already run via `postCreateCommand`.

### Option A: stdio (spawn the process directly)

- **Claude Code CLI**: `claude mcp add xule -- node src/index.js` (run from the repo root
  inside the Codespace), then start `claude` as usual.
- **Any other stdio MCP client**: point it at `node /workspaces/xule-mcp/src/index.js`.
- Sanity-check without a client: `npm start` — it sits waiting for MCP JSON-RPC messages on
  stdin, `Ctrl+C` to exit.

### Option B: HTTP, with an auto-started server + ready-to-paste connector URL

The devcontainer's `postStartCommand` runs
[`.devcontainer/start-server.sh`](.devcontainer/start-server.sh) automatically every time the
Codespace (re)starts, and [`.vscode/tasks.json`](.vscode/tasks.json) reruns it in a visible
terminal every time the editor attaches. The script:

1. Looks for an `MCP_AUTH_TOKEN` value — if you've added a
   [Codespaces secret](https://github.com/settings/codespaces) by that name scoped to this
   repo, GitHub injects it as this env var automatically and the script picks it up with no
   extra step.
2. Otherwise, if it's running in a real terminal, prompts you to paste a token (20s timeout).
3. Otherwise (or if you just hit Enter), generates a random one for the session.
4. Starts the server, waits for it to come up, and prints a ready-to-paste connector URL and
   a `claude mcp add` command for header-based clients.
5. On rerun, detects the server's already up and just reprints the same URL instead of
   starting a second instance.

Port 3000 is declared `"visibility": "public"` in `devcontainer.json`'s `portsAttributes`, so
it's reachable without a GitHub-authenticated session (required for Claude's Connectors
backend to reach it at all) — this applies automatically to a fresh Codespace, or after a
**Rebuild Container** on an existing one. The script also makes a best-effort attempt to set
this via `gh codespace ports visibility`, but that call needs Codespaces-management OAuth
scope the auto-injected token doesn't have, so expect it to report failure and fall back to
the declarative setting — if the port still isn't public, set it manually in the **Ports**
tab (right-click → Port Visibility → Public).

The script prints a plain `/mcp` URL plus the token: paste the URL into Claude's Desktop/web
Connectors UI, which will drive you through the server's own OAuth `/authorize` page (see
**Authentication** below) — enter the token there when prompted. The `/mcp/<token>` path
fallback is also printed as a no-OAuth alternative if you'd rather skip that.

## Running as a server (Docker, Streamable HTTP)

For a shared/remote deployment, the server can also speak MCP over **Streamable HTTP**
instead of stdio, so any number of clients can connect over the network to one running
process rather than each spawning their own.

```bash
docker build -t xule-mcp .
docker run -d --name xule-mcp -p 3000:3000 \
  -e MCP_AUTH_TOKEN=your-long-random-token \
  -e MCP_ALLOWED_HOSTS=your.server.hostname \
  xule-mcp
```

Or with Compose (edit the env values in [`docker-compose.yml`](docker-compose.yml) first):

```bash
docker compose up -d --build
```

Endpoints:

- `POST /mcp` — the MCP JSON-RPC endpoint (stateless: no session ID, each request is
  independent). Point your MCP client's "url" config at `http://<host>:3000/mcp`. Always
  requires a bearer token — see **Authentication** below for the three ways to get one.
- `POST /mcp/<token>` — same endpoint, with the static `MCP_AUTH_TOKEN` in the path instead
  of a header. Only registered when `MCP_AUTH_TOKEN` is set. Exists for clients that can do
  neither OAuth nor custom headers — weaker than a header since path segments can end up in
  proxy/tunnel logs, but reasonable here since this server has no side-effecting tools and no
  sensitive data.
- `GET/POST /authorize`, `POST /token`, `POST /register`, `POST /revoke`,
  `/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource/mcp` —
  the OAuth 2.1 authorization server (see **Authentication** below).
- `GET /healthz` — plain liveness check for container orchestration, unauthenticated.

### Authentication

`/mcp` always requires a valid bearer token now, obtained one of three ways:

1. **OAuth** (for Claude's Desktop/web Connectors UI, which drives a full OAuth
   registration + authorization-code handshake as its default connection mechanism
   regardless of what the resource endpoint itself accepts): Settings → Connectors → Add
   custom connector → paste the plain `https://<host>/mcp` URL. Claude will open the
   server's `/authorize` page in your browser; if `MCP_AUTH_TOKEN` is set, you'll be asked
   to enter it once as a one-field approval gate before a code is issued. See
   [`src/oauth.js`](src/oauth.js) — this is a deliberately minimal, **single-user** OAuth
   provider (in-memory, auto-approves if no `MCP_AUTH_TOKEN` is set, no real login system).
   It's an appropriate match for this server's tool surface (read-only reference data, no
   side effects) but isn't meant to be reused as-is for a multi-tenant service.
2. **`Authorization: Bearer <MCP_AUTH_TOKEN>` header** — for clients that support custom
   headers (e.g. Claude Code CLI) and don't need the OAuth round-trip at all.
3. **`/mcp/<MCP_AUTH_TOKEN>` path fallback** — see above.

`MCP_PUBLIC_URL` (below) must match the URL clients actually use to reach the server, since
OAuth's issuer identity and redirect validation depend on it.

Environment variables:

| Var | Default | Purpose |
|---|---|---|
| `MCP_TRANSPORT` | `http` (set in the image) | `http` for Streamable HTTP, anything else falls back to stdio. |
| `PORT` | `3000` | Port to listen on. |
| `HOST` | `0.0.0.0` | Bind address. |
| `MCP_PUBLIC_URL` | auto-detected (Codespaces) or `http://localhost:<PORT>` | The externally-reachable base URL clients use to reach this server. **Set this explicitly for any real deployment** (a plain Docker host, a VPS, etc.) — OAuth's issuer/resource identity and redirect-URI validation depend on it matching reality. Must be `https://` unless the hostname is literally `localhost`/`127.0.0.1`. |
| `MCP_AUTH_TOKEN` | unset | Gates the OAuth `/authorize` approval step, and doubles as a static bearer token / path-token fallback. **Set this for any deployment reachable outside a fully trusted network** — without it, OAuth auto-approves any client with no consent gate at all (still a trivial extra round-trip vs. no auth, but not a real barrier). |
| `MCP_ALLOWED_HOSTS` | unset | Comma-separated Host-header allowlist (DNS-rebinding protection). Set to the hostname(s) clients actually use to reach the server; if unset, the server logs a startup warning. |

Run locally without Docker the same way via `npm run start:http` (defaults to stdio
otherwise — pass `--http` or set `MCP_TRANSPORT=http` to switch).

## Notes / limitations

- This is a **Public Working Draft**, not a final Recommendation — syntax details in the
  upstream spec may still change before it's finalized.
- The reference data was compiled directly from the spec's object-model, query-block, and
  function-catalog sections, cross-checked against known XULE 1.2.2 syntax where the two
  overlap (they're very close but not guaranteed identical in every corner case — XULE is
  the vendor implementation this spec formalizes).
- `xule_lint` works on raw text; it doesn't resolve taxonomies or validate that a concept
  QName actually exists, that a property is valid on the inferred object type, etc. — that
  needs a real processor (e.g. Arelle + the xule plugin).
- `xule_dqc_conventions` / `xule_dqc_snippet` are sampled from a handful of real rules in
  the XBRL US Data Quality Committee's
  [dqc_us_rules](https://github.com/DataQualityCommittee/dqc_us_rules) repository
  (`source/us/2026`) — a real-world-usage sample layered on top of the spec reference,
  not an exhaustive survey. They exist because production XULE sometimes uses different
  keywords than the XBRL International Public Working Draft (e.g. `rule-focus`/`rule-suffix` instead of
  `query-focus`/`query-suffix`) — code written to the spec's terms may not run on a real
  processor.