@ doist/ mcp 分数
Todoist MCP服务器共享核心(待办事宜- mcp, (中文(简体) ), 通讯- mcp 和Automations MCP服务器:工具合同,工具注册,工具结果如何塑造给电线,合同一个服务器提供为它服务的主机,合同测试套件每个服务器运行在自己的工具上.
TypeScript, 仅限无害环境管理, 第24节, npm 第11节.
安装
npm install @doist/mcp-core
MCP SDK和Zod是同行的依赖关系,因此服务器和核心各共享一份. 这很重要:一个为多个 MCP 服务器服务的主机将它们解开 instanceof McpServer与第二本SDK相对抗的失败.
| 端点 | 范围 |
|---|---|
@modelcontextprotocol/server | ^2.2.0 |
zod | ^4.3.0 |
./testing 进口 vitest (4.1或以后)和 @modelcontextprotocol/client (2.2或后),来自服务器本身的devDependency. 他们故意不是同伴: npm在生产中安装了生产依赖的同伴,所以宣布他们将在每个服务器的Vitest中放入 npm ci --omit=dev。 。 。 .
里面有什么东西
| 模块 | 主要出口 |
|---|---|
tool | McpTool, (中文(简体) ), AnyMcpTool, (中文(简体) ), ToolResult, (中文(简体) ), ExecuteResult, (中文(简体) ), RequiredToolAnnotations, (中文(简体) ), ToolSchemaSource (类型) |
register-tool | createToolRegistrar, (中文(简体) ), ToolRegistrarOptions, (中文(简体) ), RegisterTool, (中文(简体) ), ToolFailure |
tool-output | getToolOutput, (中文(简体) ), getErrorOutput, (中文(简体) ), ToolOutputOptions |
sanitize | removeNullFields, (中文(简体) ), RemoveNullFieldsOptions |
tool-schemas | getToolSchemas, (中文(简体) ), ToolSchemas |
annotations | getMcpAnnotations, (中文(简体) ), formatToolTitle |
app-meta | normalizeToolMeta |
host (顺从) | validateBearerToken, (中文(简体) ), ValidateBearerTokenOptions, (中文(简体) ), TokenValidationError, (中文(简体) ), TokenValidationResult, (中文(简体) ), ValidateToken, (中文(简体) ), McpServerOptions, (中文(简体) ), isTransientValidationError, (中文(简体) ), buildBearerChallenge, (中文(简体) ), createTokenVerdictCache |
testing (顺从) | describeOutputSchemaNullability, (中文(简体) ), describeAdvertisedToolSchemas, (中文(简体) ), describeToolAnnotations, (中文(简体) ), describeTokenFootprint, (中文(简体) ), measureTokenFootprint, (中文(简体) ), findNullablePaths, (中文(简体) ), listAdvertisedTools |
使用量
定义工具
McpTool<Params, Output, Context> 是对每个呼叫的通用,所以每个服务器会选择自己的上下文:一个API客户端,或者一个携带一个带有中止信号和进度报告器的对象. 一个服务器通常用所填上下文来代替它.
import type { McpTool } from '@doist/mcp-core'
import { z } from 'zod'
import type { NotesApi } from './notes-api.js'
type NotesTool<P extends z.ZodRawShape, O extends z.ZodRawShape = Record<string, never>> = McpTool<
P,
O,
NotesApi
>
export const findNotes = {
name: 'find-notes',
description: 'Finds notes whose text contains the search term.',
parameters: { searchText: z.string().describe('Text to look for.') },
outputSchema: { notes: z.array(z.object({ id: z.string(), text: z.string() })) },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
async execute({ searchText }, api) {
const notes = await api.search(searchText)
return { textContent: `Found ${notes.length} notes.`, structuredContent: { notes } }
},
} satisfies NotesTool<
{ searchText: z.ZodString },
{ notes: z.ZodArray<z.ZodObject<{ id: z.ZodString; text: z.ZodString }>> }
>
登记工具
createToolRegistrar 返回在服务器上注册工具的函数。 产品之间有差异的一切都通过它的选择:
import { createToolRegistrar } from '@doist/mcp-core'
import { McpServer } from '@modelcontextprotocol/server'
export function getMcpServer({ token }: { token: string }): McpServer {
const server = new McpServer({ name: 'notes-mcp-server', version: '1.0.0' })
const api = new NotesApi(token)
const register = createToolRegistrar<NotesApi>({
titlePrefix: 'Notes',
createContext: () => api,
formatError: (error) => (error instanceof Error ? error.message : 'Something went wrong.'),
// Optional: runs around every call, for retries, request tagging or rewriting the result.
wrapExecute: (tool, run) => withRetry(run),
// Optional: replaces the default log line, which never includes the arguments.
onError: ({ tool, error }) => logger.error({ tool: tool.name, error }),
})
for (const tool of [findNotes]) register(server, tool)
return server
}
对于每个工具:
- 将输入和输出计划汇编成JSON Schema 2020-12,每个进程一次,但许多服务器都予以登记;
- 标题 "
<titlePrefix>数字 :<Title Case Name>" 和默认openWorldHint作假,工具本身的注解获胜; - 填写 MCP Apps UI 资源的两种形式(
_meta.ui.resourceUri财务报告和审定财务报表_meta['ui/resourceUri']当工具设置时; - 形状
getToolOutput,以下; - 抓到任何东西
execute扔出,传到onError返回时formatError给模型的文本作为isError结果.
createContext 收到工具和SDK的 ServerContext,所以一个长期工具可以读取 serverContext.mcpReq.signal 或发送进度通知.
结果的线形
getToolOutput 转出 ToolResult 改为 a CallToolResult数字 :
- 裸体和空物体被剥去
structuredContent(单位:千美元)removeNullFields) (中文(简体) ). 空数组留相. 过verbatimKeys给那些虚构的钥匙 比如一个存储的JSON Schema. content按照顺序:工具本身contentItems,则structuredContent作为JSON文本块,那么textContent。只读取文本的客户端content[0](OpenAI 响应 API 下降)structuredContent仍然得到有效载荷.structuredContentAsText: false离开JSON的封锁, 用于部署 其客户全部读取structuredContent。 。 。 .
从主机为服务器服务
@doist/mcp-core/host 是服务器软件包与服务于其中几个软件主机之间的合同,例如 todoist-ai-Institutions。 每个服务器输出相同的两个形状:
getMcpServer(options),则McpServerOptions(单位:千美元){ token, baseUrl? }),或McpServerOptions & { ... }当需要更多时;- (单位:千美元)
ValidateToken数字 :(token, baseUrl?) => Promise<'valid' | 'invalid' | 'forbidden'>。 。 。 .
验证者只在符上做出裁决并拒绝其他任何内容,所以主机可以重试一次失败而不把它作为裁决. validateBearerToken 仅从状态构建一个 GET数字 :
import { validateBearerToken, type ValidateToken } from '@doist/mcp-core/host'
export const validateNotesToken: ValidateToken = (token, baseUrl = 'https://notes.example.com') =>
validateBearerToken({ url: `${baseUrl}/api/v1/me`, token })
| API 答案 | validateBearerToken |
|---|---|
| 第2条 | valid |
| 401 (中文(简体) ) | invalid: 主机发送401个挑战,客户端刷新其符号 |
| 403 (韩语) | forbidden,或任何东西 classifyForbidden(response) 返回时 |
| 任何其它状态,包含重定向 | 掷出 TokenValidationError,其 httpStatusCode 让主机将 5xx 作为瞬态 |
没有 timeoutMs (第5类) | 拒绝 TimeoutError |
| 网络失败 | 以未更改的错误拒绝 |
身体被排出未读取,所以改变反应的外形不能使一个符号被错误地分类,连接会回到池中去. 重新定向没有被遵循:一个签入的页面,如果答案200的话会通过一个被接受的符号. 过 classifyForbidden 当一个403也可以意味着拒绝 同意不能解除, headers 标记请求,以及 fetch 当服务器的 API 客户端有自己的传输(从 HTTPS_PROXY因此,验证的方式 与其它呼叫一样.
在主机方面,有三个帮助者将验证器的答案变成响应,无论HTTP框架如何:
import {
buildBearerChallenge,
createTokenVerdictCache,
isTransientValidationError,
} from '@doist/mcp-core/host'
const verdicts = createTokenVerdictCache({ ttlMs: 120_000, maxEntries: 10_000 })
async function checkToken(token: string, baseUrl: string): Promise<Response | 'proceed'> {
let result = verdicts.get(token, baseUrl)
if (!result) {
try {
result = await validateNotesToken(token, baseUrl)
} catch (error) {
if (isTransientValidationError(error)) return 'proceed' // or a 503; never cached
throw error
}
verdicts.set(token, baseUrl, result)
}
if (result === 'valid') return 'proceed'
const forbidden = result === 'forbidden'
return new Response(null, {
status: forbidden ? 403 : 401,
headers: {
'WWW-Authenticate': buildBearerChallenge({
error: forbidden ? 'insufficient_scope' : undefined,
resourceMetadataUrl:
'https://notes.example.com/.well-known/oauth-protected-resource/mcp',
}),
},
})
}
isTransientValidationError:超时,取消或拒绝连接,或5xx,拒绝值重试. 转机或404不是.buildBearerChallenge数字 :Bearer,与error="insufficient_scope"财务报告和审定财务报表resource_metadata="…"当给予.createTokenVerdictCache: 以符号和基准 URL 键入的判决,保存于ttlMs最古老的落地时满地.
合同测试
@doist/mcp-core/testing 持有服务器运行在它自己的工具注册簿上的套件 :
import {
describeAdvertisedToolSchemas,
describeOutputSchemaNullability,
} from '@doist/mcp-core/testing'
import { getMcpServer } from './mcp-server.js'
import { registeredTools } from './tool-registry.js'
describeOutputSchemaNullability(registeredTools)
describeAdvertisedToolSchemas({
tools: registeredTools,
createServer: () => getMcpServer({ token: 'test-token' }),
})
describeOutputSchemaNullability: 每个工具一个测试, 任何允许输出字段都失败null。 。 。 .describeAdvertisedToolSchemas:服务器完全广告这些工具,每个工具都有JSON Schema 2020-12 schemas匹配了直接Zod转换.
都拿一个 { name, parameters, outputSchema? },这样服务器就可以在工具移动到 McpTool。 。 。 .
有两个只读客户端收到的东西,所以他们不重复注册的内容:
import { describeTokenFootprint, describeToolAnnotations } from '@doist/mcp-core/testing'
import { encode } from 'gpt-tokenizer'
const createServer = () => getMcpServer({ token: 'test-token' })
describeTokenFootprint({
createServer,
countTokens: (text) => encode(text).length,
budget: 13_250,
})
describeToolAnnotations({
createServer,
expected: {
'find-notes': {
title: 'Notes: Find Notes',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
},
})
describeTokenFootprint: 每届会议支付的固定费用(tools/list有效载荷为一个阵列,加instructions保留在budget一半失踪也失败了 它打印每个工具的成本。 将预算视为权宜之计:需要增加预算,以证明造成预算变化的理由.measureTokenFootprint返回相同的数字。 标语器是你的,所以Mcp分数 取决于无.describeToolAnnotations:每个广告工具都包含准确的预期说明、标题和缺省(openWorldHint默认为虚假),每个预期工具都会被公示. 客户端从这些决定一个电话是否需要用户的确认.
工具规则
每个 Doist MCP 服务器都持有这个 核心可以执行一个, 它说如何.
- 输出计划从未使用
.nullable()。 。 。 . 结果在SDK验证它们之前被剥去无效值,所以一个无效的字段会保证一个工具永远无法发送的值,一旦剥去"输出验证错误",则调用失败. 申报字段.optional()并留出密钥(foo: value ?? undefined) (中文(简体) ).describeOutputSchemaNullability执行这个. - 说明描述了该工具最坏的情况. 它们是每个工具,而不是每个行动,所以一个将几个行动分组的工具被附加了最危险的一个.
readOnlyHint: true仅当无事持续存在,且无事发生(文件或可共享链接计数)时.openWorldHint: true当工具到达用户自己账户以外的任何人时:通知他人,或制作公共URL.destructiveHint: true当它删除、覆盖、替换先前状态、删除访问权限或以其他方式难以撤消时;发送通知是添加剂而不是破坏性的.RequiredToolAnnotations3个提示是强制性的. - 描述一个输出字段,只在计划不能时进行. 输出计划发送到每个
tools/list,共享的则每个工具内嵌一次。 描述单位和格式、惯例、结合、有条件的存在和不明显的含义;保留任何重复字段名称或列出enum值的内容。 输入描述是一个模型如何学会调用工具,应该像需要的那样完整. - 用字符串清除可选字段, 不
null。 。 。 . 一些提供商,特别是双子座公司,对无效输入处理不当. 使用描述式哨兵( I)"unassign", (中文(简体) ),"remove"),并用实地描述记录. - 批量写入工具报告每个项目失败, 绝不扔出 . 单枪匹马地解决每件物品,把那些失败的同那些成功的东西一起归还,即使每件物品都失败。 被抛出的错误隐藏了写的东西,所以重试一次写了两次.
- 不默认日志工具参数 . 写作工具中包含用户内容. 默认
onError只记录工具名称和出错.
许可证
麻省理工学院