Agent skill
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.
Install this agent skill to your Project
npx add-skill https://github.com/cyanheads/mcp-ts-core/tree/main/skills/add-service
Metadata
Additional technical details for this skill
- type
- reference
- author
- cyanheads
- version
- 1.2
- audience
- external
SKILL.md
Context
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:
node_modules/@cyanheads/mcp-ts-core/CLAUDE.md
Steps
- Ask the user for the service domain name and what it integrates with
- Create the directory at
src/services/{{domain}}/ - Create the service file at
src/services/{{domain}}/{{domain}}-service.ts - Create types at
src/services/{{domain}}/types.tsif needed - Register in
setup()in the server's entry point (src/index.ts, orsrc/worker.tsfor Worker-only servers) - Run
bun run devcheckto verify
Template
Service file
/**
* @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;
}
Entry point registration
// src/index.ts
import { createApp } from '@cyanheads/mcp-ts-core';
import { init{{ServiceName}} } from './services/{{domain}}/{{domain}}-service.js';
await createApp({
tools: [/* existing tools */],
resources: [/* existing resources */],
prompts: [/* existing prompts */],
setup(core) {
init{{ServiceName}}(core.config, core.storage);
},
});
Usage in tool handlers
import { get{{ServiceName}} } from '@/services/{{domain}}/{{domain}}-service.js';
handler: async (input, ctx) => {
return get{{ServiceName}}().doWork(input.query, ctx);
},
Resilience (External API Services)
When a service wraps an external API, apply these patterns. For the framework retry contract, see skills/api-utils/SKILL.md.
Retry wraps the full pipeline
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,
);
const text = await response.text();
return this.parseResponse<Item>(text);
},
{
operation: 'fetchItem',
context: ctx,
baseDelayMs: 1000, // calibrate to upstream recovery time
signal: ctx.signal,
},
);
}
Key principles
- Calibrate backoff to the upstream. 200–500ms for ephemeral failures, 1–2s for rate-limited APIs, 2–5s for service degradation. The default
baseDelayMs: 1000suits most APIs. - Check HTTP status before parsing.
fetchWithTimeoutalready throwsServiceUnavailableon non-OK responses — this prevents feeding HTML error pages into XML/JSON parsers. - Classify parse failures by content. If the upstream returns HTTP 200 with an HTML error page, detect it and throw
ServiceUnavailable(transient) instead ofSerializationError(non-transient). - Exhausted retries say so.
withRetryautomatically enriches the final error with attempt count — callers know retries were already attempted.
Response handler pattern
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...
}
Sparse upstream payloads
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:
- Raw upstream types default to optional unless presence is guaranteed. Trust the docs only after you've verified real payloads.
- Preserve absence when it means "unknown". Missing data is different from
false,0,'', or an empty array. - Don't fabricate defaults during normalization unless the upstream contract or your own tool semantics explicitly define them.
- With
exactOptionalPropertyTypes, omit absent fields instead of returningundefined. 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 } : {}),
};
}
API Efficiency
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.
Batch over N+1
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.
Field selection
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.
Pagination awareness
If a batch request might exceed the API's page size limit, either:
- Paginate internally (loop until all pages consumed), or
- Assert/throw when the response indicates truncation (e.g.,
nextPageTokenpresent)
Silent truncation is a data integrity bug — the caller thinks it has all results when it doesn't.
Checklist
- Directory created at
src/services/{{domain}}/ - Service file created with init/accessor pattern
- JSDoc
@fileoverviewand@moduleheader present - Service methods accept
Contextfor logging and storage -
initfunction registered insetup()callback insrc/index.ts - Accessor throws
Errorif not initialized - If wrapping external API: retry covers full pipeline (fetch + parse), backoff calibrated
- If wrapping external API: raw/domain types reflect real upstream sparsity; missing values are preserved as unknown, not fabricated into concrete facts
- If wrapping external API: batch endpoints used where available, field selection applied, pagination handled
-
bun run devcheckpasses
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
add-resource
Scaffold a new MCP resource definition. Use when the user asks to add a resource, expose data via URI, or create a readable endpoint.
field-test
Exercise tools, resources, and prompts with real-world inputs to verify behavior end-to-end. Use after adding or modifying definitions, or when the user asks to test, try out, or verify their MCP surface. Calls each definition with realistic and adversarial inputs and produces a report of issues, pain points, and recommendations.
release
Verify release readiness and publish. The git wrapup protocol handles version bumps, changelog, README, commits, and tagging during the coding session. This skill verifies nothing was missed, runs final checks, and presents the irreversible publish commands.
add-export
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).
api-errors
McpError constructor, JsonRpcErrorCode reference, and error handling patterns for `@cyanheads/mcp-ts-core`. Use when looking up error codes, understanding where errors should be thrown vs. caught, or using ErrorHandler.tryCatch in services.
api-utils
API reference for all utilities exported from `@cyanheads/mcp-ts-core/utils`. Use when looking up utility method signatures, options, peer dependencies, or usage patterns.
Didn't find tool you were looking for?