Skip to main content

Ads.Onyx MCP Server

An MCP server that gives an AI assistant — Claude Code, Claude Desktop, Cursor — working access to an Ads.Onyx instance: its pipelines, workflows, runs, connections, reports, scripts, schedules, secrets keys and project variables.

It reads widely, writes deliberately, and runs nothing.

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.

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 One removal exists, and only one: update_workflow can take a task out of a workflow when asked to explicitly. That removes the link, never the pipeline or script it called
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
Commit to, or delete from, the declared git repository That branch is what the workers copy, and a tenant often follows it by webhook: a commit is code that runs later, unattended, that nobody reviewed
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. One tool writes a PDF report, at the absolute path you give it and never over an existing file — an output, not state

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, nothing needs installing at all — uvx fetches the package on demand:

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

Or install it into an environment:

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 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.

Behind a reverse proxy that shares one host (Traefik, typically), the API is reached under a path: https://onyx.client.com/api. Give that URL as is. Onyx routes already start with /api, so a trailing /api on ONYX_API_URL is not repeated: requests go to https://onyx.client.com/api/TokenAuth/…, never …/api/api/…. --check prints the routes it will call.

⚠️ 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

onyx-mcp-server --check

This does not speak MCP. It first lists every ONYX_* variable it reads: the value, where it came from (the host, or the .env file), or the default it falls back to. Secrets are shown partially — a token by its prefix, first eight and last four characters, a password by its length only. Stray spaces or quotes around a value are flagged, and so is any unknown ONYX_* variable, which would otherwise be ignored in silence. The same list follows the error message when the configuration is refused, and goes to the host's log at startup with ONYX_LOG_LEVEL=INFO.

Then 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
Variables d'environnement
========================================================================
  ONYX_API_URL           'https://api.onyx.monclient.fr'  [hôte]
  ONYX_TOKEN             onyx_pat_K3nZ8vQa…r2Xw (52 caractères)  [.env (C:\projet\.env)]
  ONYX_TENANT_ID         absente (défaut : None)
  …

Vérification de la chaîne Onyx
========================================================================
  instance      : https://api.onyx.monclient.fr
  routes        : https://api.onyx.monclient.fr/api/TokenAuth/…, https://api.onyx.monclient.fr/api/services/app/…
  tenant        : porté par le token
  identifiants  : token applicatif onyx_pat_K3nZ8vQa…r2Xw (52 caractères)
  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:

