编程 Laravel MCP 升级 1.0:initialize 换成 server/discover,每个 POST 都要带三个 header

2026-10-17 20:04:28

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 的优先级:

  1. 传给 withOAuth() 的 clientId;
  2. 服务器声明了 client_id_metadata_document_supported 时,用应用自己的 metadata 文档;
  3. 最后才回落到动态注册。

用 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 动态注册,这几处是硬失败,必须跟着改。

复制全文 生成海报 Laravel MCP PHP AI Agent OAuth 协议升级

推荐文章

程序员茄子在线接单