黑白梦黑白梦

  • 文章
  • 专栏
  • 文章
  • 专栏
全部文章

模型上下文协议(MCP):构建基于令牌鉴权与双通道协同的远程 MCP 服务

发布于 2026-11-09约 30 分钟

远程 MCP 服务是基于模型上下文协议与 Next.js App Router 构建的外部智能体接入网关。该服务采用 Streamable HTTP 传输,通过个人访问令牌、异步上下文身份注入与二进制双通道实现权限隔离与长文稿同步。

方案架构与核心组件

外部 Agent 接入的架构权衡

基于模型上下文协议(Model Context Protocol,MCP)将博客系统的混合检索、文章管理与专栏编排能力开放给本地 IDE 或终端 Agent 时,面临三项核心架构决策:

决策维度 备选方案 选定方案与技术依据
传输协议 本地 stdio 进程 vs 远程 Streamable HTTP 采用远程 HTTP(/api/mcp):无需在客户端机器配置数据库连接或打包发布独立 CLI,直接复用服务端已部署的业务逻辑与本地向量索引
身份认证 改造全部 Service 函数签名传入 actor vs 请求级上下文注入 采用个人访问令牌(PAT)配合 Node.js AsyncLocalStorage:在路由包装层校验 Bearer Token 并注入上下文,getCurrentUser() 优先读取该上下文,保持业务层 requireAdmin() 零改动
长文稿传输 通过 MCP 工具参数直接传输万字正文 vs MCP + HTTP 双通道协同 采用双通道协同:元数据查询与局部补丁走 MCP 工具通道;本地整篇 Markdown 文稿的推送与拉取走 curl --data-binary HTTP 通道,免除模型重新输出万字正文带来的 Token 消耗、延迟与篡改风险

双通道职责划分

远程 MCP 服务通过 instructions 字段向连接的外部 Agent 下发操作规范,将任务分流至两个互补通道:

  1. MCP 工具通道(/api/mcp):承载语义检索(searchPosts)、概况与列表查询(getBlogOverview、listAdminPosts、listCollections、getCollection)、分段详情读取(getPost)以及轻量写操作(createPost、updatePost 局部补丁/文末追加、createCollection、updateCollection)。
  2. 本地文稿 HTTP 通道(/api/admin/posts/markdown 与 /api/admin/upload/image):当作者在本地编辑器中编写完含一级标题的标准 Markdown 文件(Post Markdown File)时,外部 Agent 直接调用系统自带的 curl 命令以二进制流原样上传或下载文稿,不经过大语言模型的输出 Token 序列。

个人访问令牌(PAT)与请求级身份上下文

令牌哈希存储与校验

外部 MCP 客户端无法使用浏览器的 Lucia Session Cookie。系统在管理端提供个人访问令牌(Personal Access Token)签发能力:原始令牌以 hbm_pat_ 为前缀加上 24 字节随机 Base64URL 字符串,仅在创建时向管理员明文展示一次;数据库仅存储其 SHA-256 哈希值(token_hash)、权限范围(read 或 write)、过期时间与撤销时间戳。

实现个人访问令牌的生成与校验逻辑:

TypeScript
import { createHash, randomBytes } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { db } from './db/db';
import {
  personalAccessTokenTable,
  userTable,
  USER_STATUS_ACTIVE,
  type TokenScope,
  type UserRole,
} from './db/schema';

export const PAT_PREFIX = 'hbm_pat_';

export function hashToken(rawToken: string): string {
  return createHash('sha256').update(rawToken).digest('hex');
}

export function generateRawToken(): string {
  return `${PAT_PREFIX}${randomBytes(24).toString('base64url')}`;
}

