feat(mcp): mcp 独立应用与 /mcp Streamable HTTP 端点(官方 SDK + PSR-7 桥接)

This commit is contained in:
augushong
2026-08-16 22:15:19 +08:00
parent 1ab276b1e9
commit c21bf9f240
9 changed files with 489 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace app\mcp\controller;
use base\mcp\controller\IndexBase;
/**
* MCP 协议端点(/mcp无中间件免 Session/CSRF先例 app/tools/.
*/
class Index extends IndexBase
{
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace app\mcp\service;
use base\mcp\service\McpDispatchBase;
/**
* MCP 工具分发器.
*/
class McpDispatch extends McpDispatchBase
{
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace app\mcp\service;
use base\mcp\service\Psr16ThinkCacheBase;
/**
* MCP 会话 PSR-16 适配器(包装 Cache::store('mcp_session').
*/
class Psr16ThinkCache extends Psr16ThinkCacheBase
{
protected function defaultStore(): string
{
return 'mcp_session';
}
}

View File

@@ -32,6 +32,11 @@ return [
// 缓存保存目录
'path' => App::getRuntimePath() . 'login/',
],
// MCP 会话存储StreamableHttpTransport 会话,经 Psr16ThinkCache 适配 PSR-16
'mcp_session' => [
'type' => 'File',
'path' => App::getRuntimePath() . 'mcp_session/',
],
// 更多的缓存连接
],
];

21
config/mcp.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
use think\facade\Env;
// +----------------------------------------------------------------------
// | MCP 服务配置(/mcp 端点app/mcp 独立应用)
// +----------------------------------------------------------------------
return [
// 是否启用 MCP 端点false 时所有请求 403
'enable' => Env::get('mcp.enable', true),
// MCP serverInfo.nameinitialize 响应中的服务名)
'server_name' => Env::get('mcp.server_name', 'ulthon_admin'),
// DNS rebinding 防护 Host 白名单(不含端口)。
// 为空(默认):不启用 DNS rebinding 防护中间件——端点已有 Bearer 密钥认证,
// 且生产部署的 Host 是业务域名SDK 默认白名单(仅 localhost 变体)会导致全部 403。
// 非空(如本地调试场景):启用 SDK DnsRebindingProtectionMiddleware仅放行列表内 Host/Origin。
'allowed_hosts' => array_filter(array_map('trim', explode(',', (string) Env::get('mcp.allowed_hosts', '')))),
];

View 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 薄适配),
* 适配失败回退 FileSessionStoreruntime/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 响应(含状态码/头/bodyMcp-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 ServerRequestGuzzle 实现).
*
* 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\ServerserverInfo + 全量注册 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 内部键),规避按名注入与
* 工具参数名的潜在冲突;剔除内部键后转 McpDispatchT8 填充实现)。
*/
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);
}
}

View 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 实现)';
}
}

View 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 视为 0think 驱动默认有效期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));
}
}

View File

@@ -1,2 +1,6 @@
<?php
use think\facade\Route;
// MCP 协议端点(多应用模式下 /mcp 默认 PATH_INFO 已映射 mcp/index/index
// 本规则作为显式兜底注册;若全局路由文件在多应用模式下不加载则依赖默认映射)
Route::rule('mcp', 'mcp/index/index');