Skip to content

Commit 48d1c7c

Browse files
committed
base implementation
1 parent 74eb0a5 commit 48d1c7c

File tree

10 files changed

+650
-1
lines changed

10 files changed

+650
-1
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
__pycache__
2+
.venv

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.12

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2021 Dmitry Maslennikov
3+
Copyright (c) 2025 Dmitry Maslennikov
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

pyproject.toml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
[project]
2+
name = "mcp-server-iris"
3+
version = "0.1.0"
4+
description = "A Model Context Protocol server for InterSystems IRIS."
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
authors = [{ name = "CaretDev Corp." }]
8+
maintainers = [{ name = "Dmitry Maslennikov", email = "[email protected]" }]
9+
keywords = ["iris", "mcp", "llm", "automation"]
10+
license = { text = "MIT" }
11+
classifiers = [
12+
"Development Status :: 4 - Beta",
13+
"Intended Audience :: Developers",
14+
"License :: OSI Approved :: MIT License",
15+
"Programming Language :: Python :: 3",
16+
"Programming Language :: Python :: 3.10",
17+
]
18+
dependencies = [
19+
"intersystems-irispython>=5.1.0",
20+
"mcp[cli]>=1.2.0",
21+
"starlette>=0.36.0",
22+
"uvicorn>=0.27.0",
23+
]
24+
25+
[project.scripts]
26+
mcp-server-iris = "mcp_server_iris:main"
27+
28+
[build-system]
29+
requires = ["hatchling"]
30+
build-backend = "hatchling.build"
31+
32+
[tool.uv]
33+
dev-dependencies = ["pyright>=1.1.389", "ruff>=0.7.3", "pytest>=8.0.0"]
34+
35+
[tool.pytest.ini_options]
36+
testpaths = ["tests"]
37+
python_files = "test_*.py"
38+
python_classes = "Test*"
39+
python_functions = "test_*"

src/mcp_server_iris/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .server import main as main # noqa
2+
# from .server import run as run # noqa

