Laravel MCP 1.0 升级笔记:initialize 换成 server/discover,每个 POST 都要带 MCP-Protocol-Version / Mcp-Method / Mcp-Name
项目信息
- GitHub:https://github.com/laravel/mcp
- 文档:https://laravel.com/framework/docs/mcp
- Packagist:https://packagist.org/packages/laravel/mcp
- 最新版本:v1.0.2(2026-10-11)
- 依赖:PHP
^8.2、ext-json、ext-mbstring,illuminate/*为^11.45.3 | ^12.41.1 | ^13.0(Laravel 13.x 已可用)
先跑起来:起一个 MCP Server
三条命令,从零到有一个能被客户端连上的 MCP 端点。
composer require laravel/mcp
php artisan vendor:publish --tag=ai-routes
php artisan make:mcp-server WeatherServer
vendor:publish --tag=ai-routes 发布的是 routes/ai.php,MCP 路由不写在 routes/web.php 里,单独一个文件。make:mcp-server 生成 app/Mcp/Servers/WeatherServer.php。
同一个包里还有两个生成器,按需用:
php artisan make:mcp-tool CurrentWeatherTool # app/Mcp/Tools
php artisan make:mcp-prompt DescribeWeatherPrompt
Server 类
Server 类只做三件事:声明自己是谁、暴露哪些 Tool / Resource / Prompt。
get('location');
return Response::text('The weather is ...');
}
public function schema(JsonSchema $schema): array
{
return [
'location' => $schema->string()
->description('The location to get the weather for.')
->required(),
];
}
}
数组的键就是参数名,->string() 定类型,->description() 是给模型看的字段说明,->required() 标记必填。handle() 里用 $request->get('location') 取到的就是经过 schema 校验后的值,返回值用 Response::text() 包一层。
这个 Tool 要出现在服务端,得在 Server 的 $tools 里注册——上文的 $tools 数组已经包含它。
升级到 1.0:先改 composer.json
"laravel/mcp": "^1.0"
包本身没有新增扩展依赖,PHP ^8.2、ext-json、ext-mbstring 和原来的 illuminate/* 版本约束不变。真正的破坏点全在协议层和客户端层,下面按影响面从大到小排。
协议版本与 initialize 握手
服务端默认说 2026-07-28 这版协议。initialize 握手被 server/discover 方法取代,并且每一个现代请求都要在自己的 params._meta 里带上协议版本和客户端能力。
改造前的原始报文:
// Before...
--> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"my-client","version":"1.0.0"}}}
--> {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
改造后:
// After...
--> {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
两个变化值得注意:
一是握手和业务请求彻底解耦。以前 initialize 建立的状态,现在由每个请求自己在 _meta 里重申;server/discover 只负责一次性问出 supportedVersions、capabilities、instructions。
二是 _meta 不再是可选项。tools/list 这种看起来「无状态」的请求,也必须带 io.modelcontextprotocol/protocolVersion 和 io.modelcontextprotocol/clientCapabilities。
如果你只写了 Tool、Resource、Prompt、Server 这几类类,服务端代码本身不需要改动——前提是客户端支持 2026-07-28。Laravel\Mcp\Client 和 MCP Inspector 都支持这一版。
旧客户端不会立刻坏
这条决定了升级节奏。仍然用 initialize 开场的客户端可以继续工作:
- 服务端按客户端请求的版本回应握手,
2025-11-25或2025-06-18,二选一。 - 该客户端后续的请求不要求
_meta,也不要求那些 MCP HTTP header。 - 请求
2025-03-26或2024-11-05的客户端,会被降级为2025-11-25。 - 但只要请求的
_meta里出现io.modelcontextprotocol/protocolVersion或io.modelcontextprotocol/clientCapabilities,就一律按2026-07-28请求对待并校验。
也就是说,判断走哪条路径的依据是「这个请求有没有在 _meta 里声明协议信息」,不是握手方法名。半新半旧的客户端——比如握手用了 initialize,但业务请求开始塞 _meta——会被当成新协议校验。
MCP HTTP Headers:最疼的一刀
新增的 ValidateMcpHeaders 中间件由 Mcp::web(...) 自动套到每一条路由上,不需要手动 ->middleware()。
规则:
- 每个 POST 请求必须带
MCP-Protocol-Version和Mcp-Method两个 header,值与请求 body 一致。 tools/call、prompts/get、resources/read还要额外带Mcp-Name,与params.name匹配;resources/read则匹配params.uri。- 不一致时返回 HTTP 400,body 是 JSON-RPC 错误码
-32020。 - 来自旧
initialize客户端的请求,_meta里没有协议元数据,免于 header 校验。 - 名称里含非法的 header 字符时,
Mcp-Name可以用=?base64??=形式编码。
影响面最大的地方在这里:这是对所有 HTTP 调用方生效的,包括你自己的测试。CI 里那些 postJson 调用会最先红。
改造前:
// Before...
$this->postJson('mcp-endpoint', [
'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call',
'params' => ['name' => 'say-hi', 'arguments' => []],
]);
改造后:
// After...
$message = [
'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call',
'params' => [
'name' => 'say-hi',
'arguments' => [],
'_meta' => [
'io.modelcontextprotocol/protocolVersion' => '2026-07-28',
'io.modelcontextprotocol/clientCapabilities' => [],
],
],
];
$this->postJson('mcp-endpoint', $message, [
'MCP-Protocol-Version' => '2026-07-28',
'Mcp-Method' => 'tools/call',
'Mcp-Name' => 'say-hi',
]);
postJson 的第三个参数是 header。三处要同步改:body 里的 _meta、header 里的版本与方法名、以及 Mcp-Name 对应的 params.name。少一处就是 400 + -32020。
Session ID 被移除
Request::sessionId() 和 Request::setSessionId() 都不存在了,MCP-Session-Id header 一并移除。每个 HTTP 请求、每条 stdio 消息现在都是独立处理的。
// Before...
public function handle(Request $request): Response
{
$sessionId = $request->sessionId();
Cache::put("session:{$sessionId}:last-seen", now());
}
没有替代品。服务端不再替你维护跨调用的上下文,任何依赖 session 的写法都得自己想办法。确实需要把几次调用串起来的话,把自定义标识塞进请求参数或 _meta 里,由调用方带上来。
这段代码在升级后会直接报错——方法不存在,是硬失败,不会静默。
SessionInitialized 事件
Laravel\Mcp\Events\SessionInitialized 事件连同派发它的 initialize 握手一起被删除。没有替代事件,注册过监听的要在 EventServiceProvider 或 #[On] 属性里清掉,否则监听器永远等不到触发。
错误码
两处语义变化:
resources/read请求一个无法解析的 URI,返回-32602(Invalid params),不再是-32002。- 新增
Laravel\Mcp\Enums\ErrorCode枚举,其中-32020表示 header 与请求体不匹配,-32022表示不支持的协议版本。后者以前是-32602。
分支判断建议直接对着枚举写,别硬编码数字:
use Laravel\Mcp\Enums\ErrorCode;
match (ErrorCode::tryFrom($e->getCode())) {
ErrorCode::INVALID_PARAMS => /* invalid params or unresolvable resource URI */,
ErrorCode::UNSUPPORTED_PROTOCOL_VERSION => /* unsupported protocol version */,
ErrorCode::HEADER_MISMATCH => /* an MCP header did not match the request body */,
};
注意 INVALID_PARAMS 这一支现在同时覆盖「参数非法」和「资源 URI 无法解析」两种情况。以前靠 -32002 区分「资源找不到」的逻辑,需要重新判断。
PKCE 变成强制项
OAuthClient::redirect() 在授权服务器 metadata 缺少 code_challenge_methods_supported 时直接抛 OAuthException。
以前只是拒绝那些声明了该字段但不含 S256 的服务器;服务器压根不写这个字段时,流程还能往下走,只是无法保证 PKCE 支持。现在这条路堵死了,没有 opt-out。
两种处理方式:
- 自己控制的授权服务器:在
/.well-known/oauth-authorization-server文档里发布"code_challenge_methods_supported": ["S256"]。 - 第三方服务器:如果 client credentials grant 适合你的场景,用预签发的
client_id和client_secret走clientCredentials()。
升级后 OAuth 流程在 redirect 阶段就抛异常,通常比在授权页面上失败更早暴露。
Client ID Metadata Documents
MCP 2026-07-28 弃用 Dynamic Client Registration,改用 Client ID Metadata Documents:client_id 变成一个 HTTPS URL,指向描述该客户端的 JSON 文档。
客户端确定 client_id 的优先级:
- 传给
withOAuth()的clientId; - 服务器声明了
client_id_metadata_document_supported时,用应用自己的 metadata 文档; - 最后才回落到动态注册。
用 metadata 文档时,你的应用被当作 public client 处理,回调里拿到的 client_id 是 URL,且没有 client secret:
Mcp::oAuthRoutesFor('github', function (string $client, TokenSet $token) {
// Before...
// $token->clientId === 'a1b2c3d4-...'
// $token->clientSecret === 'sk_live_...'
// After...
// $token->clientId === 'https://acme.com/mcp/oauth/github/client-metadata.json'
// $token->clientSecret === null
});
落库层面要做三件事:
- 存
clientSecret的数据库列改成 nullable。 - 把 secret 传给
refreshCredentials()的代码要能接受null。这种情况下,token 请求用的token_endpoint_auth_method是none。 - 检查所有把
clientId当 UUID 处理的逻辑——现在它可能是一个 URL,长度和字符集都变了。
顺带修掉一个旧 bug:以前每次调用 redirect() 都会重新做一次动态注册,导致授权服务器上每连接一次就多一条 client 记录。改完不再重复注册。
client-metadata 路由
Mcp::oAuthRoutesFor() 现在除 connect 和 callback 之外,还会注册一条:
GET /mcp/oauth/{client}/client-metadata.json
路由名 mcp.oauth.{client}.client-metadata。传入的 middleware 不会应用到这条路由上——授权服务器必须能在无认证的情况下拉取这个文档。
路径和内容都可改:
Mcp::oAuthRoutesFor('github', $handler, clientMetadataUri: 'oauth/github/metadata.json', clientMetadata: [
'client_name' => 'Acme Dashboard',
'logo_uri' => 'https://acme.com/logo.png',
]);
有几项是算出来的,不能覆盖:
client_id和默认的redirect_uris由APP_URL和路由表推导。token_endpoint_auth_method固定为none。- 额外提供的
redirect_uris会和生成的 callback URL 一起出现,不是替换关系。 client_secret、client_secret_expires_at、registration_access_token这几个键会从文档里剥掉。
两个部署注意点:文档是从 APP_URL 而不是从进来的请求构造的,生产环境的 APP_URL 必须正确;这个路径要检查一下有没有和已有路由冲突。
MCP Apps 能力
Server::CAPABILITY_UI 常量删除。这个能力不再以顶层 io.modelcontextprotocol/ui 布尔值的形式声明,改为嵌套在 capabilities.extensions 下。
改造前:
// Before... { "tools": {...}, "io.modelcontextprotocol/ui": true }
if (! $this->hasCapability(Server::CAPABILITY_UI)) {
$this->addCapability(Server::CAPABILITY_UI);
}
改造后:
// After... { "tools": {...}, "extensions": { "io.modelcontextprotocol/ui": {} } }
use Laravel\Mcp\Enums\Extension;
class MyServer extends Server
{
protected array $extensions = [Extension::Ui];
}
MCP Apps 的行为本身没变。继承 AppResource 的类依然会自动声明这个能力,不需要手动写 $extensions。
自定义客户端传输
Laravel\Mcp\Client\Contracts\Transport 契约不再声明 setProtocolVersion() 方法。协议版本的处理移到 Laravel\Mcp\Client\Contracts\UsesProtocol 接口上:
class MyTransport implements Transport, UsesProtocol
{
public function useProtocol(ProtocolVersion $protocolVersion): void
{
$this->version = $protocolVersion;
}
}
实现自定义传输的类要同时实现 Transport 和 UsesProtocol。用内置 HttpTransport 或 StdioTransport 的应用不用改。
客户端新增异常
Laravel\Mcp\Client\Exceptions 下新增两个类:
TransportException,继承ClientException;TimeoutException,继承TransportException。
stdio 超时现在抛 TimeoutException;部分 HTTP 传输失败会抛 TransportException。两者仍然是 ClientException 的子类,原来 catch (ClientException $e) 的位置不会漏。
需要留意的是 JsonRpcException:它是 ClientException 的兄弟节点,不是子类。服务端返回的结构化错误不会被 catch (ClientException) 捕获,还是得单独写一个 catch 块。
以上按影响面排。服务端只写 Tool / Resource / Prompt / Server 的话,代码侧基本不用动,真正要改的是调用方——所有走 HTTP 的客户端和测试都得补 header 和 _meta;反过来,如果代码里用了 session、监听了 SessionInitialized、或者依赖 OAuth 动态注册,这几处是硬失败,必须跟着改。