itential-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ # Copyright (c) 2025 Itential, Inc
2
+ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
3
+
4
+ from importlib.metadata import version
5
+
6
+ from .cli import run
7
+
8
+ __version__ = version("itential_mcp")
9
+
10
+ __all__ = ("run",)
@@ -0,0 +1,9 @@
1
+ # Copyright (c) 2025 Itential, Inc
2
+ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
3
+
4
+ import asyncio
5
+
6
+ from .cli import run
7
+
8
+ if __name__ == "__main__":
9
+ asyncio.run(run())
itential_mcp/cli.py ADDED
@@ -0,0 +1,162 @@
1
+ # Copyright (c) 2025 Itential, Inc
2
+ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
3
+
4
+ import os
5
+ import sys
6
+ import asyncio
7
+ import argparse
8
+
9
+ from collections.abc import Sequence
10
+
11
+ from typing import Any
12
+
13
+ from . import server
14
+ from . import env
15
+
16
+
17
+ def parse_args(args: Sequence) -> dict[str, Any]:
18
+ """
19
+ Parses any arguments
20
+
21
+ This function will parse the arguments identified by the `args` argument
22
+ and return a Namespace object with the values. Typically this is used
23
+ to parse command line arguments passed when the application starts.
24
+
25
+ Args:
26
+ args (Sequence): The list of arguments to parse
27
+
28
+ Returns:
29
+ dict[str, Any]: Returns a Python dict object that has parsed the args
30
+
31
+ Raises:
32
+ None
33
+ """
34
+ parser = argparse.ArgumentParser(prog="itential-mcp")
35
+
36
+ # MCP Server arguments
37
+ server_group = parser.add_argument_group("MCP Server")
38
+
39
+ server_group.add_argument(
40
+ "--transport",
41
+ default="stdio",
42
+ help="The MCP server transport to use (default=stdio)"
43
+ )
44
+
45
+ server_group.add_argument(
46
+ "--host",
47
+ default="localhost",
48
+ help="Address to listen for connections on (default=localhost)"
49
+ )
50
+
51
+ server_group.add_argument(
52
+ "--port",
53
+ type=int,
54
+ default=8000,
55
+ help="Port to listen for connections on (default=8000)",
56
+ )
57
+
58
+ server_group.add_argument(
59
+ "--log-level",
60
+ default="INFO",
61
+ help="Logging level. One of DEBUG, INFO, WARNING, ERROR, CRITICAL. (default=INFO)"
62
+ )
63
+
64
+ # Itential Platform arguments
65
+ platform_group = parser.add_argument_group("Itential Platform")
66
+
67
+ platform_group.add_argument(
68
+ "--platform-host",
69
+ default="localhost",
70
+ help="The host address of Itential Platform to connect to (default=localhost)"
71
+ )
72
+
73
+ platform_group.add_argument(
74
+ "--platform-port",
75
+ type=int,
76
+ default=0,
77
+ help="The port to use when connecting to Itential Platform (default=0)"
78
+ )
79
+
80
+ platform_group.add_argument(
81
+ "--platform-disable-tls",
82
+ action="store_true",
83
+ help="Disable using TLS to connect to the server (default=False)"
84
+ )
85
+
86
+ platform_group.add_argument(
87
+ "--platform-disable-verify",
88
+ action="store_true",
89
+ help="Disable certificate verification (default=False)",
90
+ )
91
+
92
+ platform_group.add_argument(
93
+ "--platform-user",
94
+ default="admin",
95
+ help="Username to use when authenticating to the server (default=admin)"
96
+ )
97
+
98
+ platform_group.add_argument(
99
+ "--platform-password",
100
+ default="admin",
101
+ help="Password to use when authenticating to the server (default=admin)"
102
+ )
103
+
104
+ platform_group.add_argument(
105
+ "--platform-client-id",
106
+ help="Client ID to use when authenticating to the server with OAuth",
107
+ )
108
+
109
+ platform_group.add_argument(
110
+ "--platform-client-secret",
111
+ help="Client secret to use when authenticating to the server with OAuth",
112
+ )
113
+
114
+ platform_group.add_argument(
115
+ "--platform-timeout",
116
+ type=int,
117
+ default=30,
118
+ help="Configure the connection timeout in seconds (default=30)",
119
+ )
120
+
121
+ args = parser.parse_args(args=args)
122
+
123
+ kwargs = {}
124
+
125
+ for key, value in dict(args._get_kwargs()).items():
126
+ envkey = f"ITENTIAL_MCP_{key}".upper()
127
+
128
+ if key.startswith("platform"):
129
+ if value is not None:
130
+ if envkey not in os.environ:
131
+ os.environ[envkey] = str(value)
132
+ else:
133
+ if isinstance(value, bool):
134
+ v = env.getbool(envkey, default=value)
135
+ elif isinstance(value, int):
136
+ v = env.getint(envkey, default=value)
137
+ elif isinstance(value, str):
138
+ v = env.getstr(envkey, default=value)
139
+ else:
140
+ v = value
141
+ kwargs[key] = v
142
+
143
+ return kwargs
144
+
145
+
146
+ def run() -> int:
147
+ """
148
+ Main entry point for the application
149
+
150
+ Args:
151
+ None
152
+
153
+ Returns:
154
+ int:
155
+
156
+ Raises:
157
+ None
158
+ """
159
+ try:
160
+ return asyncio.run(server.run(**parse_args(sys.argv[1:])))
161
+ except Exception:
162
+ sys.exit(1)
itential_mcp/client.py ADDED
@@ -0,0 +1,182 @@
1
+ # Copyright (c) 2025 Itential, Inc
2
+ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
3
+
4
+ import ipsdk
5
+
6
+ from ipsdk.platform import AsyncPlatform
7
+
8
+ import httpx
9
+
10
+ from . import config
11
+ from . import response
12
+
13
+
14
+ class PlatformClient(object):
15
+
16
+ def __init__(self):
17
+ self.client = self._init_client()
18
+
19
+ def _init_client(self) -> AsyncPlatform:
20
+ """
21
+ Initializes the client connection to Itential Platform
22
+
23
+ Args:
24
+ None
25
+
26
+ Returns:
27
+ AsyncPlatform: An instance of AsyncPlatform
28
+
29
+ Raises:
30
+ None
31
+ """
32
+ return ipsdk.platform_factory(want_async=True, **config.get())
33
+
34
+ async def _make_response(self, res: httpx.Response) -> response.Response:
35
+ """
36
+ Creates a response object and returns it
37
+
38
+ Args:
39
+ res (httpx.Response): The response object returned from the HTTP API
40
+ request
41
+
42
+ Returns:
43
+ Response: A HTTP Response object
44
+
45
+ Raises:
46
+ None
47
+ """
48
+ return response.Response(res)
49
+
50
+ async def send_request(
51
+ self,
52
+ method: str,
53
+ path: str,
54
+ params: dict = None,
55
+ json: str | bytes | dict | list | None = None
56
+ ) -> response.Response:
57
+ """
58
+ Send the request to the server and return the response
59
+
60
+ Args:
61
+ method (str): The HTTP method to invoke. This should be one of
62
+ "GET", "POST", "PUT", "DELETE"
63
+
64
+ path (str): The full URL path to send the reques to
65
+
66
+ params (dict): A Python dict objec to be converted into a query
67
+ string and appeneded to the URL
68
+
69
+ json (str|bytes|dict|list): A Python object that can be serialized
70
+ into a JSON object.
71
+
72
+ Returns:
73
+ Response: The HTTP response from the server
74
+
75
+ Raises:
76
+ None
77
+ """
78
+ res = await self.client._send_request(method, path, params, json)
79
+ return await self._make_response(res)
80
+
81
+ async def get(
82
+ self,
83
+ path: str,
84
+ params: dict | None = None
85
+ ) -> response.Response:
86
+ """
87
+ Send a HTTP GET request to the server
88
+
89
+ Args:
90
+ path (str): The full path to send the HTTP request o
91
+
92
+ params (dict): A Python dict object to be converted to a query
93
+ string and appended to the path
94
+
95
+ Returns:
96
+ Response: An HTTP Response object from the server
97
+
98
+ Raises:
99
+ None
100
+ """
101
+ return await self.send_request(
102
+ method="GET", path=path, params=params
103
+ )
104
+
105
+ async def post(
106
+ self,
107
+ path: str,
108
+ params: dict | None = None,
109
+ json: str | dict | list | None = None,
110
+ ) -> response.Response:
111
+ """
112
+ Send a HTTP POST request to the server
113
+
114
+ Args:
115
+ path (str): The full path to send the HTTP request o
116
+
117
+ params (dict): A Python dict object to be converted to a query
118
+ string and appended to the path
119
+
120
+ json (str | dict | list): A Python object that can be serialized
121
+ to a JSON string and sent as the body of the request
122
+
123
+ Returns:
124
+ Response: An HTTP Response object from the server
125
+
126
+ Raises:
127
+ None
128
+ """
129
+ return await self.send_request(
130
+ method="POST", path=path, params=params, json=json
131
+ )
132
+
133
+ async def put(
134
+ self,
135
+ path: str,
136
+ params: dict | None = None,
137
+ json: str | dict | list | None = None,
138
+ ) -> response.Response:
139
+ """
140
+ Send a HTTP PUT request to the server
141
+ Args:
142
+ path (str): The full path to send the HTTP request o
143
+
144
+ params (dict): A Python dict object to be converted to a query
145
+ string and appended to the path
146
+
147
+ json (str | dict | list): A Python object that can be serialized
148
+ to a JSON string and sent as the body of the request
149
+ Returns:
150
+ Response: An HTTP Response object from the server
151
+
152
+ Raises:
153
+ None
154
+ """
155
+ return await self.send_request(
156
+ method="PUT", path=path, params=params, json=json
157
+ )
158
+
159
+ async def delete(
160
+ self,
161
+ path: str,
162
+ params: dict | None = None,
163
+ ) -> response.Response:
164
+ """
165
+ Send a HTTP DELETE request to the server
166
+ Args:
167
+ path (str): The full path to send the HTTP request o
168
+
169
+ params (dict): A Python dict object to be converted to a query
170
+ string and appended to the path
171
+
172
+ json (str | dict | list): A Python object that can be serialized
173
+ to a JSON string and sent as the body of the request
174
+ Returns:
175
+ Response: An HTTP Response object from the server
176
+
177
+ Raises:
178
+ None
179
+ """
180
+ return await self.send_request(
181
+ method="DELETE", path=path, params=params
182
+ )
itential_mcp/config.py ADDED
@@ -0,0 +1,37 @@
1
+ # Copyright (c) 2025 Itential, Inc
2
+ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
3
+
4
+ from . import env
5
+
6
+
7
+ def get() -> dict:
8
+ """
9
+ Load the configuration from the environment
10
+
11
+ This function will load the Platform configuration from the environment
12
+ and return it as a Python dict object. The returned object can be passed
13
+ into the `platform_factory(...)` function to create a new instance.
14
+
15
+ Args:
16
+ None
17
+
18
+ Returns:
19
+ dict: Returns a Python dict object that represents the configuration
20
+
21
+ Raises:
22
+ None
23
+ """
24
+ return {
25
+ "host": env.getstr("ITENTIAL_MCP_PLATFORM_HOST", default="localhost"),
26
+ "port": env.getint("ITENTIAL_MCP_PLATFORM_PORT", default=0),
27
+
28
+ "use_tls": not env.getbool("ITENTIAL_MCP_PLATFORM_DISABLE_TLS", default=False),
29
+ "verify": not env.getbool("ITENTIAL_MCP_PLATFORM_DISABLE_VERIFY", default=False),
30
+ "timeout": env.getint("ITENTIAL_MCP_PLATFORM_TIMEOUT", default=30),
31
+
32
+ "user": env.getstr("ITENTIAL_MCP_PLATFORM_USER", default="admin"),
33
+ "password": env.getstr("ITENTIAL_MCP_PLATFORM_PASSWORD", default="admin"),
34
+
35
+ "client_id": env.getstr("ITENTIAL_MCP_PLATFORM_CLIENT_ID"),
36
+ "client_secret": env.getstr("ITENTIAL_MCP_PLATFORM_CLIENT_SECRET"),
37
+ }
itential_mcp/env.py ADDED
@@ -0,0 +1,39 @@
1
+ # Copyright (c) 2025 Itential, Inc
2
+ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
3
+
4
+ import os
5
+
6
+ from typing import Callable, Any
7
+
8
+ from functools import partial
9
+
10
+ from . import stringutils
11
+
12
+
13
+ def get(f: Callable, key: str, default: Any | None=None) -> Any:
14
+ """
15
+ Get the value for key from the current environment
16
+
17
+ Args:
18
+ f (Callable): The function to transform the value
19
+ key (str): The key to looking in the environment
20
+ default (Any): The default value to use if the key doesn't exist
21
+
22
+ Returns:
23
+ Any: The transformed value from the environment or the default vaule
24
+ if the key doesn't exist
25
+
26
+ Raises:
27
+ None
28
+ """
29
+ return f(os.getenv(key, default=default))
30
+
31
+
32
+ getstr = partial(get, stringutils.tostr)
33
+ getstr.__doc__ = "Get a string value from the environment"
34
+
35
+ getint = partial(get, stringutils.toint)
36
+ getint.__doc__ = "Get a integer value from the envirnment"
37
+
38
+ getbool = partial(get, stringutils.tobool)
39
+ getbool.__doc__ = "Get a boolean value from the environment"
@@ -0,0 +1,54 @@
1
+ # Copyright (c) 2025 Itential, Inc
2
+ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
3
+
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+
9
+ class Response(object):
10
+
11
+ def __init__(self, res: httpx.Response):
12
+ """
13
+ Initialize a new response instance
14
+
15
+ Args:
16
+ res: An instance of httpx.Response
17
+ """
18
+ self.response = res
19
+
20
+ @property
21
+ def status_code(self) -> int:
22
+ """
23
+ Returns the HTTP status code
24
+ """
25
+ return self.response.status_code
26
+
27
+ @property
28
+ def reason(self) -> str:
29
+ """
30
+ Returns the HTTP status code message
31
+ """
32
+ return self.response.reason_phrase
33
+
34
+ @property
35
+ def text(self) -> str:
36
+ """
37
+ Return the response body as text
38
+ """
39
+ return self.response.text
40
+
41
+ def json(self) -> Any:
42
+ """
43
+ Attempt to return the response body as a Python object
44
+
45
+ Args:
46
+ None
47
+
48
+ Returns:
49
+ Any: Returns the response text as a object
50
+
51
+ Raises:
52
+ None
53
+ """
54
+ return self.response.json()
itential_mcp/server.py ADDED
@@ -0,0 +1,166 @@
1
+ # Copyright (c) 2025 Itential, Inc
2
+ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
3
+
4
+ import os
5
+ import sys
6
+ import inspect
7
+ import importlib.util
8
+
9
+ from collections.abc import AsyncGenerator
10
+ from contextlib import asynccontextmanager, AsyncExitStack
11
+
12
+ from typing import Any, Literal
13
+
14
+ from fastmcp import FastMCP
15
+
16
+ from . import client
17
+
18
+
19
+ @asynccontextmanager
20
+ async def lifespan(mcp: FastMCP) -> AsyncGenerator[dict[str | Any], None]:
21
+ """
22
+ Manage the lifespan of Itential Platform servers
23
+
24
+ This function is responsible for creating the client connection to
25
+ Itential Platform and yielding it to FastMCP to be included in the
26
+ context.
27
+
28
+ Args:
29
+ mcp (FastMCP): An instance of FastMCP
30
+
31
+ Returns:
32
+ AsyncGenerator: Yields an AsyncGenerator with a dict object
33
+
34
+ Raises:
35
+ None
36
+ """
37
+ async with AsyncExitStack():
38
+ yield {
39
+ "client": client.PlatformClient()
40
+ }
41
+
42
+
43
+ def register_tools(mcp: FastMCP) -> None:
44
+ """
45
+ Register all functions in the tools folder with mcp
46
+
47
+ This function will recursively load all modules found in the `tools`
48
+ folder as long as they module name does not start with underscore (_). It
49
+ will then inspect the module to find all public functions and attach
50
+ them to the instance of mcp as a tool.
51
+
52
+ Args:
53
+ mcp (FastMCP): An instance of FastMCP to attach tools to
54
+
55
+ Returns:
56
+ None
57
+
58
+ Raises:
59
+ None
60
+ """
61
+ path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "tools")
62
+
63
+ # Get a list of all files in the directory
64
+ module_files = [f[:-3] for f in os.listdir(path) if f.endswith(".py") and f != "__init__.py"]
65
+
66
+ # Import the modules, add them to globals and mcp
67
+ for module_name in module_files:
68
+ if not module_name.startswith("_"):
69
+ spec = importlib.util.spec_from_file_location(module_name, os.path.join(path, f"{module_name}.py"))
70
+ module = importlib.util.module_from_spec(spec)
71
+ spec.loader.exec_module(module)
72
+
73
+ # Add the module to globals
74
+ globals()[module_name] = module
75
+
76
+ # Inspect the module to retreive all of the functions
77
+ for name, f in inspect.getmembers(module, inspect.isfunction):
78
+ if not name.startswith("_"):
79
+ mcp.add_tool(f)
80
+
81
+
82
+ async def run(
83
+ transport: str = "stdio",
84
+ host: str | None = "localhost",
85
+ port: int = 8000,
86
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO",
87
+ ) -> int:
88
+ """
89
+ Run the MCP server
90
+
91
+ This function will run the MCP server using the configured transport
92
+ option.
93
+
94
+ Args:
95
+ args (Sequence | None):
96
+ transport (str): Server transport to use. Valid values for this
97
+ argument are `sse` or `stdio`. The default value is `stdio`
98
+
99
+ host (str | None): The host to run the server on. The default
100
+ value for host is "localhost"
101
+
102
+ port (int): The port on the host to listen for connections on. The
103
+ default value for port is 0.
104
+
105
+ Returns:
106
+ int: Returns a int value as the return code from running the server
107
+ A value of 0 is success and any other value is an error
108
+
109
+ Raises:
110
+ ValueError: When the value of transport is not a valid value
111
+ ValueError: When the value of log_level is not a valid value
112
+ """
113
+ if transport not in ("stdio", "sse"):
114
+ raise ValueError("invalid setting for transport")
115
+
116
+ if isinstance(log_level, str):
117
+ if log_level.upper() not in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
118
+ raise ValueError("invalid setting for log_level")
119
+
120
+
121
+
122
+ # Initialize FastMCP server
123
+ # Available keyword args:
124
+ # - name
125
+ # - instructions
126
+ # - lifespan
127
+ # - tags
128
+ # - host
129
+ # - port
130
+ # - log_level
131
+ # - on_duplicate_tools
132
+ # - on_duplicate_resources
133
+ # - on_duplicate_prompts
134
+ #
135
+ # The server settings can also be passed using env variables prefixed
136
+ # with FASTMCP_SERVER_
137
+ mcp = FastMCP(
138
+ name="Itential Platform MCP",
139
+ instructions="Itential tools and resources for interacting with Itential Platform",
140
+ lifespan=lifespan,
141
+ log_level=log_level.upper(),
142
+ )
143
+
144
+ try:
145
+ register_tools(mcp)
146
+ except Exception as exc:
147
+ print(f"ERROR: failed to import tool: {str(exc)}", file=sys.stderr)
148
+ sys.exit(1)
149
+
150
+ kwargs = {
151
+ "transport": transport,
152
+ }
153
+
154
+ if transport == "sse":
155
+ kwargs.update({
156
+ "host": host,
157
+ "port": port,
158
+ })
159
+
160
+ try:
161
+ await mcp.run_async(**kwargs)
162
+ except KeyboardInterrupt:
163
+ sys.exit(0)
164
+ except Exception as exc:
165
+ print(f"ERROR: failed to import tool: {str(exc)}", file=sys.stderr)
166
+ sys.exit(1)