Skip to main content
Glama

ServiceNow MCP Server

server.py13.8 kB
""" ServiceNow MCP Server This module provides the main implementation of the ServiceNow MCP server. """ import json import logging import os from typing import Any, Dict, List, Union import mcp.types as types import yaml from mcp.server.lowlevel import Server from pydantic import ValidationError from servicenow_mcp.auth.auth_manager import AuthManager from servicenow_mcp.tools.knowledge_base import ( create_category as create_kb_category_tool, ) from servicenow_mcp.tools.knowledge_base import ( list_categories as list_kb_categories_tool, ) from servicenow_mcp.utils.config import ServerConfig from servicenow_mcp.utils.tool_utils import get_tool_definitions # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Define path for the configuration file TOOL_PACKAGE_CONFIG_PATH = os.getenv("TOOL_PACKAGE_CONFIG_PATH", "config/tool_packages.yaml") def serialize_tool_output(result: Any, tool_name: str) -> str: """Serializes tool output to a string, preferably JSON indented.""" try: if isinstance(result, str): # If it's already a string, assume it's intended as such # Try to parse/re-dump JSON for consistent formatting if it looks like JSON try: parsed = json.loads(result) return json.dumps(parsed, indent=2) except json.JSONDecodeError: return result # Return as is if not valid JSON elif isinstance(result, dict): # Dump dicts to JSON return json.dumps(result, indent=2) elif hasattr(result, "model_dump_json"): # Pydantic v2 # Prefer Pydantic v2 model_dump_json # The indent argument might not be supported by all versions/models, # so we might need to dump to dict first if indent is crucial. try: return result.model_dump_json(indent=2) except TypeError: # Handle case where indent is not supported return json.dumps(result.model_dump(), indent=2) elif hasattr(result, "model_dump"): # Pydantic v2 fallback # Fallback to Pydantic v2 model_dump -> dict -> json return json.dumps(result.model_dump(), indent=2) elif hasattr(result, "dict"): # Pydantic v1 # Fallback to Pydantic v1 dict -> json return json.dumps(result.dict(), indent=2) else: # Absolute fallback: convert to string logger.warning( f"Could not serialize result for tool '{tool_name}' to JSON, falling back to str(). Type: {type(result)}" ) return str(result) except Exception as e: logger.error(f"Error during serialization for tool '{tool_name}': {e}", exc_info=True) # Return an error message string formatted as JSON return json.dumps( {"error": f"Serialization failed for tool {tool_name}", "details": str(e)}, indent=2 ) class ServiceNowMCP: """ ServiceNow MCP Server implementation. This class provides a Model Context Protocol (MCP) server for ServiceNow, allowing LLMs to interact with ServiceNow data and functionality. It supports loading specific tool packages via the MCP_TOOL_PACKAGE env var. """ def __init__(self, config: Union[Dict, ServerConfig]): """ Initialize the ServiceNow MCP server. Args: config: Server configuration, either as a dictionary or ServerConfig object. """ if isinstance(config, dict): self.config = ServerConfig(**config) else: self.config = config self.auth_manager = AuthManager(self.config.auth, self.config.instance_url) self.mcp_server = Server("ServiceNow") # Use low-level Server self.name = "ServiceNow" self.package_definitions: Dict[str, List[str]] = {} self.enabled_tool_names: List[str] = [] self.current_package_name: str = "none" self._load_package_config() self._determine_enabled_tools() # Get tool definitions, passing the aliased KB tool functions if needed self.tool_definitions = get_tool_definitions( create_kb_category_tool, list_kb_categories_tool ) self._register_handlers() def _register_handlers(self): """Register the list_tools and call_tool handlers.""" self.mcp_server.list_tools()(self._list_tools_impl) self.mcp_server.call_tool()(self._call_tool_impl) logger.info("Registered list_tools and call_tool handlers.") def _load_package_config(self): """Load tool package definitions from the YAML configuration file.""" config_path = TOOL_PACKAGE_CONFIG_PATH if not os.path.isabs(config_path): config_path = os.path.join(os.path.dirname(__file__), "..", "..", config_path) config_path = os.path.abspath(config_path) try: with open(config_path, "r") as f: loaded_config = yaml.safe_load(f) if isinstance(loaded_config, dict): self.package_definitions = loaded_config logger.info(f"Successfully loaded tool package config from {config_path}") else: logger.error( f"Invalid format in {config_path}: Expected a dictionary, got {type(loaded_config)}. No packages loaded." ) self.package_definitions = {} except FileNotFoundError: logger.error( f"Tool package config file not found at {config_path}. No packages loaded." ) self.package_definitions = {} except yaml.YAMLError as e: logger.error( f"Error parsing tool package config file {config_path}: {e}. No packages loaded." ) self.package_definitions = {} except Exception as e: logger.error(f"Unexpected error loading tool package config {config_path}: {e}") self.package_definitions = {} def _determine_enabled_tools(self): """Determine which tool package and tools to enable based on environment variable.""" requested_package = os.getenv("MCP_TOOL_PACKAGE", "full").strip() if not requested_package: self.current_package_name = "full" logger.info("MCP_TOOL_PACKAGE is empty, defaulting to 'full' package.") elif requested_package in self.package_definitions: self.current_package_name = requested_package logger.info(f"MCP_TOOL_PACKAGE set to '{self.current_package_name}'.") else: self.current_package_name = "none" logger.warning( f"MCP_TOOL_PACKAGE '{requested_package}' is not a valid package name. " f"Valid packages: {list(self.package_definitions.keys())}. Loading 'none' package." ) if self.package_definitions: self.enabled_tool_names = self.package_definitions.get(self.current_package_name, []) else: self.enabled_tool_names = [] logger.info( f"Loading package '{self.current_package_name}' with {len(self.enabled_tool_names)} tools." ) async def _list_tools_impl(self) -> List[types.Tool]: """Implementation for the list_tools MCP endpoint.""" tool_list: List[types.Tool] = [] # Add the introspection tool if not 'none' package if self.current_package_name != "none": tool_list.append( types.Tool( name="list_tool_packages", description="Lists available tool packages and the currently loaded one.", inputSchema={ "type": "object", "properties": { "random_string": { "type": "string", "description": "Dummy parameter for no-parameter tools", } }, "required": ["random_string"], }, ) ) # Iterate through defined tools and add enabled ones for tool_name, definition in self.tool_definitions.items(): if tool_name in self.enabled_tool_names: _impl_func, params_model, _return_annotation, description, _serialization = ( definition ) try: schema = params_model.model_json_schema() tool_list.append( types.Tool(name=tool_name, description=description, inputSchema=schema) ) except Exception as e: logger.error( f"Failed to generate schema for tool '{tool_name}': {e}", exc_info=True ) logger.debug(f"Listing {len(tool_list)} tools for package '{self.current_package_name}'.") return tool_list async def _call_tool_impl(self, name: str, arguments: dict) -> list[types.TextContent]: """ Implementation for the call_tool MCP endpoint. Handles argument parsing, tool execution, result serialization (to string), and returning a list containing a single TextContent object. Args: name: The name of the tool to call. arguments: The arguments for the tool as a dictionary. Returns: A list containing a single TextContent object with the tool output. Raises: ValueError: If the tool is unknown, disabled, or if arguments are invalid. RuntimeError: If tool execution or serialization fails. """ logger.info(f"Received call_tool request for tool '{name}'") # Handle the introspection tool separately if name == "list_tool_packages": if self.current_package_name == "none": raise ValueError( "Tool 'list_tool_packages' is not available in the 'none' package." ) result_dict = self._list_tool_packages_impl() serialized_string = json.dumps(result_dict, indent=2) # Return a list with a TextContent object return [types.TextContent(type="text", text=serialized_string)] # Check if the tool exists and is enabled if name not in self.tool_definitions: raise ValueError(f"Unknown tool: {name}") if name not in self.enabled_tool_names: raise ValueError( f"Tool '{name}' is not enabled in the current package '{self.current_package_name}'." ) # Get tool definition (we don't need the serialization hint anymore) definition = self.tool_definitions[name] impl_func, params_model, _return_annotation, _description, _serialization = definition # Validate and parse arguments using the Pydantic model try: params = params_model(**arguments) logger.debug(f"Parsed arguments for tool '{name}': {params}") except ValidationError as e: logger.error(f"Invalid arguments for tool '{name}': {e}", exc_info=True) raise ValueError(f"Invalid arguments for tool '{name}': {e}") from e except Exception as e: logger.error( f"Unexpected error parsing arguments for tool '{name}': {e}", exc_info=True ) raise ValueError(f"Failed to parse arguments for tool '{name}': {e}") # Execute the tool implementation function try: result = impl_func(self.config, self.auth_manager, params) logger.debug(f"Raw result type from tool '{name}': {type(result)}") except Exception as e: logger.error(f"Error executing tool '{name}': {e}", exc_info=True) raise RuntimeError(f"Error during execution of tool '{name}': {e}") from e # Serialize the result to a string (preferably JSON) using the helper serialized_string = serialize_tool_output(result, name) logger.debug(f"Serialized value for tool '{name}': {serialized_string[:500]}...") # Return a list with a TextContent object return [types.TextContent(type="text", text=serialized_string)] def _list_tool_packages_impl(self) -> Dict[str, Any]: """Implementation logic for the list_tool_packages tool.""" available_packages = list(self.package_definitions.keys()) return { "current_package": self.current_package_name, "available_packages": available_packages, "message": ( f"Currently loaded package: '{self.current_package_name}'. " f"Set MCP_TOOL_PACKAGE env var to one of {available_packages} to switch." ), } def start(self) -> Server: """ Prepares and returns the configured low-level MCP Server instance. The caller (e.g., cli.py) is responsible for obtaining the server instance from this method and running it within an appropriate async transport context (e.g., mcp.server.stdio.stdio_server). Returns: The configured mcp.server.lowlevel.Server instance. """ logger.info( "ServiceNowMCP instance configured. Returning low-level server instance for external execution." ) # The actual running of the server (server.run(...)) must happen # within an async context managed by the caller (e.g., using anyio # and a specific transport like stdio_server or SseServerTransport). return self.mcp_server

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/echelon-ai-labs/servicenow-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server