身份验证 (Authentication)
在 LangChain 中使用 Bearer Token、OAuth 2.1 或用户级凭证验证 MCP 连接。
大多数远程 MCP 服务器都需要身份验证。servers 下的每个 HTTP 或 SSE 服务器都可以配置各自的 authProvider 和 headers,MCPAdapter 会将该 provider 对象原样传递给 MCP SDK 传输层,并在每次请求时发送这些请求头。Stdio 服务器两者均不支持。对于应用程序自行管理的凭证,请使用请求头或 Token 提供者;对于 OAuth 2.1,请使用 OAuth 客户端提供者。当每次运行都需要以调用者本人的身份访问服务器时,请使用按用户身份验证。
NOTE
本页面需要 @langchain/mcp-adapters 2.0 或更高版本。如果你正在从 1.x 升级,请参阅迁移指南。
Bearer Token
对于固定的 API Key,可以发送静态的 Authorization 请求头。对于由应用程序颁发或轮换的 Token,请传入一个 AuthProvider({ token, onUnauthorized? })作为 authProvider。
该示例使用了一个由应用程序提供的 tokenStore。请实现 current() 来读取 Token,并实现 refresh() 来保存更新的 Token 并返回刷新是否成功。
import { MCPAdapter, UnauthorizedError } from "@langchain/mcp-adapters";
const adapter = new MCPAdapter({
servers: {
// 固定的 API Key,作为请求头发送。
search: {
url: "https://search.example.com/mcp",
headers: { Authorization: `Bearer ${process.env.SEARCH_API_KEY}` },
},
// 应用程序颁发和轮换的 Token。
crm: {
url: "https://crm.example.com/mcp",
authProvider: {
token: async () => tokenStore.current(),
onUnauthorized: async () => {
// 当不存在更新的 Token 时抛出异常:在 SSE 传输中,SDK 每次重新连接被拒绝时都会再次调用此方法。
if (!(await tokenStore.refresh())) {
throw new UnauthorizedError("No newer token for the CRM server");
}
},
},
},
},
});token():在每次请求之前运行,并返回当前 Token。onUnauthorized():可选。在遇到 401 时运行,随后请求会重试一次。在 SSE 传输中,SDK 在每次重连被拒绝时都会再次调用它,没有次数上限。当没有更新的 Token 时,请抛出UnauthorizedError。
如果没有 onUnauthorized,401 错误会使连接失败并抛出 SDK 的 UnauthorizedError。请参阅处理身份验证错误。
每个具名服务器都会打开独立的连接,因此同一个适配器中的各个服务器是独立进行身份验证的,正如本例中的两个服务器一样。
适配器重新导出了 AuthProvider 和 OAuthClientProvider 类型,因此你无需导入 SDK 即可为提供者声明类型。
OAuth 身份验证
OAuth 允许用户授权你的应用程序访问 MCP 服务器。请传入一个 OAuthClientProvider 作为 authProvider。SDK 会负责处理发现(discovery)、客户端注册、授权码交换以及 Token 刷新。你的应用程序需要负责提供提供者的存储(storage)、浏览器重定向以及回调处理程序(callback handler)。
将 @modelcontextprotocol/client 2.2.0 或更高版本作为直接依赖项添加。适配器依赖于相同的版本范围,因此你的应用程序和适配器将共享同一份 SDK 副本。回调传输层、SdkHttpError 和 SseError 均来自该依赖。请参考 SDK 的 OAuth 客户端指南来实现你的提供者。为其配置指向你回调路由的 redirectUrl,并存储客户端信息、Token、PKCE 验证器(verifier)以及发现状态。请务必包含 invalidateCredentials(),以便在刷新的凭证被拒绝时触发重新登录。
为每个用户和每个 MCP 服务器分别保持各自的提供者及其存储相互独立。下面的示例还将每个待处理的登录与已通过身份验证的应用程序会话进行绑定。请从应用程序的会话中间件中读取用户 ID 和会话 ID,切勿直接从回调查询参数中读取。
下面的 createMcpSession() 辅助函数串联了以下步骤:
- 调用
listTools()发现工具。如果需要登录,将用户的浏览器重定向到返回的authorizationUrl。 - 在你的回调路由中,针对同一个会话调用
completeLogin()。它会在交换授权码之前检查调用者并消耗掉待处理的state。 - 使用返回的工具。授权码交换完成后,该辅助函数会使用已保存的 Token 重新尝试发现。
会话辅助函数 (Session helper)
此示例将待处理的登录状态保存在单个 Node.js 进程中。对于多进程或服务重启的场景,请使用带有过期时间的共享存储。请在单个原子操作中检查 state、用户、会话和服务器,并消耗该记录。SDK 不会验证
state。typescriptimport { randomUUID } from "node:crypto"; import { MCPAdapter, MCPClientError, UnauthorizedError, type OAuthClientProvider, } from "@langchain/mcp-adapters"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; function createMcpSession( serverUrl: string, userId: string, sessionId: string, authProvider: OAuthClientProvider, ) { let pendingState: string | undefined; let authorizationUrl: URL | undefined; authProvider.state = () => (pendingState = randomUUID()); authProvider.redirectToAuthorization = (url) => { authorizationUrl = url; }; const adapter = new MCPAdapter({ servers: { crm: { url: serverUrl, authProvider } }, }); function causedByUnauthorized(error: unknown): boolean { // Discovery 会将 UnauthorizedError 包装在 MCPClientError 中, // 当 HTTP 连接降级到 SSE 时还会再包装一层。向下遍历几层。 let current: unknown = error; for (let depth = 0; depth <= 3; depth += 1) { if (UnauthorizedError.isInstance(current)) return true; if (!(current instanceof Error)) return false; current = current.cause; } return false; } async function listTools() { // 复用待处理的登录,而不是启动另一次重定向。 if (authorizationUrl) return { authorizationUrl }; try { return { tools: await adapter.listTools() }; } catch (error) { if ( MCPClientError.isInstance(error) && causedByUnauthorized(error) && authorizationUrl ) { return { authorizationUrl }; } throw error; } } async function completeLogin( callbackUrl: URL, callbackUserId: string, callbackSessionId: string, ) { const params = callbackUrl.searchParams; if ( callbackUserId !== userId || callbackSessionId !== sessionId || !pendingState || params.get("state") !== pendingState ) { throw new Error("Invalid OAuth callback"); } // 检查与消耗 state 之间无需 await:确保只有一个回调胜出。 pendingState = undefined; const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider, }); try { await transport.finishAuth(params); } catch { // SDK 错误可能包含授权服务器提供的文本。 throw new Error("OAuth login failed"); } finally { authorizationUrl = undefined; await transport.close(); } return listTools(); } return { listTools, completeLogin, close: () => adapter.close() }; }
要开始发现,请传入你的提供者以及经过身份验证的调用者身份:
const crm = createMcpSession(
"https://crm.example.com/mcp",
userId,
sessionId,
authProvider,
);
const result = await crm.listTools();
// 如果 result.authorizationUrl 存在,将浏览器重定向到该地址。
// 否则,将 result.tools 传递给你的智能体。在回调完成之前,请在该应用程序的会话中保持 crm 可用。切勿与其他会话共享其提供者。
完成重定向
在用户登录后,授权服务器会重定向到你提供者的 redirectUrl。在该路由中,获取同一个 crm 实例以及调用者经过验证的用户 ID 和会话 ID,然后完成登录:
const result = await crm.completeLogin(new URL(req.url, "http://localhost"), userId, sessionId);
// 辅助函数会交换授权码并使用已保存的 Token 重新尝试发现。
// 将 result.tools 传递给你的智能体,或者在需要再次登录时进行重定向。该辅助函数会将完整的查询参数传递给 finishAuth(),以便 SDK 可以验证授权服务器的 iss 参数。在交换授权码之前,它会拒绝缺失、不匹配或已被消耗的 state。在使用其工具期间请保持适配器处于开启状态,然后在应用程序会话结束时调用 await crm.close()。
对于配置了 transport: "sse" 的服务器,请在回调辅助函数中使用 SSEClientTransport。对于无需用户的机器对机器(machine-to-machine)访问,请使用 SDK 的 ClientCredentialsProvider 而不是浏览器登录。
处理身份验证错误
因凭证原因被拒绝的连接会抛出 MCPClientError。SDK 错误位于其 cause 链的下一层;当 HTTP 连接降级到 SSE 时,位于下两层。此示例向下遍历了几层,并处理了具有以下任一原因的 HTTP 身份验证失败:
UnauthorizedError:SDK 无法自行恢复。需要进行 OAuth 登录,或者 Token 提供者未提供onUnauthorized。- HTTP 401(带有
status: 401的SdkHttpError,或者在 SSE 传输中带有code: 401的SseError):服务器拒绝了该凭证,或者未提供任何凭证。
import { MCPClientError, UnauthorizedError } from "@langchain/mcp-adapters";
import { SdkHttpError, SseError } from "@modelcontextprotocol/client";
function authFailure(error: unknown): "login" | "rejected" | undefined {
if (!MCPClientError.isInstance(error)) return undefined;
let current: unknown = error;
for (let depth = 0; depth <= 3; depth += 1) {
if (UnauthorizedError.isInstance(current)) return "login";
if (SdkHttpError.isInstance(current) && current.status === 401)
return "rejected";
if (SseError.isInstance(current) && current.code === 401)
return "rejected";
if (!(current instanceof Error)) return undefined;
current = current.cause;
}
return undefined;
}适配器重新导出了 UnauthorizedError。从 @modelcontextprotocol/client 中导入 SdkHttpError 和 SseError。MCPClientError 的错误消息中包含了 SDK 的错误文本,因此请将其记录到日志中,而不是直接展示给用户。
当服务器在工具调用期间拒绝凭证时,工具会失败并抛出 ToolException。在 createAgent 下,模型会将其视为一条错误消息,并且运行会继续进行。
发现过程会在下一次调用时重试因 UnauthorizedError 或 HTTP 401 引起的失败,即使配置了 onConnectionError 跳过失败的服务器也是如此。后续的登录可以在不重新创建适配器的情况下恢复。来自 token() 或 onUnauthorized() 的其他错误(例如失败的 token-store 请求)不计为身份验证失败。当 onConnectionError 在出现这些错误之一后跳过某个连接时,该连接将保持阻塞状态,直到调用 close() 将其清除。
请求头优先级
一旦提供者获取了 Token,它就会替换配置的 Authorization 请求头。在此之前,该请求头仍会被发送,因此静态 API Key 可以降级回退到 OAuth。
SDK 还会将静态的 Authorization 请求头转发给授权服务器的发现(discovery)、注册(registration)和 Token 端点,而不仅是发送给 MCP 服务器。请勿将机密的 API Key 与授权服务器位于其他源(origin)的 OAuth 提供者搭配使用。
按用户身份验证
在生产部署中,每次运行都应当以发起该运行的用户身份访问 MCP 服务器,而不是使用单一的共享凭证。在 listTools()、listToolsets()、getClient() 或资源方法(listResources()、listResourceTemplates() 和 readResource())的选项中传入 authProvider 或 headers:
import { MCPAdapter, type AuthProvider } from "@langchain/mcp-adapters";
// 单服务器适配器,因为每个用户携带不同的凭证。
const crm = new MCPAdapter({
servers: { crm: { url: "https://crm.example.com/mcp" } },
});
// 每个用户复用一个 provider 对象:每个对象都会打开独立的连接。
const providers = new Map<string, AuthProvider>();
function providerFor(userId: string): AuthProvider {
let provider = providers.get(userId);
if (!provider) {
provider = { token: async () => tokenFor(userId) };
providers.set(userId, provider);
}
return provider;
}
const tools = await crm.listTools(["crm"], {
authProvider: providerFor(userId),
});在此示例中,请实现 tokenFor(userId) 以检索已通过身份验证的用户的 Token。
返回的工具会通过该用户的连接调用服务器。以下规则适用于方法级别的身份验证:
- 适配器全局覆盖:方法选项中的
authProvider或headers适用于适配器持有的每一个 HTTP 和 SSE 服务器,而不仅仅是你指定的那个服务器。当不同用户需要不同凭证时,请使用单服务器适配器。 - 请求头优先级:无论大小写如何,服务器配置的请求头优先于同名的方法级请求头。当传递按用户的
Authorization请求头时,请从服务器配置中省略该请求头。提供者的 Token 仍然优先于这两种请求头中的任何一个。 - 每个提供者对象一个连接:每个不同的提供者对象都会获得自己独立的连接,并保持到调用
close()为止。请为每个用户复用同一个提供者对象,而不是每次调用都创建一个。 - 隔离的目录:目录以提供者对象和请求头作为键进行索引,因此一个用户绝不会收到另一个用户缓存的工具列表。当同一个提供者对象背后的账户发生变更时,请重新创建适配器。
从传入的请求中解析用户,例如在 LangGraph 服务器上使用自定义身份验证处理程序。
