Metadata-Version: 2.5
Name: raynet-mcp
Version: 0.2.1
Summary: MCP server exposing the Raynet CRM REST API to an LLM agent over stdio
Project-URL: Homepage, https://github.com/davidesner/raynet-mcp
Project-URL: Repository, https://github.com/davidesner/raynet-mcp
Project-URL: Issues, https://github.com/davidesner/raynet-mcp/issues
Project-URL: Changelog, https://github.com/davidesner/raynet-mcp/blob/main/CHANGELOG.md
Author: David Esner
License-Expression: MIT
License-File: LICENSE
Keywords: agent,crm,fastmcp,llm,mcp,model-context-protocol,raynet
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 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: fastmcp<5,>=3.4
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.7
Description-Content-Type: text/markdown

# Raynet MCP

An [MCP](https://modelcontextprotocol.io) server that exposes the
[Raynet CRM](https://raynet.cz) REST API to an LLM agent over stdio, built with
[FastMCP](https://gofastmcp.com).

The server exposes **32 read tools and 27 write tools** covering business
cases, companies, people, leads, activities (meetings, tasks, events, phone
calls, e-mails), products, price lists, sales orders, offers and invoices,
discussion posts and attachments, plus a metadata layer for discovering an
instance's value lists and custom fields. Writes are off by default -- see
[Read and write separation](#read-and-write-separation) -- so a fresh install
is read-only (`GET` calls only) until `RAYNET_WRITE_ENABLED=true` is set
deliberately.

It was built and validated against a single Raynet instance. The endpoints and
tools are generic, but anything this README calls an observation rather than a
documented contract -- filter behaviour, value-list contents, which fields an
instance renamed -- may differ on yours.

## Install

Requires Python >= 3.11.

```bash
uv tool install raynet-mcp     # or: pipx install raynet-mcp
```

Nothing to install if you use `uvx raynet-mcp`, which fetches and runs it.

The server takes its token from the environment and never reads a `.env` file
itself, so the token belongs in your MCP client's `env` block.

### Claude Desktop, Claude Code (`.mcp.json`)

```json
{
  "mcpServers": {
    "raynet": {
      "command": "uvx",
      "args": ["raynet-mcp"],
      "env": {
        "RAYNET_API_TOKEN": "your-token-here",
        "RAYNET_WRITE_ENABLED": "false"
      }
    }
  }
}
```

### Codex (`~/.codex/config.toml`)

```toml
[mcp_servers.raynet]
command = "uvx"
args = ["raynet-mcp"]
env = { RAYNET_API_TOKEN = "your-token-here", RAYNET_WRITE_ENABLED = "false" }
```

With no token in the environment, startup stops with
`RAYNET_API_TOKEN is not set`.

### From source

```bash
git clone https://github.com/davidesner/raynet-mcp && cd raynet-mcp
uv sync
cp .env.example .env          # then fill in RAYNET_API_TOKEN
uv run --env-file .env python -m raynet_mcp
```

`--env-file` is what loads `.env` into the child process; it applies to a
clone only. The installed `raynet-mcp` console script and `python -m
raynet_mcp` are equivalent entry points.

## Configuration

All configuration is read from the environment. This server does not read
`.env` itself: either export the variables, put them in your MCP client's
`env` block, or pass `--env-file .env` to `uv run` as shown above. (The test
suite is the one exception -- `tests/conftest.py` loads `.env` on its own, so
`uv run pytest -m live` works without `--env-file`.)

| Variable | Default | Meaning |
|---|---|---|
| `RAYNET_API_TOKEN` | *(required)* | Bearer token for the Raynet instance. It already carries the instance context, so no separate instance header is needed. |
| `RAYNET_BASE_URL` | `https://app.raynet.cz/api/v2/` | API base URL. Regional alternatives exist for other Raynet deployments. |
| `RAYNET_WRITE_ENABLED` | `false` | Leave unset or `false` for a read-only server. Setting this to `true` exposes the 27 write tools. |
| `RAYNET_CACHE_TTL` | `300` | Seconds a value list or custom-field config is cached before being re-read. Business records are never cached. |
| `RAYNET_DATE_FORMAT` | `native` | Set to `iso8601` to request ISO-8601 timestamps from Raynet instead of its native format. |
| `RAYNET_MAX_CONCURRENCY` | `3` | Concurrent requests this client will issue. Capped at 3 regardless of this setting, because Raynet rejects a 5th concurrent connection. |
| `RAYNET_UI_BASE_URL` | *(derived from `RAYNET_BASE_URL`)* | Override the host used to build UI deep links, e.g. for a custom domain or reverse proxy. |

## The ids-first contract

Reference fields on a business case -- phase, type, category, classification
1-3, source -- and similar fields elsewhere are **value lists**: their ids are
specific to this Raynet instance and are not documented anywhere outside it.
Never guess or hard-code one.

The intended sequence is:

1. Call `raynet_describe_business_case_fields` (for business cases) or
   `raynet_valuelist` (for any other list) to read the current ids and
   labels.
2. Pass the id you found. For example:

   ```json
   {"business_case_phase": 123}
   ```

3. Optionally, pass the checked pair form instead of a bare id, to have the
   id and label cross-checked against each other:

   ```json
   {"business_case_phase": {"id": 123, "label": "Negotiation"}}
   ```

   This catches a digit slip that a bare id cannot: two adjacent ids in the
   same list are both "valid" but can mean quite different things.

The same contract applies to writes: every reference id passed to a create
or update tool -- phase, type, category, classification 1-3, source,
currency, tax rate -- is validated against the instance's own value lists
*before* any request is sent, and a refused write never reaches the network.
On success, the result echoes a `resolved` map from each API field it set to
the label of the id it resolved to, so the caller can see in the response
which record it actually pointed at, not just the id it supplied.

The tools deliberately do **not** validate read *filter* ids (as opposed to
ids passed when creating or updating a record, which *are* validated). This
avoids an extra metadata round trip on every list call, and it costs nothing,
because Raynet validates filters itself. Observed against a live instance:

| mistake | what Raynet answers |
|---|---|
| filter *name* the endpoint does not have | `400` `BadRequest: Unsupported filter column [<name>]` |
| reference *id* that does not exist, on most filters (`company`, `owner`, `category`, …) | `404` `EntityNotFoundException` |
| reference *id* that does not exist, on `businessCasePhase` or `businessCaseType` | `500` `ConversionFailedException` |

So a wrong filter fails loudly rather than silently, and **an empty result
from a filtered list tool does mean no matching records** -- the call would
have raised otherwise. The server quotes Raynet's own `type` and `message` in
the error either way, including on the 500, so the reply says which value it
choked on.

These are observations from one live instance, not a documented contract; the
status codes may differ elsewhere, but in every case tried the call failed
rather than returning an empty page.

**`owner` takes a person id, not a userAccount id.** Every tool that accepts
`owner` (creates, updates, and list filters) says so in its docstring, and
`raynet_users` reports the person id to pass. This is not validated by the
server: the two id spaces overlap, so a userAccount id that happens to name
some other person is accepted -- misattributing a record on a write, or
filtering on the wrong person on a read. (An id matching no person at all
does fail: Raynet answers 404.) Getting the value from `raynet_users` is what
keeps this straight.

## The `url` field

Every record this server returns (from a `_get` tool, and from list rows
where an id and account are known) carries a `url` field: a deep link into
the Raynet web UI for that exact record, of the form
`https://<host>/<account>/?view=DetailView&en=<Entity>&ei=<id>`. Use it to
hand a human a link to the record you just read, rather than describing where
to find it.

Some record kinds have no confirmed detail-view route in the web UI (price
lists and invoices, at the time of writing); their records simply have no
`url` field. A missing link is intentional in that case -- this server would
rather omit a link than hand back one that silently lands on the dashboard.

## Read and write separation

This server enforces read-only behaviour in two independent places:

- The HTTP client's `write()` method refuses to issue `PUT`/`POST`/`DELETE`
  unless `RAYNET_WRITE_ENABLED=true`, raising `RaynetWriteDisabled` if it is
  called anyway.
- At startup, if writes are not enabled, every tool tagged `write` is
  deregistered entirely rather than merely disabled. An agent connected to
  this server never sees a write tool in its tool list unless writes are on
  -- it cannot discover, let alone call, one.

### Tool count

| | read | write | total |
|---|---|---|---|
| `RAYNET_WRITE_ENABLED` unset/`false` | 32 | 0 | 32 |
| `RAYNET_WRITE_ENABLED=true` | 32 | 27 | 59 |

(Verified by running the server with `list_tools()` under both settings;
see `tests/test_annotations.py`.)

## Writing to Raynet

Set `RAYNET_WRITE_ENABLED=true` and restart the server to enable the 23
write tools -- creates, updates, deletes, line-item edits, discussion posts
and attachments. With it unset (or `false`), the write tools are not merely
disabled, they are absent from the tool list entirely (see above), and the
one HTTP method that could mutate data (`client.write`) refuses before
issuing a request.

A few things that are easy to get wrong:

- **`bulk_update` does not accept a phase.** A phase is only valid for its
  own business case type, and a bulk selection can span several types, so a
  single phase id would silently corrupt some of the records it was applied
  to. Use `raynet_business_case_set_phase` once per record instead.
- **Line item `price` excludes VAT; `totalAmount` includes it.** Raynet does
  not convert between the two, so a value copied from one into the other is
  wrong by the tax rate.
- **`owner` is a person id, not a userAccount id** -- see
  [The ids-first contract](#the-ids-first-contract).
- **Every write echoes the resolved label** of each reference id it set, in
  a `resolved` map on the result, so a wrong-but-valid id is visible in the
  response rather than silently accepted.
- **There is no raw escape hatch.** Earlier versions exposed
  `raynet_raw_get` and `raynet_raw_write`, which took a caller-supplied path
  and body and checked neither. They were withdrawn in 0.2.0: an
  agent-steerable path and an unvalidated body are too sharp an edge to hand
  a model, and a CRM record carrying injected instructions could aim them at
  any endpoint the token can reach. Every write now goes through a tool that
  knows its own endpoint. If you need an endpoint this server does not model,
  open an issue -- or call the API directly, where the decision is yours.
- **Six tools delete a whole record** -- a client, person, lead, business
  case, business-case line item, or one activity. Each is annotated
  `destructiveHint`, so a client that filters on annotations can surface or
  withhold them. There is no cascade and no force: Raynet refuses while other
  records still reference the target, and that refusal is passed through
  unchanged. `raynet_activity_delete` requires `type` because the five
  activity kinds have separate id spaces -- the same number is a different
  record in each.
- **`state`, `role` and `rating` on a client, and `priority` on a lead, are
  required by the API and this server hard-codes no accepted values.** They
  are string codes rather than value lists, and an instance can use a code
  the vendor document does not name -- live data carries `role="E_OWN"`,
  which the document's enum omits. Read the codes your instance uses off
  existing records (`raynet_company_list` and `raynet_lead_list` return them)
  rather than guessing, and expect the API, not this server, to reject a bad
  one.

## Testing

Three independent layers of verification exist:

```bash
# Offline unit tests. No network access, safe to run anywhere, anytime.
# This is the default: pyproject.toml sets addopts = "-m 'not live'", so a
# bare `uv run pytest` deselects the live suite below and runs 0 live tests.
uv run pytest

# Live read-only tests against a real Raynet instance. Opt-in only -- the
# `-m live` flag overrides the addopts default above. Requires
# RAYNET_API_TOKEN in .env or the environment. Refuses (loudly, via
# pytest.fail) to run at all if RAYNET_WRITE_ENABLED is true, and forces
# writes off for its own session regardless of ambient configuration. Every
# test in this suite issues only GET requests.
uv run pytest -m live -v

# End-to-end check through a real MCP client (the `claude` CLI), driving the
# server exactly as an agent would. Writes are hard-disabled in
# e2e/.mcp.json; this cannot mutate the CRM.
./e2e/run_e2e.sh
```

A fresh clone with a populated `.env` never touches the network on a bare
`uv run pytest` or in CI -- the live suite only runs when `-m live` is passed
explicitly, and even then it is read-only.

## A note on instance-specific vocabulary

Fields like `businessCaseClassification1`, `..2` and `..3` are generic slots
that each Raynet instance relabels for its own purposes -- one instance's
"classification 1" might be called "Industry" and mean nothing like another
instance's. The same is true of most value lists. Because of this, the tools
in this server deliberately hard-code **no instance-specific vocabulary**:
no list of expected category names, no assumed meaning for a classification
slot, nothing that would work on one instance's data and quietly mean the
wrong thing on another's. Discover an instance's actual vocabulary with
`raynet_describe_business_case_fields` or `raynet_valuelist` before assuming
anything about what a field means.

## Licence

MIT -- see [LICENSE](LICENSE).

`openapi_raynet.json` is the vendor's own API specification, retrieved
unmodified from <https://app.raynet.cz/api/doc/> and copyright RAYNET s.r.o.
It lives in this repository so the contract test in
`tests/test_openapi_contract.py` runs offline, is **not** included in the
published sdist or wheel, and is not covered by this project's licence. See
[NOTICE](NOTICE). That test skips automatically when the file is absent, so an
installed package runs the rest of the suite cleanly.
