@reinsjs/tools-mcp

中文文档
0.3.3发布于 2026/11/02MITTypeScriptES Module

reins — MCP servers as a Socket: tools/list once per run, tools/call as Tool.execute, annotations → risk / approval defaults. Streamable HTTP on the Web-standard entry, stdio under ./node

原始使用文档
文档目录9 节
  1. What happens at run time
  2. Connection lifecycle
  3. Hot reload without restarts
  4. Expiring credentials
  5. Gateway-style servers
  6. Large flat tool tables
  7. Verified upstream behaviour
  8. Options
  9. License

@reinsjs/tools-mcp

Plug an MCP server into a reins agent as one Socket. Every tool the server lists becomes a reins Tool; the loop, the brain modules (spill, approval, budget, compact…) and the timeline treat them exactly like in-process tools. The server never learns that reins exists.

import { httpTransport, mcpTools } from "@reinsjs/tools-mcp"
import { approval, spill } from "@reinsjs/brain"
import { createAgent } from "@reinsjs/agent"

const github = mcpTools({
  transport: httpTransport({
    url: "https://mcp.example.com/mcp",
    headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` },
  }),
})

export const agent = createAgent({
  model,
  store,
  tools: [myInProcessTool],
  sockets: [github, spill(), approval()],
})

Node-only stdio servers (a subprocess speaking JSON-RPC over stdin/stdout) live behind the /node entry:

import { stdioTransport } from "@reinsjs/tools-mcp/node"

const files = mcpTools({
  transport: stdioTransport({ command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"] }),
  prefix: "fs_",
})

The main entry uses only Web-standard APIs (fetch, URL, Headers) and runs on Cloudflare Workers without nodejs_compat (verified on a 2023 compatibility date, see spikes/edge-runtime-check), and on Bun, Deno (--allow-net is enough) and Vercel's edge-runtime (spikes/runtime-matrix — tools/list + tools/call against a local server in each). Only /node imports node:*.

What happens at run time

Tools are bound per run. When a run starts, the socket calls tools/list once and turns the result into the run's tool table. The table does not change until the run ends — a notifications/tools/list_changed from the server only affects the next run. This is deliberate: a stable tool table is what keeps prompt caches warm, and it is where reins' "no dynamic registration" stance lands. The loop records the table in a core.tools_bound event (invisible to the model) and, when it differs from the previous run's, appends a visible system_note naming the added and removed tools so the model knows its hands changed.

Calls go through tools/call. The result's content blocks become reins content parts (text and images as-is; audio, resource links and binary resources become one-line descriptions — lossy, and said so). isError passes through. A thrown error (unknown tool, dropped connection, timeout) becomes a tool_result with isError: true; the run continues and the model decides what to do.

Annotations set defaults, not permissions. readOnlyHint → risk: "low"; destructiveHint → risk: "high" and needsApproval: true; anything else (including no annotations) → risk: "medium". The MCP spec says annotations are hints and that untrusted servers' annotations must not drive authorization — so reins only uses them to default the fields that @reinsjs/brain's approval() policies read, and override lets the host change any of it:

mcpTools({
  transport,
  override: (tool, info) => {
    if (info.name === "delete_repo") return false // never expose this one
    if (info.annotations?.openWorldHint) return { ...tool, needsApproval: true }
    return undefined // keep the default translation
  },
})

Note the spec's own default for destructiveHint is true when absent. reins does not treat "unannotated" as "destructive" — that would put every tool of every casual server behind an approval prompt. If you want that strictness, add it in override or run approval({ unmatched: "ask" }).

Connection lifecycle

mcpTools() does not connect. The first run that needs the tool table opens the connection; later runs reuse it. If the transport closes (server restarted, network dropped), the next call that needs it — a tools/call in the same run or tools/list at the next run start — rebuilds the connection from the transport recipe once. There is no retry policy inside a call: a failed call is reported to the model as an error result, and a failed reconnect at run start throws before anything is written to the log (fail-closed). Pass optional: true to instead start the run without that server's tools and warn once; the model then sees them listed as removed.

Call close() when you are done with a socket (process shutdown, or the host replaced the configuration).

Hosts that build the agent per request should cache sockets by configuration, not rebuild them every time: rebuilding forces a fresh MCP handshake per request. examples/mcp/agent.ts shows a small cache keyed by the server's config JSON, closing sockets whose config disappeared.

Hot reload without restarts

There is no reload API because none is needed. createAgent() only assembles objects, and the tool table is resolved at run start. A platform that stores each user's MCP servers in a database reads that configuration when a request arrives, builds (or fetches from cache) the matching mcpTools() sockets, and runs. A server added or removed by the user takes effect on their next message; the loop tells the model what changed.

One consequence to know about: a run paused for approval is signed with the tool table it had. If the configuration changes while it is paused, resuming is rejected as configuration drift (RunStateError config_mismatch). Hosts have three honest options: wait for the session to finish before applying the change, accept that the paused run is abandoned and start a new one, or pass allowConfigDrift: true to resume anyway — that resume also emits the tool-change note. What the library will not do is silently resume with a different tool table than the one the approver saw.

Expiring credentials

A fixed token can live in headers. A token that expires cannot: headers is fixed when the transport is built, while the connection is created lazily, reused across runs and rebuilt from the same recipe after a drop — so once the token expires, even the rebuild carries the stale one. Pass auth instead:

httpTransport({
  url: "https://business-api.tiktok.com/open_mcp/tt-ads-mcp-layer",
  auth: {
    token: () => db.accessToken(userId),          // called before every request
    onUnauthorized: () => db.refresh(userId),     // called on 401, then the request is retried once
  },
})

auth and an Authorization header are mutually exclusive — passing both throws at construction time rather than letting one silently win.

McpAuth is deliberately these two methods and nothing more, and it does not reference any MCP SDK type. It is not the SDK's OAuthClientProvider: the valuable part of that interface is driving a browser authorization prompt, which a server-side agent cannot do. Obtain and store the tokens in your web application; the loop only reads and refreshes them. Full OAuth flows are still not wrapped — call the SDK's refreshAuthorization (or your own code) from inside token / onUnauthorized.

Gateway-style servers

Some servers put hundreds of operations behind one dispatching tool. TikTok for Business is the current example: its layered endpoint registers 41 ordinary tools plus tool_list / tool_get / tool_execute, and the other ~330 operations live only in a server-side registry, reached through tool_execute({ tool_name, params }).

This costs the loop nothing — the tool table is fixed for the whole run, so the prompt-cache prefix and config hash are unaffected. But it silently defeats approval policies written against tool names. Creating a campaign, changing a budget and deleting an asset group are all called tool_execute; what actually happens is in the arguments.

// WRONG — this never matches. Every operation is named tool_execute.
approval({ deny: ["*_delete"], allow: ["*"] })

// RIGHT — decide on the operation name inside the arguments.
const operationOf = (args: unknown): string =>
  typeof args === "object" && args !== null && "tool_name" in args
    ? String((args as { tool_name?: unknown }).tool_name ?? "")
    : ""

approval({
  ask: [{
    id: "gateway.writes",
    match: (call) =>
      call.name === "tool_execute" && /_(create|update|delete|upload)$/.test(operationOf(call.args)),
    summary: (call) => `TikTok ${operationOf(call.args)}`,   // the approver sees the real operation
  }],
  allow: [
    { id: "gateway.reads", match: (call) =>
        call.name === "tool_execute" && /_(get|list|search)$/.test(operationOf(call.args)) },
    "tool_list",
    "tool_get",
  ],
})

The executable version of this recipe is packages/brain/src/gateway-tool.recipe.test.ts, including the negative case that proves the name-based rule lets a delete through.

With no rules configured the default is safe but blunt: such a server declares no annotations, so the translated tool carries no risk, and unmatched: "byRisk" sends every call — reads included — to a human. That is fail-closed by design; open up the read path with the recipe above rather than by allowing the dispatcher wholesale.

Two more things worth knowing about servers of this shape:

  • A registry listing can be large. TikTok's tool_list returns ~57 KB. Under spill that becomes a preview plus a fetch_blob handle — useless, because choosing a tool is exactly what the full listing is for. Raise the budget for that one tool: override: (tool, info) => info.name === "tool_list" ? { ...tool, resultPolicy: { maxTokens: 100_000, overflow: "spill" } } : tool.
  • Upstream business errors may not set isError. TikTok returns {"code": 40001, "message": "..."} as a successful MCP result; only MCP-level failures (unknown tool, transport error) are isError. Anything keying off isError — retries, your own policies, onEvent observers — will not see those unless you translate them; the withBusinessErrors wrapper in the next section does exactly that.

Large flat tool tables

The opposite shape: hundreds of ordinary tools, each annotated honestly. TikTok's flat endpoint lists 377 of them, about 200 reads and 130 writes, every one with readOnlyHint. Two things you usually want there: keep the writes out of the model's reach entirely (they go through your own tools, with your own authorization and audit trail), and stop sending two hundred tool definitions on every request. Both are one override plus @reinsjs/brain's lazyTools():

import { lazyTools } from "@reinsjs/brain"
import { normalizeToolOutput, type Tool, type ToolResult } from "@reinsjs/core"

/** TikTok puts business errors in a successful result: text = {"code": <non-zero>, "message": ...} */
const businessErrorOf = (result: ToolResult): string | undefined => {
  if (result.isError) return undefined
  for (const part of result.content) {
    if (part.type !== "text") continue
    try {
      const body = JSON.parse(part.text) as { code?: unknown; message?: unknown }
      if (typeof body.code === "number" && body.code !== 0)
        return `Upstream error ${body.code}: ${String(body.message ?? "")}`
    } catch {
      // not JSON, not an error envelope
    }
  }
  return undefined
}

/** Keep the original text for the model, but mark it as an error so retries, policies and observers see it */
const withBusinessErrors = (tool: Tool): Tool => ({
  ...tool,
  async execute(input, ctx) {
    const result = normalizeToolOutput(await tool.execute?.(input, ctx))
    const error = businessErrorOf(result)
    return error ? { content: [...result.content, { type: "text", text: error }], isError: true } : result
  },
})

/** reads: on the menu, loaded on demand; everything else: not a tool the model has */
const readOnlyLazy = (tool: Tool, info: McpToolInfo) =>
  info.annotations?.readOnlyHint === true ? withBusinessErrors({ ...tool, lazy: true }) : false

const tiktok = mcpTools({ transport, override: readOnlyLazy })

createAgent({ sockets: [tiktok, lazyTools()] }) // order matters — see below

lazyTools() builds its menu from the tools bound before it: the host's, plus those of every socket registered earlier. So it goes after mcpTools(). The other order is not an error, just useless: the MCP tools are sent in full every turn, and lazyTools() warns once, naming them. With a lowering layer that has a native place for deferred tools (@reinsjs/lowering-fetch on Anthropic) loading a tool does not even re-warm the prompt cache; see the brain README for the numbers.

Loaded definitions are trusted like the tools block itself (tool_find results are trust: "system") — the same descriptions were already going to the model unwrapped. A 200-line menu costs roughly 10k tokens of system prompt; it sits in the cached prefix and does not change within a run, and lazyTools({ summarize }) can shorten it.

The executable version of this recipe is packages/tools-mcp/src/readonly-lazy.recipe.test.ts, including the wrong-order case.

Verified upstream behaviour

Requests whose history contains tool_use / tool_result for a tool that is no longer in the tool table were accepted by DeepSeek's Anthropic-protocol endpoint and by the OpenAI Responses protocol (both with another tool present and with an empty tool table); the model answered and correctly listed only its current tools. So removing a tool does not require a new session on those upstreams. Anthropic's own endpoint, reached through Cloudflare AI Gateway's passthrough path, accepted both variants as well (2026-09-15, spikes/l1-deferred-tools P8); an aggregator's Claude endpoint returned a truncated stream, a known quirk of that proxy rather than a protocol rejection. Script: spikes/mcp-removed-tool-history.

Options

OptionDefaultMeaning
transport—httpTransport({ url, headers?, auth?, fetch?, requestInit? }) or stdioTransport({ command, args?, env?, cwd?, stderr? })
prefixnonePrepended to tool names shown to the model; the server still sees the original name
callTimeoutMs60 000Per tools/call timeout; expiry becomes an error result
optionalfalseOn tools/list failure at run start: throw (default) or contribute no tools and warn once
override(tool, info)—Per-tool rewrite; return false to drop the tool
clientInfo{ name: "reins", version }Sent in the MCP handshake
warnconsole.warnWhere degradations are reported

Tool names that the model APIs reject (^[A-Za-z0-9_-]{1,64}$) are rewritten (files.read → files_read) with a one-time warning; the original name is used on the wire.

Not supported: sampling, elicitation, resources, prompts, MCP Apps, and driving an OAuth authorization prompt (obtain tokens in your application and hand them over through auth).

License

MIT © 2026 NewRate Limited.

版本与发布记录

@reinsjs/tools-mcp 共发布 8 个版本,首次发布于 2026/09/14;过去 12 个月发布了 8 个版本。

版本发布日期距今
0.3.3latest2026/11/021 天前
0.3.22026/10/313 天前
0.3.12026/10/303 天前
0.3.02026/09/221 个月前
0.2.12026/09/151 个月前
0.2.02026/09/151 个月前

关于 @reinsjs/tools-mcp 的常见问题

@reinsjs/tools-mcp 是什么?

缰绳- MCP服务器作为套接字:每次运行一次工具/列表,工具/调用作为工具。执行、注释→风险/审批默认值。在web标准条目上的可流式HTTP,在。/node下的studio

如何安装 @reinsjs/tools-mcp?

在项目目录运行 npm install @reinsjs/tools-mcp,或使用 pnpm add / yarn add / bun add。

@reinsjs/tools-mcp 支持 TypeScript 吗?

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

@reinsjs/tools-mcp 支持 ESM 还是 CommonJS?

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

@reinsjs/tools-mcp 需要什么版本的 Node.js?

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

@reinsjs/tools-mcp 可以商用吗?

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

@reinsjs/tools-mcp 还在维护吗?

最新版本 0.3.3 发布于 2026/11/02(1 天前);至今共发布 8 个版本,其中过去 12 个月发布了 8 个;首次发布于 2026/09/14。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。