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=truerefuses every write.SN_ALLOWED_TABLES/SN_DENIED_TABLESrestrict which tables may be touched.SN_MAX_BATCHbounds a batch.- The instance URL must be
https://(orhttp://for local development). - Every write is journalled;
sn_local action=undoreverts 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:
- One unified namespace across every table, not one tool per domain and a choice of 500 names.
- A local store. Notes, a record cache, saved queries, call history and a journalled write log with undo. Nothing above has one.
- A batch action that applies many writes in one HTTP request.
- Schema-aware parameters named after the documented
sysparm_*query parameters, so behaviour is predictable. - 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_modelandsys_csdm_contextare probed throughsys_db_objectfirst. 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
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
.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.
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=30cannot see a mutation oflimit: 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 reasonsurvives 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
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.
.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.
mutmutcaches its verdicts inmutants/. After changing a test, runrm -rf mutantsor 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.