Skip to content

Instantly share code, notes, and snippets.

@engalar
Last active October 9, 2025 08:38
Show Gist options
  • Select an option

  • Save engalar/1f290599776b6e6c80a5f0a135fe2310 to your computer and use it in GitHub Desktop.

Select an option

Save engalar/1f290599776b6e6c80a5f0a135fe2310 to your computer and use it in GitHub Desktop.
studio pro plugin for mcp server

Of course, here is the updated README.md file reflecting the changes in the provided code, including the new MCP server controls.


架构知识库:Mendix 前后端 RPC 控制面板

1. 总体架构概述 (High-Level Architecture)

本系统是一个基于Mendix Studio Pro扩展API构建的Web应用,包含一个前端UI(index.html)和一个后端脚本(main.py)。它现在集成了Mendix Copilot (MCP) 服务,允许用户通过UI直接控制后端的MCP服务器生命周期。

  • 核心模式: 客户端-服务器 (Client-Server)
  • 通信协议: 基于 window.postMessage异步RPC(远程过程调用)
  • 核心设计原则:
    1. 关注点分离 (SoC): 前后端、UI、业务逻辑、通信等模块各自独立,职责单一。
    2. 依赖倒置 (DIP): 组件依赖于稳定的抽象(接口/契约),而非不稳定的具体实现。
    3. 控制反转 (IoC): 通过依赖注入(前端redi,后端dependency-injector)和调度器(后端AppController)来管理和解耦组件。

2. 集成契约:RPC 通信协议 (The Contract)

这是前后端之间必须遵守的“法律”,是整个系统中最稳定的部分。

2.1 请求 (Request)

从前端发送到后端的每个消息都必须是一个包含以下字段的JSON对象:

字段名 类型 描述 示例
type string 必须。命令的类型,用于后端路由。 "OPEN_EDITOR"
payload object 必须。与命令相关的数据。 {"moduleName": "Admin"}
correlationId string 必须。请求的唯一ID,用于匹配响应。 "req-123"
timestamp string 可选。ISO 8601格式的客户端时间戳。 "2023-10-27T10:00:00Z"

2.2 响应 (Response)

从后端返回到前端的每个消息都必须是一个包含以下字段的JSON对象:

字段名 类型 描述 示例
status "success"/"error" 必须。表示操作是否成功。 "success"
data any 可选。statussuccess时返回的数据。 {"opened": true}
message string 可选。statuserror时的错误信息。 "Module not found."
correlationId string 必须。必须与触发此响应的请求ID完全相同。 "req-123"

2.3 已定义的命令 (Defined Commands)

type payload 结构 成功时 data 结构 描述
ECHO { "content": any } { "echo_response": { "content": any } } 后端将收到的 payload 原样返回,用于测试连通性。
OPEN_EDITOR { "moduleName": string, "entityName": string} { "opened": boolean, ... } 请求后端在Mendix Studio Pro中打开指定实体的编辑器。
MCP_CONTROL { "action": "start" | "stop" | "get_status" | "list_tools" } 依赖于 action (见下文) 管理后端的MCP服务器生命周期。

MCP_CONTROL 命令的 data 结构:

  • actionstart, stop, 或 get_status 时, data 返回服务器状态: { "status": "running" | "stopped", "port": number }
  • actionlist_tools 时, data 返回可用工具列表: { "tools": [{ "name": string, "description": string }] }

3. 前端知识库 (Frontend Knowledge Base)

关注点 1: RPC 通信 (RPC Communication)

  • 契约/接口 (DIP): IMessageService

    • 职责: 定义一个标准的、与实现无关的后端通信接口。系统中任何需要与后端交互的组件都必须依赖此接口。
    • API:
      • call(type: string, payload: object): Promise<any>: 发起一个RPC调用,返回一个Promise,该Promise会用后端的data来解决,或在后端返回错误时被拒绝。
  • 实现: BrowserMessageService

    • 职责: IMessageService接口的具体实现。它封装了所有window.postMessage的复杂性。
    • 内部机制: 管理唯一的correlationIdpendingRequests映射、响应监听和请求超时。
  • 辅助工具: useRpc Hook

    • 职责: 一个可复用的React Hook,封装了调用 IMessageService 的异步流程。它负责管理isLoading, error, 和 data状态,简化了UI组件中的异步代码。

