@doist/mcp-core

中文文档
0.4.1发布于 2026/11/11MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 5535

Shared core for the Doist MCP servers

此软件包已被标记为弃用

Moved to @todoist/mcp-core. Replace @doist/mcp-core with @todoist/mcp-core in your dependencies and imports.

原始使用文档
文档目录10 节
  1. Install
  2. What's in it
  3. Usage
  4. Defining a tool
  5. Registering tools
  6. The wire shape of a result
  7. Serving a server from a host
  8. Contract tests
  9. Rules for tools
  10. Licence

@doist/mcp-core

Shared core for Todoist's MCP servers (todoist-mcp, comms-mcp and the Automations MCP server): the tool contract, tool registration, how a tool result is shaped for the wire, the contract a server offers the host that serves it, and the contract test suites every server runs over its own tools.

TypeScript, ESM-only, Node ≥ 24, npm ≥ 11.

Install

npm install @doist/mcp-core

The MCP SDK and Zod are peer dependencies, so the server and the core share one copy of each. That matters: a host that serves several MCP servers unwraps them with instanceof McpServer, which fails against a second copy of the SDK.

PeerRange
@modelcontextprotocol/server^2.2.0
zod^4.3.0

./testing also imports vitest (4.1 or later) and @modelcontextprotocol/client (2.2 or later), from the server's own devDependencies. They are deliberately not peers: npm installs a peer of a production dependency in production, so declaring them would put vitest in every server's npm ci --omit=dev.

What's in it

ModuleKey exports
toolMcpTool, AnyMcpTool, ToolResult, ExecuteResult, RequiredToolAnnotations, ToolSchemaSource (types)
register-toolcreateToolRegistrar, ToolRegistrarOptions, RegisterTool, ToolFailure
tool-outputgetToolOutput, getErrorOutput, ToolOutputOptions
sanitizeremoveNullFields, RemoveNullFieldsOptions
tool-schemasgetToolSchemas, ToolSchemas
annotationsgetMcpAnnotations, formatToolTitle
app-metanormalizeToolMeta
host (subpath)validateBearerToken, ValidateBearerTokenOptions, TokenValidationError, TokenValidationResult, ValidateToken, McpServerOptions, isTransientValidationError, buildBearerChallenge, createTokenVerdictCache
testing (subpath)describeOutputSchemaNullability, describeAdvertisedToolSchemas, describeToolAnnotations, describeTokenFootprint, measureTokenFootprint, findNullablePaths, listAdvertisedTools

Usage

Defining a tool

McpTool<Params, Output, Context> is generic over what each call receives, so each server picks its own context: an API client, or an object carrying one with an abort signal and a progress reporter. A server usually aliases it with its context filled in.

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 }>> }
>

Registering tools

createToolRegistrar returns a function that registers tools on a server. Everything that differs between products comes in through its options:

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
}

For each tool this:

  • compiles its input and output schemas to JSON Schema 2020-12 once per process, however many servers register it;
  • titles it "<titlePrefix>: <Title Case Name>" and defaults openWorldHint to false, with the tool's own annotations winning;
  • fills in both forms of an MCP Apps UI resource (_meta.ui.resourceUri and _meta['ui/resourceUri']) when the tool sets one;
  • shapes every result with getToolOutput, below;
  • catches anything execute throws, passes it to onError, and returns formatError's text to the model as an isError result.

createContext receives the tool and the SDK's ServerContext, so a long-running tool can read serverContext.mcpReq.signal or send progress notifications.

The wire shape of a result

getToolOutput turns a ToolResult into a CallToolResult:

  1. Nulls and empty objects are stripped from structuredContent (removeNullFields). Empty arrays stay. Pass verbatimKeys for keys whose nulls mean something, such as a stored JSON Schema.
  2. content is, in order: the tool's own contentItems, then structuredContent as a JSON text block, then textContent. A client that reads only a text content[0] (the OpenAI Responses API drops structuredContent) still gets the payload.
  3. structuredContentAsText: false leaves the JSON block out, for deployments whose clients all read structuredContent.

Serving a server from a host

@doist/mcp-core/host is the contract between a server package and a host that serves several of them, such as todoist-ai-integrations. Every server exports the same two shapes:

  • getMcpServer(options), taking McpServerOptions ({ token, baseUrl? }), or McpServerOptions & { ... } when it needs more;
  • a ValidateToken: (token, baseUrl?) => Promise<'valid' | 'invalid' | 'forbidden'>.

A validator resolves only with a verdict on the token and rejects for anything else, so the host can retry a failure without caching it as a verdict. validateBearerToken builds one from a status-only 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 })
The API answersvalidateBearerToken
2xxvalid
401invalid: the host sends a 401 challenge, and the client refreshes its token
403forbidden, or whatever classifyForbidden(response) returns
any other status, a redirect includedthrows TokenValidationError, whose httpStatusCode lets the host treat a 5xx as transient
nothing within timeoutMs (5s)rejects with the TimeoutError
a network failurerejects with the error unchanged

