ワンクリックで
add-service
// Scaffold a new service integration. Use when the user asks to add a service, integrate an external API, or create a reusable domain module with its own initialization and state.
// Scaffold a new service integration. Use when the user asks to add a service, integrate an external API, or create a reusable domain module with its own initialization and state.
Land working-tree changes as logical commits — the work grouped by concern, topped by a release commit (version bump, changelog, regenerated artifacts) and an annotated tag. Verify, commit, tag. Stops at "committed and tagged locally" — no push, no publish. The release-and-publish skill picks up from here. Distilled from the git_wrapup_instructions protocol.
Pick and run a multi-phase workflow that chains foundational task skills (`git-wrapup`, `release-and-publish`, `maintenance`, `field-test`, `setup`, etc.) end-to-end. Routes user intent to a workflow file under `workflows/` — greenfield builds, maintenance + release, field-test + fix, or known-work + release. Single source for the universal rules (no commits without authorization, no destructive git, no marketing language), the orchestrator posture (own the goal, ground sub-agents in primary sources, verify against the goal), and the sub-agent strategy (orient block, parallel fanout, isolation, normalization) that apply across every workflow. Sub-agents are an optional capability — workflows run linearly when fanout isn't available.
Scaffold an MCP App tool + UI resource pair. Use when the user asks to add a tool with interactive UI, create an MCP App, or build a visual/interactive tool.
Add a new subpath export to the @cyanheads/mcp-ts-core package. Use when creating a new public API surface that consumers import from a dedicated subpath (e.g., @cyanheads/mcp-ts-core/newutil).
Finalize documentation and project metadata for a ship-ready MCP server. Use after implementation is complete, tests pass, and devcheck is clean. Safe to run at any stage — each step checks current state and only acts on what still needs work.
Post-init orientation for an MCP server built on @cyanheads/mcp-ts-core. Use after running `@cyanheads/mcp-ts-core init` to understand the project structure, conventions, and skill sync model. Also use when onboarding to an existing project for the first time.
| name | add-service |
| description | Scaffold a new service integration. Use when the user asks to add a service, integrate an external API, or create a reusable domain module with its own initialization and state. |
| metadata | {"author":"cyanheads","version":"1.6","audience":"external","type":"reference"} |
Services use the init/accessor pattern: initialized once in createApp's setup() callback, then accessed at request time via a lazy getter. Each service lives in src/services/[domain]/ with an init function and accessor.
Service methods receive Context for correlated logging (ctx.log) and tenant-scoped storage (ctx.state). Convention: ctx.elicit and ctx.sample should only be called from tool handlers, not from services.
For the full service pattern, CoreServices, and Context interface, read the framework's CLAUDE.md/AGENTS.md (loaded at session start).
src/services/{{domain}}/src/services/{{domain}}/{{domain}}-service.tssrc/services/{{domain}}/types.ts if neededsetup() in the server's entry point (src/index.ts, or src/worker.ts for Worker-only servers)bun run devcheck to verify/**
* @fileoverview {{SERVICE_DESCRIPTION}}
* @module services/{{domain}}/{{domain}}-service
*/
import type { AppConfig } from '@cyanheads/mcp-ts-core/config';
import type { StorageService } from '@cyanheads/mcp-ts-core/storage';
import type { Context } from '@cyanheads/mcp-ts-core';
export class {{ServiceName}} {
constructor(
private readonly config: AppConfig,
private readonly storage: StorageService,
) {}
async doWork(input: string, ctx: Context): Promise<string> {
ctx.log.debug('Processing', { input });
// Domain logic here
return `result: ${input}`;
}
}
// --- Init/accessor pattern ---
let _service: {{ServiceName}} | undefined;
export function init{{ServiceName}}(config: AppConfig, storage: StorageService): void {
_service = new {{ServiceName}}(config, storage);
}
export function get{{ServiceName}}(): {{ServiceName}} {
if (!_service) {
throw new Error('{{ServiceName}} not initialized — call init{{ServiceName}}() in setup()');
}
return _service;
}
Add the setup() callback and import to the existing createApp() call — preserve the existing tool/resource/prompt arrays:
// In src/index.ts (or src/worker.ts for Worker-only servers)
import { init{{ServiceName}} } from './services/{{domain}}/{{domain}}-service.js';
// Add setup() alongside existing options:
setup(core) {
init{{ServiceName}}(core.config, core.storage);
},
import { get{{ServiceName}} } from '@/services/{{domain}}/{{domain}}-service.js';
handler: async (input, ctx) => {
return get{{ServiceName}}().doWork(input.query, ctx);
},
When a service wraps an external API, apply these patterns. For the framework retry contract, see skills/api-utils/SKILL.md.
Place retry at the service method level — covering both HTTP fetch and response parsing/validation. The HTTP client should be single-attempt; the service owns retry. Use withRetry from @cyanheads/mcp-ts-core/utils:
import { withRetry, fetchWithTimeout } from '@cyanheads/mcp-ts-core/utils';
import type { Context } from '@cyanheads/mcp-ts-core';
async fetchItem(id: string, ctx: Context): Promise<Item> {
return withRetry(
async () => {
const response = await fetchWithTimeout(
`${this.baseUrl}/items/${id}`,
10_000,
ctx,
{ signal: ctx.signal },
);
const text = await response.text();
return this.parseResponse<Item>(text);
},
{
operation: 'fetchItem',
context: ctx,
baseDelayMs: 1000, // calibrate to upstream recovery time
signal: ctx.signal,
},
);
}
baseDelayMs: 1000 suits most APIs.fetchWithTimeout already throws on non-OK responses with granular status mapping (401→Unauthorized, 403→Forbidden, 404→NotFound, 408/425→Timeout, 422→ValidationError, 429→RateLimited, 5xx→ServiceUnavailable/InternalError) — this prevents feeding HTML error pages into XML/JSON parsers.ServiceUnavailable (transient) instead of SerializationError (non-transient).withRetry automatically enriches the final error with attempt count — callers know retries were already attempted.fetchWithTimeout already maps status codes to appropriate error codes (see key principle 2 above). Use httpErrorFromResponse instead when you need Retry-After header capture, request body passthrough in error data, or custom service/data fields on the thrown error:
import { httpErrorFromResponse, withRetry } from '@cyanheads/mcp-ts-core/utils';
async fetchItem(id: string, ctx: Context): Promise<Item> {
return withRetry(
async () => {
const response = await fetch(`${this.baseUrl}/items/${id}`, { signal: ctx.signal });
if (!response.ok) {
throw await httpErrorFromResponse(response, {
service: 'MyAPI',
data: { itemId: id },
});
}
return this.parseResponse<Item>(await response.text());
},
{ operation: 'fetchItem', context: ctx, signal: ctx.signal },
);
}
httpErrorFromResponse maps the full status table (401/403/408/422/429/5xx) to the appropriate JsonRpcErrorCode, captures the response body (truncated), and forwards Retry-After headers into error.data.retryAfter. The codes it produces line up with withRetry's transient-code set, so retryable HTTP failures (429, 503, 504) are retried automatically and non-retryable ones (401, 404, 422) fail immediately.
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
parseResponse<T>(text: string): T {
// Detect HTML error pages masquerading as successful responses
if (/^\s*<(!DOCTYPE\s+html|html[\s>])/i.test(text)) {
throw serviceUnavailable('API returned HTML instead of expected format — likely rate-limited.');
}
// Parse and validate...
}
Third-party APIs often omit fields entirely instead of returning null. If your raw response types, normalized domain types, or tool output schemas are stricter than the real upstream payloads, you'll either fail validation or silently invent facts.
Guidance:
false, 0, '', or an empty array.exactOptionalPropertyTypes, omit absent fields instead of returning undefined. Conditional spreads keep the normalized object honest.type RawRepo = {
id: string;
name: string;
archived?: boolean;
star_count?: number;
description?: string | null;
};
type Repo = {
id: string;
name: string;
archived?: boolean;
starCount?: number;
description?: string;
};
function normalizeRepo(raw: RawRepo): Repo {
const description = raw.description?.trim();
return {
id: raw.id,
name: raw.name,
...(typeof raw.archived === 'boolean' && { archived: raw.archived }),
...(typeof raw.star_count === 'number' && { starCount: raw.star_count }),
...(description ? { description } : {}),
};
}
Services don't declare errors: [...] contracts and don't have ctx.fail — that contract surface is tool/resource-only. Inside services:
Throw via factories when a specific code matters: throw notFound(...), throw rateLimited(...), throw serviceUnavailable(...). The framework's auto-classifier catches anything else.
Wrap risky pipelines in ErrorHandler.tryCatch when you want structured logging + auto-classification without writing try/catch boilerplate. It always rethrows — never swallows. Useful for parsing untrusted input (JSON, config) or third-party SDK calls whose error types you don't control:
import { ErrorHandler } from '@cyanheads/mcp-ts-core/utils';
const parsed = await ErrorHandler.tryCatch(
() => JSON.parse(rawConfig),
{ operation: 'MyService.parseConfig', errorCode: JsonRpcErrorCode.ConfigurationError },
);
Tool/resource handlers bubble service errors unchanged — the contract advertises the advertised failure surface, and any code thrown from a service still reaches the client correctly via the auto-classifier. The conformance lint scans handler source text only, so service-thrown codes aren't flagged.
Carry contract reason via data: { reason } when the calling tool declares an errors[] contract entry for this failure mode. Services can't call ctx.fail, but passing the reason in data flows through the auto-classifier untouched, so clients see the same error.data.reason they'd see from ctx.fail — no handler-side catch-and-rethrow needed:
// tool declares: errors: [{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError, when: '…', recovery: '…' }]
throw validationError('Expression cannot be empty.', { reason: 'empty_expression' });
Resolve contract recovery via ctx.recoveryFor to land the contract's recovery hint on the wire without duplicating the string. Always-present on Context, returns {} when the calling tool has no matching reason — spread-safe regardless:
throw validationError('Parse failed: ' + err.message, {
reason: 'parse_failed',
...ctx.recoveryFor('parse_failed'), // resolves from caller's contract
});
The contract recovery (validated ≥5 words at lint time) is the single source of truth. Services that opt in via the resolver carry the same hint to the wire that handler-level ctx.fail callers do — no drift, no auto-population. For dynamic recovery (interpolating runtime values into the hint), pass an explicit { recovery: { hint: '…' } } instead.
When a service wraps an external API, design methods to minimize upstream calls. These patterns compound — a tool calling 3 service methods that each make N requests is 3N calls; batching drops it to 3.
If the API supports filter-by-IDs, bulk GET, or batch query endpoints, expose a batch method instead of (or alongside) the single-item method. One request for 20 items beats 20 sequential requests — it eliminates serial latency, avoids rate-limit accumulation, and simplifies error handling.
/** Fetch multiple studies in a single request via filter.ids. */
async getStudiesBatch(nctIds: string[], ctx: Context): Promise<Study[]> {
const response = await this.searchStudies({
filterIds: nctIds,
fields: ['NCTId', 'BriefTitle', 'HasResults', 'ResultsSection'],
pageSize: nctIds.length,
}, ctx);
return response.studies;
}
Cross-reference the response against the requested IDs to detect missing items — don't assume the API returns everything you asked for.
If the API supports fields, select, or include parameters, request only what the caller needs. A full record might be 70KB; four fields might be 5KB. Expose field selection as a parameter on the service method, or use sensible defaults per method.
If a batch request might exceed the API's page size limit, either:
nextPageToken present)Silent truncation is a data integrity bug — the caller thinks it has all results when it doesn't.
src/services/{{domain}}/init function accepts (config: AppConfig, storage: StorageService) and stores the instanceError if not initialized@fileoverview and @module header presentconsole calls — use ctx.log for service-level loggingContext for logging and storageinit function registered in setup() callback in the server's entry point (src/index.ts or src/worker.ts)bun run devcheck passes