{
  "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.

Thirty-six tools. Fifteen only read; twenty-one 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
check_infrastructure Whether anything is there to run work, and whether anything is stuck waiting to be picked up
get_documentation An object's documentation, as fields plus prose

Reading what will run

Tool Role
get_script A script's source, Python, shell or SQL, with credentials masked, plus its environment or its connection
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
get_repository Which git repository the tenant declares, on which branch, and whether workers copy it
list_repository_files A folder of that repository, or a file search by name
get_repository_file A file's content from the declared branch, with credentials masked

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_project An empty project, in an organization unit you can see
create_workflow A workflow and its composition — which tasks, in which order, and what happens when one fails. Never scheduled, never started
update_workflow Changes an existing workflow's tasks and settings, after you have read it. The one tool that can remove something: a task, never the object it calls
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
update_shell_script Replaces a shell body, after you have read it. Free-form, and not analysed: only credentials and substitutions are checked
create_sql_script A SQL script on a database connection, reviewed as text, inert
update_sql_script Replaces a SQL query, 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
create_audit_report A PDF of what happened over a period, with a chapter for what stands out. The one tool that writes a file

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_audit_report turns the audit log into a document. Onyx records every API call — who, which service and method, when, how long, whether it failed — which reads well line by line and badly over a week. The report gives an overview, who acted, what was called most, then a chapter for what stands out: impersonation, deletions, cancellations, manual runs, secrets and connections touched, account and settings changes, code rewritten by Onyx's own AI, exports, failures, and actions outside the declared working hours. None of those is an accusation, and the report says so on each one.

Two things it refuses to do. It never reads the call parameters. The audit log keeps the full serialised payload of every call — a secret's value, a connection string, a script body — so a report that copied them would leak through the back door what the rest of this server closes at the front. It keeps that a call happened, and of an error only its type. And it never writes over a file: the PDF goes to the absolute path you give, or the call is refused.

It records calls, not jobs: a pipeline that ran overnight under a schedule is not an API call and is absent from it. Unlike the four tools above it needs no particular Onyx version — the audit log long predates 10.2.0 — but it does need the tenant's audit-log permission, which is separate from every other permission this server uses.

The repository tools close a gap create_shell_script left open. That tool writes a script which invokes a .py file from the git repository copied onto the worker, and its own documentation says the code "does not live in Onyx", so "reviewing it means looking at the repository" — which was not possible from here. Now a path can be checked before it is named, and the code behind a scheduled job can be read. Two things the answers insist on: the file comes from the declared branch at its current head, which a webhook can move between reading and running; and sync_enabled decides whether any of it reaches a worker at all — with synchronisation off the worker never clones, so the code you read is not the code that would run.

Writing is not exposed, and that is the deliberate part. SaveFile commits straight to the declared branch — no pull request, no review — and that branch is what the workers copy. A commit would therefore be code that runs later, unattended, reviewed by nobody: execution reached without ever calling an execution endpoint. It would also remove the review that is the only reason to prefer repository code over create_python_script in the first place.

check_infrastructure sees the one thing the history cannot: work that never started. A job sent to a queue no worker listens to has no run, so it has no failure and appears nowhere — it simply waits. Queues are named freely on a pipeline, a script or a workflow while each worker declares what it listens to, and nothing checks that the two match; on the reference instance a queue named v190 is listened to by nobody. The same answer carries the load picture — slots busy against slots that exist, and the age of the oldest queued job — which is what separates an instance at capacity from one whose work is stranded. Requires Onyx 10.2.0 or later: OInfrastructure does not exist before it.

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.

update_workflow changes what a workflow runs, and a workflow under an active schedule runs tonight, unattended — so it refuses until told the caller knows, and a schedule list Onyx fails to return counts as active. It requires the revision get_workflow returned. Tasks can be added, reordered, switched on or off, and removed. Removal is the only deletion this server performs: it takes the task out, never the pipeline or script it called, and it cannot be undone — switching a task off with active: false is the reversible alternative. Everything is checked before the first write, removals are written last, and a failure part way says exactly what was applied. The execution queue is deliberately out of reach: a queue no worker listens to strands the work without a trace.

create_project only accepts an organization unit the account can see. Onyx itself does not check it, and a project placed outside its author's units would exist and be invisible to them the moment it was created.

get_script masks anything that looks like a credential before answering, and the mask is what forces the fix: the three update tools refuse 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

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. Questions, and requests for other terms: contact@alchimiedatasolutions.com.

Metadata

Release files for ads-onyx-mcp 0.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ads-onyx-mcp 0.7.0
File Size Uploaded Provenance
ads_onyx_mcp-0.7.0.tar.gz 305.2 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for ads-onyx-mcp 0.7.0
File Interpreter ABI Platform
ads_onyx_mcp-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 621.3 kB

Release files / ads_onyx_mcp-0.7.0.tar.gz

Download URL ads_onyx_mcp-0.7.0.tar.gz
Size 305.2 kB
Tags Source
SHA-256 checksum
How to use checksums
c2f646536ae714539fb9cfd6fb1bb7ee7f36cb509739eef2a55603be9c8f5e2e
BLAKE2b-256 checksum
How to use checksums
d50324e33efab9818a8a3ff5d7e39c94d5a6582d64ffaf1d9fdcc901fa84d675
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release files / ads_onyx_mcp-0.7.0-py3-none-any.whl

Download URL ads_onyx_mcp-0.7.0-py3-none-any.whl
Size 316.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
64ee1fec443dee618eda1d9109e70b662016a1fb3be38c3ab093c92749c93475
BLAKE2b-256 checksum
How to use checksums
58864f29fca42b31168ded362aa0d9aa0c511c417109f2a7ca221f9bafa7d8d7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Security for ads-onyx-mcp 0.7.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page