Raynet MCP
An MCP server that exposes the Raynet CRM REST API to an LLM agent over stdio, built with FastMCP.
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 -- 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.
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)
{
"mcpServers": {
"raynet": {
"command": "uvx",
"args": ["raynet-mcp"],
"env": {
"RAYNET_API_TOKEN": "your-token-here",
"RAYNET_WRITE_ENABLED": "false"
}
}
}
}
Codex (~/.codex/config.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
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:
-
Call
raynet_describe_business_case_fields(for business cases) orraynet_valuelist(for any other list) to read the current ids and labels. -
Pass the id you found. For example:
{"business_case_phase": 123}
-
Optionally, pass the checked pair form instead of a bare id, to have the id and label cross-checked against each other:
{"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 issuePUT/POST/DELETEunlessRAYNET_WRITE_ENABLED=true, raisingRaynetWriteDisabledif it is called anyway. - At startup, if writes are not enabled, every tool tagged
writeis 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_updatedoes 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. Useraynet_business_case_set_phaseonce per record instead.- Line item
priceexcludes VAT;totalAmountincludes it. Raynet does not convert between the two, so a value copied from one into the other is wrong by the tax rate. owneris a person id, not a userAccount id -- see The ids-first contract.- Every write echoes the resolved label of each reference id it set, in
a
resolvedmap 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_getandraynet_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_deleterequirestypebecause the five activity kinds have separate id spaces -- the same number is a different record in each. state,roleandratingon a client, andpriorityon 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 carriesrole="E_OWN", which the document's enum omits. Read the codes your instance uses off existing records (raynet_company_listandraynet_lead_listreturn them) rather than guessing, and expect the API, not this server, to reject a bad one.
Testing
Three independent layers of verification exist:
# 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.
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. That test skips automatically when the file is absent, so an
installed package runs the rest of the suite cleanly.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file raynet_mcp-0.2.1.tar.gz.
File metadata
- Download URL: raynet_mcp-0.2.1.tar.gz
- Upload date:
- Size: 96.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1db97ef74478ff994a511f0ee53e09a45a654edbcface2b2804baff40cf362a4
|
|
| MD5 |
c2280918c8cdd41878b34945bef34944
|
|
| BLAKE2b-256 |
5142a341fdf05387841c8b6de33f2dfec27cfd4b37ee43cce9523acce9415ac9
|
File details
Details for the file raynet_mcp-0.2.1-py3-none-any.whl.
File metadata
- Download URL: raynet_mcp-0.2.1-py3-none-any.whl
- Upload date:
- Size: 54.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e8b984f3630a1695012f387f6be73cfdce7db80755148c726fe77f770bc3939e
|
|
| MD5 |
0fce756829e17d06d49ab5959456ff0a
|
|
| BLAKE2b-256 |
0f75e82a82d95588149b7533d4dbdce0183a017c1bd79d79f1453dfb345b74db
|