src/mcp_server_iris/__main__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from mcp_server_iris import main
2+
3+
main()
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
from enum import Enum
2+
from mcp_server_iris.mcpserver import Context
3+
from mcp_server_iris.tools import transaction, raise_on_error
4+
from iris import IRISReference
5+
6+
7+
class ProductionStatus(Enum):
8+
Unknown = 0
9+
Running = 1
10+
Stopped = 2
11+
Suspended = 3
12+
Troubled = 4
13+
NetworkStopped = 5
14+
ShardWorkerProhibited = 6
15+
16+
17+
log_types = [
18+
"ASSERT",
19+
"ERROR",
20+
"WARNING",
21+
"INFO",
22+
"TRACE",
23+
"ALERT",
24+
]
25+
26+
27+
class LogType(Enum):
28+
Assert = 1
29+
Error = 2
30+
Warning = 3
31+
Info = 4
32+
Trace = 5
33+
Alert = 6
34+
35+
36+
def production_items_status(iris, running: bool, name: str) -> list[str]:
37+
result = []
38+
namespace = iris.classMethodString("%SYSTEM.Process", "NameSpace")
39+
prod = iris.classMethodObject("Ens.Config.Production", "%OpenId", name)
40+
if not prod:
41+
raise ValueError(f"Production {name} not found")
42+
items = prod.getObject("Items")
43+
for i in range(1, items.invokeInteger("Count") + 1):
44+
item = items.invokeObject("GetAt", i)
45+
item_name = item.getString("Name")
46+
status_info = []
47+
enabled = item.getBoolean("Enabled")
48+
status_info += [f"Enabled={enabled}"]
49+
if enabled:
50+
val = iris.getString(
51+
"^IRIS.Temp.EnsHostMonitor", namespace, item_name, "%Status"
52+
)
53+
status_info += [f"Status={val}"]
54+
55+
result.append(f"{item_name}: " + "; ".join(status_info))
56+
return result
57+
58+
59+
def init(server, logger):
60+
@server.tool(description="Create an Interoperability Production")
61+
async def interoperability_production_create(name: str, ctx: Context) -> bool:
62+
if "." not in name:
63+
raise ValueError(
64+
"Production name must in format packagenamespace.productionname, where packagenamespace can have multiple parts separated by dots"
65+
)
66+
iris = ctx.request_context.lifespan_context["iris"]
67+
with transaction(iris):
68+
prod = iris.classMethodObject(
69+
"%Dictionary.ClassDefinition", "%OpenId", name
70+
)
71+
if prod:
72+
raise ValueError(f"Class {name} already exists")
73+
logger.info(f"Creating Interoperability Production: {name}")
74+
prod = iris.classMethodObject("Ens.Config.Production", "%New", name)
75+
raise_on_error(iris, prod.invokeString("SaveToClass"))
76+
raise_on_error(iris, prod.invokeString("%Save"))
77+
raise_on_error(
78+
iris, iris.classMethodString("%SYSTEM.OBJ", "Compile", name, "ck-d")
79+
)
80+
return True
81+
82+
@server.tool(description="Status of an Interoperability Production")
83+
async def interoperability_production_status(
84+
ctx: Context,
85+
name: str = None,
86+
full_status: bool = False,
87+
) -> str:
88+
logger.info("Interoperability Production Status" + f": {name}" if name else "")
89+
iris = ctx.request_context.lifespan_context["iris"]
90+
refname = IRISReference(iris)
91+
refname.setValue(name)
92+
refstatus = IRISReference(iris)
93+
raise_on_error(
94+
iris,
95+
iris.classMethodString(
96+
"Ens.Director", "GetProductionStatus", refname, refstatus
97+
),
98+
)
99+
if not refname.getValue():
100+
raise ValueError("No running production found")
101+
name = refname.getValue()
102+
status = ProductionStatus(int(refstatus.getValue()))
103+
reason = IRISReference(iris)
104+
needsupdate = iris.classMethodBoolean(
105+
"Ens.Director", "ProductionNeedsUpdate", reason
106+
)
107+
reason_update = (
108+
f"Production needs update: {reason.getValue()}" if needsupdate else ""
109+
)
110+
111+
if status == ProductionStatus.Running and full_status:
112+
items_status = production_items_status(
113+
iris, status == ProductionStatus.Running, name
114+
)
115+
return f"Production {name} is running with items: \n{"\n".join(items_status)}\n{reason_update}"
116+
return f"Production {name} with status: {status.name}\n{reason_update}"
117+
118+
@server.tool(description="Start an Interoperability Production")
119+
async def interoperability_production_start(
120+
ctx: Context,
121+
name: str = None,
122+
) -> str:
123+
logger.info(
124+
"Starting Interoperability Production" + f": {name}" if name else "."
125+
)
126+
iris = ctx.request_context.lifespan_context["iris"]
127+
raise_on_error(
128+
iris, iris.classMethodString("Ens.Director", "StartProduction", name)
129+
)
130+
return "Started production"
131+
132+
@server.tool(description="Stop an Interoperability Production")
133+
async def interoperability_production_stop(
134+
ctx: Context,
135+
timeout: int = None,
136+
force: bool = False,
137+
) -> str:
138+
logger.info("Sopping Interoperability Production.")
139+
iris = ctx.request_context.lifespan_context["iris"]
140+
raise_on_error(
141+
iris,
142+
iris.classMethodString("Ens.Director", "StopProduction", timeout, force),
143+
)
144+
return "Stopped production"
145+
146+
@server.tool(description="Recover an Interoperability Production")
147+
async def interoperability_production_recover(
148+
ctx: Context,
149+
) -> str:
150+
logger.info("Recovering Interoperability Production")
151+
iris = ctx.request_context.lifespan_context["iris"]
152+
raise_on_error(
153+
iris, iris.classMethodString("Ens.Director", "RecoverProduction")
154+
)
155+
return "Recovered"
156+
157+
@server.tool(description="Check if an Interoperability Production needs update")
158+
async def interoperability_production_needsupdate(
159+
ctx: Context,
160+
) -> str:
161+
logger.info("Checking if Interoperability Production needs update")
162+
iris = ctx.request_context.lifespan_context["iris"]
163+
reason = IRISReference(iris)
164+
result = iris.classMethodBoolean(
165+
"Ens.Director", "ProductionNeedsUpdate", reason
166+
)
167+
if result:
168+
raise ValueError(f"Production needs update: {reason.getValue()}")
169+
return "Production does not need update"
170+
171+
@server.tool(description="Update Interoperability Production")
172+
async def interoperability_production_update(
173+
ctx: Context,
174+
timeout: int = None,
175+
force: bool = False,
176+
) -> str:
177+
iris = ctx.request_context.lifespan_context["iris"]
178+
raise_on_error(
179+
iris,
180+
iris.classMethodString("Ens.Director", "UpdateProduction", timeout, force),
181+
)
182+
return "Production updated"
183+
184+
@server.tool(description="Get Interoperability Production logs")
185+
async def interoperability_production_logs(
186+
ctx: Context,
187+
item_name: str = None,
188+
limit: int = 10,
189+
log_type_info: bool = False,
190+
log_type_alert: bool = False,
191+
log_type_error: bool = True,
192+
log_type_warning: bool = True,
193+
) -> str:
194+
logs = []
195+
log_type = []
196+
log_type_info and log_type.append(LogType.Info.value)
197+
log_type_alert and log_type.append(LogType.Alert.value)
198+
log_type_error and log_type.append(LogType.Error.value)
199+
log_type_warning and log_type.append(LogType.Warning.value)
200+
db = ctx.request_context.lifespan_context["db"]
201+
with db.cursor() as cur:
202+
sql = f"""
203+
select top ? TimeLogged , %External(Type) Type, ConfigName, Text
204+
from Ens_Util.Log
205+
where
206+
{"ConfigName = ?" if item_name else "1=1"}
207+
{f"and type in ({', '.join(['?'] * len(log_type))})" if log_type else ""}
208+
order by id desc
209+
"""
210+
params = [limit, *([item_name] if item_name else []), *log_type]
211+
cur.execute(sql, params)
212+
for row in cur.fetchall():
213+
logs.append(f"{row[0]} {row[1]} {row[2]} {row[3]}")
214+
return "\n".join(logs)

0 commit comments

Comments
 (0)