snowmcp
# snowmcp — a consolidated ServiceNow MCP server
One MCP server for a ServiceNow instance. **Thirteen tools** instead of the
17–500 that other ServiceNow MCP servers expose, with a local store that
remembers what it learns.
## Why thirteen tools
Every ServiceNow MCP server in the wild exposes one tool per operation:
`sn_query`, `sn_get`, `sn_create`, `sn_update`, `sn_delete`, `sn_aggregate`,
`sn_schema`, `sn_relationships`, `sn_attach`, `sn_syslog`, … and the list
goes on. That is the wrong shape for an agent. Each tool's schema competes
for context, and the model has to work out which of 500 names to reach for
before it can do anything.
Here each tool covers a **domain**, and an `action` parameter selects the
operation. One schema does the work of many, and a single call can carry a
whole job — `sn_write` with `action=batch` applies many changes in one HTTP
request instead of one call per change.
| Tool | Read-only | What it covers |
|---|---|---|
| `sn_read` | yes | query / get / count records on any table |
| `sn_write` | no (destructive) | create / update / delete / batch |
| `sn_aggregate` | yes | stats and grouped statistics |
| `sn_schema` | yes | tables, fields, CMDB classes, field references |
| `sn_graph` | yes | Service Graph: relations, traverse, dependents, impact, CSDM |
| `sn_catalog` | no | catalog items, categories, variables, ordering |
| `sn_files` | no | attachments: list, upload, download, delete |
| `sn_code` | yes | the instance's own scripts, rules, policies, ACLs |
| `sn_ops` | yes | system log, health, jobs, properties, stats |
| `sn_knowledge` | yes | read what the server already knows: notes, saved queries, history, cache |
| `sn_state` | no | write the server's own local memory: notes, saved queries, cache |
| `sn_journal` | no (destructive) | list applied writes, revert one |
| `sn_admin` | no | update sets, scripts, ATF tests, users, groups, roles |
Thirteen tools, no tool carrying more than eight actions. Read-only tools
carry `readOnlyHint: true`, so a client running with `trust: untrusted` only
asks for approval on the tools that can actually change the instance — and
`sn_journal`'s `undo` is marked destructive because it does.
## What it stores
`sn_local` is the server's own memory, all local — nothing leaves the box:
- **knowledge base** — markdown notes the agent writes and re-reads
- **record cache** — records already fetched, so a second lookup costs no
API call
- **saved queries** — a named query becomes one call later, not ten
- **call history** — what the agent already did, so it does not do it twice
- **write journal** — every applied write, with a plan to revert it
## Safety
- Every write needs `confirm=true`. The server refuses without it.
- `SN_READ_ONLY=true` refuses every write.
- `SN_ALLOWED_TABLES` / `SN_DENIED_TABLES` restrict which tables may be touched.
- `SN_MAX_BATCH` bounds a batch.
- The instance URL must be `https://` (or `http://` for local development).
- Every write is journalled; `sn_local action=undo` reverts it.
## How this relates to ServiceNow's own MCP servers
ServiceNow ships **official** MCP servers, and that changes what a
third-party server is for:
| Server | What it is |
|---|---|
| **MCP Server Console** (`sn_mcp_server`) | A platform product for building MCP servers on an instance. Exposes any REST API, AI skills, subflows, actions and Knowledge Graph schemas as tools. Streamable HTTP at `https://<instance>/sncapps/mcp-server/mcp/<name>`, OAuth 2.0. |
| **ITSM MCP Server** (`sn_itsm_mcp_server`) | First-party ITSM tools: `incident.get_details`, `incident.modify`, `change.lifecycle`, `change.analyze`, `change.query`, `change.relation`, requester tools. |
| **CSM MCP Server** | `get_case_tasks`, `get_customer_service_cases`, `case_summarization`, `resolution_notes_generation`, `sentiment_analysis`. |
| **MCP for Enterprise Architecture** | Includes *Related Entities Resolver*, whose `traverse` mode does multi-level impact analysis from a single CI. |
This server does not compete on raw connectivity — ServiceNow already has
that covered. It exists for the gaps the first-party servers leave:
1. **One unified namespace across every table**, not one tool per domain and
a choice of 500 names.
2. **A local store.** Notes, a record cache, saved queries, call history and
a journalled write log with undo. Nothing above has one.
3. **A batch action** that applies many writes in one HTTP request.
4. **Schema-aware parameters** named after the documented `sysparm_*` query
parameters, so behaviour is predictable.
5. **Client-side graph traversal** with resolved relationship-type labels.
## Endpoints it calls
Verified against ServiceNow's own API reference, not assumed.
| Area | Endpoint |
|---|---|
| Table API | `GET/POST/PUT/PATCH/DELETE /api/now/table/{table}` and `/{sys_id}` |
| Aggregate (stats) | `GET /api/now/stats/{table}` with `sysparm_aggregate` |
| Batch | `POST /api/now/v1/batch` with `rest_requests[]` (id, method, relative url, Base64 body) |
| CMDB class schema | `GET /api/now/cmdb/meta/{className}` — the only *documented* class-introspection endpoint |
| CMDB relations | `GET /api/now/table/cmdb_rel_ci` (the traversal table), types resolved from `cmdb_rel_type` |
| Attachments | `GET/POST/DELETE /api/now/attachment` |
| Service Catalog | `/api/sn_sc/servicecatalog/items`, `.../{sys_id}`, `.../{sys_id}/order_now` — note the API has **its own root**, not `/api/now/...` |
| Update sets | `GET/POST /api/now/table/sys_update_set` |
| Server-side script | **No documented REST endpoint exists.** Point `SN_SCRIPTED_REST_URL` at a Scripted REST API you control. |
Two things are deliberately *not* hardcoded, because ServiceNow's public
contract does not name them:
- `sys_csdm_domain_model` and `sys_csdm_context` are probed through
`sys_db_object` first. If the instance does not have them, the tool says so
plainly instead of returning a confusing 404.
- CMDB class field lists are read from the instance, never hardcoded — CSDM
class models are extensible per instance.
## Install
```bash
python -m venv .venv
.venv/bin/pip install -e .
```
## Configure
Copy `.env.example` to your environment and fill it in.
| Variable | Meaning |
|---|---|
| `SN_INSTANCE_URL` | `https://yourcompany.service-now.com` (required) |
| `SN_USERNAME` / `SN_PASSWORD` | basic auth |
| `SN_OAUTH_CLIENT_ID` / `SN_OAUTH_CLIENT_SECRET` / `SN_OAUTH_TOKEN_URL` | OAuth2 client credentials (instead of basic) |
| `SN_SCRIPTED_REST_URL` | path of a Scripted REST API that executes server-side script, e.g. `/api/sn_gsc/v1/execute` |
| `SN_READ_ONLY` | `true` refuses every write |
| `SN_ALLOWED_TABLES` | comma-separated allow list |
| `SN_DENIED_TABLES` | comma-separated deny list |
| `SN_REQUIRE_CONFIRM` | `false` drops the `confirm=true` requirement |
| `SN_MAX_BATCH` | maximum operations in one batch (default 100) |
| `SN_STORE_PATH` | where the local store lives |
| `SN_TIMEOUT` | HTTP timeout in seconds (default 60) |
## Run
```bash
.venv/bin/snowmcp # stdio
.venv/bin/python -m snowmcp # the same
```
## Use it from Hermes
Full walkthrough - `hermes mcp add`, the trust model, the safety switches and
troubleshooting: **[docs/HERMES_SETUP.md](docs/HERMES_SETUP.md)**.
```yaml
mcp_servers:
servicenow:
command: "/home/agent/projects/snowmcp/.venv/bin/python"
args: ["-m", "snowmcp"]
env:
PYTHONPATH: "/home/agent/projects/snowmcp/src"
SN_INSTANCE_URL: "${env:SN_INSTANCE_URL}"
SN_USERNAME: "${env:SN_USERNAME}"
SN_PASSWORD: "${env:SN_PASSWORD}"
SN_STORE_PATH: "/home/agent/.hermes/data/snowmcp/store.sqlite3"
enabled: false
trust: untrusted
timeout: 120
```
It is registered in this profile's config with `enabled: false`. Set the
instance details in your profile's secret store, then flip `enabled` to
`true` (or run `hermes mcp test servicenow` to check the connection).
`trust: untrusted` makes Hermes ask for approval on any write-capable tool
call. The server's own `confirm=true` requirement still applies on top.
## Four test tiers
Tier 1 - unit: domain rules in isolation, no I/O.
Tier 2 - integration: real components against real collaborators.
Tier 3 - QA: whole-system, from the composition root, plus architecture.
Tier 4 - mutation: the tests must actually fail when the code is wrong.
There is a **fifth kind of test** that pays for itself, even though it is
not a tier of its own: the **contract test**. For each entry point that
talks to the outside world, assert the exact request it makes - the path,
the method, every query parameter, the body - not only what comes back.
Result-level tests pass while the request quietly changes shape. Contract
tests catch that, and they are what closes most mutation survivors.
Three contract rules that the mutation gate taught us the hard way:
- **Test the default, not just the explicit value.** A contract test that
always passes `limit=30` cannot see a mutation of `limit: int = 200`.
Every service is also called with only its required arguments, and the
resulting request is pinned.
- **Test the defensive branches.** A guard for "the result is not a list"
changes nothing about what the happy path returns, so the happy-path
test cannot see it. Those branches are tested directly.
- **Assert the whole message, not a substring.** `assert "not part of the
public contract" in reason` survives a mutation of any other word in
that message.
## Architecture
Clean Architecture, dependencies pointing strictly inward:
```
src/snowmcp/domain/ business rules, value objects, policy. No framework.
src/snowmcp/application/ use cases, ports, endpoint policy.
src/snowmcp/adapters/ HTTP transport, SQLite store, MCP surface.
src/snowmcp/infrastructure/ configuration and the composition root.
```
`tests/qa/test_architecture.py` parses every import and fails the build on an
outward dependency, so the rule is enforced rather than merely described.
## Development
```bash
make setup # uv venv + pytest, pytest-cov, mutmut, hypothesis
make test # the suite
make coverage # 100% branch coverage, enforced
make mutation # mutation testing + the score gate
make gate # both
```
The mutation gate reports a **score** and enforces a floor
(`MIN_MUTATION_SCORE`, currently 88). It is a ratchet: raise it as survivors
are closed, never lower it.
```bash
.venv/bin/python scripts/mutation_gate.py --min-score 88 --json
```
**Current score: 88.89%** — 2337 of 2629 mutants killed, 290 survived, 2 timed
out. The gate refuses to score a partial run, and counts a timeout against
the score rather than crediting it: a worker killed for overrunning neither
passed nor failed, so it proves nothing about the coverage.
The gate also refuses results that belong to a different version of the
source. mutmut records a hash of every function it mutated; the gate
recomputes those hashes from the current source and fails if any of them
differ. This matters because two mutation runs that share the `mutants/`
directory — the second starting with `rm -rf mutants` while the first is
still going — produce verdicts about mutations of code that is no longer
there, and the mutant *names* are identical in both, so only the hashes
reveal it.
**Scope:** the mutation gate covers `src/snowmcp/domain/` and
`src/snowmcp/application/` — the business rules and use cases. The adapters
(transport, SQLite store, MCP surface) are excluded by an explicit
`only_mutate` in `pyproject.toml`, because a mutant there is a changed
default limit or a renamed dict key rather than a wrong rule, and the
contract tests already pin those exactly. The scope is declared in the
config so the score is never mistaken for a full-tree number.
Coverage says a line ran; only mutation testing says a wrong answer would
have been caught. Most surviving mutants are *argument* mutants - a changed
default, a removed keyword argument - which is why the contract tests
(`tests/integration/test_*_contracts.py`) matter as much as the unit tests.
**The work queue** is the survivors, grouped by function. The current
largest clusters are `GraphService.relations` (12) and
`CodeService.where_used` (9); everything else is seven or fewer. Closing
them means one of four things each time: a contract test that pins the
exact request, a test that exercises the *default* value of a parameter
rather than an explicit one, a test of the defensive branch, or an
assertion on the journal entry a write leaves behind. Every round of
fixes so far has raised the score and never lowered it.
Some survivors are **equivalent mutants** and cannot be killed by any
test: a fallback value that is only reachable in a branch the code never
takes. Those are recorded rather than chased — a test contorted to kill
one would be asserting something the code does not promise.
> `mutmut` caches its verdicts in `mutants/`. After changing a test, run
> `rm -rf mutants` or the next run replays stale results. A full scoped run
> takes about an hour on four cores, so run it in the background - and do
> not run other suites while it does, or the workers will be killed for
> overrunning and the score will be understated.
## Licence
MIT.
TDQS
Scored across 13 tools
Tools are cleanly partitioned by domain (schema, read, write, aggregate, graph, catalog, files, code, ops, knowledge, state, journal, admin), so an agent can generally pick the right one. Minor overlap remains: sn_ops action=stats versus sn_aggregate action=stats, and sn_code action=where_used versus sn_graph action=impact, though their descriptions mostly clarify the intended scope.
All tool names follow a strict sn_<domain> snake_case pattern with no mixing of conventions. The action=... parameter style is used uniformly across every multi-action tool, making the surface highly predictable.
Thirteen tools is well-scoped for a broad platform like ServiceNow, and the multi-action design consolidates many operations into coherent domains. Each tool earns its place without bloating the surface.
The surface covers the full lifecycle: schema introspection, CRUD via sn_read/sn_write, aggregation, CMDB graph traversal, catalog ordering, attachments, code search, instance operations, local memory, write journaling/undo, and admin surfaces. No obvious dead ends for the stated ServiceNow automation purpose.