Skip to main content

add-tool

Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.

설치로 이동

소스 정보

저장소
cyanheads/obsidian-mcp-server
최근 소스 활동
2026년 9월 19일 15:47
감지된 SKILL.md 언어
영어
스타
682
포크
103

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
name
add-tool
description
Scaffold a new MCP tool definition. Use when the user asks to add a tool, create a new tool, or implement a new capability for the server.
metadata
{"author":"cyanheads","version":"2.29","audience":"external","type":"reference"}
## Context Tools use the `tool()` builder from `@cyanheads/mcp-ts-core`. Each tool lives in `src/mcp-server/tools/definitions/` with a `.tool.ts` suffix. The standard registration pattern uses a `definitions/index.ts` barrel that collects all tools into an `allToolDefinitions` array for `createApp()`. Fresh scaffolds from `init` start with direct imports in `src/index.ts` — the barrel is introduced as definitions grow. Match the pattern already used by the project you're editing. ## Steps 1. **Gather** the tool's name, purpose, and input/output shape from the user's request — ask only if genuinely absent 2. **Determine if it needs input the caller may not supply** — a confirmation, a choice, the client's roots — which makes it a multi-round-trip handler (`ctx.requestInput` / `ctx.inputs`, see `api-context`) 3. **Create the file** at `src/mcp-server/tools/definitions/{{tool-name}}.tool.ts` 4. **Register** the tool in the project's existing `createApp()` tool list (directly in `src/index.ts` for fresh scaffolds, or via a barrel if the repo already has one) 5. **Run `bun run devcheck`** to verify — if Biome reports formatting issues, run `bun run format` to auto-fix, then re-run devcheck 6. **Smoke-test** with `bun run rebuild && bun run start:stdio` (or `start:http`) ## Naming Tools use lowercase snake_case with a canonical server/domain prefix: `{server}_{verb}_{noun}` — 3 words. Examples: `pubmed_search_articles`, `pubmed_fetch_fulltext`, `clinicaltrials_find_eligible`. The server prefix is judged on clarity, not length: the brand name or the plain well-known word for the domain both pass (`pubmed_`, `patents_`); an abbreviation fails only when it reads as something else out of context (`loc_`, `ct_`). A fourth segment is fine when the noun is inherently two words (`openfda_search_device_clearances`). When a name resists the schema — can't pick a verb, noun feels generic, the *verb* wants a second word — that's usually a signal the scope is fuzzy; split the tool, rename, or reconsider. For shape selection (Workflow or Instruction variants — standard single-action tools are the default), see the `design-mcp-server` skill's Tool shapes section. ## Template ```typescript /** * @fileoverview {{TOOL_DESCRIPTION}} * @module mcp-server/tools/definitions/{{TOOL_NAME}} */ import { tool, z } from '@cyanheads/mcp-ts-core'; import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; export const {{TOOL_EXPORT}} = tool('{{tool_name}}', { title: '{{TOOL_TITLE}}', // Single cohesive paragraph — pack operational guidance into prose sentences, // not bullet lists or blank-line-separated sections. Descriptions render inline. description: '{{TOOL_DESCRIPTION}}', annotations: { readOnlyHint: true }, input: z.object({ // All fields need .describe(). Only JSON-Schema-serializable Zod types allowed. }), output: z.object({ // All fields need .describe(). Only JSON-Schema-serializable Zod types allowed. }), // Agent-facing context on the success path — empty-result notices, the query as // the server parsed it, pagination totals. The counterpart to errors[]: merged // into structuredContent AND mirrored into content[] automatically (no format() // entry needed, never touched by format-parity). Populate via ctx.enrich(...) in // the handler or service layer. Keys must be disjoint from output. Delete if unused. enrichment: { effectiveQuery: z.string().describe('The query as the server parsed it.'), totalCount: z.number().describe('Total matches before any limit was applied.'), }, // auth: ['tool:{{tool_name}}:read'], // Each entry declares a domain-specific failure mode and types // `ctx.fail(reason, …)` against the declared union. Baseline codes // (InternalError, ServiceUnavailable, Timeout, ValidationError, // SerializationError) bubble freely — only declare domain-specific reasons. // Delete this block if no domain failures apply. // // Keep contracts inline on this tool, even when other tools have similar // entries. The contract is part of the tool's documented public surface — // don't extract a shared `errors[]` constant; per-tool repetition is the // intended cost of self-contained tool defs. // // `recovery` is required (≥ 5 words) — it's the agent's next move when this // failure fires. Forcing function for thoughtful guidance: placeholders like // "Try again." get flagged by the linter. The contract `recovery` is the // single source of truth for what flows to the wire — opt in at the throw // site by spreading `ctx.recoveryFor('reason')` into the `data` arg. errors: [ { reason: 'queue_full', code: JsonRpcErrorCode.RateLimited, when: 'Local queue at capacity.', retryable: true, recovery: 'Wait a few seconds before retrying or reduce batch size.' }, ], async handler(input, ctx) { ctx.log.info('Processing', { /* relevant input fields */ }); // Pure logic — throw on failure, no try/catch. // With an `errors[]` contract: `throw ctx.fail('reason_id', message?, data?)`. // Without: throw via factories (`notFound`, `validationError`, …) or plain `Error`. const items = await search(input); if (queue.full()) { // Static recovery — resolve from the contract via ctx.recoveryFor('reason'). // Single source of truth: the string lives in errors[] above; this spread // pulls it onto the wire so format()-only clients see the recovery hint. throw ctx.fail('queue_full', undefined, { ...ctx.recoveryFor('queue_full') }); } // Surface what the agent reasons with — echoed query, true total — on BOTH // client surfaces, with no format() plumbing. An empty result is a notice, // not a throw: reserve ctx.fail for genuine failures (queue full, upstream down). ctx.enrich.echo(input.query); ctx.enrich.total(items.length); if (items.length === 0) { ctx.enrich.notice(`No items matched "${input.query}". Try broader terms or check the spelling.`); } return { items }; }, // format() populates MCP content[] — the markdown twin of structuredContent. // Different clients read different surfaces (Claude Code → structuredContent, // Claude Desktop → content[]), so both must carry the same data. // Enforced at lint time: every field in `output` must appear in the rendered text. format: (result) => { const lines: string[] = []; // Render each item with all relevant fields — not just a count or title. // A thin one-liner (e.g., "Found 5 items") leaves the model blind to the data. for (const item of result.items) { lines.push(`## ${item.name}`); lines.push(`**ID:** ${item.id} | **Status:** ${item.status}`); if (item.description) lines.push(item.description); } return [{ type: 'text', text: lines.join('\n') }]; }, }); ``` ### Multi-round-trip variant A handler that needs something the caller didn't supply returns `ctx.requestInput(...)` and is re-entered with the answers on `ctx.inputs`. There is no mid-handler `await` for user input, and no capability check — the surface is always present, on every transport and both protocol eras. Whether the caller can *answer* is a separate question — a 2025-era HTTP client cannot when the server runs `MCP_SESSION_MODE=stateless`, which a server needing that leg declares with `createApp({ sessionMode: { require: 'stateful' } })` rather than leaving to a deployment (`api-context` § `ctx.requestInput`). Treat an unanswered round as terminal, never as consent. ```typescript import { inputRequired, tool, z } from '@cyanheads/mcp-ts-core'; import { validationError } from '@cyanheads/mcp-ts-core/errors'; const Confirm = z.object({ confirm: z.boolean().describe('Whether to proceed.') }); export const {{TOOL_EXPORT}} = tool('{{tool_name}}', { description: '{{TOOL_DESCRIPTION}}', input: z.object({ /* ... */ }), output: z.object({ /* ... */ }), annotations: { destructiveHint: true }, handler(input, ctx) { // Read what a prior round collected before asking for anything. const answer = ctx.inputs.accepted('confirm', Confirm); if (!answer) { // A declined or cancelled prompt is a dead end — don't re-ask it. const view = ctx.inputs.view('confirm'); if (view.kind === 'elicit' && view.action !== 'accept') { throw validationError(`User ${view.action} the confirmation.`); } return ctx.requestInput({ inputRequests: { confirm: inputRequired.elicit({ message: `Proceed with ${input.target}?`, requestedSchema: Confirm, }), }, }); } // `answer` is narrowed here. return { /* output */ }; }, }); ``` Write it as `return ctx.requestInput(...)` — the `never` return type makes it valid in return position for any output, and it is what lets TypeScript narrow the line below. Full reference (`inputRequired.elicitUrl` / `.createMessage` / `.listRoots`, `requestState`, decline handling): `framework-skills/api-context`. ### Registration ```typescript // src/index.ts (fresh scaffold default) import { createApp } from '@cyanheads/mcp-ts-core'; import { existingTool } from './mcp-server/tools/definitions/existing-tool.tool.js'; import { {{TOOL_EXPORT}} } from './mcp-server/tools/definitions/{{tool-name}}.tool.js'; await createApp({ tools: [existingTool, {{TOOL_EXPORT}}], resources: [/* existing resources */], prompts: [/* existing prompts */], }); ``` If the repo already uses `src/mcp-server/tools/definitions/index.ts`, update that barrel instead of switching patterns midstream. ### Feature-flagged tools (`disabledTool` wrapper) When a tool is gated behind config (e.g., `BRAPI_ENABLE_WRITES`, `FOO_PRO_FEATURES`), the gate has two failure modes when wired naively. **Excluding the tool from the array** hides it from MCP registration *and* from the HTTP landing page — operators see a smaller catalog than the README documents and have no in-page hint that the tool exists at all. **Always registering it** lets clients call the tool and forces handler-side `forbidden` throws, which keeps the dangerous surface in the LLM's reach. `disabledTool()` resolves this: the wrapped tool is **present in the manifest and rendered on the landing page** (muted card, with a reason and an optional hint for how to enable it), but **skipped during MCP server registration** so clients cannot call it. ```typescript import { disabledTool, tool, z } from '@cyanheads/mcp-ts-core'; import { getServerConfig } from '@/config/server-config.js'; const submitObservationsDef = tool('brapi_submit_observations', { description: 'Submit observation records (POST/PUT) with elicit gate.', annotations: { readOnlyHint: false, destructiveHint: false }, input: z.object({ /* … */ }), output: z.object({ /* … */ }), async handler(input, ctx) { /* … */ }, }); export const submitObservations = getServerConfig().enableWrites ? submitObservationsDef : disabledTool(submitObservationsDef, { reason: 'Writes are turned off in this deployment.', hint: 'BRAPI_ENABLE_WRITES=true', }); ``` `DisabledMetadata` shape: `{ reason: string; hint?: string; since?: string }`. The `reason` renders as a sentence on the disabled card; `hint` (when present) renders as a code-styled block — use whatever the gate is (env var line, config key, doc reference). `since` annotates the card with a small "since vX" tag — useful when phasing a tool out behind a flag before removal. **Three tool listings** to keep straight: | Surface | Disabled tools? | |:---|:---| | `tools/list` (MCP protocol — what clients call) | **No** — disabled tools are skipped at registration | | `/.well-known/mcp.json` (Server Card) | **No** — the card carries no per-tool entries at all, so a discovery agent reading it cannot see a disabled tool | | `/` (HTML landing page) | **Yes**, in a 4th muted bucket after `read \| write \| destructive` — the only surface where a disabled tool is visible | The wrapper preserves all original definition fields (handler, schemas, auth scopes, error contracts) — when re-enabled, the tool already conforms to every lint rule. #### Audit what still names the tool Gating a tool removes it from `tools/list`, but nothing rewrites the rest of the server. Every reference that survives points a client at a name it cannot call. Sweep for the tool's name across three surfaces and fix what the gate makes wrong: | Surface | What the gate requires | |:---|:---| | **Static prose** — server `instructions`, tool descriptions, field `.describe()` text | Do not describe a disabled tool as currently callable. | | **Recovery text** — `errors[].recovery`, `ctx.fail` hints, `ctx.enrich` notices, service summaries | Offer an available next step, or say the capability is unavailable in this deployment. | | **Structured suggestions** — `nextToolSuggestions`, or any `{ toolName, args }` entry a client executes | Emit a suggestion only when its target is enabled under the same configuration. | A suggestion is executable; prose is not. When no callable alternative exists, prose may still explain the limitation — but the executable entry goes: ```typescript const { enableWrites } = getServerConfig(); // The suggestion is emitted only under the config that registers its target. const nextToolSuggestions = enableWrites ? [{ toolName: 'brapi_submit_observations', args: { studyDbId } }] : []; return { observations, nextToolSuggestions, ...(enableWrites ? {} : { notice: 'Submitting observations is turned off in this deployment.' }), }; ``` The same audit applies to a tool's own `errors[].recovery`: a hint naming a tool that this deployment gates off sends the agent to a dead end at exactly the moment it is recovering from a failure. ## Schemas: what the framework stores vs. what clients see `tool()` and the handler factory do not hand your Zod schemas to the SDK verbatim. Two deliberate transforms sit in between. ### Input is strict `tool()` stores `input` with `.strict()` applied, and the advertised `inputSchema` carries `additionalProperties: false` to match. An unrecognized argument key is **rejected by name** before the handler runs: ```text Input validation error: Invalid arguments for tool <name>: Unrecognized key: "querry" ``` That arrives as an `isError: true` result, not a JSON-RPC error, and produces no framework span or log. The alternative — silently stripping the key — turns a caller's typo into a wrong answer they cannot detect: the value vanishes before the handler runs and the call fails downstream pointing at the wrong problem. Two limits worth knowing when you write a schema:
GitHub에서 보기
이 SKILL.md는 매우 커서 SkillsMP가 여기에는 첫 섹션만 미리 보여줍니다. GitHub에서 보기