The body is drained unread, so a change to the response's shape can't misclassify a token and the connection goes back to the pool. Redirects aren't followed: a sign-in page that answers 200 would otherwise pass for an accepted token. Pass classifyForbidden when a 403 can also mean a refusal that consent can't lift, headers to tag the request, and fetch when the server's API client has its own transport (a proxy from HTTPS_PROXY, say), so validation goes the same way as every other call.

On the host's side, three helpers turn a validator's answer into a response, whatever the HTTP framework:

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: a timeout, a dropped or refused connection, or a 5xx, the rejections worth retrying. A redirect or a 404 is not.
  • buildBearerChallenge: Bearer, with error="insufficient_scope" and resource_metadata="…" when given.
  • createTokenVerdictCache: verdicts keyed by token and base URL, kept for ttlMs, the oldest dropped when full.

Contract tests

@doist/mcp-core/testing holds suites a server runs over its own tool registry:

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: one test per tool, failing on any output field that admits null.
  • describeAdvertisedToolSchemas: the server advertises exactly these tools, each with JSON Schema 2020-12 schemas matching a direct Zod conversion.

Both take any { name, parameters, outputSchema? }, so a server can run them before its tools move to McpTool.

Two more read only what a client receives, so they restate nothing about registration:

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: the fixed cost every session pays (the tools/list payload as one array, plus instructions) stays under budget, and either half going missing fails too. It prints the cost per tool. Treat the budget as a ratchet: a rise needs justifying in the change that causes it. measureTokenFootprint returns the same numbers. The tokenizer is yours to pass, so mcp-core depends on none.
  • describeToolAnnotations: every advertised tool has exactly the expected annotations, title and defaults included (openWorldHint defaults to false), and every expected tool is advertised. Clients decide from these whether a call needs the user's confirmation.

Rules for tools

These hold for every Doist MCP server. Where the core can enforce one, it says how.

  • Output schemas never use .nullable(). Results are stripped of nulls before the SDK validates them, so a nullable field promises a value the tool can never send, and the call fails with "Output validation error" once it is stripped. Declare the field .optional() and leave the key out (foo: value ?? undefined). describeOutputSchemaNullability enforces this.
  • Annotations describe the tool's worst case. They are per tool, not per action, so a tool that groups several operations is annotated for the riskiest one. readOnlyHint: true only when nothing is persisted and nothing user-visible happens (a file or a shareable link counts). openWorldHint: true when the tool reaches anyone outside the user's own account: notifying another person, or producing a public URL. destructiveHint: true when it deletes, overwrites, replaces prior state, removes access or is otherwise hard to undo; sending a notification is additive, not destructive. RequiredToolAnnotations makes the three hints mandatory.
  • Describe an output field only where the schema can't. Output schemas are sent on every tools/list, and shared ones are inlined once per tool. Describe units and formats, conventions, unions, conditional presence and non-obvious meaning; leave off anything that restates the field name or lists enum values. Input descriptions are how a model learns to call the tool, and should be as full as they need to be.
  • Clear an optional field with a string, not null. Some providers, Gemini in particular, handle a nullable input badly. Use a descriptive sentinel ("unassign", "remove") and document it in the field's description.
  • Batch write tools report per-item failures, never throw. Settle each item on its own and return the ones that failed alongside the ones that succeeded, even when every item fails. A thrown error hides what was written, so a retry writes it twice.
  • Never log tool arguments by default. Write tools carry user content in them. The default onError logs the tool name and the error only.

Licence

MIT

版本与发布记录

@doist/mcp-core 共发布 8 个版本,首次发布于 2026/11/09;过去 12 个月发布了 8 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.4.1latest2026/11/112 天前
0.4.02026/11/094 天前
0.3.02026/11/094 天前
0.2.02026/11/094 天前
0.1.12026/11/094 天前
0.1.02026/11/094 天前

关于 @doist/mcp-core 的常见问题

@doist/mcp-core 是什么?

Shared core for the Doist MCP servers注意:该包已被作者标记为弃用——Moved to @todoist/mcp-core. Replace @doist/mcp-core with @todoist/mcp-core in your dependencies and imports.

如何安装 @doist/mcp-core?

在项目目录运行 npm install @doist/mcp-core,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:zod(^4.3.0)、@modelcontextprotocol/server(^2.2.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@doist/mcp-core 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@doist/mcp-core 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@doist/mcp-core 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@doist/mcp-core 可以商用吗?

@doist/mcp-core 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@doist/mcp-core 的下载量有多少?

根据 npm 官方下载统计,@doist/mcp-core 在 2026/10/13 – 2026/11/11 这 30 天内被下载 5,535 次,日均约 185 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@doist/mcp-core 还在维护吗?

最新版本 0.4.1 发布于 2026/11/11(2 天前);至今共发布 8 个版本,其中过去 12 个月发布了 8 个;首次发布于 2026/11/09。作者已将其标记为弃用,新项目建议寻找替代方案。