mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 04:35:33 +08:00
feat(mcp): mcp 独立应用与 /mcp Streamable HTTP 端点(官方 SDK + PSR-7 桥接)
This commit is contained in:
14
app/mcp/controller/Index.php
Normal file
14
app/mcp/controller/Index.php
Normal 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
|
||||||
|
{
|
||||||
|
}
|
||||||
14
app/mcp/service/McpDispatch.php
Normal file
14
app/mcp/service/McpDispatch.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\mcp\service;
|
||||||
|
|
||||||
|
use base\mcp\service\McpDispatchBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP 工具分发器.
|
||||||
|
*/
|
||||||
|
class McpDispatch extends McpDispatchBase
|
||||||
|
{
|
||||||
|
}
|
||||||
18
app/mcp/service/Psr16ThinkCache.php
Normal file
18
app/mcp/service/Psr16ThinkCache.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,11 @@ return [
|
|||||||
// 缓存保存目录
|
// 缓存保存目录
|
||||||
'path' => App::getRuntimePath() . 'login/',
|
'path' => App::getRuntimePath() . 'login/',
|
||||||
],
|
],
|
||||||
|
// MCP 会话存储(StreamableHttpTransport 会话,经 Psr16ThinkCache 适配 PSR-16)
|
||||||
|
'mcp_session' => [
|
||||||
|
'type' => 'File',
|
||||||
|
'path' => App::getRuntimePath() . 'mcp_session/',
|
||||||
|
],
|
||||||
// 更多的缓存连接
|
// 更多的缓存连接
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
21
config/mcp.php
Normal file
21
config/mcp.php
Normal 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.name(initialize 响应中的服务名)
|
||||||
|
'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', '')))),
|
||||||
|
];
|
||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
use think\facade\Route;
|
use think\facade\Route;
|
||||||
|
|
||||||
|
// MCP 协议端点(多应用模式下 /mcp 默认 PATH_INFO 已映射 mcp/index/index,
|
||||||
|
// 本规则作为显式兜底注册;若全局路由文件在多应用模式下不加载则依赖默认映射)
|
||||||
|
Route::rule('mcp', 'mcp/index/index');
|
||||||
|
|||||||
Reference in New Issue
Block a user