Metadata-Version: 2.4
Name: snow-mcp-server
Version: 0.1.0
Summary: Production-grade, read-only MCP server for ServiceNow
Project-URL: Homepage, https://github.com/inogen-ai/snow-mcp-server
Project-URL: Repository, https://github.com/inogen-ai/snow-mcp-server
Project-URL: Issues, https://github.com/inogen-ai/snow-mcp-server/issues
License-Expression: Apache-2.0
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: mcp>=1.2
Requires-Dist: pydantic-settings>=2.2
Description-Content-Type: text/markdown

# snow-mcp-server

[![CI](https://img.shields.io/github/actions/workflow/status/inogen-ai/snow-mcp-server/ci.yml?branch=main&label=CI)](https://github.com/inogen-ai/snow-mcp-server/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)

![read-only ServiceNow tools driven over MCP](docs/assets/demo.gif)

A production-grade, **read-only** MCP server for ServiceNow: query any table, fetch a
single record, full-text search published knowledge articles, and discover a table's
schema — from Claude Desktop, Claude Code, or any MCP client — with throttling handled
properly (429/503 retried honoring `Retry-After`) and no live instance required to run
the test suite.

**Read-only by construction:** the HTTP layer this server is built on
(`snow_mcp.client.SnowClient`) exposes exactly one verb — `get`. There is no `post`,
`put`, `patch`, or `delete` method anywhere in the codebase, so there is nothing in the
tool layer that could ever issue a write, no matter what a caller asks for.

**The server can read whatever the account it's configured with can read.** ServiceNow
ACLs are the actual security boundary — this server enforces nothing beyond what the
account itself is permitted to see. **Use a dedicated, least-privilege read account,
not an admin account**, especially against a production instance. Row-level ACLs can
also filter individual records out of a table's results *and* its counts — a query
that should match N rows may legitimately show fewer, silently, if the account can't
see all of them.

`snow-mcp-server` is not affiliated with, endorsed by, or sponsored by ServiceNow, Inc.

## Quickstart

Requires [uv](https://docs.astral.sh/uv/) and a ServiceNow instance to point at. The
easiest way to get one is a free Personal Developer Instance (PDI) from
[developer.servicenow.com](https://developer.servicenow.com/) — no cost, ready in a few
minutes.

Once you have an instance, create a dedicated, least-privilege user for this server
rather than reusing an admin account (see the warning above) — or, on a throwaway PDI
where that's overkill, the built-in `admin` user is fine to start with.

    uvx snow-mcp-server

That starts the server over stdio. Until `snow-mcp-server` is published to PyPI, run it
from a local checkout instead:

    uv run --directory <path-to-checkout> snow-mcp-server
    # or, straight from GitHub without cloning:
    uvx --from git+https://github.com/inogen-ai/snow-mcp-server snow-mcp-server

In practice you'll point an MCP client at it instead of running it directly — for
Claude Code:

**Note:** until `snow-mcp-server` is published to PyPI, replace `uvx snow-mcp-server`
in the commands below with the from-source form
`uvx --from git+https://github.com/inogen-ai/snow-mcp-server snow-mcp-server` (see the
run-from-checkout note above).

    claude mcp add servicenow \
      -e SNOW_MCP_INSTANCE_URL=https://dev12345.service-now.com \
      -e SNOW_MCP_USERNAME=<your-username> \
      -e SNOW_MCP_PASSWORD=<your-password> \
      -- uvx snow-mcp-server

For Claude Desktop, add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "servicenow": {
      "command": "uvx",
      "args": ["snow-mcp-server"],
      "env": {
        "SNOW_MCP_INSTANCE_URL": "https://dev12345.service-now.com",
        "SNOW_MCP_USERNAME": "<your-username>",
        "SNOW_MCP_PASSWORD": "<your-password>"
      }
    }
  }
}
```

## Tools

| Tool | Parameters | Returns |
|---|---|---|
| `query_records` | `table: str`, `query: str = ""`, `limit: int = 20`, `fields: str = ""` | Records from any table (e.g. `incident`, `problem`, `change_request`, `sys_user`) matching the encoded `query`, restricted to `fields` (comma-separated) when given. `sys_id` is always present; `number` and `short_description`/`name` are prioritized when the table has them. Says plainly when more records are available than `limit` returned. |
| `get_record` | `table: str`, `sys_id: str` | One full record (all fields, display values) — typically the `sys_id` from a prior `query_records` or `search_knowledge` call. |
| `search_knowledge` | `query: str`, `limit: int = 10` | Full-text search across **published** knowledge base articles (`kb_knowledge`) — number, short_description, an HTML-stripped snippet of the article body, and sys_id per hit. `query` is plain search text, not an encoded query. The published-only filter is best-effort relevance filtering, not a security boundary — ServiceNow ACLs are the boundary, same as every other tool here. |
| `list_table_fields` | `table: str` | The fields (element name, column label, internal type) defined on a table — schema discovery so you know what's queryable before writing an encoded query. Capped at 200 fields per table. Reads `sys_dictionary`, which a hardened instance may ACL — grant the account read access to it, or expect empty results. |

### Encoded-query cheat sheet

`query_records`'s `query` parameter is a ServiceNow **encoded query**, the same syntax
the platform's own list views use:

| Syntax | Meaning |
|---|---|
| `^` | AND — `active=true^priority=1` |
| `^OR` | OR — `priority=1^ORpriority=2` |
| `field=value` | Exact match |
| `fieldLIKEvalue` | Partial/substring match |
| `123TEXTQUERY321=text` | Full-text search over the table (what `search_knowledge` uses internally against `kb_knowledge`) |

`123TEXTQUERY321` relies on ServiceNow's Zing full-text indexing, which is on by
default for `kb_knowledge` but may not be enabled (or may lag behind recent writes)
for other tables — if a full-text query on a non-`kb_knowledge` table comes back empty
unexpectedly, that's the first thing to check.

`fields` (a separate parameter, comma-separated) restricts which columns come back —
`sys_id` is always included even if you don't ask for it, so results always compose
into a follow-up `get_record` call.

## Environment variables

All settings are prefixed `SNOW_MCP_` and can be set in the environment or a `.env`
file (see `.env.example`).

| Variable | Default | Purpose |
|---|---|---|
| `SNOW_MCP_INSTANCE_URL` | *(unset, required)* | Your instance's bare origin, e.g. `https://dev12345.service-now.com` — must be https, no path/query/fragment. |
| `SNOW_MCP_USERNAME` | *(unset)* | Basic-auth username. Required together with `SNOW_MCP_PASSWORD` unless `SNOW_MCP_TOKEN` is set. |
| `SNOW_MCP_PASSWORD` | *(unset)* | Basic-auth password. |
| `SNOW_MCP_TOKEN` | *(unset)* | OAuth bearer token, sent as `Authorization: Bearer`. Mutually exclusive with `SNOW_MCP_USERNAME`/`SNOW_MCP_PASSWORD` — set exactly one auth mode. |
| `SNOW_MCP_ITEM_LIMIT` | `20` | Default max records returned per `query_records`/`search_knowledge` call before "more available" is reported. |
| `SNOW_MCP_TIMEOUT_SECONDS` | `30.0` | HTTP timeout (seconds) per ServiceNow request. |

Exactly one auth mode must be configured — basic (`SNOW_MCP_USERNAME` +
`SNOW_MCP_PASSWORD`) or bearer (`SNOW_MCP_TOKEN`). Setting both, or neither, fails
startup immediately with a message naming the env vars to fix, rather than failing
obscurely on the first tool call.

## Security notes

- **Read-only by construction**, not by policy: the HTTP client this server is built
  on has no write method to call in the first place. See the warning at the top of
  this README — the account's ServiceNow ACLs, not this server, decide what's
  actually readable.
- **Throttling handled.** 429/503 responses are retried honoring the instance's
  `Retry-After` header (clamped to at most 60s so a hostile or buggy value can't hang
  the process), falling back to exponential backoff (1→2→4s) when no header is sent,
  capped at 3 retries.
- Credentials never appear in error messages, logs, or exceptions raised to an MCP
  client — failures are reduced to plain, credential-free sentences before they leave
  the client layer.
- Not affiliated with, endorsed by, or sponsored by ServiceNow, Inc.

## Development

Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/).

    uv sync
    uv run pytest -q
    uv run ruff check .

No live ServiceNow instance is needed for the test suite — the Table API is faked at
the `httpx.MockTransport` boundary. See
[docs/manual-verification.md](docs/manual-verification.md) for the live-PDI check a
maintainer runs before releases.

## Contributing

Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup and PR
expectations, and [SECURITY.md](SECURITY.md) for reporting vulnerabilities privately.

---

Not affiliated with, endorsed by, or sponsored by ServiceNow, Inc.

Part of [InoGen's open-source portfolio](https://github.com/inogen-ai): [kilnworks](https://github.com/inogen-ai/kilnworks) (self-hostable RAG assistant) and the read-only MCP connectors [m365](https://github.com/inogen-ai/m365-mcp-server), [servicenow](https://github.com/inogen-ai/snow-mcp-server), [salesforce](https://github.com/inogen-ai/sfdc-mcp-server), and [hubspot](https://github.com/inogen-ai/hubspot-mcp-server).

Built and maintained by [InoGen](https://inogen.ai).
