远程 MCP 服务是基于模型上下文协议与 Next.js App Router 构建的外部智能体接入网关。该服务采用 Streamable HTTP 传输,通过个人访问令牌、异步上下文身份注入与二进制双通道实现权限隔离与长文稿同步。
基于模型上下文协议(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 下发操作规范,将任务分流至两个互补通道:
/api/mcp):承载语义检索(searchPosts)、概况与列表查询(getBlogOverview、listAdminPosts、listCollections、getCollection)、分段详情读取(getPost)以及轻量写操作(createPost、updatePost 局部补丁/文末追加、createCollection、updateCollection)。/api/admin/posts/markdown 与 /api/admin/upload/image):当作者在本地编辑器中编写完含一级标题的标准 Markdown 文件(Post Markdown File)时,外部 Agent 直接调用系统自带的 curl 命令以二进制流原样上传或下载文稿,不经过大语言模型的输出 Token 序列。外部 MCP 客户端无法使用浏览器的 Lucia Session Cookie。系统在管理端提供个人访问令牌(Personal Access Token)签发能力:原始令牌以 hbm_pat_ 为前缀加上 24 字节随机 Base64URL 字符串,仅在创建时向管理员明文展示一次;数据库仅存储其 SHA-256 哈希值(token_hash)、权限范围(read 或 write)、过期时间与撤销时间戳。
实现个人访问令牌的生成与校验逻辑:
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,
},
};
}为了在不修改现有业务服务层(src/services/*)函数签名的前提下让 requireUser() 与 requireAdmin() 识别令牌身份,使用 Node.js 原生的 AsyncLocalStorage 构建请求级身份存储:
实现 token-context.ts 与路由鉴权高阶包装函数 withTokenAuth:
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() 的写服务自动兼容令牌鉴权。
在 /api/mcp/route.ts 中,使用 mcp-handler 的 createMcpHandler 创建支持 Streamable HTTP 协议的处理器,并在初始化回调中根据当前令牌的 scope(read 或 write)动态决定向客户端暴露的工具集合:
实现 /api/mcp/route.ts 路由端点:
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;与站内对话助手(依赖消息流暂停的人机协同审批卡片)不同,外部 MCP 客户端(如 Claude Code、Cursor)自身已具备工具调用确认机制。因此,MCP 服务端不重复引入流式暂停审批,而是通过以下四层服务端约束保护数据一致性:
read 权限时,仅注册 searchPosts、getBlogOverview、listAdminPosts、getPost、listCollections、getCollection 6 个只读工具;仅当令牌为 write 权限时,才额外挂载 createPost、updatePost、savePostFromMarkdown、createCollection、updateCollection,且不暴露任何删除类工具。contentHash / baseHash):getPost 与 listAdminPosts 在返回文章数据时均附带正文的 SHA-256 哈希值 contentHash。当调用 updatePost 执行全量正文替换或调用 savePostFromMarkdown 更新已有文章时,必须传入 baseHash;若与数据库当前正文哈希不一致,服务端立即拒绝并返回冲突提示,防止覆盖期间产生的新修改。updatePost 复用与站内助手相同的 validateContentPatches 与 appendPostContent 纯函数,支持在不传输全文的情况下安全微调段落。updateCollection 重排专栏文章时,通过 resolveCollectionMembership 校验传入的 postIds 是否遗漏了当前已收录且未显式列入 removePostIds 的文章,防止模型因上下文遗忘而静默移出已有文章。实现 MCP 工具按权限注册与乐观锁校验:
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 文件既能直接阅读又无需维护繁琐的 YAML Frontmatter,系统定义了统一的“文章 Markdown 文稿(Post Markdown File)”结构规范:
# 标题)。remark):紧随一级标题之后的第一个非纯图片段落自动剥离 Markdown 格式提取为文章简介(同时保留在正文中);在更新已有文章时,若正文首段未发生变化,则保留服务端原有简介不被覆盖。marked.lexer 产出的语法树,自动检测所有非 http:// 或 https:// 开头的相对路径图片(如 ./diagram.png)。若存在未上传的本地图片,服务端拒绝入库并返回全部本地图片路径清单,引导外部 Agent 先调用 /api/admin/upload/image 上传替换后再推送。实现基于 marked AST 的 Markdown 文稿解析器:
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,
};
}/api/admin/posts/markdown 路由同样使用 withTokenAuth 包装:GET 请求需要 read 权限,返回标准 Markdown 文本并在 x-content-hash 响应头中附带正文哈希;PUT 请求需要 write 权限,直接读取请求体原始文本(await req.text())并执行并发哈希校验、同名文章防重校验与事务入库。
实现 /api/admin/posts/markdown/route.ts 接口:
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 服务:
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,按如下标准命令流完成本地长文稿的拉取、修改与字节级原样推送:
# 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