export async function validatePersonalAccessToken(rawToken: string) {
  if (!rawToken || !rawToken.startsWith(PAT_PREFIX)) {
    return null;
  }

  const tokenHash = hashToken(rawToken);

  const [row] = await db
    .select({
      token: personalAccessTokenTable,
      user: {
        id: userTable.id,
        username: userTable.username,
        nickname: userTable.nickname,
        role: userTable.role,
        status: userTable.status,
        delete_time: userTable.delete_time,
      },
    })
    .from(personalAccessTokenTable)
    .innerJoin(
      userTable,
      eq(personalAccessTokenTable.user_id, userTable.id),
    )
    .where(eq(personalAccessTokenTable.token_hash, tokenHash))
    .limit(1);

  if (!row) return null;

  const { token, user } = row;

  // 校验令牌未撤销、未过期且关联用户处于正常激活状态
  if (token.revoke_time !== null) return null;
  if (token.expire_time && token.expire_time.getTime() < Date.now()) {
    return null;
  }
  if (user.status !== USER_STATUS_ACTIVE || user.delete_time !== null) {
    return null;
  }

  // 异步更新最近使用时间,不阻塞当前请求
  void db
    .update(personalAccessTokenTable)
    .set({ last_used_time: new Date() })
    .where(eq(personalAccessTokenTable.id, token.id))
    .catch(() => {});

  return {
    tokenId: token.id,
    scope: token.scope as TokenScope,
    user: {
      id: user.id,
      username: user.username,
      nickname: user.nickname,
      role: user.role as UserRole,
    },
  };
}

基于 AsyncLocalStorage 的无侵入身份透传

