# Design Document: DB Planet MCP Tools Refactoring
## Overview
This design describes the refactoring of the DB Planet MCP Server from a single monolithic `SearchDBPlanet` tool into ~27 focused, single-responsibility MCP tools organized across 8 capability areas. The current architecture creates a new `HaiiloClient` per tool invocation and bundles search + full content fetching into one tool. The refactored architecture shares a single `HaiiloClient` per session, separates concerns into individual tools, and adds consistent error handling with 401 retry logic.
The server continues to use StreamableHTTP transport on the existing `/servers/dbplanet/mcp` endpoint, preserving backward compatibility for transport and authentication.
## Architecture
### High-Level Structure
```mermaid
graph TD
A[Express Server
server.ts] -->|creates per session| B[McpServer
getToolServer.ts]
A -->|extracts credentials| C[HaiiloClient
haiiloClient.ts
shared per session]
B -->|registers tools from| D[Tool Registry Modules
src/tools/]
D --> D1[searchTools.ts]
D --> D2[workspaceTools.ts]
D --> D3[newsTools.ts]
D --> D4[peopleTools.ts]
D --> D5[eventTools.ts]
D --> D6[socialTools.ts]
D --> D7[notificationTools.ts]
D --> D8[pageTools.ts]
D --> D9[contentTools.ts]
D1 --> C
D2 --> C
D3 --> C
D4 --> C
D5 --> C
D6 --> C
D7 --> C
D8 --> C
D9 --> C
C -->|OAuth2 + HTTP| E[Haiilo REST API]
```
### Key Design Decisions
1. **One HaiiloClient per session, not per tool call.** Currently `registerHaiiloTools` creates a new `HaiiloClient` on every invocation. Instead, `getToolServer.ts` will create the client once and pass it to all registration modules. The client already caches the access token internally.
2. **Tool registration modules by capability area.** Each file in `src/tools/` registers tools for one capability area. This keeps files small and allows independent evolution. A barrel `registerAllTools` function in a new `src/tools/index.ts` calls each module's registration function.
3. **401 retry in HaiiloClient.** The client will intercept 401 responses, clear the cached token, re-authenticate, and retry the request once. This is implemented as an Axios response interceptor.
4. **Consistent error mapping.** A shared `handleApiError` utility maps Axios errors to MCP `CallToolResult` with `isError: true`, including the HTTP status code and a human-readable message. All tools use this utility.
5. **Zod schemas with descriptions.** Each tool defines its input schema using Zod with `.describe()` annotations so AI agents understand parameter semantics.
### File Structure After Refactoring
```
src/
├── server.ts # Express server (unchanged)
├── getToolServer.ts # Creates McpServer, HaiiloClient, registers all tools
├── haiiloClient.ts # HaiiloClient with 401 retry (enhanced) — API client layer
├── errorHandler.ts # Shared error mapping utility
├── tools/
│ ├── index.ts # registerAllTools barrel function
│ ├── searchTools.ts # Search_FullText, Search_QuickEntity, Search_Grouped
│ ├── workspaceTools.ts # Workspace_List, Workspace_Get, Workspace_GetMembers, Workspace_Join, Workspace_Create
│ ├── newsTools.ts # News_GetFeed
│ ├── peopleTools.ts # People_GetMe, People_List, People_Search
│ ├── eventTools.ts # Event_Get, Event_GetMemberships, Event_UpdateStatus, Event_Create
│ ├── socialTools.ts # Social_GetComments, Social_PostComment, Social_GetLikes, Social_ToggleLike
│ ├── notificationTools.ts # Notification_List, Notification_GetStatus, Notification_MarkSeen
│ ├── pageTools.ts # Page_List, Page_Get, Page_GetMembers, Page_Create
│ └── contentTools.ts # Content_GetFull
```
## Components and Interfaces
### 1. HaiiloClient (Enhanced)
The existing `HaiiloClient` class is enhanced with:
- A generic `request` method for arbitrary API calls (GET, POST, PUT, DELETE)
- Axios response interceptor for automatic 401 retry with token refresh
- The existing `search`, `getContentByType`, `fetchWidgetContent`, `extractTextFromWidgets`, and `cleanHtml` methods remain for use by `Content_GetFull`
```typescript
interface HaiiloCredentials {
username: string;
password: string;
clientId: string;
clientSecret: string;
baseUrl: string;
}
class HaiiloClient {
// Existing fields
private client: AxiosInstance;
private credentials: HaiiloCredentials;
private accessToken?: string;
constructor(credentials: HaiiloCredentials);
// New generic request method used by all tools
async request(config: {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
params?: Record;
data?: any;
}): Promise;
// Existing methods (kept for Content_GetFull)
private async getAccessToken(): Promise;
async search(term: string): Promise;
private async getContentByType(token: string, item: any): Promise;
private async fetchWidgetContent(token: string, url: string): Promise;
private extractTextFromWidgets(widgetData: any): string;
private cleanHtml(text: string): string;
}
```
The `request` method:
1. Calls `getAccessToken()` to get/reuse the cached token
2. Makes the HTTP request with `Authorization: Bearer {token}`
3. On 401 response: clears `accessToken`, calls `getAccessToken()` again, retries once
4. Returns `response.data`
### 2. Error Handler
A shared utility that converts Axios errors into MCP error responses:
```typescript
// src/tools/errorHandler.ts
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
function handleApiError(error: unknown): CallToolResult;
```
Mapping:
| HTTP Status | Message |
|---|---|
| 401 | Authentication failed. Check DB Planet credentials. |
| 403 | Insufficient permissions to access this resource. |
| 404 | The requested resource was not found. |
| ECONNABORTED / timeout | Request timed out. |
| Other | `Error: {status} - {statusText}` |
All error responses follow the shape:
```typescript
{
isError: true,
content: [{
type: 'text',
text: `[{status}] {message}`
}]
}
```
### 3. Tool Registration Modules
Each module exports a single function:
```typescript
type ToolRegistrationFn = (server: McpServer, client: HaiiloClient) => void;
```
Example — `searchTools.ts`:
```typescript
export function registerSearchTools(server: McpServer, client: HaiiloClient): void {
server.tool(
'Search_FullText',
'Full-text search across DB Planet. Returns result metadata (id, title, type, excerpt, modified date) without full content. Use Content_GetFull to fetch full content for a specific result.',
{
query: z.string().describe('Search query term'),
page: z.number().optional().describe('Page number (0-based)'),
pageSize: z.number().optional().describe('Results per page'),
},
async ({ query, page, pageSize }): Promise => {
try {
const data = await client.request({
method: 'GET',
path: '/api/search',
params: { term: query, _page: page, _pageSize: pageSize },
});
// Format and return metadata only
} catch (error) {
return handleApiError(error);
}
}
);
// ... Search_QuickEntity, Search_Grouped
}
```
### 4. Barrel Registration (`src/tools/index.ts`)
```typescript
export function registerAllTools(server: McpServer, client: HaiiloClient): void {
registerSearchTools(server, client);
registerWorkspaceTools(server, client);
registerNewsTools(server, client);
registerPeopleTools(server, client);
registerEventTools(server, client);
registerSocialTools(server, client);
registerNotificationTools(server, client);
registerPageTools(server, client);
registerContentTools(server, client);
}
```
### 5. Updated `getToolServer.ts`
```typescript
const getToolAPIServer = async (credentials: HaiiloCredentials) => {
const server = new McpServer({ name: 'DBPlanet-MCP-Server', version: '0.1.0' }, { capabilities: { logging: {} } });
validateCredentials(credentials);
const client = new HaiiloClient(credentials);
registerAllTools(server, client);
return server;
};
```
Credential validation moves to server creation time. If credentials are missing, the server still starts but tools return descriptive errors on invocation (lazy validation is also acceptable since some tools may not need all credentials).
### Complete Tool Inventory
| # | Tool Name | HTTP Method | API Path | Module |
|---|---|---|---|---|
| 1 | Search_FullText | GET | /api/search | searchTools |
| 2 | Search_QuickEntity | GET | /api/quick-entity-search | searchTools |
| 3 | Search_Grouped | GET | /api/search/grouped | searchTools |
| 4 | Workspace_List | GET | /api/workspaces | workspaceTools |
| 5 | Workspace_Get | GET | /api/workspaces/{id} | workspaceTools |
| 6 | Workspace_GetMembers | GET | /api/workspaces/{id}/members | workspaceTools |
| 7 | Workspace_Join | PUT | /api/workspaces/{id}/members/join | workspaceTools |
| 8 | Workspace_Create | POST | /api/workspaces | workspaceTools |
| 9 | News_GetFeed | GET | /web/blog/newsfeed | newsTools |
| 10 | People_GetMe | GET | /api/users/me | peopleTools |
| 11 | People_List | GET | /api/users | peopleTools |
| 12 | People_Search | GET | /api/users/chooser/search | peopleTools |
| 13 | Event_Get | GET | /api/events/{id} | eventTools |
| 14 | Event_GetMemberships | GET | /api/events/{id}/memberships | eventTools |
| 15 | Event_UpdateStatus | PUT | /api/events/{id}/memberships/status | eventTools |
| 16 | Event_Create | POST | /api/events | eventTools |
| 17 | Social_GetComments | GET | /api/comments | socialTools |
| 18 | Social_PostComment | POST | /api/comments | socialTools |
| 19 | Social_GetLikes | GET | /api/like-targets/{targetType}/{targetId} | socialTools |
| 20 | Social_ToggleLike | POST | /api/like-targets/{targetType}/{targetId}/likes/{userId} | socialTools |
| 21 | Notification_List | GET | /api/notifications | notificationTools |
| 22 | Notification_GetStatus | GET | /api/notifications/status | notificationTools |
| 23 | Notification_MarkSeen | PUT | /api/notifications/mark-seen | notificationTools |
| 24 | Page_List | GET | /api/pages | pageTools |
| 25 | Page_Get | GET | /api/pages/{id} | pageTools |
| 26 | Page_GetMembers | GET | /api/pages/{id}/members | pageTools |
| 27 | Page_Create | POST | /api/pages | pageTools |
| 28 | Content_GetFull | GET | (varies by type) | contentTools |
## Data Models
### Input Schemas (Zod)
Each tool defines its input schema using Zod. Below are the schemas grouped by capability area.
#### Search Tools
```typescript
// Search_FullText
{
query: z.string().describe('Search query term'),
page: z.number().optional().describe('Page number (0-based)'),
pageSize: z.number().optional().describe('Results per page'),
}
// Search_QuickEntity
{
query: z.string().describe('Quick search query'),
}
// Search_Grouped
{
query: z.string().describe('Search query term'),
}
```
#### Workspace Tools
```typescript
// Workspace_List
{} // no parameters
// Workspace_Get
{ id: z.string().describe('Workspace ID') }
// Workspace_GetMembers
{ id: z.string().describe('Workspace ID') }
// Workspace_Join
{ id: z.string().describe('Workspace ID to join') }
// Workspace_Create
{
name: z.string().describe('Workspace name'),
description: z.string().optional().describe('Workspace description'),
}
```
#### News Tools
```typescript
// News_GetFeed
{
page: z.number().optional().describe('Page number (0-based)'),
pageSize: z.number().optional().describe('Items per page'),
}
```
#### People Tools
```typescript
// People_GetMe
{} // no parameters
// People_List
{
page: z.number().optional().describe('Page number (0-based)'),
pageSize: z.number().optional().describe('Users per page'),
}
// People_Search
{
query: z.string().describe('Search query for people'),
}
```
#### Event Tools
```typescript
// Event_Get
{ id: z.string().describe('Event ID') }
// Event_GetMemberships
{ id: z.string().describe('Event ID') }
// Event_UpdateStatus
{
id: z.string().describe('Event ID'),
status: z.string().describe('Attendance status (e.g., accepted, declined, tentative)'),
}
// Event_Create
{
title: z.string().describe('Event title'),
description: z.string().optional().describe('Event description'),
startDate: z.string().describe('Start date/time in ISO 8601 format'),
endDate: z.string().describe('End date/time in ISO 8601 format'),
location: z.string().optional().describe('Event location'),
}
```
#### Social Tools
```typescript
// Social_GetComments
{ targetId: z.string().describe('ID of the entity to get comments for') }
// Social_PostComment
{
targetId: z.string().describe('ID of the entity to comment on'),
body: z.string().describe('Comment text'),
}
// Social_GetLikes
{
targetType: z.string().describe('Type of the like target (e.g., blog-article, timeline-item)'),
targetId: z.string().describe('ID of the like target'),
}
// Social_ToggleLike
{
targetType: z.string().describe('Type of the like target'),
targetId: z.string().describe('ID of the like target'),
userId: z.string().describe('User ID to toggle like for'),
}
```
#### Notification Tools
```typescript
// Notification_List
{
page: z.number().optional().describe('Page number (0-based)'),
pageSize: z.number().optional().describe('Notifications per page'),
}
// Notification_GetStatus
{} // no parameters
// Notification_MarkSeen
{} // no parameters
```
#### Page Tools
```typescript
// Page_List
{} // no parameters
// Page_Get
{ id: z.string().describe('Page ID or slug') }
// Page_GetMembers
{ id: z.string().describe('Page ID') }
// Page_Create
{
title: z.string().describe('Page title'),
content: z.string().optional().describe('Page content'),
}
```
#### Content Tools
```typescript
// Content_GetFull
{
contentId: z.string().describe('ID of the content item'),
contentType: z.string().describe('Content type: blog-article, wiki-article, page, workspace, app, timeline-item'),
appId: z.string().optional().describe('App ID (required for blog and wiki articles)'),
slug: z.string().optional().describe('Slug (alternative identifier for pages and workspaces)'),
}
```
### Response Shapes
All tools return `CallToolResult` from the MCP SDK:
```typescript
// Success
{
content: [{ type: 'text', text: string }]
}
// Error
{
isError: true,
content: [{ type: 'text', text: string }]
}
```
Tool responses format data as human-readable text (not JSON) for LLM consumption, consistent with the current `SearchDBPlanet` tool's approach. Each tool formats its response with clear labels and structure.
### HaiiloClient Request/Response
```typescript
// Generic request config
interface RequestConfig {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string; // e.g., '/api/search'
params?: Record; // query parameters
data?: any; // request body (POST/PUT)
}
// The client prepends credentials.baseUrl to path and adds Authorization header
```
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Tool-to-endpoint routing correctness
*For any* registered tool and any valid input for that tool, the tool SHALL dispatch an HTTP request using the correct method (GET/POST/PUT) to the correct API path with the input parameters correctly mapped to query params or request body.
**Validates: Requirements 3.1, 3.3, 3.4, 4.1, 4.2, 4.3, 4.4, 4.5, 5.1, 6.1, 6.2, 6.3, 7.1, 7.2, 7.3, 7.4, 8.1, 8.2, 8.3, 8.4, 9.1, 9.3, 9.4, 10.1, 10.2, 10.3, 10.4, 11.1**
### Property 2: Pagination parameter forwarding
*For any* tool that accepts optional pagination parameters (page, pageSize), when those parameters are provided, they SHALL be forwarded as query parameters to the underlying API request. When omitted, the API request SHALL not include them.
**Validates: Requirements 3.2, 5.2, 6.4, 9.2**
### Property 3: Error handler maps HTTP status to correct MCP error
*For any* HTTP error response with a status code (401, 403, 404, timeout, or other), the `handleApiError` function SHALL produce a `CallToolResult` with `isError: true` containing both the HTTP status code and a human-readable message appropriate for that status.
**Validates: Requirements 12.1, 12.2, 12.3, 12.4, 12.5**
### Property 4: Tool metadata completeness
*For any* registered tool, it SHALL have a name matching the pattern `{Area}_{Action}`, a non-empty description string, and a Zod input schema.
**Validates: Requirements 2.3, 2.4, 2.5**
### Property 5: Token caching — single authentication per session
*For any* sequence of N requests made through the same `HaiiloClient` instance (where N > 1 and no 401 is received), the client SHALL make exactly one OAuth2 token request and reuse the cached token for all subsequent requests.
**Validates: Requirements 1.1, 1.2**
### Property 6: Missing credentials produce error
*For any* subset of the required credentials (username, password, clientId, clientSecret, baseUrl) where at least one field is missing or empty, invoking any tool SHALL return an MCP error response with a descriptive message about the missing credentials.
**Validates: Requirements 1.4**
### Property 7: Search_FullText returns metadata only
*For any* search query, the `Search_FullText` tool's response SHALL contain result metadata (id, title, type, excerpt, modified date) and SHALL NOT contain full rendered content for any result.
**Validates: Requirements 3.5**
### Property 8: HTML widget text extraction removes all tags
*For any* HTML string input, the `cleanHtml` function SHALL return a string containing no HTML tags (no `<...>` sequences), and all HTML entities SHALL be decoded to their character equivalents.
**Validates: Requirements 11.3**
### Property 9: Unsupported content type returns error
*For any* content type string that is not in the supported set (blog-article, wiki-article, page, workspace, app, timeline-item), the `Content_GetFull` tool SHALL return an MCP error response indicating the unsupported content type.
**Validates: Requirements 11.4**
### Property 10: 401 retry — re-authenticate and retry once
*For any* API request that receives a 401 response, the `HaiiloClient` SHALL clear the cached token, re-authenticate, and retry the request exactly once. If the retry also fails with 401, the error SHALL be propagated.
**Validates: Requirements 1.3**
## Error Handling
### Error Flow
```mermaid
graph TD
A[Tool Handler] -->|try| B[client.request]
B -->|success| C[Format & Return CallToolResult]
B -->|401| D[Clear token, re-auth, retry once]
D -->|success| C
D -->|fail| E[handleApiError]
B -->|other error| E
E -->|401| F["[401] Authentication failed. Check DB Planet credentials."]
E -->|403| G["[403] Insufficient permissions to access this resource."]
E -->|404| H["[404] The requested resource was not found."]
E -->|timeout| I["[TIMEOUT] Request timed out."]
E -->|other| J["[{status}] {statusText or message}"]
```
### Error Response Contract
Every tool handler wraps its logic in try/catch and delegates to `handleApiError`:
```typescript
async (params): Promise => {
try {
const data = await client.request({ method, path, params });
return { content: [{ type: 'text', text: formatResponse(data) }] };
} catch (error) {
return handleApiError(error);
}
}
```
The `handleApiError` function:
1. Checks if the error is an Axios error with a response status
2. Maps known status codes to human-readable messages
3. Handles network errors (ECONNABORTED, ECONNREFUSED, etc.)
4. Always returns `{ isError: true, content: [{ type: 'text', text }] }`
### Credential Validation
Credentials are validated lazily — when the first tool is invoked and `client.request()` calls `getAccessToken()`. If credentials are missing, the OAuth request fails and the error propagates through `handleApiError`. This avoids blocking server startup when credentials might be provided later via headers.
### 401 Retry Logic
Implemented as an Axios response interceptor on the `HaiiloClient`:
```typescript
this.client.interceptors.response.use(
response => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retried) {
error.config._retried = true;
this.accessToken = undefined;
const token = await this.getAccessToken();
error.config.headers['Authorization'] = `Bearer ${token}`;
return this.client.request(error.config);
}
return Promise.reject(error);
}
);
```
The `_retried` flag prevents infinite retry loops.
## Testing Strategy
### Property-Based Testing Library
Use **fast-check** (`fc`) for TypeScript property-based testing. It integrates well with the existing TypeScript/ESM setup and supports custom arbitraries for generating tool inputs.
```bash
npm install --save-dev fast-check vitest @types/node
```
### Test Structure
```
tests/
├── properties/
│ ├── toolRouting.property.test.ts # Property 1: Tool-to-endpoint routing
│ ├── pagination.property.test.ts # Property 2: Pagination forwarding
│ ├── errorHandler.property.test.ts # Property 3: Error mapping
│ ├── toolMetadata.property.test.ts # Property 4: Tool metadata
│ ├── tokenCaching.property.test.ts # Property 5: Token caching
│ ├── credentials.property.test.ts # Property 6: Missing credentials
│ ├── searchMetadata.property.test.ts # Property 7: Search metadata only
│ ├── htmlExtraction.property.test.ts # Property 8: HTML extraction
│ ├── contentType.property.test.ts # Property 9: Unsupported content type
│ └── retryLogic.property.test.ts # Property 10: 401 retry
├── unit/
│ ├── errorHandler.test.ts # Specific error code examples
│ ├── haiiloClient.test.ts # Client construction, auth flow
│ ├── contentTools.test.ts # Content type routing examples
│ └── registration.test.ts # All tools registered, backward compat
```
### Property-Based Testing Approach
Each property test:
- Runs a minimum of 100 iterations
- Uses fast-check arbitraries to generate random inputs
- References the design property in a comment tag
- Mocks the Axios client to verify request dispatch without hitting real APIs
Example property test structure:
```typescript
import fc from 'fast-check';
import { describe, it, expect } from 'vitest';
describe('Tool-to-endpoint routing', () => {
// Feature: dbplanet-mcp-tools, Property 1: Tool-to-endpoint routing correctness
it('dispatches to correct HTTP method and path for any valid input', () => {
fc.assert(
fc.property(
toolInputArbitrary, // generates { toolName, input } pairs
({ toolName, input }) => {
// Mock client.request, invoke tool, verify method + path
const expectedRoute = TOOL_ROUTE_MAP[toolName];
// ... assertion
}
),
{ numRuns: 100 }
);
});
});
```
### Unit Testing Approach
Unit tests cover:
- Specific examples for each content type in `Content_GetFull` (Req 11.2)
- Backward compatibility: server listens on port 3000, /health returns 200, headers accepted (Req 13.1-13.4)
- All 28 tools registered after initialization (Req 2.2)
- 401 retry with mock returning 401 then 200 (Req 1.3)
- Content fetch failure returns descriptive error (Req 11.5)
### Mocking Strategy
- Mock `axios.create()` to return a mock Axios instance
- Mock the OAuth token endpoint to return a test token
- Mock API endpoints to return fixture data
- Use Axios interceptor spies to verify retry behavior
### Test Configuration
Each property-based test must include:
- Comment tag: `// Feature: dbplanet-mcp-tools, Property {N}: {title}`
- Minimum 100 iterations: `{ numRuns: 100 }`
- Clear assertion messages for debugging failures