关注点 2: 视图组合与布局 (View Composition & Layout)

  • 契约/接口 (DIP): IView

    • 职责: 这是一个“标记接口”。任何希望被主应用动态渲染为独立UI块的React组件,都应注册为IView的提供者。
  • 管理器: ViewManagementService

    • 职责: 通过依赖注入收集所有注册为IView的组件。App根组件依赖此服务来获取并渲染所有视图。

关注点 3: 用户界面视图 (UI Views)

每个视图都是一个独立的React组件,具有单一职责。

  • McpControlView (新增):
    • 职责: 提供一个完整的UI来管理后端MCP服务器。它允许用户启动、停止、刷新服务器状态,并在服务器运行时显示可用工具列表。
    • 依赖: IMessageService (通过 useRpc hook)。

关注点 4: 依赖注入配置 (Dependency Injection Setup)

  • 配置器: AppWithDependencies
    • 职责: 这是IoC容器的配置中心。它负责将抽象(如IMessageService)绑定到具体的实现(如BrowserMessageService),并注册所有的服务和视图(包括新的McpControlView)。

4. 后端知识库 (Backend Knowledge Base)

关注点 1: 请求入口与调度 (Request Entry & Dispatching)

  • 入口点: onMessage(e) 函数

    • 职责: 作为Mendix环境消息的唯一入口,负责反序列化消息、将其传递给AppController处理,并将返回的响应序列化并发回前端。
  • 调度器: AppController

    • 职责: 系统的核心路由器。它维护一个命令type到具体**命令处理器(Command Handler)**的映射。dispatch方法根据请求的type字段,调用相应的处理器,实现了控制反转。

关注点 2: 命令处理器与业务逻辑 (Command Handlers & Business Logic)

业务逻辑被封装在实现了ICommandHandler接口的类中,每个类负责处理一种命令类型。

  • 接口 (DIP): ICommandHandler

    • 职责: 定义所有命令处理器的契约,要求实现command_type属性和execute(payload)方法。
  • 实现: EchoCommandHandler & EditorCommandHandler

    • 职责: 分别实现ECHOOPEN_EDITOR命令的逻辑。
  • 实现: MCPCommandHandler (新增)

    • 职责: 处理MCP_CONTROL命令。它本身不包含复杂的逻辑,而是作为一个外观(Facade),将start, stop, get_status等具体操作委托给MCPService

关注点 3: 核心服务 (Core Services)

  • MendixEnvironmentService

    • 职责: 封装与Mendix Studio Pro宿主环境的交互,如访问项目模型(currentApp)、打开编辑器(dockingWindowService)和发送消息(PostMessage)。
    • 其它
      • extensionFileService
      • logService
      • microflowActivitiesService
      • microflowExpressionService
      • microflowService
      • untypedModelAccessService
      • dockingWindowService
      • domainModelService
      • backgroundJobService
      • configurationService
      • extensionFeaturesService
      • httpClientService
      • nameValidationService
      • navigationManagerService
      • pageGenerationService
      • appService https://github.com/mendix/ExtensionAPI-Samples/ * blob/main/API%20Reference/Mendix.StudioPro.ExtensionsAPI. * UI.Services/IAppService.md
      • dialogService
      • entityService
      • findResultsPaneService
      • localRunConfigurationsService
      • notificationPopupService
      • runtimeService
      • selectorDialogService
      • versionControlService
      • messageBoxService
  • MCPService (新增)

    • 职责: 管理FastMCP服务器的完整生命周期。
    • 内部机制:
      1. 后台执行: 在一个独立的threading.Thread中启动和运行uvicorn服务器,避免阻塞Mendix主线程。
      2. 生命周期管理: 提供start()stop()方法来控制服务器。
      3. 状态查询: is_running(), get_status(), get_tools()等方法允许其他部分查询服务器状态和配置。
      4. 热重载: 在启动时使用importlib.reload来重新加载工具模块,确保每次启动都能获取最新的工具定义。
      5. 自动关闭: 启动一个监控线程,在Mendix脚本被取消或重新运行时(通过cancellation_token)自动关闭服务器,防止僵尸进程。

关注点 4: 依赖注入配置 (Dependency Injection Setup)

  • IoC容器: Container (基于 dependency-injector)
    • 职责: 负责实例化和装配系统中的所有组件。
    • 配置:
      1. 所有服务(如MendixEnvironmentService, MCPService)都被注册为Singleton
      2. 所有ICommandHandler的实现(EchoCommandHandler, EditorCommandHandler, MCPCommandHandler)被聚合到一个providers.List中。
      3. AppController被注入这个处理器列表,使其能够动态发现所有可用的命令,而无需硬编码依赖。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script src="assets/tailwindcss.js"></script>
