Metadata-Version: 2.5
Name: ads-onyx-mcp
Version: 0.3.0
Summary: MCP server for Ads.Onyx — a client-side façade over the platform's REST API
Project-URL: Homepage, https://www.alchimiedatasolutions.com
Project-URL: Repository, https://github.com/AlchimieDataSolutions/ads.onyx.mcp
Project-URL: Issues, https://github.com/AlchimieDataSolutions/ads.onyx.mcp/issues
Author-email: Alchimie Data Solutions <contact@alchimiedatasolutions.com>
License-Expression: LicenseRef-AdsOnyx-Commercial
License-File: LICENSE
Keywords: ads-onyx,data-integration,etl,mcp,model-context-protocol,onyx
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: mcp>=2.0
Requires-Dist: pydantic-settings>=2.2
Requires-Dist: pydantic>=2.7
Requires-Dist: pyyaml>=6.0
Description-Content-Type: text/markdown

# Ads.Onyx MCP Server

An [MCP](https://modelcontextprotocol.io) server that gives an AI assistant — Claude Code, Claude
Desktop, Cursor — working access to an [Ads.Onyx](https://onyx.paris) instance: its pipelines,
workflows, runs, connections, reports, scripts, schedules, secrets keys and project variables.

It reads widely, writes deliberately, and **runs nothing**.

```bash
pip install ads-onyx-mcp
```

> **Licence.** This is proprietary software, free to use for holders of an Ads.Onyx licence from
> Alchimie Data Solutions. Publishing it here makes it easy for customers to install; it does not
> make it free software. See [LICENSE](https://github.com/AlchimieDataSolutions/ads.onyx.mcp/blob/main/LICENSE).

## What it is

A **client-side façade over the Onyx REST API**. No business logic is duplicated here: when a
capability is missing, the Onyx API gets it, which benefits every channel rather than this one.

The security model is one line:

> **One process = one user = one tenant = one Onyx instance.**

Everything follows from that. The server arbitrates no permission because it needs none: it can
do nothing its configured user could not already do in the Onyx web interface. A user without
`Pages.OJobInstances` gets a 403 from Onyx, whether the request came from a browser or from a
language model. There is **no service account**, so there is no confused-deputy problem — the risk
is absent by construction rather than mitigated.

## What it will never do

These are not defaults. They are boundaries, and they hold whatever a conversation asks for.

| Never | Why |
|---|---|
| Run a script, a SQL statement, or a shell command | This is *the* vector that would turn a prompt injection into code execution |
| Start, stop or cancel a job; activate a schedule | Making something run on its own, at night, is a person's decision |
| Delete an Onyx object | Nothing here removes anything |
| Return the **value** of a secret, or write one | `list_secrets` returns keys, `create_secrets` creates empty entries. A key is a name someone chose; it is not a secret |
| Write a connection string, or a variable's SQL query | Both are code that Onyx would later execute |
| Call an LLM | An MCP server talks to a host, not to a model |
| Open a port | The transport is stdio. There is no listening socket, no OAuth, no token issuance |
| Keep state between sessions | No database, no cache, no file |

Anything this server creates arrives **inert**: a new script is neither scheduled nor wired into a
workflow, a new form is not published, a new schedule is not active. A person makes it live, in
Onyx.

## Install

The MCP **host** starts the server as a subprocess and speaks JSON-RPC over stdin/stdout. You
never start it yourself, and nothing listens on a port.

With [`uv`](https://docs.astral.sh/uv/), nothing needs installing at all — `uvx` fetches the
package on demand:

```bash
uvx --from ads-onyx-mcp onyx-mcp-server --check
```

Or install it into an environment:

```bash
pip install ads-onyx-mcp
onyx-mcp-server --check
```

Python 3.11 or later.

## Configure

Two settings, from the environment:

| Variable | Role |
|---|---|
| `ONYX_API_URL` | Root of the Onyx **API** — not the Angular front end, see below |
| `ONYX_TOKEN` | An application token, created in Onyx |

You make a token in Onyx itself: **Develop → Use AI → Application tokens**. It is shown **once**,
when you create it: Onyx keeps only its hash, so no screen can ever show it again. Lose it and you
revoke it and make another.

The token carries exactly the permissions of the person who created it, resolved at each call: a
right taken away from the person is taken away from the token at the same instant. Revoking it is
immediate, and touches neither the account nor its password.

**There is no tenant to configure.** The token names its own tenant, which Onyx resolves from it
and returns. That removes the entire class of confusion described below, where a wrong tenant id
falls back to the host and authenticates.

Optional: `ONYX_REQUEST_TIMEOUT_S`, `ONYX_VERIFY_TLS`, `ONYX_MAX_PAGE_SIZE`, `ONYX_LOG_LEVEL`,
`ONYX_ENV_FILE`.

`ONYX_ENV_FILE` points at a `.env` file to read on startup. It is how you keep the token out of
your host's configuration file, which is plain JSON on disk. See
[`.env.example`](https://github.com/AlchimieDataSolutions/ads.onyx.mcp/blob/main/.env.example) for the shape.

### Password authentication, for older instances

An instance without the "Use AI" page has no tokens to issue. Against one, authenticate with
`ONYX_USER`, `ONYX_PASSWORD`, and `ONYX_TENANT_ID` (the **numeric** tenant id) or `ONYX_TENANT`
(its name, resolved at startup). The two modes are exclusive: supplying both is refused at startup
rather than silently preferring one.

Prefer a token wherever there is one. Password authentication cannot complete at all once the
tenant enforces two-factor authentication or reCAPTCHA (see below), and it drags the tenant
question along with it.

### ⚠️ `ONYX_API_URL` is the API, not the front end

In a typical Onyx deployment the Angular front end and the API live on **two different hosts**.
Pointing `ONYX_API_URL` at the front end reaches **a different Onyx instance**: it answers, it
authenticates, and it issues a perfectly valid token — for another tenant, or for the host. Nothing
in the response says so.

The server guards against this: after authenticating it checks that the token actually carries the
tenant that was asked for, and refuses to start otherwise, naming the confusion. Better not to get
there: the URL is the one your API client consumes, not the one you open in a browser.

### ⚠️ Prefer the numeric tenant id (password authentication only)

None of this applies when you use `ONYX_TOKEN`: the token carries its tenant, so there is nothing
here to get wrong. It is kept because it is the sharpest reason to move off passwords.

`Abp.TenantId` expects an integer. The server also accepts `ONYX_TENANT`, the tenant *name*, which
it resolves at startup through `Account/IsTenantAvailable` — but **that discovery is not always
available**.

Since tenant ids are incrementing integers, an instance may deliberately disable discovery to stop
them being enumerated. On such an instance, resolving a name yields a **wrong** id. ABP then
discards the non-existent tenant and falls back to the **host**, and the symptom misleads:

- authentication **succeeds** on a same-named host account — with host permissions, therefore
  without the `MultiTenancySides.Tenant` permissions that `Pages.OConnections` and
  `Pages.OJobInstances` are;
- or it **fails** on a perfectly valid tenant account, with "Invalid user name or password".

Nothing in the response points at the cause. `onyx-mcp-server --check` does, by decoding the token
it obtained: a host token carries no tenant claim.

## Verify before wiring anything up

```bash
onyx-mcp-server --check
```

This does not speak MCP. It exchanges the token, reads back which user and which tenant that
actually got you, reads the instance version and the account's permissions, then calls two tools
and shows a sample. It exists to separate two questions
that are otherwise debugged together: *do my Onyx credentials and rights work?* and *is my host
configuration right?*

```
# les valeurs ci-dessous sont un exemple
  instance      : https://api.onyx.monclient.fr
  tenant        : porté par le token
  identifiants  : token applicatif onyx_pat_K3nZ8vQa…
  TLS vérifié   : True

[1/8] Tenant (porte par le token, rien a resoudre)
      OK - il sera annonce par Onyx a l'etape 2
[2/8] Echange du token (TokenAuth/AuthenticateWithApplicationToken)
      OK - jeton d'acces obtenu
      tenant annonce par Onyx : 7
[3/8] Identite portee par le jeton (revendications JWT)
      utilisateur : mcp.lecteur (id 42)
      tenant      : 7 - celui que porte le token
[4/8] Version de l'instance (Session/GetCurrentLoginInformations)
      version Onyx : 10.2.0
[5/8] Permissions accordées au compte (AbpUserConfiguration/GetAll)
      OK - 104 permission(s) accordée(s)
[6/8] Tool list_objects (le point d'entree : c'est lui qui rend les identifiants)
      OK - 157 objet(s) visible(s)
[7/8] Tool list_connections (permission Pages.OConnections)
      OK - 19 connexion(s) visible(s)
[8/8] Tool get_run_history (permission Pages.OJobInstances)
      OK - 12480 exécution(s) visible(s)
```

Each step fails by naming its cause. Step 3 decodes the access token, the only reliable judge of
which tenant was actually applied, worth reading even when the token brought its own. Step 5
separates "I lack the right" from "the configuration is wrong". Steps 6 to 8 are attempted
independently, so a refusal on one says nothing about the others.

Neither the application token nor a password is printed: only the token's prefix and a few
characters, enough to tell which of your tokens is in play and not enough to replay it. The output
can be pasted into a ticket as is.

### ⚠️ Two-factor authentication and reCAPTCHA

If the tenant enforces 2FA, or reCAPTCHA is active on login, **password authentication cannot
complete**: a subprocess has nobody to ask for a code. Onyx signals these cases with an HTTP 200
carrying no token, which the server detects and reports plainly rather than failing opaquely.

**This is what application tokens are for.** A token is not a login: it is exchanged for an access
token without a password, so neither 2FA nor reCAPTCHA is in the way. If you hit this, the fix is to
switch to `ONYX_TOKEN`, not to weaken the account.

## Wire it into a host

### Claude Code

`.mcp.json`, in your project:

```json
{
  "mcpServers": {
    "onyx": {
      "command": "uvx",
      "args": ["--from", "ads-onyx-mcp", "onyx-mcp-server"],
      "env": { "ONYX_ENV_FILE": "C:/Users/you/onyx/.env" }
    }
  }
}
```

Restart Claude Code, then type `/mcp`: `onyx` should appear with its tools. Then ask a question in
plain language — there is no tool to pick and no API call to write:

> — *Which Oracle connections are configured?*
>
> — *What failed last night, and how long did it run?*
>
> — *This script references `{{dwh_pwd}}`. Does that secret exist?*

### Claude Desktop (Windows)

Same server, same transport. The file is `%APPDATA%\Claude\claude_desktop_config.json`, and it only
exists after the application has run once.

Three differences, all specific to the desktop app:

1. **Use an absolute path to the executable.** Claude Desktop does not inherit the shell's `PATH`,
   so `"command": "uvx"` fails silently. Find it with `where uvx`, and use forward slashes or
   double the backslashes — it is JSON.
2. **Quit the application completely**, from the notification-area icon, not just close the window.
   Otherwise the configuration is not re-read.
3. **Logs are in** `%APPDATA%\Claude\logs\` — `mcp-server-onyx.log` for the server's stderr,
   `mcp.log` for the dialogue. A missing setting is reported there.

### Cursor

`.cursor/mcp.json` in the project, same structure as Claude Code's `.mcp.json`.

### One server per host — there is nothing to start

A common question: can I start the server in VS Code and use it from Claude Desktop? No, and there
is no need to want to.

Over **stdio**, an MCP server is not a shared service. It opens no port and listens to nothing: its
input and output are pipes owned by the process that spawned it. A second host has no address to
connect to.

What happens in practice is simpler than the question assumes. You never start the server — the
host spawns it on first need and kills it at the end of the session. Each host runs its own copy,
each with its own token and no shared state, so "one process = one user = one tenant" stays true.
And there is no conflict between them, because nothing is kept between sessions.

## The tools

Tool descriptions and schemas are **in English**: that is what the model reads, and it reasons
better on them. The project's own documentation is in French.

Twenty-six tools. Eleven only read; fifteen write, and each states what it refuses.

**Finding your way around**

| Tool | Role |
|---|---|
| `list_objects` | Inventory by type, with ids. **The entry point** — every other tool takes a GUID |
| `get_tenant` | Which Onyx tenant this session is on, by name. No setting carries it |
| `search_objects` | Search by name, or by what an object's documentation says |
| `list_connections` | Sources and targets, technology, writable or not. Never a credential |
| `get_run_history` | Statuses, start and end times, computed durations, rows written |
| `get_workflow` | What a workflow runs, when, and who is told when it breaks |
| `check_hidden_failures` | Steps that fail without failing their workflow, across the instance |
| `get_documentation` | An object's documentation, as fields plus prose |

**Reading what will run**

| Tool | Role |
|---|---|
| `get_script` | A script's source, Python or shell, **with credentials masked**, plus its environment |
| `list_secrets` | Which secret keys exist, and which hold nothing yet. **No value, ever** |
| `list_project_variables` | Project variables, with their values — the middle link when a `{{name}}` resolves |

**Writing**

| Tool | Writes |
|---|---|
| `set_documentation` | An object's documentation. Requires the revision from `get_documentation` |
| `create_table_report` | A report and its widget. Accepts no SQL — it generates its own from the connection's catalogue |
| `add_widget_filter` | A filter on an existing widget, with an empty-safe, dialect-correct predicate |
| `create_form` | A form definition. **Does not publish it**: activation stays a click in Onyx |
| `create_form_report` | A report showing an existing, already-active form |
| `create_pipeline` | A source-to-target transfer. Never starts it, and never defaults the destructive action |
| `create_workflow` | A workflow and its composition — which tasks, in which order, and what happens when one fails. **Never scheduled, never started** |
| `create_python_script` | A Python script, statically reviewed, inert |
| `create_shell_script` | A shell script that invokes repository code — the command is **generated**, not supplied |
| `update_python_script` | Replaces a Python body, after you have read it |
| `set_script_environment` | A script's environment variables, where credentials belong as `{{references}}` |
| `create_schedule` / `update_schedule` | A cron formula. **Cannot activate it** |
| `set_project_variable` | A project variable's value |
| `create_secrets` | The secret **entries** a script refers to, created empty. **Never a value** |

A few of these deserve a note.

**`list_objects`** was added after a measurement: on an instance with 157 documented objects, 112
were unreachable, because `get_run_history` was the only source of ids and reports and projects
have no run history at all.

**`check_hidden_failures`** finds what no screen shows. Onyx lets a workflow step be configured not
to interrupt its workflow when it fails; the workflow then finishes as a *success* and the failure
appears nowhere — not in the history, not in the error notification, since that is tied to the
workflow failing, which it does not. On a real instance this hid two scripts that had failed on
every run for three months. The tool also reports `alerts_that_can_never_fire`: workflows with an
error notification where every active step is non-blocking. They look monitored and are not.

**`create_secrets`** creates the entries, never the values, and the distinction is the whole
design. It saves the typing — the ten keys a script refers to, spelled as the script spells them,
in one call — and someone fills the values in Onyx. Two things it says every time, because both
are easy to get wrong: a key with no value resolves to the empty string exactly like a key that
does not exist, so nothing depending on it should run before it is filled; and the entries are
created *not hidden*, deliberately, because Onyx blanks a hidden secret's value in its listing and
a hidden entry could no longer be told apart from one nobody has filled in. Ticking 'hidden'
belongs to the same gesture as filling the value in. An existing key is never sent back to Onyx:
the same endpoint creates and edits, and its edit branch would have unhidden a secret in place.

**`create_workflow`** writes the one Onyx object that calls others, and two of its defaults are
worth knowing. Tasks that share an `order` **run at the same time** — Onyx groups steps by order and
starts each group together — so omitting the order gives the plain sequence people usually mean.
And `stops_workflow_on_error` defaults to true, because false is exactly what produces the failure
`check_hidden_failures` exists to find: the task fails, the workflow still reports success, and the
error notification stays silent. Every task is checked against what Onyx says the project holds
before anything is created, and if a step cannot be written the whole creation is undone.

**`get_script`** masks anything that looks like a credential before answering, and the mask is what
forces the fix: `update_python_script` refuses a body that still carries one, so correcting a script
means putting `{{a_secret_name}}` there instead. On the reference instance, that is how seven Python
scripts out of seventeen turned out to hold a credential in the clear.

**`list_secrets`** exists because a `{{key}}` that does not exist raises nothing: Onyx replaces it
with the empty string, silently, and the script runs with a missing value looking as though it
worked. It also counts the secrets whose *hidden* box is unticked — Onyx hands those values out in
cleartext to anyone with the secrets permission, and ticking the box costs nothing.

**`set_project_variable`** refuses a variable backed by a SQL query, and the reason is worth
knowing: Onyx returns a variable's value without running its query whenever the value is not empty.
Setting a value on such a variable would not sit alongside the query — it would retire it, for good,
with no error and no trace.

## Safety

`safety/` is not decorative. It mitigates the two real risks of this project: indirect prompt
injection, and exfiltration.

Some of the text flowing through this server is **written by customer data**: database driver
error messages, table names, extracts of failing rows. A run's `message` field is the direct
example. That text is treated as untrusted by principle — patterns imitating the *structure* of a
conversation are neutralised, invisible characters and ANSI sequences are stripped, and untrusted
content is **labelled as such** in the response so the model knows it is reading data, not
instructions.

This is not complete neutralisation. No pattern list is exhaustive, and claiming otherwise would be
the real fault. The labelling is the part that matters most.

Any truncation is **announced**, with what is needed to ask for the rest. A partial answer the
model believes is complete produces confident, wrong conclusions — "there are only three
pipelines".

Static review of a script is, likewise, a text reading. It catches what is written in the clear,
not what is hidden. It is **not a sandbox** and the tools say so in their own words rather than
letting a reader assume otherwise.

## Development

```bash
uv sync
uv run pytest        # 727 tests, no network access
```

The tests never touch the network: the client is wired to an `httpx.MockTransport` and every Onyx
response comes from a fixture. That is a structural property, not a convention — there is no
socket.

```
src/onyx_mcp/
├── __main__.py       the ONLY file that knows about the transport (stdio)
├── server.py         server assembly and tool declarations
├── config.py         environment variables → typed, validated settings
├── errors.py         errors written to be read by a model
├── onyx/             Onyx API client: auth, ABP envelope, endpoints
├── safety/           sanitising, pagination, static review of Python
└── tools/            tool implementations, with no dependency on the protocol
```

**`__main__.py` is the only file that knows about the transport.** The day an HTTP transport is
needed, an entry point is added beside it; `server.py`, `tools/`, `onyx/` and `safety/` do not move.
Two tests keep that isolation from eroding.

### API stability

The **public contract** of this package is twofold: the **list of MCP tools** and the **environment
variables**. That is all.

The Python modules stay importable, but their API is **internal and offers no stability guarantee**:
signatures, names and structure may change without a major version. Do not write code that depends
on them.

## Licence

Proprietary — use is reserved to holders of an Ads.Onyx licence from Alchimie Data Solutions.
See [LICENSE](https://github.com/AlchimieDataSolutions/ads.onyx.mcp/blob/main/LICENSE). Questions, and requests for other terms:
contact@alchimiedatasolutions.com.
