mistral-simple-mcp
by maxbth
README.md
# mistral-simple-mcp
[](LICENSE.md)
A [Model Context Protocol](https://modelcontextprotocol.io) server that gives an agent two tools
backed by [Mistral](https://mistral.ai): single-shot text completion, and structured data
extraction validated against a JSON Schema you supply.
An independent project, not affiliated with or endorsed by Mistral AI.
## What this is
Two tools, served over Streamable HTTP and stdio:
- **`mistral_complete`** — single-shot text completion: summarize, rewrite, classify, draft.
- **`mistral_extract`** — structured data extraction against a JSON Schema you supply, with the
response validated before it comes back.
Streamable HTTP is served at `POST /mcp`; stdio is selected with the `--stdio` flag. Both tools
call a paid, non-deterministic API, so neither is annotated as read-only or idempotent.
## Quick start
Requires [Bun](https://bun.sh) 1.3+.
```bash
bun install
cp .env.example .env
# edit .env and set MISTRAL_API_KEY (console.mistral.ai/api-keys)
bun run dev
```
The server starts on Streamable HTTP by default, listening at `http://127.0.0.1:3000/mcp`.
`GET /health` answers `{"status":"ok"}` once it's up.
## Client configuration
### stdio
For a client that spawns the server as a subprocess — Claude Code, Claude Desktop, or anything
else that launches a process and speaks MCP over stdin/stdout:
```json
{
"mcpServers": {
"mistral": {
"command": "bun",
"args": ["run", "/path/to/mistral-simple-mcp/src/index.ts", "--stdio"],
"env": {
"MISTRAL_API_KEY": "your-api-key-here"
}
}
}
}
```
`--stdio` overrides `MCP_TRANSPORT` no matter what `.env` says. After `bun run build`, point
`args` at `dist/index.js` instead of `src/index.ts` — both run the same server.
### Streamable HTTP
Start the server (`bun run dev`, or the Docker image below), then point a client at `/mcp`:
```json
{
"mcpServers": {
"mistral": {
"type": "http",
"url": "http://127.0.0.1:3000/mcp"
}
}
}
```
If `MCP_AUTH_TOKEN` is set, add a matching header:
```json
{
"mcpServers": {
"mistral": {
"type": "http",
"url": "http://127.0.0.1:3000/mcp",
"headers": {"Authorization": "Bearer YOUR_TOKEN_HERE"}
}
}
}
```
## When to use it
**Delegating a bounded subtask to a separate model.** An agent already holding a large context of
its own can hand off a self-contained piece of work — summarizing a document, rewriting a
paragraph in a different tone, classifying a support ticket — to `mistral_complete` instead of
doing it inline. Each call is single-shot and keeps no conversation state between invocations, so
this fits a "delegate, get an answer, continue" pattern rather than a back-and-forth chat.
**Getting schema-validated JSON out of unstructured text.** When a completion's result is going to
be read by code rather than a person — parsed into a struct, inserted into a database, passed to
another tool — `mistral_extract` is the better fit. Supply a JSON Schema describing the shape you
need; the response is validated against that same schema before it's returned, so a successful
call is guaranteed to match, and a mismatch comes back as a clear, retryable error instead of
downstream code tripping over the wrong shape.
## Tool reference
Descriptions below are copied from each tool's own schema, so this section and the server cannot
drift apart. Example responses show the request/response shape; exact wording and token counts
will differ per call.
### `mistral_complete`
Generate text with a Mistral model. Use this to delegate a self-contained subtask — summarizing,
rewriting, classifying, drafting — to a separate model. Send the whole input in `prompt`; this is
a single-shot call that keeps no conversation state between invocations. For output that must
match a specific JSON shape, use `mistral_extract` instead.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `prompt` | string | yes | — | The instruction and any input text it operates on. |
| `system` | string | no | none | System prompt setting the role, tone or output rules. |
| `model` | `mistral-small-latest` \| `mistral-medium-latest` \| `mistral-large-latest` | no | server-configured model (`MISTRAL_DEFAULT_MODEL`) | Model to use. Defaults to the server-configured model. |
| `temperature` | number, 0–2 | no | Mistral's own default | Sampling temperature. Lower is more deterministic. Mistral recommends 0.0-0.7. |
| `maxTokens` | integer > 0 | no | Mistral's own default | Maximum tokens to generate. |
**Example call**
```json
{
"prompt": "Rewrite this for a support ticket, one sentence: users cant login when they use special chars in password",
"system": "You write clear, professional bug report summaries.",
"temperature": 0.2
}
```
**Example response**
```json
{
"text": "Login fails for users whose password contains special characters.",
"model": "mistral-medium-latest",
"finishReason": "stop",
"usage": {
"promptTokens": 42,
"completionTokens": 12,
"totalTokens": 54
}
}
```
### `mistral_extract`
Extract structured data matching a JSON Schema you supply. Returns an object validated against
that schema, so a successful call always matches the shape requested. Use this instead of
`mistral_complete` whenever the result is going to be read by code rather than a person. Optional
properties are returned absent, not null.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `prompt` | string | yes | — | The instruction and the text to extract from. |
| `schema` | object (JSON Schema) | yes | — | JSON Schema describing the object to return. Standard JSON Schema: an object with `type`, `properties` and `required`, nested as deeply as you need. Two things are rejected before any model call, both because they make a small schema extremely expensive to compile: `$ref` in any form — inline the definition instead, and note that this means recursive shapes cannot be expressed — and an array-valued `type` on a node that also has subschemas under it, so give such a node a single `type`. An array-valued `type` is fine on a node with no subschemas, so `{"type": ["string", "null"]}` is the way to say a field is nullable. Constructs Zod cannot represent, such as if/then/else and not, are also rejected before any model call. |
| `schemaName` | string, matching `^[a-zA-Z0-9_-]+$` | no | `extraction` | Name for the schema in the API request. Letters, digits, underscores and hyphens only. |
| `system` | string | no | none | System prompt setting extraction rules. |
| `model` | `mistral-small-latest` \| `mistral-medium-latest` \| `mistral-large-latest` | no | server-configured model (`MISTRAL_DEFAULT_MODEL`) | Model to use. Defaults to the server-configured model. |
| `temperature` | number, 0–2 | no | Mistral's own default | Sampling temperature. Extraction usually wants a low value. |
| `strict` | boolean | no | `false` | Enable Mistral strict mode. Requires the schema to set `additionalProperties: false` on every object and list every property in `required`; Mistral rejects the request otherwise. Leave false unless the schema meets those conditions. |
**Example call**
```json
{
"prompt": "Extract the person described: Ada Lovelace, age 36.",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
},
"required": ["name", "age"]
},
"schemaName": "person"
}
```
**Example response**
```json
{
"data": {
"name": "Ada Lovelace",
"age": 36
},
"model": "mistral-medium-latest",
"usage": {
"promptTokens": 20,
"completionTokens": 8,
"totalTokens": 28
}
}
```
See [Structured output](#structured-output) below for what `schema` can and can't express.
## Structured output
`mistral_extract`'s `schema` argument is sent to Mistral **verbatim** — it is never normalized or
rewritten. That is what makes the rest of this section true.
**The schema is compiled to a Zod validator, and that validator checks the response.** Both happen
inline: compiling is cheap, and the two constructs that could make it expensive are refused first.
Anything Zod cannot represent — `if`/`then`/`else`, `not`, `dependentSchemas`,
`unevaluatedProperties` — fails at compile time, before any request is sent, and the tool call
reports a message naming the problem. A bad schema costs nothing.
**`$ref` is not supported, in any form.** Inline the definition instead. A reference lets a few
hundred bytes describe a large or infinite structure, and a cycle that never descends through
`properties` or `items` compiles fine and then never returns when a response is checked against it,
because it recurses without ever looking at the data. The practical consequence is that **recursive
schemas cannot be expressed** — a tree or linked-list shape needs `$ref`. If that matters for your
use case, this is the limitation to weigh.
**An array-valued `type` is rejected on a node that has subschemas under it.** The compiler converts
that node's children once per entry in the array, so cost doubles at every level while the document
grows by a few characters per level. `{"type": ["object", "object"], "properties": {…}}` nested 18
deep is 881 bytes and takes 3.5 seconds; at 22 deep, about 18. Give such a node a single `type`.
**An array-valued `type` on a leaf is fine**, which is the case that actually comes up:
`{"type": ["string", "null"]}` is the ordinary way to say a field is nullable, has no children to
multiply, and compiles in well under a millisecond however deeply it is nested.
With those two refused, the remaining cost is proportional to the size of the schema, which the
transport already bounds — a 300 KB schema compiles in about 13 ms, and deep nesting, `allOf`,
`anyOf` and `patternProperties` all scale linearly. A schema deep enough to exhaust the stack
throws, and that is caught and reported like any other schema problem.
**The response is validated before it is returned.** Because the schema is not normalized, `strict`
defaults to `false` and Mistral's constrained decoding is not guaranteeing the shape — this
validation is what holds the tool's contract. A mismatch comes back as a `SchemaError` listing each
offending field path, so a calling model can correct and retry rather than guess.
**Optional properties come back absent, not null**, and **extra properties are not stripped**. Both
follow from sending the schema verbatim: an optional property stays optional, and a schema that does
not set `additionalProperties: false` does not forbid extras.
## Configuration
| Variable | Default | Notes |
|---|---|---|
| `MISTRAL_API_KEY` | — | **required** |
| `MISTRAL_DEFAULT_MODEL` | `mistral-medium-latest` | `mistral-small-latest`, `mistral-medium-latest`, or `mistral-large-latest` |
| `MISTRAL_TIMEOUT_MS` | `60000` | per-request timeout; also bounds retry backoff (see below) |
| `MISTRAL_BASE_URL` | unset | self-hosted or proxied endpoints; must be a valid URL |
| `MCP_TRANSPORT` | `http` | `http` or `stdio`; the `--stdio` CLI flag overrides this |
| `MCP_HOST` | `127.0.0.1` | the image sets `0.0.0.0` |
| `MCP_PORT` | `3000` | |
| `MCP_HTTP_PATH` | `/mcp` | the HTTP path the MCP endpoint is served on; must start with `/` |
| `MCP_AUTH_TOKEN` | unset | when set, a matching bearer token is required on `/mcp` |
| `MCP_ALLOWED_ORIGINS` | empty | comma-separated **hostnames** (not full origins), added to the localhost defaults on a localhost bind |
There is deliberately no retry-count setting. The Mistral SDK has no attempt-count option — its
retry behavior is a backoff shape (initial interval, max interval, exponent), not a fixed number
of tries — so the knob this server exposes is `MISTRAL_TIMEOUT_MS`, which bounds how long that
backoff sequence is allowed to run rather than how many times it runs. The retry budget is set to
80% of it, deliberately less than the whole: the SDK only reports the upstream response once its
retry budget is spent, so a budget equal to the deadline means a rate limit comes back as a
timeout instead of as a rate limit.
## Docker
```bash
docker build -t mistral-simple-mcp .
docker run -d -p 3000:3000 \
-e MISTRAL_API_KEY=your-api-key-here \
-e MCP_AUTH_TOKEN=generate-a-long-random-string \
mistral-simple-mcp
```
Or with Compose — copy [`docker-compose.example.yml`](docker-compose.example.yml), fill in the
two values, and run `docker compose -f docker-compose.example.yml up -d`:
```yaml
services:
mistral-simple-mcp:
image: ghcr.io/maxbth/mistral-simple-mcp:latest
ports:
- '3000:3000'
environment:
MISTRAL_API_KEY: your-api-key-here
MCP_AUTH_TOKEN: generate-a-long-random-string
restart: unless-stopped
```
For stdio instead, keep the entrypoint and override the default args:
```bash
docker run -i --rm -e MISTRAL_API_KEY=your-api-key-here mistral-simple-mcp --stdio
```
### `MCP_AUTH_TOKEN` and `0.0.0.0`
The image binds `MCP_HOST=0.0.0.0` so the container is reachable from outside itself — a container
listening on `127.0.0.1` only accepts connections from inside its own network namespace, which in
practice means none. **Always set `MCP_AUTH_TOKEN`** when running the image: without it, anything
that can reach the published port can call `mistral_complete` and `mistral_extract` with no
authentication at all, and spend the owner's Mistral API credits doing it. The server logs a
warning to stderr on startup whenever it's bound wide open with no token configured.
`MCP_AUTH_TOKEN` protects `/mcp` with a constant-time bearer-token check. `/health` stays
unauthenticated on purpose — it returns nothing but `{"status":"ok"}`, and container runtimes need
to reach it without a token to run their health probe.
## Known limitations
`mistral_extract` compiles JSON Schema supplied by the caller, so it refuses the two constructs
that make compilation cost wildly more than the schema's size suggests: `$ref` in any form, and an
array-valued `type` on a node that has subschemas under it. The practical cost is that **recursive
schemas are not supported**.
See [docs/known-limitations.md](docs/known-limitations.md) for the full list, including the three
known unbounded-work classes and what defends against them.
## Development
```bash
bun install
bun test
bun run typecheck # Bun does not typecheck; this is what does
bun run lint:check
```
`bun run lint:check` does not catch every formatting rule Prettier enforces — trailing commas
in particular have no ESLint equivalent in this config, so lint can pass on a diff Prettier
would still reject. Treat it as a separate gate and run it before committing:
```bash
bunx prettier --check src scripts # or: bun run format, to fix in place
```
Tests are colocated with what they test (`src/config.ts` / `src/config.test.ts`), run with no
network access and no real API key — a fake `MistralClient` is injected in place of the real one.
**`bun run build` bundles and then runs what it built.**
```bash
bun run build # bundle into dist/, then verify it
bun run verify:build # just the verification, against an existing dist/
```
`build` bundles `src/index.ts` to `dist/`. The Dockerfile runs the same command with `--minify`.
## License
[MIT](LICENSE.md) © Maxime Bertheau
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues