-
-
Notifications
You must be signed in to change notification settings - Fork 1
Architecture
How the servicenow-platform-mcp server is built, from process shape down to the moving parts inside a single tool call.
The server is a Python 3.12+ async process that speaks the Model Context Protocol over stdio. It is built on FastMCP, uses httpx for all ServiceNow HTTP traffic, and ships as a src-layout package (src/servicenow_mcp/). The console entry point is servicenow_mcp.server:main.
There is no HTTP listener, no multi-tenant routing, and no background scheduling. The MCP client (an AI assistant runtime) launches the process and owns its lifecycle. All I/O is cooperative async on a single thread.
create_mcp_server() in server.py assembles the runtime in this order:
-
Settings()- loads configuration from environment variables via pydantic-settings (.envthen.env.local). -
create_auth(settings)- returns aBasicAuthProvider. -
setup_sentry(settings)- initializes the Sentry SDK (no-op when DSN is empty or the SDK is missing). - Sets Sentry server context (instance hostname, environment, production flag, tool package).
-
FastMCP("servicenow-platform-mcp")- creates the protocol server instance. - Creates shared registries:
ChoiceRegistry(settings, auth_provider)andDictionaryRegistry(settings, auth_provider). -
attach_servicenow_state(mcp, settings, auth_provider, choices, dictionary)- injects shared state onto the MCP instance. - Registers the
list_tool_packagestool inline (see below). - Resolves the active package via
get_package(settings.mcp_tool_package), then for each group callsimportlib.import_moduleand invokes the module'sregister_tools.
main() runs mcp.run(transport="stdio") in a try/finally that calls shutdown_sentry() on exit.
Every tool group module exports a register_tools function with this exact signature:
def register_tools(
mcp: FastMCP,
settings: Settings,
auth_provider: BasicAuthProvider,
choices: ChoiceRegistry | None = None,
dictionary: DictionaryRegistry | None = None,
) -> None:
...Inside, each tool is defined as a closure decorated with @mcp.tool() then @tool_handler:
@mcp.tool()
@tool_handler
async def my_tool(param: str, correlation_id: str = "") -> str:
validate_identifier(param)
check_table_access(param)
async with ServiceNowClient(settings, auth_provider) as client:
result = await client.some_method(param)
return format_response(data=result, correlation_id=correlation_id)The one exception is list_tool_packages, registered directly in server.py as a bare closure. It returns serialize(list_packages()) with no @tool_handler wrapper, no correlation_id, and no error envelope. It is the only tool that is not wrapped by @tool_handler.
Defined in decorators.py, this decorator is the central safety and observability layer:
-
Injects
correlation_id- generates a UUID4 viagenerate_correlation_id()and passes it as a keyword argument to the wrapped function. -
Hides
correlation_idfrom the schema - overrides__signature__and deletes__wrapped__so FastMCP does not expose it to callers. -
Attaches Sentry telemetry - sets tags (
tool.name,tool.correlation_id) and a context blob with tool name, correlation ID, and redacted arguments. Sensitive argument keys are replaced wholesale with"***REDACTED***"before transmission (see Telemetry for the full key list). -
Wraps execution in
safe_tool_call()which catches exceptions in this order:
| Caught | Envelope behavior |
|---|---|
ACLError |
Verbose message |
ForbiddenError |
Verbose message |
ServiceNowMCPError (all subclasses) |
Verbose str(e)
|
ValueError |
Verbose str(e)
|
Generic Exception
|
Opaque "Internal error (correlation_id=...)" - full detail goes to logger.exception and Sentry |
Tool functions never raise to the MCP layer.
Every tool (except list_tool_packages) returns a serialized JSON string built by format_response() in utils.py:
{
"correlation_id": "...",
"status": "success | error",
"data": "<any>",
"error": {"message": "..."} | null,
"pagination": {"limit": 0, "offset": 0, "total": 0} | null,
"warnings": ["..."] | null
}When error is passed as a plain string it is wrapped into {"message": str}. When passed as a dict it is used verbatim. The keys error, pagination, and warnings are omitted when their value is None; correlation_id, status, and data are always present (data is serialized as null when it is None).
ServiceNowClient(settings, auth_provider) is an async context manager over httpx.AsyncClient. The timeout is configured via HTTPX_TIMEOUT_SECONDS (default 30 seconds, range 1-600).
async with ServiceNowClient(settings, auth_provider) as client:
record = await client.get_record("incident", sys_id)Auth headers are produced by BasicAuthProvider.get_headers() (async for future OAuth2 extensibility). Each request also carries an X-Correlation-ID header (a fresh UUID4).
_raise_for_status() maps HTTP status codes to the exception hierarchy defined in errors.py:
| HTTP | Exception |
|---|---|
| 401 | AuthError |
| 403 |
ACLError (when body matches ACL indicator regex) or ForbiddenError
|
| 404 | NotFoundError |
| 500+ | ServerError |
| Other 4xx | ServiceNowMCPError |
The only in-memory mutable state is PreviewTokenStore in state.py:
- TTL: 300 seconds (5 minutes).
- Capacity: 1000 entries.
-
create(payload) -> strstores a dict and returns a UUID token. -
consume(token) -> dict | Noneretrieves and deletes in one step (single-use). - All mutations are serialized through an
asyncio.Lock. -
_sweep_expired()reclaims stale entries before capacity checks.
This backs the preview-then-apply write flow: record_write (with preview=True) creates a token containing the validated payload; record_apply consumes that token and executes the write.
ChoiceRegistry maps human-readable labels to internal values for state-like fields. It ships 6 default table-field mappings (incident, change_request, problem, cmdb_ci, sc_request, sc_req_item) and lazily fetches instance-specific overrides from sys_choice. Labels are normalized to lowercase with spaces replaced by underscores. See Tool-Reference for the resolve_choice tool surface.
DictionaryRegistry resolves which fields on a given table carry executable script or markup content. It walks the sys_db_object.super_class chain child-first (bounded at depth 8, cycle-guarded), classifies fields by internal_type and attribute heuristics, and caches per-table results for the server lifetime. See Tool-Reference for the describe tool's list_script_fields action.
| Path | Role |
|---|---|
src/servicenow_mcp/server.py |
Bootstrap, main(), list_tool_packages
|
src/servicenow_mcp/tools/ |
Tool group modules (one per group) and underscore-prefixed helpers |
src/servicenow_mcp/investigations/ |
Investigation modules (7 files) |
src/servicenow_mcp/policy.py |
Table access, field masking, query safety, write gating |
src/servicenow_mcp/client.py |
ServiceNowClient async HTTP layer |
src/servicenow_mcp/config.py |
Settings model (pydantic-settings) |
src/servicenow_mcp/decorators.py |
@tool_handler and Sentry arg redaction |
src/servicenow_mcp/errors.py |
Exception hierarchy |
src/servicenow_mcp/state.py |
PreviewTokenStore |
src/servicenow_mcp/utils.py |
format_response, safe_tool_call, ServiceNowQuery, validators |
src/servicenow_mcp/sentry.py |
Opt-in Sentry SDK setup and helpers |
See also: Safety-and-Policy for guardrails and write gating, Telemetry for Sentry integration details, Tool-Packages for the full package registry.