We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/jmagar/homelab-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
# Extending Tools - Registry Pattern
## Overview
The tool registration system uses a **Registry Pattern** to enable plugin-style tool additions without modifying core registration code. This document explains how to add new MCP tools to synapse-mcp.
## Architecture
```
┌──────────────┐
│ McpServer │
└──────┬───────┘
│ registerTool()
▼
┌──────────────┐
│ ToolRegistry │ manages
└──────┬───────┘
│
▼
┌──────────────────┐
│ ToolDefinition │ ◄─── Implement this interface
└──────────────────┘
△
│ implements
├────────────────────────┐
│ │
┌──────┴────────┐ ┌───────┴──────┐
│ FluxTool │ │ ScoutTool │
└───────────────┘ └──────────────┘
```
## Built-In Tools
### FluxTool
- **Purpose**: Docker infrastructure management
- **Actions**: container, compose, docker, host operations
- **Destructive**: Yes (prune, recreate, remove)
- **File**: `src/tools/definitions/flux.ts`
### ScoutTool
- **Purpose**: SSH remote operations
- **Actions**: file, process, system inspection
- **Destructive**: No
- **File**: `src/tools/definitions/scout.ts`
## Adding a New Tool
### Step 1: Create Tool Definition
Create a new file in `src/tools/definitions/`:
```typescript
// src/tools/definitions/my-tool.ts
import type { ToolDefinition } from "../registry.js"
import { z } from "zod"
import type { ServiceContainer } from "../../services/container.js"
// Define input schema
const MyToolSchema = z.object({
action: z.enum(["action1", "action2"]),
param1: z.string(),
param2: z.number().optional()
})
// Implement handler
async function handleMyTool(params: unknown, container: ServiceContainer): Promise<string> {
// Validate input
const validated = MyToolSchema.parse(params)
// Use services from container
const someService = container.getSomeService()
// Implement tool logic
switch (validated.action) {
case "action1":
return await handleAction1(validated, someService)
case "action2":
return await handleAction2(validated, someService)
default: {
const exhaustiveCheck: never = validated.action
throw new Error(`Unknown action: ${exhaustiveCheck}`)
}
}
}
// Export tool definition
export const MyTool: ToolDefinition = {
name: "my-tool",
title: "My Custom Tool",
description: "Description of what my tool does",
inputSchema: MyToolSchema,
annotations: {
readOnlyHint: true, // Does it only read data?
destructiveHint: false, // Can it delete/destroy data?
idempotentHint: false, // Same input → same result?
openWorldHint: true // Can it access external systems?
},
handler: handleMyTool
}
```
### Step 2: Export from Definitions Index
Add to `src/tools/definitions/index.ts`:
```typescript
export { FluxTool } from "./flux.js"
export { ScoutTool } from "./scout.js"
export { MyTool } from "./my-tool.js" // Add your tool
```
### Step 3: Register Tool
Update `src/tools/index.ts`:
```typescript
import { ToolRegistry } from "./registry.js"
import { FluxTool, ScoutTool, MyTool } from "./definitions/index.js"
export function registerTools(server: McpServer, container?: ServiceContainer): void {
if (!container) {
throw new Error("ServiceContainer is required for tool registration")
}
const registry = new ToolRegistry(container)
registry.register(FluxTool)
registry.register(ScoutTool)
registry.register(MyTool) // Register your tool
registry.registerAll(server)
}
```
### Step 4: Write Tests
Create comprehensive tests:
```typescript
// src/tools/definitions/my-tool.test.ts
import { describe, it, expect, vi } from "vitest"
import { MyTool } from "./my-tool.js"
import { ServiceContainer } from "../../services/container.js"
describe("MyTool", () => {
it("should have correct metadata", () => {
expect(MyTool.name).toBe("my-tool")
expect(MyTool.title).toBe("My Custom Tool")
})
it("should handle action1", async () => {
const container = new ServiceContainer()
const input = { action: "action1", param1: "test" }
const result = await MyTool.handler(input, container)
expect(result).toContain("expected output")
})
it("should validate input schema", async () => {
const container = new ServiceContainer()
const invalidInput = { action: "invalid" }
await expect(MyTool.handler(invalidInput, container)).rejects.toThrow()
})
})
```
## Tool Definition API
### Required Fields
| Field | Type | Description |
| ------------- | ----------- | ----------------------------------------------- |
| `name` | `string` | Unique tool identifier (used in MCP tool calls) |
| `title` | `string` | Human-readable tool title |
| `inputSchema` | `ZodSchema` | Zod schema for input validation |
| `handler` | `Function` | Async function implementing tool logic |
### Optional Fields
| Field | Type | Description |
| ------------- | -------- | -------------------------------------------------------- |
| `description` | `string` | Tool description (extracted from schema if not provided) |
| `annotations` | `object` | MCP SDK annotations for tool behavior hints |
### Annotations
Annotations help MCP clients understand tool behavior:
```typescript
annotations: {
// Does tool only read data? (no mutations)
readOnlyHint: boolean
// Can tool destroy/delete data?
destructiveHint: boolean
// Same input always produces same result?
idempotentHint: boolean
// Can tool access external systems (APIs, files, etc.)?
openWorldHint: boolean
}
```
**Defaults** (if not specified):
```typescript
{
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
```
## Handler Function Pattern
### Signature
```typescript
async function handler(params: unknown, container: ServiceContainer): Promise<string>
```
### Implementation Pattern
1. **Validate input** with Zod schema
2. **Extract services** from ServiceContainer
3. **Implement business logic** with proper error handling
4. **Return formatted output** (markdown or JSON)
### Example Handler
```typescript
async function handleMyTool(params: unknown, container: ServiceContainer): Promise<string> {
// 1. Validate input
const validated = MyToolSchema.parse(params)
// 2. Extract services
const docker = container.getDockerService()
const ssh = container.getSSHService()
const format = validated.response_format ?? "markdown"
// 3. Implement logic
const data = await docker.listContainers([validated.host])
// 4. Return formatted output
if (format === "json") {
return JSON.stringify(data, null, 2)
}
return formatAsMarkdown(data)
}
```
## Input Schema Patterns
### Basic Schema
```typescript
const MyToolSchema = z.object({
action: z.enum(["list", "create", "delete"]),
name: z.string().min(1),
optional_param: z.number().optional()
})
```
### With Discriminated Unions
```typescript
const MyToolSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("list"),
filter: z.string().optional()
}),
z.object({
action: z.literal("create"),
name: z.string(),
config: z.object({ key: z.string() })
})
])
```
### With Common Fields
```typescript
const BaseSchema = z.object({
host: z.string().optional(),
response_format: z.enum(["markdown", "json"]).optional()
})
const MyToolSchema = BaseSchema.extend({
action: z.enum(["list", "create"]),
name: z.string()
})
```
## Error Handling
### Validation Errors
Zod automatically throws validation errors with clear messages:
```typescript
// Will throw ZodError with detailed validation issues
const validated = MyToolSchema.parse(params)
```
### Business Logic Errors
Throw descriptive errors for business logic failures:
```typescript
if (!resource) {
throw new Error(`Resource not found: ${resourceId}`)
}
if (!hasPermission) {
throw new Error(`Permission denied for action: ${action}`)
}
```
### Error Logging
The registry automatically wraps handlers with error logging. Errors are:
1. Logged with operation context and sanitized parameters
2. Re-thrown to MCP client with original error message
## Output Formatting
### Markdown Output
Use existing formatters or create new ones:
```typescript
import { formatContainersMarkdown } from "../../formatters/index.js"
async function handler(params, container) {
const data = await fetchData()
return formatContainersMarkdown(data)
}
```
### JSON Output
Support both markdown and JSON formats:
```typescript
async function handler(params, container) {
const validated = MySchema.parse(params)
const data = await fetchData()
if (validated.response_format === "json") {
return JSON.stringify(data, null, 2)
}
return formatAsMarkdown(data)
}
```
### Using FormatterFactory
Use strategy pattern for flexible formatting:
```typescript
import { FormatterFactory } from "../../formatters/index.js"
async function handler(params, container) {
const validated = MySchema.parse(params)
const data = await fetchData()
const format = validated.response_format ?? "markdown"
const formatter = FormatterFactory.create(format)
return formatter.format(data)
}
```
## Testing Best Practices
### Unit Tests
Test handler logic in isolation:
```typescript
describe("MyTool handler", () => {
it("should handle valid input", async () => {
const container = new ServiceContainer()
const input = { action: "list" }
const result = await MyTool.handler(input, container)
expect(result).toBeDefined()
})
it("should reject invalid input", async () => {
const container = new ServiceContainer()
const input = { action: "invalid" }
await expect(MyTool.handler(input, container)).rejects.toThrow()
})
})
```
### Integration Tests
Test with real services (using mocks):
```typescript
describe("MyTool integration", () => {
it("should interact with Docker service", async () => {
const mockDocker = {
listContainers: vi.fn().mockResolvedValue([])
}
const container = new ServiceContainer()
container.setDockerService(mockDocker)
const input = { action: "list", host: "test-host" }
await MyTool.handler(input, container)
expect(mockDocker.listContainers).toHaveBeenCalledWith(["test-host"])
})
})
```
### Registry Tests
Test registration behavior:
```typescript
describe("MyTool registration", () => {
it("should register without errors", () => {
const registry = new ToolRegistry(new ServiceContainer())
expect(() => registry.register(MyTool)).not.toThrow()
})
it("should be retrievable after registration", () => {
const registry = new ToolRegistry(new ServiceContainer())
registry.register(MyTool)
expect(registry.has("my-tool")).toBe(true)
expect(registry.get("my-tool")).toBe(MyTool)
})
})
```
## Common Patterns
### Multi-Action Tool
```typescript
const MyToolSchema = z.discriminatedUnion("action", [
z.object({ action: z.literal("list"), filter: z.string().optional() }),
z.object({ action: z.literal("get"), id: z.string() }),
z.object({ action: z.literal("create"), name: z.string(), config: z.object({}) })
])
async function handleMyTool(params, container) {
const validated = MyToolSchema.parse(params)
switch (validated.action) {
case "list":
return await handleList(validated, container)
case "get":
return await handleGet(validated, container)
case "create":
return await handleCreate(validated, container)
default: {
const exhaustiveCheck: never = validated.action
throw new Error(`Unknown action: ${exhaustiveCheck}`)
}
}
}
```
### Host-Aware Tool
```typescript
async function handleMyTool(params, container) {
const validated = MyToolSchema.parse(params)
// Load hosts from repository
const hosts = await container.getHostConfigRepository().loadHosts()
// Resolve target hosts
const targetHosts = validated.host ? hosts.filter((h) => h.name === validated.host) : hosts
if (targetHosts.length === 0) {
throw new Error(`Host not found: ${validated.host}`)
}
// Execute on target hosts
const results = await Promise.all(targetHosts.map((host) => executeOnHost(host, validated)))
return formatResults(results)
}
```
### Destructive Operation with Force Flag
```typescript
const DestructiveSchema = z.object({
action: z.literal("delete"),
id: z.string(),
force: z.boolean().optional().default(false)
})
async function handleDestructive(params, container) {
const validated = DestructiveSchema.parse(params)
if (!validated.force) {
throw new Error("Destructive operation requires force=true")
}
await performDeletion(validated.id)
return "Resource deleted successfully"
}
```
## References
- Registry Pattern: https://martinfowler.com/eaaCatalog/registry.html
- Implementation: `src/tools/registry.ts`
- Tests: `src/tools/registry.test.ts`
- Built-in tools: `src/tools/definitions/`
- MCP SDK docs: https://modelcontextprotocol.io/