</head>
<body class="bg-gray-100 font-sans">
<div id="app"></div>
<script src="assets/vendor-bundle.umd.js"></script>
<script src="assets/babel.min.js"></script>
<script type="text/babel">
const { React, ReactDOM, redi, rediReact } = globalThis.__tmp;
delete globalThis.__tmp;
// keep unused import for redi and rediReact
const { Inject, Injector, LookUp, Many, Optional, Quantity, RediError, Self, SkipSelf, WithNew, createIdentifier, forwardRef, isAsyncDependencyItem, isAsyncHook, isClassDependencyItem, isCtor, isDisposable, isFactoryDependencyItem, isValueDependencyItem, setDependencies } = redi;
const { RediConsumer, RediContext, RediProvider, WithDependency, connectDependencies, connectInjector, useDependency, useInjector, useObservable, useUpdateBinder } = rediReact;
const { useState, useEffect, useReducer, Fragment, useCallback } = React;
// ===================================================================
// 1. STATE & COMMUNICATION SERVICES
// ===================================================================
// 1.2. COMMUNICATION SERVICE (REFACTORED for RPC)
const IMessageService = createIdentifier('IMessageService');
class BrowserMessageService {
constructor() {
this.requestId = 0;
this.pendingRequests = new Map();
this.initializeListener();
this.RPC_TIMEOUT = 10000; // 10 seconds
}
// The core RPC 'call' method
async call(type, payload) {
const correlationId = `req-${this.requestId++}`;
const command = { type, payload, correlationId, timestamp: new Date().toISOString() };
return new Promise((resolve, reject) => {
// Set a timeout for the request
const timeoutId = setTimeout(() => {
if (this.pendingRequests.has(correlationId)) {
this.pendingRequests.delete(correlationId);
const error = new Error(`RPC call timed out for type '${type}'`);
reject(error);
}
}, this.RPC_TIMEOUT);
// Store the promise handlers
this.pendingRequests.set(correlationId, { resolve, reject, timeoutId });
// Send the command to the backend
window.parent.sendMessage("frontend:message", command);
});
}
initializeListener() { window.addEventListener('message', this.handleBackendResponse); }
handleBackendResponse = (event) => {
if (!(event.data && event.data.type === 'backendResponse')) return;
try {
const response = JSON.parse(event.data.data);
const { correlationId } = response;
if (!correlationId || !this.pendingRequests.has(correlationId)) {
console.warn("Received response for an unknown or timed-out request:", response);
return;
}
const { resolve, reject, timeoutId } = this.pendingRequests.get(correlationId);
clearTimeout(timeoutId); // Clear the timeout
this.pendingRequests.delete(correlationId); // Clean up
if (response.status === 'success') {
resolve(response.data);
} else {
reject(new Error(response.message || 'An unknown backend error occurred.'));
}
} catch (e) {
console.error("Fatal error parsing backend response:", e, event.data.data);
}
};
dispose() { window.removeEventListener('message', this.handleBackendResponse); }
}
setDependencies(BrowserMessageService, []);
// ===================================================================
// 2. VIEW ABSTRACTION AND MANAGEMENT (Unchanged)
// ===================================================================
const IView = createIdentifier('IView');
class ViewManagementService {
constructor(views) { this.views = views; }
getViews() { return this.views; }
}
setDependencies(ViewManagementService, [[new Many, IView]]);
// ===================================================================
// 3. UI COMPONENTS (ADAPTED for Async/Await and RPC)
// ===================================================================
// Reusable hook for handling RPC calls in components
const useRpc = () => {
const messageService = useDependency(IMessageService);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const execute = useCallback(async (type, payload) => {
setIsLoading(true);
setError(null);
setData(null);
try {
const result = await messageService.call(type, payload);
setData(result);
return result;
} catch (err) {
setError(err.message);
// allow component to handle the error too
throw err;
} finally {
setIsLoading(false);
}
}, [messageService]);
return { execute, isLoading, error, data };
};
// --- START: New MCP Control Component ---
const McpControlView = () => {
const [serverState, setServerState] = useState({ status: 'unknown', port: null, tools: [] });
const { execute: executeControl, isLoading: isControlLoading, error: controlError } = useRpc();
const { execute: executeQuery, isLoading: isQueryLoading, error: queryError } = useRpc();
const isRunning = serverState.status === 'running';
const isLoading = isControlLoading || isQueryLoading;
const fetchStatus = useCallback(async () => {
try {
const statusData = await executeQuery('MCP_CONTROL', { action: 'get_status' });
const toolsData = statusData.status === 'running'
? await executeQuery('MCP_CONTROL', { action: 'list_tools' })
: { tools: [] };
setServerState({ ...statusData, tools: toolsData.tools });
} catch (e) {
console.error("Failed to fetch MCP status:", e);
setServerState({ status: 'error', port: null, tools: [] });
}
}, [executeQuery]);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const handleStart = async () => {
try {
await executeControl('MCP_CONTROL', { action: 'start' });
await fetchStatus();
} catch (e) { console.error("Failed to start MCP server:", e); }
};
const handleStop = async () => {
try {
await executeControl('MCP_CONTROL', { action: 'stop' });
// Give server a moment to shut down before fetching status
setTimeout(fetchStatus, 500);
} catch (e) { console.error("Failed to stop MCP server:", e); }
};
const statusColor = isRunning ? 'bg-green-500' : (serverState.status === 'stopped' ? 'bg-red-500' : 'bg-yellow-500');
const statusText = isRunning ? `Running on port ${serverState.port}` : (serverState.status === 'stopped' ? 'Stopped' : 'Unknown');
return (
<div className="p-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-700 mb-2">MCP Server Control</h2>
<div className="flex items-center space-x-4 mb-4">
<div className="flex items-center space-x-2">
<span className={`w-3 h-3 rounded-full ${statusColor}`}></span>
<span className="font-medium text-gray-800">{statusText}</span>
</div>
<button onClick={fetchStatus} disabled={isLoading} className="px-3 py-1 text-sm bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50">
{isQueryLoading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<button onClick={handleStart} disabled={isLoading || isRunning} className="w-full px-6 py-3 bg-blue-600 text-white rounded-md font-semibold hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed">
{isControlLoading ? 'Starting...' : 'Start Server'}
</button>
<button onClick={handleStop} disabled={isLoading || !isRunning} className="w-full px-6 py-3 bg-red-600 text-white rounded-md font-semibold hover:bg-red-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed">
{isControlLoading ? 'Stopping...' : 'Stop Server'}
</button>
</div>
{(controlError || queryError) && <p className="text-red-600 mt-2 text-sm">{controlError || queryError}</p>}
{isRunning && serverState.tools.length > 0 && (
<div className="mt-4">
<h3 className="text-lg font-semibold text-gray-600 mb-2">Available Tools:</h3>
<ul className="list-disc list-inside bg-gray-50 p-3 rounded border">
{serverState.tools.map(tool => (
<li key={tool.name} className="text-gray-700">
<strong className="font-mono">{tool.name}</strong>: {tool.description}
</li>
))}
</ul>
</div>
)}
</div>
);
};
// --- END: New MCP Control Component ---
// ===================================================================
// 4. MAIN APP & IOC CONFIGURATION
// ===================================================================
const App = () => {
const viewManager = useDependency(ViewManagementService);
return (
<div className="p-4 max-w-3xl mx-auto bg-white shadow-lg rounded-lg mt-8">
<h1 className="text-3xl font-bold text-gray-800 mb-6 text-center">Mendix Backend Control Panel (RPC)</h1>
{viewManager.getViews().map((ViewComponent, index) => <Fragment key={index}><ViewComponent /></Fragment>)}
</div>
);
};
const AppWithDependencies = connectDependencies(App, [
[ViewManagementService],
[IMessageService, { useClass: BrowserMessageService }],
// Add the new MCP control view here
[IView, { useValue: McpControlView }],
]);
const root = ReactDOM.createRoot(document.getElementById('app'));
root.render(<AppWithDependencies />);
</script>
</body>
</html>
# region 样板代码
import time
import anyio
from pymx.mcp import tools
from pymx.mcp import tool_registry
from pymx.mcp.mendix_context import set_mendix_services
import importlib
from Mendix.StudioPro.ExtensionsAPI.Model.DomainModels import IDomainModel, IEntity
from Mendix.StudioPro.ExtensionsAPI.Model.Projects import IModule
import clr
from System.Text.Json import JsonSerializer
import json
import traceback
from typing import Any, Dict, Callable, Iterable, Optional
from abc import ABC, abstractmethod # 引入ABC用于定义接口
from dependency_injector import containers, providers
from dependency_injector.wiring import inject, Provide
# --- New imports for MCP server ---
import threading
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.responses import JSONResponse
from mcp.server.fastmcp import FastMCP
# --- End new imports ---
# pythonnet库嵌入C#代码
clr.AddReference("System.Text.Json")
clr.AddReference("Mendix.StudioPro.ExtensionsAPI")
# mcp
set_mendix_services(
currentApp,
messageBoxService,
extensionFileService,
microflowActivitiesService,
microflowExpressionService,
microflowService,
untypedModelAccessService,
dockingWindowService,
domainModelService,
backgroundJobService,
configurationService,
extensionFeaturesService,
httpClientService,
nameValidationService,
navigationManagerService,
pageGenerationService,
appService,
dialogService,
entityService,
findResultsPaneService,
localRunConfigurationsService,
notificationPopupService,
runtimeService,
selectorDialogService,
versionControlService
)
# 运行时环境提供的工具
PostMessage("backend:clear", '') # 清理IDE控制台日志
# ShowDevTools() # 打开前端开发者工具
def serialize_json_object(json_object: Any) -> str:
import System.Text.Json
return System.Text.Json.JsonSerializer.Serialize(json_object)
def deserialize_json_string(json_string: str) -> Any:
return json.loads(json_string)
class TransactionManager:
def __init__(self, app, transaction_name):
self.app = app
self.name = transaction_name
self.transaction = None
def __enter__(self):
self.transaction = self.app.StartTransaction(self.name)
return self.transaction
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
self.transaction.Commit()
else:
self.transaction.Rollback()
self.transaction.Dispose()
return False
# endregion
# endregion
# ===================================================================
# 1. ABSTRACTIONS AND SERVICES (THE NEW ARCHITECTURE)
# ===================================================================
class MendixEnvironmentService:
"""
一个抽象了Mendix宿主环境全局变量的服务。
(此部分保持不变)
"""
def __init__(self, app_context, window_service, post_message_func: Callable):
self.app = app_context
self.window_service = window_service
self.post_message = post_message_func
# ----------------- Step 1: Define a Command Handler Interface -----------------
class ICommandHandler(ABC):
"""
定义所有命令处理器的通用接口。
每个处理器都必须声明它能处理的命令类型,并提供一个执行方法。
"""
@property
@abstractmethod
def command_type(self) -> str:
"""返回此处理器响应的命令类型字符串,例如 "ECHO" 或 "OPEN_EDITOR"."""
...
@abstractmethod
def execute(self, payload: Dict) -> Dict:
"""执行与命令相关的业务逻辑。"""
...
# ----------------- Step 2: Refactor Services to Implement the Interface -----------------
class EchoCommandHandler(ICommandHandler):
"""处理'ECHO'命令的业务逻辑。"""
command_type = "ECHO"
def __init__(self, mendix_env: MendixEnvironmentService):
self._mendix_env = mendix_env
def execute(self, payload: Dict) -> Dict:
self._mendix_env.post_message(
"backend:info", f"Received {self.command_type} command with payload: {payload}")
return {"echo_response": payload}
class EditorCommandHandler(ICommandHandler):
"""处理'OPEN_EDITOR'命令的业务逻辑。"""
command_type = "OPEN_EDITOR"
def __init__(self, mendix_env: MendixEnvironmentService):
self._mendix_env = mendix_env
def execute(self, payload: Dict) -> Dict:
module_name = payload.get("moduleName")
entity_name = payload.get("entityName")
if not module_name or not entity_name:
raise ValueError(
"Payload must contain 'moduleName' and 'entityName'.")
self._mendix_env.post_message(
"backend:info", f"Attempting to open editor for {module_name}.{entity_name}")
target_module = next(
(m for m in self._mendix_env.app.Root.GetModules() if m.Name == module_name), None)
if not target_module:
raise FileNotFoundError(f"Module '{module_name}' not found.")
target_entity = next(
(e for e in target_module.DomainModel.GetEntities() if e.Name == entity_name), None)
if not target_entity:
raise FileNotFoundError(
f"Entity '{entity_name}' not found in module '{module_name}'.")
was_opened = self._mendix_env.window_service.TryOpenEditor(
target_module.DomainModel, target_entity)
return {"moduleName": module_name, "entityName": entity_name, "opened": was_opened}
# 实现开启或者关闭mcp的接口,另外还需要有状态检测接口,以及当前mcp工具列表
# --- START: New MCP Service and Command Handler ---
class MCPService:
"""Manages the lifecycle of the FastMCP Uvicorn server."""
def __init__(self, mendix_env: MendixEnvironmentService):
self._mendix_env = mendix_env
self._server: Optional[uvicorn.Server] = None
self._server_thread: Optional[threading.Thread] = None
self._monitor_thread: Optional[threading.Thread] = None
self._mcp_instance: Optional[FastMCP] = None
self.port = 8009
def is_running(self) -> bool:
return self._server is not None and not self._server.should_exit
def _monitor_cancellation(self):
"""
Runs in a separate thread to monitor the script's cancellation_token.
This ensures the server is gracefully shut down when the script is re-run.
"""
# Assumes `cancellation_token` is provided by the execution environment
while not cancellation_token.IsCancellationRequested:
time.sleep(1)
if self.is_running():
self._mendix_env.post_message("backend:info", "[Monitor] Cancellation detected, shutting down server.")
self._server.should_exit = True
def start(self):
if self.is_running():
raise RuntimeError("MCP server is already running.")
self._mendix_env.post_message("backend:info", "Starting MCP server...")
# 1. Create fresh instances
# 触发服务重新实例化
importlib.reload(tool_registry)
# 关键:导入 'tools' 包以触发 __init__.py 中的动态加载
importlib.reload(tools)
self._mcp_instance = tool_registry.mcp # FastMCP("mendix-modular-copilot")
@self._mcp_instance.tool()
def add(a: int, b: int) -> int:
"""Add two numbers"""
return a + b
@self._mcp_instance.tool()
def get_project_name() -> str:
"""Returns the name of the current Mendix project."""
return self._mendix_env.app.Root.Name
# 2. Define lifespan
async def lifespan(app: Starlette):
self._mendix_env.post_message("backend:info", "[Lifespan] Starting MCP session manager...")
async with self._mcp_instance.session_manager.run():
self._mendix_env.post_message("backend:info", f"[Lifespan] MCP server ready and listening on port {self.port}.")
yield
self._mendix_env.post_message("backend:info", "[Lifespan] MCP server shutting down session manager.")
# 3. Create Starlette app and Uvicorn config
app = Starlette(
routes=[
Mount("/a", app=self._mcp_instance.streamable_http_app()),
Route("/b", lambda r: JSONResponse({"status": "ok"})),
],
lifespan=lifespan
)
config = uvicorn.Config(app, host="127.0.0.1", port=self.port, log_config=None)
self._server = uvicorn.Server(config)
# 4. Run in a separate thread to avoid blocking the main Mendix thread
self._server_thread = threading.Thread(target=self._server.run)
self._server_thread.start()
self._monitor_thread = threading.Thread(target=self._monitor_cancellation)
self._monitor_thread.daemon = True # Ensure thread doesn't block script exit
self._monitor_thread.start()
self._mendix_env.post_message("backend:info", "MCP server start command issued.")
def stop(self):
if not self.is_running():
raise RuntimeError("MCP server is not running.")
self._mendix_env.post_message("backend:info", "Stopping MCP server...")
self._server.should_exit = True
# The thread will terminate on its own once the server exits.
# We don't join it to avoid blocking.
self._mendix_env.post_message("backend:info", "MCP server stop command issued.")
def get_status(self) -> Dict:
if self.is_running():
return {"status": "running", "port": self.port}
else:
return {"status": "stopped", "port": self.port}
def get_tools(self) -> Dict:
if not self.is_running() or self._mcp_instance is None:
return {"tools": []}
tool_list = []
# FIX: The internal attribute for tools in FastMCP is `_tools`, not `tools`.
for tool in self._mcp_instance._tool_manager.list_tools():
tool_list.append({
"name": tool.name,
"description": tool.description or "No description provided."
})
return {"tools": tool_list}
class MCPCommandHandler(ICommandHandler):
"""Handles all MCP-related commands by delegating to MCPService."""
command_type = "MCP_CONTROL"
def __init__(self, mcp_service: MCPService):
self._mcp_service = mcp_service
def execute(self, payload: Dict) -> Dict:
action = payload.get("action")
if action == "start":
self._mcp_service.start()
return self._mcp_service.get_status()
elif action == "stop":
self._mcp_service.stop()
return self._mcp_service.get_status()
elif action == "get_status":
return self._mcp_service.get_status()
elif action == "list_tools":
return self._mcp_service.get_tools()
else:
raise ValueError(f"Invalid action '{action}' for MCP_CONTROL.")
# --- END: New MCP Service and Command Handler ---
# ----------------- Step 3: Modify the AppController -----------------
class AppController:
"""
将前端命令路由到特定的业务逻辑服务。
现在它依赖于一个可迭代的ICommandHandler集合,而不是具体的服务。
"""
def __init__(self, handlers: Iterable[ICommandHandler], mendix_env: MendixEnvironmentService):
self._mendix_env = mendix_env
# 在构造时动态构建命令处理器字典
self._command_handlers = {h.command_type: h.execute for h in handlers}
self._mendix_env.post_message(
"backend:info", f"Controller initialized with handlers for: {list(self._command_handlers.keys())}")
def dispatch(self, request: Dict) -> Dict:
"""
分发请求的逻辑保持不变,但现在更加灵活。
"""
command_type = request.get("type")
payload = request.get("payload", {})
correlation_id = request.get("correlationId")
try:
handler_execute_func = self._command_handlers.get(command_type)
if not handler_execute_func:
raise ValueError(
f"No handler found for command type: {command_type}")
result = handler_execute_func(payload)
return self._create_success_response(result, correlation_id)
except Exception as e:
error_message = f"Error executing command '{command_type}': {e}"
self._mendix_env.post_message(
"backend:info", f"{error_message}\n{traceback.format_exc()}")
return self._create_error_response(error_message, correlation_id, {"traceback": traceback.format_exc()})
def _create_success_response(self, data: Any, correlation_id: str) -> Dict:
return {"status": "success", "data": data, "correlationId": correlation_id}
def _create_error_response(self, message: str, correlation_id: str, data: Any = None) -> Dict:
return {"status": "error", "message": message, "data": data or {}, "correlationId": correlation_id}
# ===================================================================
# 2. IOC CONTAINER CONFIGURATION
# ===================================================================
class Container(containers.DeclarativeContainer):
"""
应用的控制反转(IoC)容器。
"""
config = providers.Configuration()
mendix_env = providers.Singleton(
MendixEnvironmentService,
app_context=config.app_context,
window_service=config.window_service,
post_message_func=config.post_message_func,
)
# ----------------- Step 4: Update IoC Container Configuration -----------------
# Register the new MCPService
mcp_service = providers.Singleton(MCPService, mendix_env=mendix_env)
# Use providers.List to aggregate all command handlers
command_handlers = providers.List(
providers.Singleton(EchoCommandHandler, mendix_env=mendix_env),
providers.Singleton(EditorCommandHandler, mendix_env=mendix_env),
# Add the new MCP command handler
providers.Singleton(MCPCommandHandler, mcp_service=mcp_service),
)
# Update AppController's provider to inject the aggregated list
app_controller = providers.Singleton(
AppController,
handlers=command_handlers,
mendix_env=mendix_env,
)
# ===================================================================
# 3. APPLICATION ENTRYPOINT AND WIRING
# ===================================================================
def onMessage(e: Any):
if e.Message != "frontend:message":
return
controller = container.app_controller()
request_object = None
try:
request_string = JsonSerializer.Serialize(e.Data)
request_object = json.loads(request_string)
if "correlationId" not in request_object:
PostMessage(
"backend:info", f"Received message without correlationId: {request_object}")
return
response = controller.dispatch(request_object)
PostMessage("backend:response", json.dumps(response))
except Exception as ex:
PostMessage(
"backend:info", f"Fatal error in onMessage: {ex}\n{traceback.format_exc()}")
correlation_id = "unknown"
if request_object and "correlationId" in request_object:
correlation_id = request_object["correlationId"]
fatal_error_response = {"status": "error", "message": f"A fatal error occurred: {ex}", "data": {
"traceback": traceback.format_exc()}, "correlationId": correlation_id}
PostMessage("backend:response", json.dumps(fatal_error_response))
def initialize_app():
container = Container()
container.config.from_dict(
{"app_context": currentApp, "window_service": dockingWindowService, "post_message_func": PostMessage})
return container
# ===================================================================
# 4. APPLICATION START
# ===================================================================
container = initialize_app()
{
"name": "mcp",
"author": null,
"email": null,
"ui": "index.html",
"plugin": "main.py",
"home": "https://gist.github.com/engalar/1f290599776b6e6c80a5f0a135fe2310",
"deps": [
"pythonnet",
"dependency-injector",
"pymx"
]
}
import logging
import anyio
import asyncio
import uvicorn
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from mcp.server.fastmcp import FastMCP
from sse_starlette.sse import AppStatus
# --- Globals that are safe (constants and stateless functions) ---
port = 8004
# region logging (No changes needed here, as it's configuration)
# PostMessage("backend:clear", '')
# ShowDevTools()
class PostMessageHandler(logging.Handler):
def __init__(self, post_message_func):
super().__init__()
self.post_message_func = post_message_func
def emit(self, record):
try:
message = self.format(record)
self.post_message_func("backend:info", message)
except Exception:
self.handleError(record)
LOG_LEVEL = logging.INFO
log_formatter = logging.Formatter(
'{"level": "%(levelname)s", "time": "%(asctime)s", "logger": "%(name)s", "message": "%(message)s"}'
)
post_message_handler = PostMessageHandler(PostMessage)
post_message_handler.setLevel(LOG_LEVEL)
post_message_handler.setFormatter(log_formatter)
console_handler = logging.StreamHandler()
console_handler.setFormatter(logging.Formatter(
"%(levelname)-8s %(asctime)s [%(name)s] - %(message)s"
))
loggers_to_modify = ["uvicorn", "uvicorn.error", "uvicorn.access", "my_app"]
for logger_name in loggers_to_modify:
logger = logging.getLogger(logger_name)
logger.setLevel(LOG_LEVEL)
if logger.hasHandlers():
logger.handlers.clear()
logger.addHandler(post_message_handler)
logger.addHandler(console_handler)
logger.propagate = False
# endregion
# --- Main application logic encapsulated in a function ---
async def main_app_logic():
"""
Sets up, runs, and tears down the entire application for a single execution.
This prevents state from leaking between runs.
"""
PostMessage("backend:clear", '') # Clear console at the start of each run
# --- 应用猴子补丁,在每次运行开始时重置库的全局状态 ---
# 这解决了 sse_starlette 库在多次运行间保持 "exit" 状态的问题。
AppStatus.should_exit_event = asyncio.Event()
PostMessage("backend:info", "[Patch] 重置 sse_starlette 状态...")
# 1. Create fresh instances of all stateful objects INSIDE the function.
mcp = FastMCP("mendix-modular-copilot")
# Define tools on the new mcp instance
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers"""
return a + b
# 2. Define the lifespan context manager. It will capture the 'mcp'
# instance from this function's scope.
async def lifespan(app: Starlette):
"""Application lifespan handler."""
PostMessage("backend:info", "应用启动... 正在启动 MCP session manager...")
async with mcp.session_manager.run():
PostMessage("backend:info", "MCP session manager 已启动,服务器准备就绪。")
yield # Server runs here
PostMessage("backend:info", "应用关闭... 正在清理 MCP session manager。")
# 3. Create the Starlette app and Uvicorn config using the fresh objects.
app = Starlette(
routes=[
Mount("/a", app=mcp.streamable_http_app()),
Route("/b", lambda r: JSONResponse({"status": "ok"})),
],
lifespan=lifespan
)
config = uvicorn.Config(app=app, host="127.0.0.1",
port=port, log_config=None, timeout_graceful_shutdown=0)
server = uvicorn.Server(config)
# 4. The server and cancellation logic remains the same, but now operates
# on the locally created 'server' object.
async def monitor_cancellation():
"""Checks for cancellation and triggers a graceful server shutdown."""
while not cancellation_token.IsCancellationRequested:
await anyio.sleep(1)
PostMessage("backend:info", "检测到取消请求,正在关闭服务器...")
server.should_exit = True
async with anyio.create_task_group() as task_group:
task_group.start_soon(server.serve)
task_group.start_soon(monitor_cancellation)
PostMessage("backend:info", "服务器已关闭。")
# --- Main execution block ---
try:
# Each time the script is run, it calls the main async function.
anyio.run(main_app_logic)
except KeyboardInterrupt:
PostMessage("backend:info", "Ctrl+C 按下,退出。")
finally:
# This logic correctly reports the final status.
# Note: 'execution_id' might not be defined if the script fails early.
exec_id = locals().get('execution_id', 'unknown')
if 'cancellation_token' in locals() and cancellation_token.IsCancellationRequested:
PostMessage("backend:info", f"\n[ID:{exec_id}][Python] Cancellation detected. Uvicorn has been shut down.")
else:
PostMessage("backend:info", f"[ID:{exec_id}][Python] Uvicorn server shut down normally. Script finished.")
#endregion
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment