mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 12:45:32 +08:00
feat(mcp): mcp 独立应用与 /mcp Streamable HTTP 端点(官方 SDK + PSR-7 桥接)
This commit is contained in:
268
extend/base/mcp/controller/IndexBase.php
Normal file
268
extend/base/mcp/controller/IndexBase.php
Normal file
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace base\mcp\controller;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\McpService;
|
||||
use app\mcp\service\McpDispatch;
|
||||
use app\mcp\service\Psr16ThinkCache;
|
||||
use GuzzleHttp\Psr7\HttpFactory;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use Mcp\Capability\Registry\ReferenceHandler;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\Session\FileSessionStore;
|
||||
use Mcp\Server\Session\Psr16SessionStore;
|
||||
use Mcp\Server\Session\SessionStoreInterface;
|
||||
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
|
||||
use Mcp\Server\Transport\StreamableHttpTransport;
|
||||
use Psr\Http\Message\ResponseInterface as Psr7ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use think\facade\Log;
|
||||
use think\Response;
|
||||
|
||||
/**
|
||||
* MCP 协议端点(官方 mcp/sdk Streamable HTTP 传输 + PSR-7 桥接).
|
||||
*
|
||||
* 独立应用 app/mcp(无 middleware.php,无 Session/CSRF),单入口 index():
|
||||
* 1. SDK 存在性检查(未装 mcp/sdk 的存量项目返回 503 降级提示)
|
||||
* 2. Bearer 密钥认证(McpService::authenticate,失败 401)
|
||||
* 3. mcp.enable 开关(关闭 403)
|
||||
* 4. PSR-7 桥(Guzzle 实现)-> StreamableHttpTransport -> Mcp\Server
|
||||
* 5. PSR-7 响应转 think Response(状态码/头透传,含 Mcp-Session-Id)
|
||||
*
|
||||
* 会话:Psr16SessionStore + Cache::store('mcp_session')(PSR-16 薄适配),
|
||||
* 适配失败回退 FileSessionStore(runtime/mcp_session/)。
|
||||
*
|
||||
* 注意:本端点未初始化 MCP 协议会话时(401/403/503)直接返回 think json,
|
||||
* 无需 JSON-RPC 信封;进入 SDK 后的错误处理由 SDK 中间件与传输层负责。
|
||||
*/
|
||||
class IndexBase extends BaseController
|
||||
{
|
||||
/** MCP 会话有效期(秒). */
|
||||
protected int $sessionTtl = 3600;
|
||||
|
||||
/** Psr16SessionStore 会话键前缀. */
|
||||
protected string $sessionPrefix = 'mcp-session-';
|
||||
|
||||
/**
|
||||
* 空初始化:阻断父类的布局/调试逻辑(Log::debug、StoreValueTools 等)
|
||||
* 对协议端点的干预,保持最小继承面.
|
||||
*/
|
||||
protected function initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
// a. SDK 存在性检查:未安装 mcp/sdk 的存量项目友好降级
|
||||
if (!class_exists(Server::class)) {
|
||||
return $this->mcpErrorJson(503, 'MCP 服务不可用:未安装 mcp/sdk,请先 composer require mcp/sdk');
|
||||
}
|
||||
|
||||
// b. Bearer 提取(防御性解析,不依赖登录态实现)
|
||||
$bearer = $this->extractBearerToken();
|
||||
|
||||
// c. 密钥认证(失败 401,此时无 MCP 会话,直接 think json)
|
||||
$mcpService = new McpService();
|
||||
$auth = $bearer === '' ? null : $mcpService->authenticate($bearer);
|
||||
if ($auth === null) {
|
||||
return $this->mcpErrorJson(401, '无效的 MCP 密钥(Authorization: Bearer <key>)');
|
||||
}
|
||||
|
||||
// d. 启用开关
|
||||
if (!config('mcp.enable')) {
|
||||
return $this->mcpErrorJson(403, 'MCP 服务未启用');
|
||||
}
|
||||
|
||||
try {
|
||||
$psr7Request = $this->buildPsr7Request();
|
||||
$server = $this->buildServer($mcpService, $auth);
|
||||
$transport = new StreamableHttpTransport(
|
||||
$psr7Request,
|
||||
new HttpFactory(),
|
||||
new HttpFactory(),
|
||||
null,
|
||||
$this->buildTransportMiddleware()
|
||||
);
|
||||
|
||||
// e. 运行 SDK:返回 PSR-7 响应(含状态码/头/body,Mcp-Session-Id 等)
|
||||
$psr7Response = $server->run($transport);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('MCP server error: ' . $e->getMessage(), [
|
||||
'exception' => get_class($e),
|
||||
'file' => $e->getFile() . ':' . $e->getLine(),
|
||||
]);
|
||||
|
||||
return $this->mcpErrorJson(500, 'MCP 服务内部错误');
|
||||
}
|
||||
|
||||
return $this->toThinkResponse($psr7Response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 Authorization: Bearer 头中的密钥明文.
|
||||
*
|
||||
* 防御性解析:头缺失/格式非法/多值都返回空串(视为未提供),
|
||||
* 不复用登录态的 read_header_token(认证语义不同:密钥 vs 后台用户).
|
||||
*/
|
||||
protected function extractBearerToken(): string
|
||||
{
|
||||
$header = $this->request->header('authorization', '');
|
||||
if (is_array($header)) {
|
||||
$header = implode(',', $header);
|
||||
}
|
||||
$header = trim((string) $header);
|
||||
if ($header === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preg_match('/^Bearer\s+(\S+)$/i', $header, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* think Request -> PSR-7 ServerRequest(Guzzle 实现).
|
||||
*
|
||||
* method/uri/headers 取自当前请求,body 用 getContent() 原始内容
|
||||
* (避免 php://input 流消费状态的不确定性).
|
||||
*/
|
||||
protected function buildPsr7Request(): ServerRequestInterface
|
||||
{
|
||||
return new ServerRequest(
|
||||
strtoupper($this->request->method()),
|
||||
(string) $this->request->url(true),
|
||||
$this->request->header(),
|
||||
(string) $this->request->getContent(),
|
||||
'1.1'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 Mcp\Server:serverInfo + 全量注册 McpService::getTools 工具 + 会话存储.
|
||||
*/
|
||||
protected function buildServer(McpService $mcpService, array $auth): Server
|
||||
{
|
||||
$builder = Server::builder()
|
||||
->setServerInfo((string) config('mcp.server_name', 'ulthon_admin'), '1.0.0');
|
||||
|
||||
foreach ($mcpService->getTools($auth) as $tool) {
|
||||
$builder->addTool(
|
||||
$this->makeToolHandler($auth, $tool['name']),
|
||||
$tool['name'],
|
||||
null,
|
||||
$tool['description'],
|
||||
null,
|
||||
['type' => 'object', 'properties' => new \stdClass()]
|
||||
);
|
||||
}
|
||||
|
||||
$builder->setSession($this->makeSessionStore());
|
||||
|
||||
return $builder->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具处理器:闭包绑定 ReferenceHandler 作用域.
|
||||
*
|
||||
* SDK 的 ReferenceHandler 对绑定自身作用域的闭包直传整包参数
|
||||
* (客户端 arguments + _session/_request 内部键),规避按名注入与
|
||||
* 工具参数名的潜在冲突;剔除内部键后转 McpDispatch(T8 填充实现)。
|
||||
*/
|
||||
protected function makeToolHandler(array $auth, string $toolName): \Closure
|
||||
{
|
||||
return \Closure::bind(
|
||||
function (array $arguments) use ($auth, $toolName): string {
|
||||
unset($arguments['_session'], $arguments['_request']);
|
||||
|
||||
return (new McpDispatch())->call($auth, $toolName, $arguments);
|
||||
},
|
||||
null,
|
||||
ReferenceHandler::class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话存储:Psr16SessionStore + think mcp_session store,构造/冒烟失败回退 FileSessionStore.
|
||||
*/
|
||||
protected function makeSessionStore(): SessionStoreInterface
|
||||
{
|
||||
try {
|
||||
$adapter = new Psr16ThinkCache();
|
||||
|
||||
// 冒烟回环:确认 mcp_session store 真实可写可读(配置缺失/目录不可写会抛异常或失败)
|
||||
$probeKey = '__mcp_session_probe__';
|
||||
if (!$adapter->set($probeKey, 'ok', 10)
|
||||
|| $adapter->get($probeKey, '') !== 'ok'
|
||||
|| !$adapter->delete($probeKey)) {
|
||||
throw new \RuntimeException('mcp_session cache store roundtrip failed');
|
||||
}
|
||||
|
||||
return new Psr16SessionStore($adapter, $this->sessionPrefix, $this->sessionTtl);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('MCP Psr16SessionStore init failed, fallback to FileSessionStore: ' . $e->getMessage());
|
||||
|
||||
return new FileSessionStore(runtime_path() . 'mcp_session/', $this->sessionTtl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 传输层中间件栈:CORS + 协议版本校验必选;
|
||||
* mcp.allowed_hosts 非空时追加 DNS rebinding 防护(默认不启用,
|
||||
* SDK 默认白名单仅 localhost 变体,生产域名会全部 403).
|
||||
*/
|
||||
protected function buildTransportMiddleware(): array
|
||||
{
|
||||
$middleware = [
|
||||
new CorsMiddleware(),
|
||||
new ProtocolVersionMiddleware(),
|
||||
];
|
||||
|
||||
$allowedHosts = array_values(array_filter(array_map('strval', (array) config('mcp.allowed_hosts', []))));
|
||||
if ($allowedHosts !== []) {
|
||||
$middleware[] = new DnsRebindingProtectionMiddleware($allowedHosts);
|
||||
}
|
||||
|
||||
return $middleware;
|
||||
}
|
||||
|
||||
/**
|
||||
* PSR-7 响应 -> think Response:状态码/响应头透传(含 Mcp-Session-Id、Content-Type).
|
||||
*/
|
||||
protected function toThinkResponse(Psr7ResponseInterface $response): Response
|
||||
{
|
||||
$thinkResponse = Response::create((string) $response->getBody(), 'html', $response->getStatusCode());
|
||||
|
||||
$headers = [];
|
||||
foreach ($response->getHeaders() as $name => $values) {
|
||||
$headers[$this->normalizeHeaderName($name)] = implode(', ', $values);
|
||||
}
|
||||
|
||||
return $thinkResponse->header($headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 头名规范化:'mcp-session-id' -> 'Mcp-Session-Id'.
|
||||
*
|
||||
* 保证与 think Html 响应预设的 'Content-Type' 键完全同形,
|
||||
* array_merge 时精确覆盖而非产生大小写重复头.
|
||||
*/
|
||||
protected function normalizeHeaderName(string $name): string
|
||||
{
|
||||
return implode('-', array_map('ucfirst', explode('-', strtolower($name))));
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议外错误响应(未初始化 MCP 会话,无 JSON-RPC 信封).
|
||||
*/
|
||||
protected function mcpErrorJson(int $code, string $msg): Response
|
||||
{
|
||||
return json(['code' => $code, 'msg' => $msg, 'data' => null])->code($code);
|
||||
}
|
||||
}
|
||||
31
extend/base/mcp/service/McpDispatchBase.php
Normal file
31
extend/base/mcp/service/McpDispatchBase.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace base\mcp\service;
|
||||
|
||||
/**
|
||||
* MCP 工具分发器(T8 实现真实分发).
|
||||
*
|
||||
* 职责(T8):以 McpService::verifyNode 还原编码工具名 -> 校验权限 ->
|
||||
* 内部 HTTP 调用对应节点 -> McpService::logCall 审计。
|
||||
*
|
||||
* T7 仅提供空壳占位,保证 tools/list 与 tools/call 链路可用:
|
||||
* call 返回固定错误文案,由 ToolResultFormatter 包装为 TextContent。
|
||||
*/
|
||||
class McpDispatchBase
|
||||
{
|
||||
/**
|
||||
* 分发一次 MCP 工具调用.
|
||||
*
|
||||
* @param array $auth McpService::authenticate() 的返回结构(key_row + creator)
|
||||
* @param string $toolName MCP 编码工具名(如 system-quick--index,需 verifyNode 还原)
|
||||
* @param array $arguments 客户端原始参数(已剔除 _session/_request 内部键)
|
||||
* @return string 工具执行结果(文本)
|
||||
*/
|
||||
public function call(array $auth, string $toolName, array $arguments): string
|
||||
{
|
||||
// T8 实现真实分发逻辑
|
||||
return '[ERROR] 分发器未就绪(T8 实现)';
|
||||
}
|
||||
}
|
||||
114
extend/base/mcp/service/Psr16ThinkCacheBase.php
Normal file
114
extend/base/mcp/service/Psr16ThinkCacheBase.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace base\mcp\service;
|
||||
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use think\cache\Driver;
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* think Cache 驱动的 PSR-16 薄适配器.
|
||||
*
|
||||
* mcp/sdk 的 Psr16SessionStore 需要 Psr\SimpleCache\CacheInterface 实现,
|
||||
* think cache Driver 方法集与 PSR-16 同形(get/set/delete/clear/has/*Multiple),
|
||||
* 本类只做签名收紧与 TTL 归一(int|DateInterval|null → 秒),全部转发给
|
||||
* defaultStore() 指定的 think 缓存 store。
|
||||
*
|
||||
* 子类(app 壳)覆写 defaultStore() 返回 'mcp_session' 等具体 store 名。
|
||||
*/
|
||||
class Psr16ThinkCacheBase implements CacheInterface
|
||||
{
|
||||
protected Driver $driver;
|
||||
|
||||
public function __construct(?Driver $driver = null)
|
||||
{
|
||||
$this->driver = $driver ?? Cache::store($this->defaultStore());
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认 think 缓存 store 名(子类覆写).
|
||||
*/
|
||||
protected function defaultStore(): string
|
||||
{
|
||||
return 'file';
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->driver->get($key, $default);
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool
|
||||
{
|
||||
return (bool) $this->driver->set($key, $value, $this->normalizeTtl($ttl));
|
||||
}
|
||||
|
||||
public function delete(string $key): bool
|
||||
{
|
||||
return (bool) $this->driver->delete($key);
|
||||
}
|
||||
|
||||
public function clear(): bool
|
||||
{
|
||||
return (bool) $this->driver->clear();
|
||||
}
|
||||
|
||||
public function getMultiple(iterable $keys, mixed $default = null): iterable
|
||||
{
|
||||
$keys = is_array($keys) ? $keys : iterator_to_array($keys, false);
|
||||
|
||||
$result = [];
|
||||
foreach ($keys as $key) {
|
||||
$result[$key] = $this->driver->get($key, $default);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool
|
||||
{
|
||||
$ttl = $this->normalizeTtl($ttl);
|
||||
|
||||
$result = true;
|
||||
foreach ($values as $key => $value) {
|
||||
if (!$this->driver->set((string) $key, $value, $ttl)) {
|
||||
$result = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function deleteMultiple(iterable $keys): bool
|
||||
{
|
||||
$result = true;
|
||||
foreach ($keys as $key) {
|
||||
if (!$this->driver->delete($key)) {
|
||||
$result = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return (bool) $this->driver->has($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* PSR-16 TTL 归一为秒:DateInterval 折算秒数;null 视为 0(think 驱动默认有效期,0=永久).
|
||||
*/
|
||||
protected function normalizeTtl(null|int|\DateInterval $ttl): int
|
||||
{
|
||||
if ($ttl instanceof \DateInterval) {
|
||||
$now = new \DateTimeImmutable('@' . time());
|
||||
|
||||
return max(0, $now->add($ttl)->getTimestamp() - $now->getTimestamp());
|
||||
}
|
||||
|
||||
return max(0, (int) ($ttl ?? 0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user