为了在不修改现有业务服务层(src/services/*)函数签名的前提下让 requireUser() 与 requireAdmin() 识别令牌身份,使用 Node.js 原生的 AsyncLocalStorage 构建请求级身份存储:

实现 token-context.ts 与路由鉴权高阶包装函数 withTokenAuth:

TypeScript
import { AsyncLocalStorage } from 'node:async_hooks';
import { type NextRequest, NextResponse } from 'next/server';
import type { UserRole } from '@/services/db/schema';
import { validatePersonalAccessToken } from '@/services/personal-access-tokens';

export type TokenScope = 'read' | 'write';

export interface TokenAuthUser {
  id: number;
  username: string;
  nickname: string;
  role: UserRole;
}

export interface TokenContextData {
  user: TokenAuthUser;
  scope: TokenScope;
  tokenId?: number;
}

const tokenAsyncLocalStorage = new AsyncLocalStorage<TokenContextData>();

export function runWithTokenUser<T>(
  user: TokenAuthUser,
  scope: TokenScope,
  fn: () => Promise<T>,
  tokenId?: number,
): Promise<T> {
  return tokenAsyncLocalStorage.run({ user, scope, tokenId }, fn);
}

export function getTokenContextUser(): TokenContextData | null {
  return tokenAsyncLocalStorage.getStore() ?? null;
}

// 路由鉴权包装器:校验 Authorization: Bearer <PAT> 并校验所需 scope
export function withTokenAuth<T = any>(
  handler: (req: NextRequest, ctx?: T) => Promise<Response>,
  options?: { scope?: TokenScope },
) {
  const requiredScope = options?.scope ?? 'read';

  return async (req: NextRequest, ctx?: T): Promise<Response> => {
    const authHeader = req.headers.get('authorization');

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      // 未携带 Bearer Token 时透传给原生 Cookie 会话流程
      return handler(req, ctx);
    }

    const rawToken = authHeader.slice(7).trim();
    const tokenResult = await validatePersonalAccessToken(rawToken);

    if (!tokenResult) {
      return NextResponse.json(
        { error: 'UNAUTHORIZED_TOKEN' },
        { status: 401 },
      );
    }

    if (requiredScope === 'write' && tokenResult.scope !== 'write') {
      return NextResponse.json({ error: 'FORBIDDEN_SCOPE' }, { status: 403 });
    }

    return runWithTokenUser(
      tokenResult.user,
      tokenResult.scope,
      () => handler(req, ctx),
      tokenResult.tokenId,
    );
  };
}

在统一认证入口 getCurrentUser() 中优先读取 getTokenContextUser(),随后再回退至 Cookie 会话,从而使底层所有调用 requireAdmin() 的写服务自动兼容令牌鉴权。

远程 MCP 服务端实现与权限分流

基于 mcp-handler 的 Next.js 路由挂载

在 /api/mcp/route.ts 中,使用 mcp-handler 的 createMcpHandler 创建支持 Streamable HTTP 协议的处理器,并在初始化回调中根据当前令牌的 scope(read 或 write)动态决定向客户端暴露的工具集合:

实现 /api/mcp/route.ts 路由端点:

TypeScript
import { createMcpHandler } from 'mcp-handler';
import { registerBlogMcpTools } from '@/services/mcp/server';
import { withTokenAuth } from '@/lib/auth/with-token-auth';
import { getTokenContextUser } from '@/lib/auth/token-context';
import { MCP_INSTRUCTIONS } from '@/services/mcp/instructions';

export const runtime = 'nodejs';

const rawMcpHandler = createMcpHandler(
  async (server) => {
    const ctx = getTokenContextUser();
    const scope = ctx?.scope ?? 'read';
    registerBlogMcpTools(server, scope);
  },
  {
    serverInfo: {
      name: 'heibaimeng-blog-mcp',
      version: '1.0.0',
    },
    instructions: MCP_INSTRUCTIONS,
  },
);

const authenticatedHandler = withTokenAuth(
  async (req) => {
    return rawMcpHandler(req);
  },
  { scope: 'read' },
);

export const GET = authenticatedHandler;
export const POST = authenticatedHandler;

只读与写工具按 Scope 动态注册及并发冲突防护

与站内对话助手(依赖消息流暂停的人机协同审批卡片)不同,外部 MCP 客户端(如 Claude Code、Cursor)自身已具备工具调用确认机制。因此,MCP 服务端不重复引入流式暂停审批,而是通过以下四层服务端约束保护数据一致性:

  1. 权限范围隔离:当令牌为 read 权限时,仅注册 searchPosts、getBlogOverview、listAdminPosts、getPost、listCollections、getCollection 6 个只读工具;仅当令牌为 write 权限时,才额外挂载 createPost、updatePost、savePostFromMarkdown、createCollection、updateCollection,且不暴露任何删除类工具。
  2. 正文哈希乐观锁(contentHash / baseHash):getPost 与 listAdminPosts 在返回文章数据时均附带正文的 SHA-256 哈希值 contentHash。当调用 updatePost 执行全量正文替换或调用 savePostFromMarkdown 更新已有文章时,必须传入 baseHash;若与数据库当前正文哈希不一致,服务端立即拒绝并返回冲突提示,防止覆盖期间产生的新修改。
  3. 局部补丁与文末追加复用:updatePost 复用与站内助手相同的 validateContentPatches 与 appendPostContent 纯函数,支持在不传输全文的情况下安全微调段落。
  4. 专栏目录防漏项保护:调用 updateCollection 重排专栏文章时,通过 resolveCollectionMembership 校验传入的 postIds 是否遗漏了当前已收录且未显式列入 removePostIds 的文章,防止模型因上下文遗忘而静默移出已有文章。

实现 MCP 工具按权限注册与乐观锁校验:

TypeScript
import { z } from 'zod';
import { type TokenScope } from '@/services/db/schema';
import { computeContentHash } from '@/services/post-markdown/upsert';
import {
  appendPostContent,
  validateContentPatches,
  type ContentPatchItem,
} from '@/services/blog-agent/hitl';

export function registerBlogMcpTools(server: any, scope: TokenScope) {
  // 1. 注册只读工具(read 与 write scope 均可用)
  server.registerTool(
    'getPost',
    {
      description:
        '获取指定博客文章详情与正文切片,返回正文内容哈希(contentHash)用于并发控制。',
      inputSchema: {
        postId: z.number().int().positive().optional().describe('文章 ID'),
        title: z
          .string()
          .optional()
          .describe('文章标题(精确或模糊匹配)'),
        offset: z
          .number()
          .int()
          .min(0)
          .default(0)
          .optional()
          .describe('正文切片偏移量'),
      },
    },
    async ({ postId, title, offset = 0 }: {
      postId?: number;
      title?: string;
      offset?: number;
    }) => {
      // 查询文章并按 8000 字符分页切片...
      const PAGE_SIZE = 8000;
      const totalLength = post.content.length;
      const sliced = post.content.slice(offset, offset + PAGE_SIZE);
      const nextOffset =
        offset + PAGE_SIZE < totalLength ? offset + PAGE_SIZE : null;

      return {
        content: [
          {
            type: 'text',
            text: JSON.stringify(
              {
                id: post.id,
                title: post.title,
                status: post.status === 1 ? 'published' : 'draft',
                contentHash: computeContentHash(post.content),
                totalLength,
                offset,
                nextOffset,
                content: sliced,
              },
              null,
              2,
            ),
          },
        ],
      };
    },
  );

  // 2. 仅在 write scope 下挂载写操作工具
  if (scope === 'write') {
    server.registerTool(
      'updatePost',
      {
        description:
          '修改已有文章。正文支持三种互斥模式:局部补丁(contentPatches)、文末追加(appendContent)或全量替换(content,必须传 baseHash)。',
        inputSchema: {
          postId: z.number().int().positive().describe('文章 ID'),
          baseHash: z
            .string()
            .optional()
            .describe('全量替换 content 时的并发校验原正文哈希'),
          title: z.string().trim().min(1).optional().describe('新标题'),
          content: z
            .string()
            .optional()
            .describe('全量替换的正文(需配合 baseHash)'),
          contentPatches: z
            .array(
              z.object({
                oldText: z.string().describe('待替换的原文章唯一文本片段'),
                newText: z.string().describe('替换后的新文本片段'),
              }),
            )
            .optional(),
          appendContent: z.string().optional().describe('追加至文末的正文内容'),
        },
      },
      async (input: {
        postId: number;
        baseHash?: string;
        title?: string;
        content?: string;
        contentPatches?: ContentPatchItem[];
        appendContent?: string;
      }) => {
        const existing = await getAdminPost(input.postId);
        if (!existing) {
          return {
            content: [
              { type: 'text', text: `未找到 ID 为 ${input.postId} 的文章。` },
            ],
            isError: true,
          };
        }

        let finalContent: string | undefined = undefined;
        if (input.contentPatches?.length) {
          const patchResult = validateContentPatches(
            existing.content,
            input.contentPatches,
          );
          if (!patchResult.success) {
            return {
              content: [
                { type: 'text', text: `补丁校验失败: ${patchResult.error}` },
              ],
              isError: true,
            };
          }
          finalContent = patchResult.patchedContent;
        } else if (input.appendContent) {
          finalContent = appendPostContent(
            existing.content,
            input.appendContent,
          );
        } else if (input.content) {
          const currentHash = computeContentHash(existing.content);
          if (!input.baseHash || input.baseHash !== currentHash) {
            return {
              content: [
                {
                  type: 'text',
                  text: `并发冲突:文章正文已被修改(当前哈希: ${currentHash},提供哈希: ${input.baseHash ?? 'none'}),请先拉取最新内容后再提交。`,
                },
              ],
              isError: true,
            };
          }
          finalContent = input.content;
        }

        const updated = await updatePostService(input.postId, {
          ...(input.title ? { title: input.title } : {}),
          ...(finalContent !== undefined ? { content: finalContent } : {}),
        });

        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify(
                {
                  success: true,
                  postId: input.postId,
                  contentHash: computeContentHash(
                    updated?.content || existing.content,
                  ),
                },
                null,
                2,
              ),
            },
          ],
        };
      },
    );
  }
}

本地 Markdown 文稿解析与 HTTP 双通道同步

无 Frontmatter 的文稿解析契约

为了让本地编写的 Markdown 文件既能直接阅读又无需维护繁琐的 YAML Frontmatter,系统定义了统一的“文章 Markdown 文稿(Post Markdown File)”结构规范:

  • 唯一一级标题作为文章标题:文稿首个非空 AST 节点必须且只能是一个一级标题(# 标题)。
  • 首段自动提取为文章简介(remark):紧随一级标题之后的第一个非纯图片段落自动剥离 Markdown 格式提取为文章简介(同时保留在正文中);在更新已有文章时,若正文首段未发生变化,则保留服务端原有简介不被覆盖。
  • 未上传本地图片拦截:通过遍历 marked.lexer 产出的语法树,自动检测所有非 http:// 或 https:// 开头的相对路径图片(如 ./diagram.png)。若存在未上传的本地图片,服务端拒绝入库并返回全部本地图片路径清单,引导外部 Agent 先调用 /api/admin/upload/image 上传替换后再推送。

实现基于 marked AST 的 Markdown 文稿解析器:

TypeScript
import { marked, type Tokens, type Token } from 'marked';

export interface ParsedPostMarkdown {
  title: string;
  remark: string;
  rawFirstParagraph: string;
  content: string;
  localImagePaths: string[];
  warnings: string[];
}

export function parsePostMarkdown(rawMarkdown: string): ParsedPostMarkdown {
  if (!rawMarkdown || typeof rawMarkdown !== 'string') {
    throw new PostMarkdownParseError('必须包含且仅包含一个一级标题');
  }

  const tokens = marked.lexer(rawMarkdown);
  const warnings: string[] = [];
  const localImagePaths: string[] = [];

  extractImageUrlsFromTokens(tokens, localImagePaths);

  const topTokens = tokens.filter((t) => t.type !== 'space');
  const h1Tokens = topTokens.filter(
    (t) => t.type === 'heading' && (t as Tokens.Heading).depth === 1,
  );

  if (h1Tokens.length !== 1) {
    throw new PostMarkdownParseError(
      `必须包含且仅包含一个一级标题,当前检测到 ${h1Tokens.length} 个一级标题`,
    );
  }

  const firstTopToken = topTokens[0];
  if (
    !firstTopToken ||
    firstTopToken.type !== 'heading' ||
    (firstTopToken as Tokens.Heading).depth !== 1
  ) {
    throw new PostMarkdownParseError('第一个非空块必须是一级标题');
  }

  const titleHeading = firstTopToken as Tokens.Heading;
  const title = titleHeading.text.trim();

  // 截取一级标题之后的全部原始 Markdown 作为正文
  const headingRaw = titleHeading.raw;
  const headingIndex = rawMarkdown.indexOf(headingRaw);
  const content =
    headingIndex !== -1
      ? rawMarkdown.slice(headingIndex + headingRaw.length).trim()
      : rawMarkdown.trim();

  // 提取紧跟在一级标题后的首个文本段落作为简介
  let remark = '';
  let rawFirstParagraph = '';
  const subsequentToken = topTokens[1];
  if (subsequentToken && subsequentToken.type === 'paragraph') {
    const pToken = subsequentToken as Tokens.Paragraph;
    if (!isPureImageParagraph(pToken)) {
      rawFirstParagraph = pToken.text.trim();
      remark = stripMarkdownToPlainText(rawFirstParagraph).slice(0, 512);
    }
  }

  return {
    title,
    remark,
    rawFirstParagraph,
    content,
    localImagePaths,
    warnings,
  };
}

面向外部 Agent 的 HTTP 文稿同步接口与工作流指引

/api/admin/posts/markdown 路由同样使用 withTokenAuth 包装:GET 请求需要 read 权限,返回标准 Markdown 文本并在 x-content-hash 响应头中附带正文哈希;PUT 请求需要 write 权限,直接读取请求体原始文本(await req.text())并执行并发哈希校验、同名文章防重校验与事务入库。

实现 /api/admin/posts/markdown/route.ts 接口:

TypeScript
import { type NextRequest, NextResponse } from 'next/server';
import { withTokenAuth } from '@/lib/auth/with-token-auth';
import {
  exportPostToMarkdown,
  upsertPostFromMarkdown,
} from '@/services/post-markdown/upsert';
import { upsertMarkdownQuerySchema } from '@/validators/posts';

export const runtime = 'nodejs';

async function handleGet(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const id = Number(searchParams.get('id'));
  const exported = await exportPostToMarkdown(id);

  return new Response(exported.rawMarkdown, {
    status: 200,
    headers: {
      'content-type': 'text/markdown; charset=utf-8',
      'x-content-hash': exported.contentHash,
    },
  });
}

async function handlePut(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const queryObj = Object.fromEntries(searchParams.entries());
  const parsedQuery = upsertMarkdownQuerySchema.parse(queryObj);
  const rawMarkdown = await req.text();

  const result = await upsertPostFromMarkdown({
    rawMarkdown,
    ...parsedQuery,
  });

  return NextResponse.json({
    id: result.post.id,
    contentHash: result.contentHash,
    warnings: result.warnings,
  });
}

export const GET = withTokenAuth(handleGet, { scope: 'read' });
export const PUT = withTokenAuth(handlePut, { scope: 'write' });

客户端接入与双通道协作示例

在支持 HTTP 传输层的 MCP 客户端(或使用 @ai-sdk/mcp 的自定义 Agent)中,只需配置远程端点 URL 与 Authorization 请求头即可接入全部能力:

配置客户端连接远程 MCP 服务:

TypeScript
import { createMCPClient } from '@ai-sdk/mcp';

const mcpClient = await createMCPClient({
  transport: {
    type: 'http',
    url: 'https://heibaimeng.com/api/mcp',
    headers: {
      Authorization: `Bearer ${process.env.BLOG_TOKEN}`,
    },
  },
});

const tools = await mcpClient.tools();

当外部 Agent 连接成功后,会自动读取服务端下发的 MCP_INSTRUCTIONS,按如下标准命令流完成本地长文稿的拉取、修改与字节级原样推送:

Bash
# 1. 拉取线上文章导出为本地标准 Markdown 文稿(响应头包含 x-content-hash)
curl "https://heibaimeng.com/api/admin/posts/markdown?id=320" \
  -H "Authorization: Bearer $BLOG_TOKEN" \
  -o ./article.md

# 2. 若文稿中包含本地图片,先上传获取公网 URL 并替换文稿内的相对路径
curl -X POST "https://heibaimeng.com/api/admin/upload/image" \
  -H "Authorization: Bearer $BLOG_TOKEN" \
  -F "file=@./local-diagram.png"

# 3. 携带 baseHash 将本地 Markdown 文稿按字节原样推送更新(新建时不传 id 与 baseHash)
curl -X PUT "https://heibaimeng.com/api/admin/posts/markdown?id=320&baseHash=<CONTENT_HASH>" \
  -H "Authorization: Bearer $BLOG_TOKEN" \
  --data-binary @./article.md
目录
方案架构与核心组件外部 Agent 接入的架构权衡双通道职责划分个人访问令牌(PAT)与请求级身份上下文令牌哈希存储与校验基于 AsyncLocalStorage 的无侵入身份透传远程 MCP 服务端实现与权限分流基于 mcp-handler 的 Next.js 路由挂载只读与写工具按 Scope 动态注册及并发冲突防护本地 Markdown 文稿解析与 HTTP 双通道同步无 Frontmatter 的文稿解析契约面向外部 Agent 的 HTTP 文稿同步接口与工作流指引客户端接入与双通道协作示例

本文收录于专栏

AI 应用开发笔记

沉淀大模型智能体等 AI 应用的开发与落地笔记

0 篇文章更新于 2026-08-23
上一篇AI Agent 系统评估(Evals):基于 Evalite 的检索、压缩与端到端质量度量实践

©2015-2026 黑白梦 粤ICP备15018165号

联系: heibaimeng@foxmail.com