Skip to main content
Glama
README.md
# @qodalis/mcp-cli

A resilient [Model Context Protocol](https://modelcontextprotocol.io) server that
gives [Claude Code](https://code.claude.com) a [Qodalis CLI](https://cli.qodalis.com)
server: its CLI commands, files, data explorer, jobs and plugin tools.

Every Qodalis CLI server (.NET, Node or Python) can expose an MCP endpoint at
`<server>/api/qcli/mcp`. `mcp-cli` runs on your machine as a stdio MCP server
and proxies to that endpoint: Claude Code sees exactly the server's tools, with
the permissions of the account you configure. It handles sign-in, reconnects
after network drops and lets you narrow what the assistant may use.

## Highlights

- **No tool-specific code.** Tools come from the server's `tools/list` and are
  passed through unchanged (names, schemas, annotations); calls are forwarded.
  New commands on the server show up without updating the proxy.
- **Token or password.** Use an API token (`QCLI_TOKEN`), or sign in with
  `QCLI_USER` / `QCLI_PASSWORD`; an expired session is renewed automatically on
  the next call.
- **Read-only mode** (`QCLI_READ_ONLY=true`) exposes only the tools the server
  marks read-only, and refuses calls to anything else.
- **Client-side allow and deny lists** (`QCLI_TOOLS`, `QCLI_EXCLUDE`, trailing
  `*` allowed) on top of the server's own permissions.
- **Self-healing connection.** It connects on first use, never caches a failed
  connect, retries once when the connection drops, and fails fast (8 s) when the
  server is unreachable, with a message that says what to check.
- **Clear errors**: a rejected token, MCP not enabled on the server, a missing
  permission or an unreachable host each come back as one actionable sentence.

## Quick start

```bash
npx -y @qodalis/mcp-cli --help
```

The server must have MCP enabled (`mcp` option; see
[MCP endpoint](https://cli.qodalis.com/docs/server-integration#mcp-endpoint)).
Default ports: .NET `8046`, Node `8047`, Python `8048`.

### Claude Code

```bash
claude mcp add qcli -- npx -y @qodalis/mcp-cli
```

then set the environment in `~/.claude.json`:

```json
{
  "mcpServers": {
    "qcli": {
      "command": "npx",
      "args": ["-y", "@qodalis/mcp-cli"],
      "env": {
        "QCLI_URL": "http://localhost:8047",
        "QCLI_TOKEN": "qcli_pat_..."
      }
    }
  }
}
```

or sign in with a user name and password instead of a token:

```json
"env": {
  "QCLI_URL": "http://localhost:8047",
  "QCLI_USER": "ops",
  "QCLI_PASSWORD": "********"
}
```

The same in one command, with `-e` flags:

```bash
claude mcp add qcli -e QCLI_URL=http://localhost:8047 -e QCLI_TOKEN=qcli_pat_... -- npx -y @qodalis/mcp-cli
```

Add `--scope user` to make it available in every project. With
`--scope project` the entry goes into the project's `.mcp.json`, which is
usually committed: keep the secret out of it with
`"QCLI_TOKEN": "${QCLI_TOKEN}"` and set the variable in your shell. A `.env`
file works too:

```json
{ "command": "npx", "args": ["-y", "@qodalis/mcp-cli", "--env-file", "/path/to/.env"] }
```

Pin a version in shared configs (`@qodalis/mcp-cli@0.1.0`) so an upgrade is a
deliberate change.

### Getting a token

In the web CLI connected to the server (users plugin), signed in as the account
the assistant should act as:

```bash
token create claude --expires=90
# token create claude --permissions=commands:execute,files:read   (narrower scope)
```

The token (`qcli_pat_…`) is shown once. Without the web CLI, sign in and create
one with curl:

```bash
SESSION=$(curl -s http://localhost:8047/api/v1/qcli/sessions \
  -H 'Content-Type: application/json' -d '{"username":"ops","password":"..."}' | jq -r .token)
curl -s http://localhost:8047/api/v1/qcli/tokens -H "Authorization: Bearer $SESSION" \
  -H 'Content-Type: application/json' -d '{"name":"claude","expiresInDays":90}' | jq -r .token
curl -s -X DELETE http://localhost:8047/api/v1/qcli/sessions/current -H "Authorization: Bearer $SESSION"
```

A server **without** the users plugin protects its endpoint with a static token
(its `mcp.token` option): put that value in `QCLI_TOKEN`. If it sets none, the
endpoint is open and no auth variable is needed.

## Configuration

Every setting is an environment variable. `QODALIS_CLI_*` names are accepted as
aliases for every key.

| Variable                  | Default  | Description                                                                                        |
| ------------------------- | -------- | -------------------------------------------------------------------------------------------------- |
| `QCLI_URL`                | required | Server base URL, e.g. `https://cli.example.com` (a pasted `…/api/qcli/mcp` URL is accepted).       |
| `QCLI_TOKEN`              | none     | API token (`token create` in the web CLI) or the server's static `mcp.token`.                      |
| `QCLI_USER`               | none     | Sign in with this user name instead of a token (needs `QCLI_PASSWORD`).                            |
| `QCLI_PASSWORD`           | none     | Password for `QCLI_USER`. Never logged.                                                            |
| `QCLI_READ_ONLY`          | `false`  | Expose and allow only tools annotated `readOnlyHint: true`. See [read-only mode](#read-only-mode). |
| `QCLI_TOOLS`              | all      | Comma-separated allow-list of tool names; a trailing `*` matches a prefix (`fs_*`).                |
| `QCLI_EXCLUDE`            | none     | Comma-separated deny-list, same syntax, applied after `QCLI_TOOLS`.                                |
| `QCLI_CONNECTION_TIMEOUT` | `8000`   | Milliseconds to wait for the TCP/TLS connection before reporting the server unreachable.           |
| `QCLI_REQUEST_TIMEOUT`    | `60000`  | Milliseconds a single request (`tools/list`, `tools/call`) may take.                               |
| `QCLI_TRUST_CERT`         | `false`  | Accept a self-signed TLS certificate.                                                              |
| `QCLI_ENV_FILE`           | none     | Path to a `.env` file (same as `--env-file`). Variables already set in the environment win.        |

Set either `QCLI_TOKEN` or `QCLI_USER` + `QCLI_PASSWORD`, not both.

## Tools

The tools are whatever the server exposes to your account. A typical server
offers:

| Tools                                                                | Available when                         |
| -------------------------------------------------------------------- | -------------------------------------- |
| `server_info`                                                        | always: version, enabled features      |
| one tool per CLI command (`weather`, `weather_current`, ...)         | always                                 |
| `fs_list`, `fs_stat`, `fs_read`, `fs_write`, `fs_mkdir`, `fs_delete` | the server's filesystem API is enabled |
| `data_sources`, `data_schema`, `data_query`                          | the data explorer is enabled           |
| `jobs_list`, `jobs_get`, `jobs_history`, `jobs_trigger`, ...         | the jobs plugin is installed           |
| plugin tools                                                         | a server plugin contributes them       |

Each one is limited by the account's permissions on the server (for example
`commands:execute`, `files:read` / `files:write`, `data:query`, `jobs:manage`),
and by the token's scope when it was created with `--permissions`.

## Read-only mode

With `QCLI_READ_ONLY=true` the proxy lists only the tools the server annotates
`readOnlyHint: true` (such as `server_info`, `fs_list`, `fs_read`,
`data_schema`, `jobs_list`) and refuses calls to any other tool before they
reach the server. Command tools carry no read-only annotation, so they are
hidden in this mode; add the ones you want with a separate, non-read-only entry
and `QCLI_TOOLS`.

The filter is defence in depth. The real boundary is the account: give the
assistant a user (or a token scope) with only the permissions it should have.

## Several servers

Add one entry per server (and per access level) with a distinct name:

```json
{
  "mcpServers": {
    "qcli-dev": {
      "command": "npx",
      "args": ["-y", "@qodalis/mcp-cli"],
      "env": { "QCLI_URL": "http://localhost:8047", "QCLI_TOKEN": "qcli_pat_dev..." }
    },
    "qcli-prod": {
      "command": "npx",
      "args": ["-y", "@qodalis/mcp-cli"],
      "env": {
        "QCLI_URL": "https://cli.example.com",
        "QCLI_TOKEN": "qcli_pat_prod...",
        "QCLI_READ_ONLY": "true"
      }
    },
    "qcli-prod-jobs": {
      "command": "npx",
      "args": ["-y", "@qodalis/mcp-cli"],
      "env": {
        "QCLI_URL": "https://cli.example.com",
        "QCLI_TOKEN": "qcli_pat_prod...",
        "QCLI_TOOLS": "jobs_*,server_info",
        "QCLI_EXCLUDE": "jobs_cancel"
      }
    }
  }
}
```

## Troubleshooting

`claude mcp list` shows whether each server connects; inside a session `/mcp`
shows its status and tools and lets you reconnect. The proxy logs to stderr
(`claude --debug` shows it).

| Message                                                                       | What to do                                                                                                                                                |
| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `… answered 401 (not authenticated): QCLI_TOKEN is wrong, expired or revoked` | Create a new token (`token create claude`) and update `QCLI_TOKEN`. `token list` in the web CLI shows expiry dates.                                       |
| `… rejected QCLI_USER / QCLI_PASSWORD`                                        | Check the credentials; the account may be disabled or locked out after failed sign-ins.                                                                   |
| `… answered 403 …`                                                            | The account lacks `commands:execute`, or the server rejected the request origin.                                                                          |
| `MCP is not enabled on this server (enable the mcp option)`                   | Turn on the server's `mcp` option and restart it; check `QCLI_URL` (base URL, not a page of the web app).                                                 |
| `Cannot reach … Check QCLI_URL / VPN.`                                        | The server is down, the URL or port is wrong, or the VPN is disconnected. Retry the call once it is back: the proxy reconnects by itself.                 |
| `… did not answer within 60000 ms`                                            | A slow command; raise `QCLI_REQUEST_TIMEOUT`.                                                                                                             |
| A tool is missing                                                             | The account (or token scope) lacks its permission, the feature is not enabled on the server, or `QCLI_READ_ONLY` / `QCLI_TOOLS` / `QCLI_EXCLUDE` hide it. |
| `QCLI_URL … is required` at startup                                           | The `env` block is missing or the variable name is misspelled; `npx -y @qodalis/mcp-cli --help` lists every variable.                                     |

## How reconnection works

1. The proxy connects to the endpoint on the first `tools/list` or
   `tools/call`, not at startup, so Claude Code starts even when the server is
   down.
2. A failed connect is not cached: the next call tries again.
3. A connection-level error (refused, reset, socket closed, DNS failure) drops
   the connection and retries the request once on a fresh one. A connect timeout
   is not retried, so an unreachable host fails within `QCLI_CONNECTION_TIMEOUT`.
4. With `QCLI_USER` / `QCLI_PASSWORD`, a 401 signs in again and retries once.
5. The tool list is cached for 60 s; a call to a tool the proxy does not know
   yet refreshes it first, so new server commands work without a restart.

## Security notes

- Tokens and passwords are read from the environment, sent only to `QCLI_URL`,
  and never written to logs or tool results.
- Prefer an API token with a narrow `--permissions` scope and an expiry over a
  password; revoke it with `token revoke <id>` when it is no longer needed.
- Use `https://` for anything but `localhost`. `QCLI_TRUST_CERT=true` disables
  certificate verification for this process; use it only for self-signed
  certificates you control.
- Nothing is written to stdout except the MCP protocol stream.

See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.

## Library use

The pieces are exported for embedding:

```ts
import { createProxyServer, loadConfig, RemoteServer } from '@qodalis/mcp-cli';

const config = loadConfig(process.env);
const remote = new RemoteServer(config, { clientInfo: { name: 'my-app', version: '1.0.0' } });
const server = createProxyServer(remote, config); // a low-level MCP Server: connect any transport
```

## Development

```bash
npm install
npm run build
npm test             # unit tests and stdio end-to-end tests against a fake server (build first)
npm run typecheck
npm run check-format
npm run dev -- --env-file .env   # run from source
```

See [CONTRIBUTING.md](CONTRIBUTING.md).

## License

[MIT](LICENSE) © Qodalis Solutions SRL