Generic Table API wrappers
Query, get, create, update, delete and aggregate against any table you name.
Think: the raw platform, with a permission prompt in front of it.
← Back to cipheryogi portfolio
One Python script wraps the ServiceNow Table API as 60 MCP tools. Register the same command once per instance, change only the environment variables, and an AI client can read and write platform tables, Flow Designer logs, generative AI logs and the whole AI Agent Studio object model across every instance you work on, with nothing hosted and nothing listening on a network port.
Companion guides: Claude Code File Structure & Configuration (where instructions, settings, MCP configuration and credentials live) and Claude Code Capabilities (tools, agents, skills, hooks, permission modes, models and automation).
One Python script, launched once per ServiceNow instance, exposing 60 tools to an MCP client.
The problem it solves. You want an AI client, Claude Code or any other MCP-capable client, to read and write ServiceNow data across several instances without building a separate integration per instance and without hosting anything. The answer here is deliberately small.
server_single.py is a FastMCP server that wraps the ServiceNow Table API as 60 tools. You add a tool by editing this one file.server_single.py over stdio.| Item | Value |
|---|---|
| Tools exposed | 60 |
| Launchers | 2: Claude Code, and a second desktop MCP client |
| Instances | 2 in this example; the pattern scales to any number |
| Transport | stdio |
| Auth | Basic Auth, with the OAuth password grant optional |
| Stack | Python 3.13 · uv · FastMCP |
| Alternative covered | ServiceNow's native MCP Server Console, in sections 14 and 15 |
| Placeholder | What to substitute |
|---|---|
| <instance-a>, <instance-b>, <customer-a> | Example ServiceNow instances you are wiring up: two personal dev instances and one customer instance. |
| https://<instance>.service-now.com | Any instance base URL. |
| <username>, <password> | The integration account's credentials. |
| <client-id>, <client-secret> | OAuth Application Registry credentials. |
| <project-root> | The folder holding the server script. |
| ~/ | Your home folder. |
| <client-b-folder> | The folder holding Client B's copy of the script. |
| SN_ | The environment-variable prefix. |
| sn_ | The tool-name prefix. |
SN_ and sn_ are labels you choose when you register the server. The variable names are identical for every instance, and each process reads its own instance URL from SN_INSTANCE, so the prefix carries no meaning beyond readability. Two reading notes: every filesystem path below is shown relative to <project-root> or ~/, and inside code blocks placeholders are printed in grey.Two launchers, two copies of the script, any number of instances, all over stdio and HTTPS.
flowchart TD
subgraph LOCAL["Your machine"]
direction TB
CC["MCP client A
Claude Code · CLI or editor extension"]
CW["MCP client B
second desktop MCP client"]
CJ["Client A config
env block per server"]
CI["Client B config
env placeholders per server"]
UVA["uv run python server_single.py
one process per server"]
UVB["uv run server_single.py
one process per server"]
SA["Script A
<project-root>/mcp-server/server_single.py"]
SB["Script B · local copy
~/<client-b-folder>/server_single.py"]
CC --> CJ
CJ --> UVA
UVA --> SA
CW --> CI
CI --> UVB
UVB --> SB
end
SA -- "HTTPS REST
/api/now/table/..." --> E1["Client A: one server per instance
e.g. <instance-a>, <instance-b>, <customer-a>"]
SB -- "HTTPS REST" --> E2["Client B: one server per instance
same pattern"]
classDef client fill:#e8f1ff,stroke:#3975c6,color:#132238;
classDef cfg fill:#fff5dd,stroke:#9a6500,color:#5a3d00;
classDef code fill:#f1eaff,stroke:#7b52b9,color:#2f1f4b;
classDef inst fill:#e8f7f1,stroke:#157a5a,color:#103e31;
class CC,CW client;
class CJ,CI cfg;
class UVA,UVB,SA,SB code;
class E1,E2 inst;
flowchart TD SCRIPT["server_single.py
one file, 60 tool functions"] REG["Registered once per instance
same command, different env vars"] SCRIPT --> REG REG --> P1["Process 1
SN_INSTANCE=https://<instance-a>.service-now.com"] REG --> P2["Process 2
SN_INSTANCE=https://<instance-b>.service-now.com"] REG --> PN["Process N
SN_INSTANCE=https://<instance-n>.service-now.com"] P1 --> T1["Tools exposed as
mcp__servicenow-sn-<instance-a>__sn_*"] P2 --> T2["Tools exposed as
mcp__servicenow-sn-<instance-b>__sn_*"] PN --> TN["Tools exposed as
mcp__servicenow-sn-<instance-n>__sn_*"] classDef code fill:#f1eaff,stroke:#7b52b9,color:#2f1f4b; classDef proc fill:#e8f1ff,stroke:#3975c6,color:#132238; classDef tool fill:#e8f7f1,stroke:#157a5a,color:#103e31; class SCRIPT,REG code; class P1,P2,PN proc; class T1,T2,TN tool;
Every location that matters, and whether it is shared between servers or local to one client.
flowchart TD H["~ · home"] --> CJ["Client A config file
live credentials, env block per server"] H --> VE["~/.venvs/mcp-server
shared Python environment"] H --> AM["~/<client-b-folder>
Client B copy: server_single.py, pyproject.toml, uv.lock, .venv"] H --> LIB["~/Library/Application Support/<client-b>
integrations.json"] H --> OD["<project-root>"] OD --> MS["mcp-server
server_single.py, pyproject.toml, uv.lock, .env · unused"] OD --> DS["design, skills"] classDef a fill:#e8f1ff,stroke:#3975c6,color:#132238; classDef g fill:#e8f7f1,stroke:#157a5a,color:#103e31; classDef y fill:#fff5dd,stroke:#9a6500,color:#5a3d00; class H,OD a; class CJ,LIB,MS y; class VE,AM,DS g;
| File or folder | Purpose | Shared or local |
|---|---|---|
Client A user config, which is ~/.claude.json for Claude Code | One entry per server under mcpServers, including the env block with instance URL, username and password. The live source of credentials for Client A. | Local to the machine, shared across all servers |
<project-root>/mcp-server/ | Project folder for Script A. uv is pointed at this folder with --directory. | Shared by every Client A server |
<project-root>/mcp-server/server_single.py | Script A, the FastMCP server with all 60 tools, about 2,534 lines. | Shared by every Client A server |
<project-root>/mcp-server/pyproject.toml | Declares Python >=3.13 and three dependencies: mcp[cli]>=1.27.0, python-dotenv>=1.2.2, requests>=2.33.1. | Shared |
<project-root>/mcp-server/uv.lock | Pinned dependency versions. | Shared |
<project-root>/mcp-server/.python-version | Contains 3.13. | Shared |
<project-root>/mcp-server/.env | Legacy credentials file for one instance only. Not used in practice, as section 7 explains. | Local, legacy |
<project-root>/mcp-server/server_single.backup_<timestamp>.py | Backup of Script A taken before an edit. | Local |
swap_instance.sh, add_instance.sh, setup.sh and their .ps1 versions | Legacy scripts from an earlier one-instance-at-a-time approach. They rewrite server_single.py with sed. Not needed for the setup in this guide. | Local, legacy |
| Reference copies of Script A and Script B | Snapshot copies of both scripts kept next to this guide's source document. Nothing launches them, so editing them changes nothing. | Reference only |
~/.venvs/mcp-server | Shared Python virtual environment on Python 3.13.x. Every Client A server points at it with UV_PROJECT_ENVIRONMENT. | Shared by all Client A servers |
~/<client-b-folder>/ | Project folder for Script B, the Client B copy. Contains server_single.py, pyproject.toml, uv.lock, .python-version and its own .venv. | Local to Client B |
~/<client-b-folder>/server_single.py | Script B, about 2,478 lines. The same 60 tools, different start-up code, as section 13 explains. | Local to Client B |
~/Library/Application Support/<client-b>/integrations.json | Client B's list of MCP servers. Each stdio server has a command and an env map whose values are placeholders of the form $MCP_ENV_<server-id>_<VARIABLE>. | Local to Client B |
/opt/homebrew/bin/uv | The uv package manager and runner, installed with Homebrew on Apple silicon. | Shared |
Install these once per machine, then never think about them again.
| Item | What it is, and how to check | Notes |
|---|---|---|
| Homebrew | Installs uv. Check with brew --version. | Install it from brew.sh. On Apple silicon it lives in /opt/homebrew. |
| uv | Runs the script and creates the virtual environment. Check with /opt/homebrew/bin/uv --version. | brew install uv. Verified against uv 0.11.x. |
| Python 3.13 | Required by pyproject.toml (>=3.13). Check with python3.13 --version. | uv can fetch it for you with uv python install 3.13. The shared environment here uses 3.13.x. |
| An MCP client | Registers and launches the servers. For Claude Code, check with claude --version. | Verified against Claude Code 2.1.x. |
| ServiceNow account | A user that can read, and write if you want the authoring tools, the tables you will touch. This example uses an admin account on the personal dev instances and service accounts on customer instances. | Basic Auth must be allowed for the account. If the account is forced through SSO or MFA, create a local service account instead. |
| Network | The machine must reach https://<instance>.service-now.com. Check with curl -sI https://<instance>.service-now.com | head -1. | VPN and proxy rules apply exactly as they do for a browser. |
The primary runtime. Script in the project folder, credentials in the client config.
flowchart TD S0["0 · Prerequisites
Homebrew, uv, Python 3.13, Claude Code"] --> S1["1 · Create the project folder
pyproject.toml + server_single.py"] S1 --> S2["2 · Create the shared venv
uv sync with UV_PROJECT_ENVIRONMENT"] S2 --> S3["3 · Test the script by hand
with the three env variables"] S3 --> Q{"Prints the OK banner
and stays running?"} Q -- "No" --> T["Go to Troubleshooting"] Q -- "Yes" --> S4["4 · Register one server per instance
claude mcp add --scope user"] S4 --> S5["5 · Restart the client
tools appear as mcp__servicenow-sn-NAME__*"] S5 --> S6["6 · Prove it reaches the instance
query sys_user, limit 1"] S6 --> S7["7 · Optional: allow-list the read tools"] classDef a fill:#e8f1ff,stroke:#3975c6,color:#132238; classDef g fill:#e8f7f1,stroke:#157a5a,color:#103e31; classDef y fill:#fff5dd,stroke:#9a6500,color:#5a3d00; class S0,S1,S2,S3,S4,S5 a; class S6,S7 g; class Q,T y;
Create the folder and put two files in it: server_single.py, whose structure section 8 describes, and pyproject.toml.
mkdir -p "<project-root>/mcp-server" cd "<project-root>/mcp-server"
The whole of pyproject.toml:
[project] name = "mcp-server" version = "0.1.0" description = "ServiceNow MCP server" readme = "README.md" requires-python = ">=3.13" dependencies = [ "mcp[cli]>=1.27.0", "python-dotenv>=1.2.2", "requests>=2.33.1", ]
ls shows pyproject.toml and server_single.py. Create an empty README.md as well, because pyproject.toml names it.Point uv at one environment outside the project folder, then install the dependencies into it. All servers share this environment.
export UV_PROJECT_ENVIRONMENT="$HOME/.venvs/mcp-server"
cd "<project-root>/mcp-server"
/opt/homebrew/bin/uv sync
Check the environment is real and the dependencies import:
~/.venvs/mcp-server/bin/python --version # prints Python 3.13.x ~/.venvs/mcp-server/bin/python -c "import mcp, requests, dotenv" # prints nothing
Run it with the three environment variables set for one instance. This proves the script and its dependencies work before the AI client is involved.
cd "<project-root>/mcp-server" SN_INSTANCE="https://<instance-a>.service-now.com" \ SN_USERNAME="<username>" \ SN_PASSWORD="<password>" \ UV_PROJECT_ENVIRONMENT="$HOME/.venvs/mcp-server" \ /opt/homebrew/bin/uv run python server_single.py
[OK] Connected, then a short banner between two lines of = signs, and then waits. Press Ctrl+C to stop it.Use the Claude Code CLI at user scope so the servers are available in every project. Repeat for each instance, changing the server name, URL, username and password. The variable names stay the same for every instance, only the values change.
claude mcp add servicenow-sn-<instance-a> --scope user \ -e SN_INSTANCE="https://<instance-a>.service-now.com" \ -e SN_USERNAME="<username>" \ -e SN_PASSWORD="<password>" \ -e UV_PROJECT_ENVIRONMENT="$HOME/.venvs/mcp-server" \ -- /opt/homebrew/bin/uv --directory "<project-root>/mcp-server" run python server_single.py claude mcp add servicenow-sn-<instance-b> --scope user \ -e SN_INSTANCE="https://<instance-b>.service-now.com" \ -e SN_USERNAME="<username>" \ -e SN_PASSWORD="<password>" \ -e UV_PROJECT_ENVIRONMENT="$HOME/.venvs/mcp-server" \ -- /opt/homebrew/bin/uv --directory "<project-root>/mcp-server" run python server_single.py
The result is stored in the Claude Code user config. One entry looks like this:
"servicenow-sn-<instance-a>": { "type": "stdio", "command": "/opt/homebrew/bin/uv", "args": [ "--directory", "<project-root>/mcp-server", "run", "python", "server_single.py" ], "env": { "SN_INSTANCE": "https://<instance-a>.service-now.com", "SN_USERNAME": "<username>", "SN_PASSWORD": "<password>", "UV_PROJECT_ENVIRONMENT": "<home>/.venvs/mcp-server" } }
claude mcp list shows each server you added as connected.Every server in ~/.claude.json is built this way. All of them use the same command, arguments and shared venv as step 4. Only the env values differ, as these examples show.
| Server name | SN_INSTANCE | SN_USERNAME |
|---|---|---|
servicenow-sn-<instance-a> | https://<instance-a>.service-now.com | admin |
servicenow-sn-<instance-b> | https://<instance-b>.service-now.com | admin |
servicenow-sn-<customer-a> | https://<customer-a>.service-now.com | <service-account> |
Restart the editor window or the CLI session. Tools are named mcp__<server name>__<tool name>, for example mcp__servicenow-sn-<instance-a>__sn_query.
Ask the client: "Using the <instance-a> server, query sys_user with limit 1." A returned record proves the whole chain: client, stdio, the script, HTTPS, Basic Auth, the instance.
sys_id. An HTTP 401 means the username or password is wrong.Add entries such as mcp__servicenow-sn-<instance-a>__sn_query to the permissions.allow list in ~/.claude/settings.json. Leave write and delete tools off the list so the client still asks first.
The second runtime. A separate copy of the script, launched by a second desktop client application.
Client B is any second desktop MCP client that can launch a stdio server. The transferable part is the shape: its own copy of the script, its own virtual environment, and an integrations.json that stores the environment values as placeholders.
Client B runs its own copy. Create ~/<client-b-folder> and place server_single.py (Script B), pyproject.toml with the same dependencies, and .python-version containing 3.13 in it.
mkdir -p "$HOME/<client-b-folder>" cd "$HOME/<client-b-folder>" /opt/homebrew/bin/uv sync
.venv folder now exists inside the Client B folder. Unlike Script A, this copy uses its own environment, not the shared one.Script B is Script A with the start-up code changed: no OAuth support, and it loads a .env from its own folder rather than using python-dotenv's default search. The easiest way to produce it is to copy Script A and apply the differences listed in section 13. Both copies expose the same 60 tool names.
In the client application, add a stdio MCP server with this command, once per instance, with the three variables SN_INSTANCE, SN_USERNAME and SN_PASSWORD.
/opt/homebrew/bin/uv --directory ~/<client-b-folder> run server_single.py
Client B records each server in ~/Library/Application Support/<client-b>/integrations.json under mcp_servers. The env values are stored as placeholders of the form $MCP_ENV_<server-id>_SN_USERNAME, and the client substitutes the real value when it launches the process.
$MCP_ENV_* placeholder is not documented, so treat the credential as present on the machine in some form and rotate it accordingly.ps aux | grep <client-b-folder>
~/<client-b-folder>/.venv/bin/python3 server_single.py whose parent is uv --directory ~/<client-b-folder> run server_single.py, started by the client application's bundled helper binary.| Aspect | A: Claude Code | B: second desktop client |
|---|---|---|
| Launcher | Claude Code | Second desktop MCP client, through its bundled helper process |
| Project folder | <project-root>/mcp-server | ~/<client-b-folder> |
| Script length | About 2,534 lines | About 2,478 lines |
| Virtual environment | ~/.venvs/mcp-server, shared, set with UV_PROJECT_ENVIRONMENT | ~/<client-b-folder>/.venv, its own |
| Command | uv --directory … run python server_single.py | uv --directory ~/<client-b-folder> run server_single.py |
| Credentials stored in | Claude Code user config, as a plaintext env block | integrations.json, as placeholders resolved at launch |
| OAuth password grant | Supported when SN_CLIENT_ID and SN_CLIENT_SECRET are set | Not supported, Basic Auth only |
| Dotenv loading | load_dotenv() with the default search | load_dotenv(Path(__file__).parent / '.env') |
The environment from the client's config wins. The .env file is only a fallback.
flowchart TD A["Client starts the server process
with the env block from its config"] --> B["Environment now holds
SN_INSTANCE / SN_USERNAME / SN_PASSWORD"] B --> C["Script runs load_dotenv()"] C --> D{"Variable already set
in the environment?"} D -- "Yes" --> E["Environment value wins
.env is ignored for that variable"] D -- "No" --> F["Value is read from .env
if a .env file is found"] E --> G["os.getenv feeds ServiceNowClient"] F --> G G --> H{"Client id and secret
both set? Script A only"} H -- "Yes" --> I["OAuth password grant
POST /oauth_token.do"] H -- "No" --> J["Basic Auth
username and password"] classDef a fill:#e8f1ff,stroke:#3975c6,color:#132238; classDef g fill:#e8f7f1,stroke:#157a5a,color:#103e31; classDef y fill:#fff5dd,stroke:#9a6500,color:#5a3d00; class A,B,C,G a; class E,J g; class D,H,F,I y;
.env is never consulted for them. python-dotenv does not override variables that are already set..env in the project folder holds one instance and goes stale. It can be deleted without changing behaviour. Do not use it as the place to add new instances.https://<instance>.service-now.com, a bare name such as <instance>, which becomes the full URL, or a host name. The script normalises all three.Every tool is a Python function that calls ServiceNow's REST API and returns JSON text.
sequenceDiagram
autonumber
actor U as You
participant C as AI client
participant S as server_single.py · FastMCP over stdio
participant K as ServiceNowClient
participant N as ServiceNow instance
U->>C: Show me the last 5 failed flow logs
C->>S: tools/call sn_query_flow_logs · JSON over stdin
S->>K: table_get on sys_flow_log with query and limit
K->>N: GET /api/now/table/sys_flow_log · Basic Auth · 30 s timeout
N-->>K: 200 with a result array
K-->>S: success true, plus the data payload
S-->>C: JSON text string of records
C-->>U: Summary written from the records
| Piece | Approximate location | What it does |
|---|---|---|
| ServiceNowClient | lines 16 to ~180 | Builds the base URL, holds one requests.Session, applies Basic Auth, or OAuth when a client id and secret are set, sets JSON headers and a 30 second timeout. Every response comes back as {success, status_code, data, error}. |
| table_get / table_create / table_update / table_delete | inside the client | GET, POST, PATCH and DELETE on /api/now/table/<table>. Reads set sysparm_exclude_reference_link=true and accept limit, offset, order and display value. |
| aggregate | inside the client | Calls the Aggregate API, /api/now/stats/<table>, for counts and statistics. |
| batch_operation | inside the client | Loops table_update or table_delete over a list of records and returns {success: [...], failed: [...]}. |
| client = ServiceNowClient(...) | ~line 166 | Reads the five environment variables once at start-up. |
| mcp = FastMCP("servicenow-<instance>") | ~line 174 | Creates the MCP server. Each function decorated with @mcp.tool() becomes a tool. The docstring becomes the description the model reads. |
| qh() helper | ~line 268 | Quick query helper used by most read tools. Caps the limit at 1000 and defaults to display values. |
| _resolve_agent_fields() | ~line 195 | Probes the sn_aia_agent columns once and caches the names, because the role and instructions columns differ between releases: role against agent_role, instructions against list_of_steps. |
| mcp.run(transport="stdio") | end of file | Starts the server on stdin and stdout. |
confirm=True is passed. Most other writes have no guard, so the client's permission prompt is the safety net.Query, get, create, update, delete and aggregate against any table you name.
Think: the raw platform, with a permission prompt in front of it.
The table and field names are fixed in the function, so the model does not have to know the AI agent object model to ask a useful question.
Think: a named question, not a table scan.
Multi-record operations such as sn_create_ai_agent, sn_create_rag_pipeline, sn_clone_ai_agent and sn_add_record_operation_tool.
Think: a small piece of automation behind one call.
1. Reading the environment variables, which happens once, at import time:
import os from dotenv import load_dotenv load_dotenv() # Basic Auth uses SN_INSTANCE / SN_USERNAME / SN_PASSWORD. # For the optional OAuth password grant, also set SN_CLIENT_ID / SN_CLIENT_SECRET. # When the client id and secret are both present, OAuth is used instead of Basic Auth. client = ServiceNowClient( instance=os.getenv("SN_INSTANCE"), username=os.getenv("SN_USERNAME"), password=os.getenv("SN_PASSWORD"), client_id=os.getenv("SN_CLIENT_ID"), client_secret=os.getenv("SN_CLIENT_SECRET"), ) mcp = FastMCP("servicenow-instance") print("[OK] Connected", file=sys.stderr)
2. One representative tool definition and the helper almost every read tool calls:
@mcp.tool()
def sn_query(table: str, query: str = "", fields: str = "", limit: int = 20) -> str:
"""Query any table on the active instance."""
return qh(table, query, fields, limit)
def qh(tbl: str, qry: str = "", flds: str = "", lmt: int = 20,
ob: str = "-sys_created_on", dv: bool = True) -> str:
"""Quick helper for table queries."""
fl = [f.strip() for f in flds.split(",")] if flds else None
r = client.table_get(tbl, query=qry or None, fields=fl,
limit=min(lmt, 1000), order_by=ob,
display_value="true" if dv else "false")
if r["success"]:
return json.dumps({"count": len(r["data"].get("result", [])),
"records": r["data"].get("result", [])}, indent=2)
return json.dumps({"error": r["error"]}, indent=2)
3. The Table API request helper:
def table_get(self, table: str, sys_id: str = None, query: str = None,
fields: list = None, limit: int = 100, offset: int = 0,
order_by: str = None, display_value: str = "false") -> dict:
endpoint = (f"/api/now/table/{table}/{sys_id}" if sys_id
else f"/api/now/table/{table}")
params = {
"sysparm_limit": limit,
"sysparm_offset": offset,
"sysparm_display_value": display_value,
"sysparm_exclude_reference_link": "true",
}
if query: params["sysparm_query"] = query
if fields: params["sysparm_fields"] = ",".join(fields)
if order_by: params["sysparm_orderby"] = order_by
return self._request("GET", endpoint, params=params)
4. The single _request method every verb funnels through, which is what produces the uniform response envelope:
def _request(self, method, endpoint, params=None, data=None) -> dict:
url = f"{self.base_url}{endpoint}"
try:
response = self.session.request(method=method, url=url, params=params,
json=data, timeout=self.timeout)
return {
"success": response.ok,
"status_code": response.status_code,
"data": response.json() if response.text else None,
"error": None if response.ok
else f"HTTP {response.status_code}: {response.reason}",
}
except Exception as e:
return {"success": False, "status_code": None, "data": None, "error": str(e)}
All 60 tools in Script A, grouped by purpose. The tool names are identical in Script B.
Every tool name starts with the same prefix on every instance. In the client, the full name adds a server prefix, for example mcp__servicenow-sn-<instance-b>__sn_query, and that server part is what tells you which instance answered.
These work on any table. They are thin Table API wrappers.
| Tool name | Type | What it does |
|---|---|---|
| sn_query | read | Query any table on the active instance. |
| sn_get_record | read | Get a single record by sys_id from the instance. |
| sn_create_record | write | Create a new record in the instance. |
| sn_update_record | write | Update an existing record in the instance. |
| sn_delete_record | delete, confirm required | Delete a record from the instance. Requires confirmation. |
| sn_aggregate | read | Get aggregated statistics from the instance. |
| sn_batch_update | write | Update multiple records in the instance. |
| sn_batch_delete | delete, confirm required | Delete multiple records from the instance. Requires confirmation. |
Read syslog, Flow Designer, generative AI logs and RAG index health.
| Tool name | Type | What it does |
|---|---|---|
| sn_query_syslog | read | Query the ServiceNow syslog table for debugging. |
| sn_query_syslog_app_scope | read | Query syslog_app_scope, the scoped log view accessible without cross-scope privilege. |
| sn_query_flow_contexts | read | Query Flow Designer execution contexts, sys_flow_context. |
| sn_query_flow_logs | read | Query Flow Designer detailed logs, sys_flow_log. |
| sn_get_flow_context_details | read | Get complete details of a flow execution including its logs. |
| sn_query_generative_ai_logs_detailed | read | Query sys_generative_ai_log with full field access for detailed AI and LLM debugging. |
| sn_query_flow_reports | read | Query Flow Designer reporting data, sys_flow_report_doc_chunk. |
| sn_query_generative_ai_logs | read | Query generative AI logs, sys_generative_ai_log. |
| sn_check_rag_index_health | read | Check RAG indexing status via ais_ingest_table_stats. |
Read agents, tools, workers, triggers and execution history on the sn_aia_* tables.
| Tool name | Type | What it does |
|---|---|---|
| sn_list_agentic_workflows | read | List all agentic workflows, the use cases, configured in the system. |
| sn_list_ai_agents | read | List all AI agents configured in the system. |
| sn_get_agent_details | read | Get detailed information about a specific AI agent including its tools. |
| sn_list_agent_tools | read | List all tools available to AI agents. |
| sn_list_agent_tool_m2ms | read | List all tool M2M records wired to a specific agent, sn_aia_agent_tool_m2m. |
| sn_query_execution_plans | read | Query AI agent execution plans, sn_aia_execution_plan. |
| sn_query_execution_tasks | read | Query AI agent execution tasks, sn_aia_execution_task. |
| sn_query_tool_executions | read | Query tool executions to see which tools were called and what they returned. |
| sn_get_execution_details | read | Get complete details of an agentic workflow execution including all tasks and tool calls. |
| sn_query_agent_messages | read | Query AI agent conversation messages, sn_aia_message. |
| sn_list_trigger_configurations | read | List trigger configurations for agentic workflows. |
| sn_list_agent_child_mappings | read | List agent child mappings, sn_aia_agent_child, which is the orchestration tree. |
| sn_list_ai_workers | read | List all AI Workers configured in the system. |
| sn_get_ai_worker_details | read | Get full details of a specific AI Worker including its template and mapped users. |
| sn_list_worker_templates | read | List all AI Worker Templates in the system. |
Create, change and delete agents, workflows, tools, triggers and parent-to-child mappings.
| Tool name | Type | What it does |
|---|---|---|
| sn_create_ai_agent | write | Create a new AI agent. |
| sn_update_ai_agent | write | Update an existing AI agent. Only provide the fields you want to update. |
| sn_delete_ai_agent | delete, confirm required | Delete an AI agent. Requires confirmation. |
| sn_add_tool_to_agent | write | Add a tool to an AI agent, with optional input definitions. |
| sn_remove_tool_from_agent | write | Remove a tool from an AI agent. |
| sn_create_agentic_workflow | write | Create a new agentic workflow, or use case. |
| sn_update_agentic_workflow | write | Update an existing agentic workflow. Only provide the fields you want to update. |
| sn_delete_agentic_workflow | delete, confirm required | Delete an agentic workflow. Requires confirmation. |
| sn_create_tool | write | Create a new tool for AI agents. |
| sn_add_record_operation_tool | write | Create and attach a fully configured Record Operation, or CRUD, tool to an AI agent. |
| sn_update_tool | write | Update an existing tool. Only provide the fields you want to update. |
| sn_delete_tool | delete, confirm required | Delete a tool. Requires confirmation. |
| sn_create_trigger | write | Create a trigger for an agentic workflow. |
| sn_update_trigger | write | Update an existing trigger. Only provide the fields you want to update. |
| sn_delete_trigger | delete, confirm required | Delete a trigger. Requires confirmation. |
| sn_clone_ai_agent | write | Clone an existing AI agent with all its tools and configuration. |
| sn_cleanup_agent_configs | write | Clean up duplicate agent config records for an agent. |
| sn_create_agent_child_mapping | write | Create a parent-to-child agent mapping, sn_aia_agent_child. |
| sn_delete_agent_child_mapping | delete, confirm required | Delete an agent child mapping, removing a parent-to-child delegation relationship. |
Create and change AI Workers and Worker Templates, and map users to workers.
| Tool name | Type | What it does |
|---|---|---|
| sn_create_ai_worker | write | Create a new AI Worker and attach a Worker Template to it. |
| sn_update_ai_worker | write | Update an existing AI Worker. Only provide the fields you want to change. |
| sn_delete_ai_worker | delete, confirm required | Delete an AI Worker. Requires confirmation. |
| sn_assign_user_to_worker | write | Map a ServiceNow user to an AI Worker via sn_aia_worker_user_m2m. |
| sn_remove_user_from_worker | delete, confirm required | Remove a user mapping from an AI Worker, deleting the sn_aia_worker_user_m2m record. |
| sn_create_worker_template | write | Create a new AI Worker Template and associate it with a root agent. |
| sn_update_worker_template | write | Update an existing Worker Template. Only provide the fields you want to change. |
| sn_delete_worker_template | delete, confirm required | Delete a Worker Template. Requires confirmation. |
Build a complete RAG search pipeline and attach it to an agent. This one tool carries about 300 lines of logic.
| Tool name | Type | What it does |
|---|---|---|
| sn_create_rag_pipeline | write | Create a complete RAG search pipeline and wire it to an AI agent. |
Run these in order. Stop at the first one that fails and go to Troubleshooting.
| # | Check | Command or action | Expected result |
|---|---|---|---|
| 1 | Python and uv | /opt/homebrew/bin/uv --version and ~/.venvs/mcp-server/bin/python --version | uv 0.x prints a version, and Python prints 3.13.x |
| 2 | Dependencies | ~/.venvs/mcp-server/bin/python -c "import mcp, requests, dotenv" | No output and no error |
| 3 | Instance reachable | curl -sI https://<instance>.service-now.com | head -1 | An HTTP status line, such as 200 or 302 |
| 4 | Credentials valid | curl -s -o /dev/null -w '%{http_code}' -u '<username>:<password>' 'https://<instance>.service-now.com/api/now/table/sys_user?sysparm_limit=1' | 200. A 401 means wrong credentials, a 403 means a missing role |
| 5 | Script starts | Step 3 of section 5 | Banner with [OK] Connected, and the process stays running |
| 6 | Servers registered | claude mcp list | Every server you added listed as connected |
| 7 | Tools visible | Ask the client to list the ServiceNow tools | 60 tools per server |
| 8 | End to end | Ask the client to query sys_user with limit 1 on each server | A record is returned from each instance |
| 9 | Client B copy running | ps aux | grep <client-b-folder> | A process running ~/<client-b-folder>/.venv/bin/python3 server_single.py |
The failures you are most likely to hit, and what each one actually means.
| Symptom | Likely cause | Fix |
|---|---|---|
Server shows as failed in claude mcp list | uv not found, wrong --directory, or Python 3.13 missing | Run step 3 of section 5 by hand and read the error. Use the absolute path /opt/homebrew/bin/uv in the config, because GUI applications do not inherit your shell PATH. |
| A folder path with spaces breaks | The project path contains spaces | Keep the path as a single quoted argument. In JSON it is already one string in the args array. In the shell, wrap it in double quotes. |
| Tool returns HTTP 401: Unauthorized | Wrong username or password, or Basic Auth is blocked for the account | Re-run the credentials check, row 4 of section 10. Update the env block in the config and restart. |
| Tool returns HTTP 403: Forbidden | The user lacks a role for that table or operation | Grant the role, or use a user that has it. Reads of sn_aia_* tables need AI Agent roles or admin. |
Tool returns HTTP 404, or an empty result for sn_aia_* tables | The AI Agent Studio plugins are not installed on that instance | Check the table exists in sys_db_object on the instance. |
| Banner says Connected but every call fails | The banner is printed without ever calling the instance | Use the credentials check to find the real error. |
| Changes to the script are not picked up | The process is still the old one | Restart the client so it relaunches the server. Client B runs its own copy, so edit ~/<client-b-folder>/server_single.py for it. |
| Tools from the wrong instance answered | Every server exposes the same 60 tool names | Look at the server part of the full tool name, mcp__servicenow-sn-<name>__, to see which instance answered. |
| Timeout errors on large queries | The 30 second request timeout, and the 1000-row cap in the quick query helper | Narrow the query, lower the limit, or raise self.timeout in ServiceNowClient. |
Python or package errors after a uv upgrade | Stale virtual environment | Delete ~/.venvs/mcp-server and re-run uv sync, step 2 of section 5. |
The setup works as described. These are the risks it carries, and how to reduce them.
integrations.json, and where it stores the resolved values is not documented. Servers that run as an admin user give every tool call, including the delete and batch tools, full admin rights on the instance. Servers on service accounts can do only what those accounts' roles allow.Give it only the roles the tools need, instead of admin. A read-only user is enough if you only use the query and inspection tools.
Script A supports the OAuth password grant when SN_CLIENT_ID and SN_CLIENT_SECRET are set. It lets you revoke access without changing the user's password. Script B does not support it.
Do not put a .env holding credentials in a cloud-synced project folder. It is copied to the cloud and to your other devices.
Only allow-list the read tools in settings.json. Leave create, update and delete tools to prompt every time.
When a password changes, update each location that stores it: the matching entry in the Claude Code user config, the matching server's env values in Client B, and any other tool holding the same credential.
They are personal. Share this guide, which contains no credentials, instead.
Things that work today but will surprise the next person who opens the folder.
| Issue | Detail | Suggested action |
|---|---|---|
| Two diverged copies of the script | Script B is about 56 lines shorter than Script A: no OAuth code, and it loads a .env from its own folder instead of using the default search. The 60 tool names are the same. | Pick one as the source of truth and copy changes across, or point both launchers at one file. |
Unused .env | The project-folder .env contains one instance only, and is overridden by the env blocks. | Delete it, or keep it only as a documented fallback. |
| Instance names inside tool docstrings | A docstring that names one particular instance is misleading on every other instance, and the model reads these descriptions. | Replace with neutral wording such as "the instance". |
| Misleading start-up banner | It prints a fixed instance name and [OK] Connected for every instance, and it never calls the instance. | Print the real SN_INSTANCE value, and make one test call. |
| Legacy swap scripts | swap_instance.sh lists two hardcoded instance names and rewrites server_single.py with sed. | Do not use it with this setup. It conflicts with the env block approach. |
| Dead editor and Copilot MCP files | An editor mcp.json points at an old project path and has no credentials. A separate Copilot mcp.json targets a conda environment. | Remove or update them if you do not use those clients. |
| Dead client settings | enabledMcpjsonServers in ~/.claude/settings.json names two servers, but ~/.mcp.json is empty. The allow list also holds a stale tool entry from a renamed server. | Clean up the settings. |
| Extra copies of the script | Further copies of server_single.py exist in other local folders. Nothing launches them. | Archive or delete them, to avoid editing the wrong file. |
| Plaintext credentials in two places | The Claude Code user config holds the passwords for every Claude Code server, and the unused legacy .env holds one more. See section 12. | Apply the hardening changes. |
ServiceNow's own way to expose tools to an AI client. A complement to this setup, not a replacement for it.
| Aspect | This FastMCP setup | Native MCP Server Console |
|---|---|---|
| Adding a tool | Write one Python function in server_single.py | Build a backing implementation first, for example a Scripted REST API with an OpenAPI schema, create the tool in the console, then add it to a server record |
| Identity | One shared credential per instance, admin on dev instances and service accounts elsewhere | Per-user OAuth. The user consents once, and tools run with that user's permissions and ACLs |
| Reach | Any instance reachable over HTTPS, from one script | One server record per instance, and it needs the sn_mcp_server plugin |
| Best for | Building and debugging AI agents, flows and execution plans, which is platform authoring work | Governed, narrow business tools such as incident read and update |
| Hosting | A local process on your machine, over stdio | Hosted on the instance. The client connects to a Server URL as a custom connector |
| Audit and control | Whatever your own discipline gives you | Servers and tools are records on the instance, so update sets and reviews apply |
sn_mcp_server) and the client plugin (sn_mcp_client) installed.sn_mcp_server_registry, for example sn_itsm_mcp_server.itsm_default, sn_hr_mcp_server.hr_default and sn_csm_mcp_server.csm_default.https://claude.ai/api/mcp/auth_callback, Token Format JWT and the useraccount scope. The authenticating user needs itil or an equivalent role on incident.Use the FastMCP setup. You need broad table access across several instances, and you are the only user.
Use the native console, so calls run under a real user's permissions rather than as admin.
Use the native console. The server, its tools and the consent are records on the instance, which is what makes the access reviewable.
ServiceNow hosts the MCP server on the instance and the client connects to it with OAuth. No local Python process is involved.
Source. This walkthrough follows the ServiceNow Dev Program series by Travis Toulson, namely Build MCP Tools in the MCP Server Console, Create the MCP Server Record in ServiceNow, Connect Claude to a ServiceNow MCP Server and Claude Reads and Updates a Real ServiceNow Incident, together with the written ServiceNow Community article Building an MCP Server on ServiceNow and Connecting Claude to It. Check the scripts below against the originals before using them on a customer instance.
flowchart TD U["You"] --> CL["AI client
custom connector"] CL -- "OAuth 2.0 consent
scope useraccount" --> OA["OAuth Application Registry
MCP connector entry, JWT tokens"] CL -- "HTTPS MCP calls
to the Server URL" --> MS["MCP Server record
Incident MCP Server"] MS --> T1["Tool: Get Incident"] MS --> T2["Tool: Update Incident"] T1 -- "GET /incident_api by number" --> API["Scripted REST API
incident_api"] T2 -- "POST /incident_api by number
validated by UpdateIncidentSchema" --> API API --> GR["GlideRecord on incident
runs as the consenting user"] classDef a fill:#e8f1ff,stroke:#3975c6,color:#132238; classDef b fill:#fff5dd,stroke:#9a6500,color:#5a3d00; classDef c fill:#f1eaff,stroke:#7b52b9,color:#2f1f4b; classDef d fill:#e8f7f1,stroke:#157a5a,color:#103e31; class U,CL a; class OA b; class MS,T1,T2 c; class API,GR d;
You need a recent Australia patch, the Now Assist Admin Console plugin (sn_nowassist_admin, listed as AI Admin Hub on newer instances) and the admin role. Install Model Context Protocol Server (sn_mcp_server) from All > System Applications > All Available Applications > All.
curl https://<instance>.service-now.com/sncapps/mcp-server/health
{"status":"healthy"}. In the filter navigator, sys_service.list should show the MCP-S and mcp-server records. Then create a scoped application in Studio, which the article calls "Incident MCP Server", to hold everything below.Go to All > System Web Services > Scripted REST APIs. Create an API named Incident API with API ID incident_api and Default ACLs Scripted REST External Default. Add a resource Get Incident: method GET, relative path /{number}, the same default ACLs, with this script.
(function process(/*RESTAPIRequest*/ request, /*RESTAPIResponse*/ response) {
var number = request.pathParams.number;
if (!number) {
response.setStatus(400);
response.setBody({ message: "Incident number is required." });
return;
}
var gr = new GlideRecord('incident');
gr.addQuery('number', number);
gr.query();
if (gr.next()) {
response.setStatus(200);
response.setBody({
sys_id: gr.getValue('sys_id'),
number: gr.getValue('number'),
short_description: gr.getValue('short_description'),
description: gr.getValue('description'),
state: gr.getValue('state'),
urgency: gr.getValue('urgency'),
priority: gr.getValue('priority'),
assigned_to: gr.getDisplayValue('assigned_to'),
assignment_group: gr.getDisplayValue('assignment_group'),
opened_at: gr.getValue('opened_at'),
caller_id: gr.getDisplayValue('caller_id')
});
} else {
response.setStatus(404);
response.setBody({ message: "Incident not found: " + number });
}
})(request, response);
On the same API add a resource Update Incident: method POST, relative path /{number}, the same default ACLs, with this script.
(function process(/*RESTAPIRequest*/ request, /*RESTAPIResponse*/ response) {
var number = request.pathParams.number;
if (!number) {
response.setStatus(400);
response.setBody({ message: "Incident number is required." });
return;
}
try {
var params = request.body.data;
var gr = new GlideRecord('incident');
gr.addQuery('number', number);
gr.query();
if (!gr.next()) {
response.setStatus(404);
response.setBody({ message: "Incident not found: " + number });
return;
}
for (var key in params) {
if (params.hasOwnProperty(key)) {
if (key === 'work_notes' || key === 'comments') {
gr[key] = params[key];
} else {
gr.setValue(key, params[key]);
}
}
}
gr.update();
response.setStatus(200);
response.setBody({
message: "Incident updated successfully.",
number: gr.getValue('number'),
sys_id: gr.getUniqueValue()
});
} catch (e) {
response.setStatus(500);
response.setBody({ message: "Error occurred: " + e.message });
}
})(request, response);
In the API's Schemas related list create UpdateIncidentSchema, OpenAPI version 3.0.1, with this JSON, then link it to the POST resource through the Request Schema related list. This is what gives the tool its input fields.
{
"type": "object",
"description": "Fields to update on the incident. All fields are optional. Include only what you want to change.",
"properties": {
"short_description": { "type": "string", "description": "Brief summary of the incident.", "example": "User cannot log in to VPN" },
"description": { "type": "string", "description": "Detailed description of the incident.", "example": "User reports being unable to connect to VPN since this morning." },
"urgency": { "type": "string", "description": "Urgency code. 1=High, 2=Medium, 3=Low.", "example": "2" },
"impact": { "type": "string", "description": "Impact code. 1=High, 2=Medium, 3=Low.", "example": "2" },
"state": { "type": "string", "description": "Incident state code. 1=New, 2=In Progress, 6=Resolved, 7=Closed.", "example": "2" },
"assignment_group": { "type": "string", "description": "sys_id or name of the group to assign the incident to.", "example": "Service Desk" },
"assigned_to": { "type": "string", "description": "sys_id or username of the person to assign the incident to.", "example": "<username>" },
"work_notes": { "type": "string", "description": "Internal notes to add to the incident work notes journal. Visible to agents only.", "example": "Investigated and escalated to network team." },
"comments": { "type": "string", "description": "Customer-visible notes to add to the incident comments journal. Visible to the caller and agents.", "example": "We are investigating your issue and will update you shortly." }
}
}
UpdateIncidentSchema under Request Schema.Go to All > System OAuth > Application Registry and create a new entry. On the interceptor page choose [Deprecated UI] Create an OAuth API endpoint for external clients, which is still the right choice despite the label.
https://claude.ai/api/mcp/auth_callback, exactly, with no trailing slashuseraccountSave, then copy the Client ID (<client-id>) and Client Secret (<client-secret>).
Go to All > Admin Center > MCP Server Console > Tools > Create Tool > REST API. In the endpoint search, type /x to filter to scoped endpoints.
[GET] /x_snc_<scope>/incident_api/{number}. Label Get Incident. Description: Retrieves the details of a specific ServiceNow incident by incident number. Call this tool when the user wants to look up, check, or get information about a specific incident.[POST] /x_snc_<scope>/incident_api/{number}. Label Update Incident. Description: Updates fields on an existing ServiceNow incident by incident number. Call this tool when the user wants to change, modify, or update an incident. Pass only the fields you want to change along with the required incident number.number. The POST tool has number plus all the schema fields.In the console open Servers > Create Server. Label Incident MCP Server. Short description: Access and manage ServiceNow incidents by incident number. Use this server to retrieve incident details or update incident fields including urgency, state, assignment, and work notes. Click Add Tools, select both tools, then Create. Click Activate; if the button reads Deactivate it is already active.
In Claude.ai, go to the profile icon, then Settings > Connectors > Add > Add custom connector. Name it, for example ServiceNow Incident MCP. The Remote MCP Server URL is the Server URL, and the OAuth Client ID and Client Secret are the values from step 4. Click Add, then Connect. Sign in to ServiceNow and approve the consent screen for the connector with the useraccount scope.
Open a new chat with the connector enabled and use a test incident number.
Get the details of ServiceNow incident <incident-number>. Update incident <incident-number>. Set urgency to High and add a work note that says "Escalated to network team for investigation."
| Symptom | Cause | Fix |
|---|---|---|
| Connector shows zero tools | Token Format is Opaque instead of JWT | Change it to JWT in the Application Registry, then disconnect and reconnect |
| Tool calls return errors | The authenticating user lacks access to incident | Give the user itil or an equivalent role. The useraccount scope delegates the user's permissions, it does not add new ones |
| OAuth flow fails | The redirect URL differs from https://claude.ai/api/mcp/auth_callback, for example by a trailing slash | Correct it exactly. Being routed through SSO during the flow is normal |
| Nothing works | Plugin or service unhealthy | Re-run the health check from step 1 |
Five questions that place you in the right section of this guide.
Use this setup. Register the same command once per instance with its own SN_INSTANCE, SN_USERNAME and SN_PASSWORD, as in section 5, and read the server part of each tool name to know which instance answered.
Use the native MCP Server Console instead, as sections 14 and 15 describe. Calls then run under the consenting user's permissions and ACLs, and the server, its tools and the consent are all records on the instance.
Run the script by hand with the three variables, step 3 of section 5, and read the error. A start-up banner is not proof of a working credential, so confirm with the curl check in row 4 of section 10 before you change anything else.
The two clients run two separate copies of the script. Edit the copy that client launches, then restart the client so it relaunches the process. Sections 6 and 13 cover the drift and what to do about it.
In the client configuration, in plaintext, and the process environment always wins over any .env file. Section 7 traces the resolution order, and section 12 lists every place a rotated credential has to be updated.