Guide · TypeScript Advanced Patterns
MCP server generics
A mid-size MCP server managing users, projects, tasks, and files will have nearly identical CRUD patterns for each entity: a get_{entity} tool, a list_{entity} tool with pagination, a create_{entity} tool, an update_{entity} tool, and a delete_{entity} tool. Without generics, each entity gets its own hand-written set of five tools — similar code with different type annotations, different entity-specific logic buried in copy-pasted boilerplate. TypeScript generics let you build a createCrudTools() factory that registers all five tools for any entity given its Zod schema and a service interface. The result is 5× less code, consistent patterns, and type safety that holds across every entity.
TL;DR
Define a Repository<T, TCreate, TUpdate> interface with findById, findMany, create, update, delete methods. Pass the entity's Zod schema and a repository implementation to createCrudTools<T>(). The factory registers 5 typed tools on the MCP server — get_user, list_users, create_user, update_user, delete_user — with all arguments inferred from the schema. Entity-specific behavior is plugged in via callbacks (formatOne, formatMany) rather than forked code.
The CRUD repetition problem
Without generics, adding a new entity to an MCP server means writing this pattern five times:
// Users
server.tool('get_user', '...', { id: z.string().uuid() }, async ({ id }) => {
const user = await userRepo.findById(id);
if (!user) return { isError: true, content: [{ type: 'text', text: 'User not found' }] };
return { content: [{ type: 'text', text: formatUser(user) }] };
});
// Projects — nearly identical
server.tool('get_project', '...', { id: z.string().uuid() }, async ({ id }) => {
const project = await projectRepo.findById(id);
if (!project) return { isError: true, content: [{ type: 'text', text: 'Project not found' }] };
return { content: [{ type: 'text', text: formatProject(project) }] };
});
// Tasks — identical again
server.tool('get_task', '...', { id: z.string().uuid() }, async ({ id }) => {
const task = await taskRepo.findById(id);
if (!task) return { isError: true, content: [{ type: 'text', text: 'Task not found' }] };
return { content: [{ type: 'text', text: formatTask(task) }] };
});
When the pattern needs to change — adding logging, changing the error message format, adding rate limiting — you update five files per entity. With generics, you update one factory function and the change propagates to every entity.
Repository interface
The generic repository interface decouples the CRUD factory from any specific data layer:
interface Repository<T, TCreate, TUpdate> {
findById(id: string): Promise<T | null>;
findMany(opts: { page: number; perPage: number; filter?: Record<string, unknown> }): Promise<{
items: T[];
total: number;
}>;
create(data: TCreate): Promise<T>;
update(id: string, data: TUpdate): Promise<T | null>;
delete(id: string): Promise<boolean>;
}
// Concrete implementation for users:
const userRepo: Repository<User, CreateUser, UpdateUser> = {
async findById(id) { return db.users.findUnique({ where: { id } }); },
async findMany({ page, perPage, filter }) {
const where = filter ?? {};
const [items, total] = await Promise.all([
db.users.findMany({ where, skip: (page - 1) * perPage, take: perPage }),
db.users.count({ where }),
]);
return { items, total };
},
async create(data) { return db.users.create({ data }); },
async update(id, data) { return db.users.update({ where: { id }, data }).catch(() => null); },
async delete(id) {
try { await db.users.delete({ where: { id } }); return true; }
catch { return false; }
},
};
Generic CRUD tool factory
The factory takes the entity name, schemas, repository, and formatting callbacks, then registers five tools:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
interface CrudFactoryOptions<T, TCreate, TUpdate> {
entity: string; // 'user' — used in tool names and messages
entityPlural: string; // 'users'
createSchema: z.ZodType<TCreate>;
updateSchema: z.ZodType<TUpdate>;
repo: Repository<T, TCreate, TUpdate>;
formatOne: (item: T) => string;
formatMany: (item: T) => string;
filterSchema?: z.ZodRawShape; // optional — entity-specific filter fields
}
function createCrudTools<T, TCreate, TUpdate>(
server: McpServer,
opts: CrudFactoryOptions<T, TCreate, TUpdate>
): void {
const { entity, entityPlural, repo, formatOne, formatMany } = opts;
// get_{entity}
server.tool(
`get_${entity}`,
`Get a single ${entity} by ID`,
{ id: z.string().uuid().describe(`The ${entity}'s unique identifier`) },
async ({ id }) => {
const item = await repo.findById(id);
if (!item) return { isError: true, content: [{ type: 'text', text: `${entity} not found: ${id}` }] };
return { content: [{ type: 'text', text: formatOne(item) }] };
}
);
// list_{entityPlural}
server.tool(
`list_${entityPlural}`,
`List ${entityPlural} with pagination`,
{
page: z.number().int().min(1).default(1),
per_page: z.number().int().min(1).max(100).default(20),
...( opts.filterSchema ?? {} ),
},
async ({ page, per_page, ...filter }) => {
const { items, total } = await repo.findMany({ page, perPage: per_page, filter });
if (items.length === 0) return { content: [{ type: 'text', text: `No ${entityPlural} found.` }] };
const lines = items.map(formatMany);
const pageCount = Math.ceil(total / per_page);
return { content: [{ type: 'text', text: lines.join('\n') + `\n\n(${total} total, page ${page}/${pageCount})` }] };
}
);
// create_{entity}
server.tool(
`create_${entity}`,
`Create a new ${entity}`,
opts.createSchema,
async (data) => {
const item = await repo.create(data as TCreate);
return { content: [{ type: 'text', text: `${entity} created:\n${formatOne(item)}` }] };
}
);
// update_{entity}
server.tool(
`update_${entity}`,
`Update an existing ${entity}`,
z.object({ id: z.string().uuid(), ...( opts.updateSchema instanceof z.ZodObject ? opts.updateSchema.shape : {} ) }),
async ({ id, ...data }) => {
const item = await repo.update(id, data as TUpdate);
if (!item) return { isError: true, content: [{ type: 'text', text: `${entity} not found: ${id}` }] };
return { content: [{ type: 'text', text: `${entity} updated:\n${formatOne(item)}` }] };
}
);
// delete_{entity}
server.tool(
`delete_${entity}`,
`Permanently delete a ${entity}`,
{
id: z.string().uuid(),
confirm: z.literal(true).describe('Must be true to confirm deletion'),
},
async ({ id }) => {
const deleted = await repo.delete(id);
if (!deleted) return { isError: true, content: [{ type: 'text', text: `${entity} not found: ${id}` }] };
return { content: [{ type: 'text', text: `${entity} ${id} deleted.` }] };
}
);
}
// Register all CRUD tools for users — 5 tools with one call:
createCrudTools(server, {
entity: 'user',
entityPlural: 'users',
createSchema: CreateUserSchema,
updateSchema: UpdateUserSchema,
repo: userRepo,
formatOne: (u) => `${u.id} | ${u.name} | ${u.email} | joined ${u.createdAt.toISOString().split('T')[0]}`,
formatMany: (u) => `${u.id} ${u.name} <${u.email}>`,
filterSchema: {
org_id: z.string().uuid().optional().describe('Filter users by organization'),
role: z.enum(['admin', 'member', 'viewer']).optional(),
},
});
Generic Result container
A typed Result<T, E> container improves service layer safety and makes tool handler error paths explicit:
type Ok<T> = { readonly _tag: 'ok'; readonly value: T };
type Err<E> = { readonly _tag: 'err'; readonly error: E };
type Result<T, E = string> = Ok<T> | Err<E>;
const ok = <T>(value: T): Ok<T> => ({ _tag: 'ok', value });
const err = <E>(error: E): Err<E> => ({ _tag: 'err', error });
// Generic tool wrapper that maps Result to MCP tool result
function toMcpResult<T>(
result: Result<T>,
format: (value: T) => string
): { content: Array<{ type: 'text'; text: string }>; isError?: boolean } {
if (result._tag === 'err') {
return { isError: true, content: [{ type: 'text', text: result.error }] };
}
return { content: [{ type: 'text', text: format(result.value) }] };
}
// Service function using Result:
async function transferProjectOwnership(
projectId: string,
newOwnerId: string
): Promise<Result<Project>> {
const [project, newOwner] = await Promise.all([
projectRepo.findById(projectId),
userRepo.findById(newOwnerId),
]);
if (!project) return err(`Project not found: ${projectId}`);
if (!newOwner) return err(`User not found: ${newOwnerId}`);
if (project.ownerId === newOwnerId) return err('New owner is already the current owner');
const updated = await projectRepo.update(projectId, { ownerId: newOwnerId });
return updated ? ok(updated) : err('Update failed — project may have been deleted');
}
// Tool handler — clean and type-safe:
server.tool('transfer_project_ownership', '...', {
project_id: z.string().uuid(),
new_owner_id: z.string().uuid(),
}, async ({ project_id, new_owner_id }) => {
const result = await transferProjectOwnership(project_id, new_owner_id);
return toMcpResult(result, (p) => `Project ${p.id} ownership transferred to ${p.ownerId}`);
});
Constrained generics for safe composition
Type constraints (extends) on generic parameters prevent misuse of generic utilities with incompatible types:
// Constraint: T must have an id field to be paginated
type HasId = { id: string };
function buildPaginatedText<T extends HasId>(
items: T[],
total: number,
page: number,
perPage: number,
format: (item: T) => string
): string {
const lines = items.map(format);
const pageCount = Math.ceil(total / perPage);
return lines.join('\n') + `\n\n(${total} total, page ${page}/${pageCount})`;
}
// TypeScript error if you try to paginate a type without an id field:
// buildPaginatedText([{ name: 'Alice' }], ...) // Error: '{ name: string }' missing 'id'
// Constraint: factory only works with types that have a zod schema
function createSearchTool<
T extends HasId,
TSchema extends z.ZodObject<z.ZodRawShape>
>(
server: McpServer,
name: string,
schema: TSchema,
search: (args: z.infer<TSchema>) => Promise<{ items: T[]; total: number }>,
format: (item: T) => string
): void {
const finalSchema = z.object({
...schema.shape,
page: z.number().int().min(1).default(1),
per_page: z.number().int().min(1).max(100).default(20),
});
server.tool(name, `Search ${name.replace('search_', '')}`, finalSchema, async (args) => {
const { page, per_page, ...searchArgs } = args;
const { items, total } = await search(searchArgs as z.infer<TSchema>);
if (items.length === 0) return { content: [{ type: 'text', text: 'No results.' }] };
return { content: [{ type: 'text', text: buildPaginatedText(items, total, page, per_page, format) }] };
});
}
Monitoring generic MCP servers
Generic CRUD factories produce many tools with a consistent internal structure. When a bug in the factory affects all entity tools simultaneously, the failure surface is large — every get_*, list_*, create_*, update_*, and delete_* tool may return isError: true at once. Generic tools also make it harder to pinpoint which tool category is failing without external monitoring.
AliveMCP monitors your MCP endpoint every 60 seconds using the full protocol handshake, probing actual tool calls to detect handler failures across all tools — including factory-generated ones. When a factory-level regression hits all CRUD tools simultaneously, protocol-level monitoring catches it and alerts before your users do.
Further reading
- MCP server branded types — nominal typing for entity IDs
- MCP server discriminated unions — polymorphic tool inputs
- MCP server conditional types — inferred handler types from schemas
- MCP server declaration merging — plugin systems via module augmentation
- MCP server Zod validation — schema-first tool definitions
- MCP server type safety — TypeScript patterns for safe handlers
- AliveMCP — uptime monitoring for HTTP-deployed MCP servers