Migrate all repos into monorepo context folders

Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.env
.env.*
*.log
.DS_Store
.kiro/settings/mcp.json
debug-output.md
+177
View File
@@ -0,0 +1,177 @@
# ============================================================
# GitLab CI build & release OCI image via pipeship + deploy
# ============================================================
# Uses the pipeship "release_oci_image" product which bundles:
# stages, build/scan/promote_oci_image, semantic_release, etc.
# See: https://bridge.pipeship.comp.db.de/modules/?searchKeyword=release_oci_image&filterTypeSubmit=product
# ============================================================
include:
- remote: https://bahnhub.tech.rz.db.de/artifactory/pipeship-generic-release-local/canary/products/release_oci_image/4.9.96-20250909050014-9d6e6c21.yaml
variables:
PIPESHIP_PRODUCT: "release_oci_image"
PIPESHIP_VERSION: "$CI_DEFAULT_BRANCH"
# Version schema
PGV_SCHEMA: "stateful-semver"
# Enable semantic-release
SEMANTIC_RELEASE_ENABLED: "true"
SR_PLUGINS: "@semantic-release/commit-analyzer,@semantic-release/release-notes-generator,@semantic-release/gitlab"
# Disable unused jobs
RST_JOB_DISABLED: "true"
LINT_CODE_JOB_DISABLED: "true"
SDC_JOB_DISABLED: "true"
SCAN_SONARQUBE_JOB_DISABLED: "true"
SSQ_JOB_DISABLED: "true"
SSG_JOB_DISABLED: "true"
CAON_JOB_DISABLED: "true"
# Version file for promote_oci_image
POI_SOURCE_IMAGE_VERSION_FILE: "VERSION"
POI_DESTINATION_IMAGE_VERSION_FILE: "VERSION"
POI_TAG_LATEST: "true"
# Use dotenv artifact from build_oci_image
SOIST_USE_URI_ARTIFACT: "true"
SOIT_USE_URI_ARTIFACT: "true"
POI_USE_URI_ARTIFACT: "true"
CAON_USE_URI_ARTIFACT: "true"
CAON_USE_VERSION_FILE: "false"
# Use Aqua scanner
AQUA_ENABLED: "true"
# Node base image for test jobs
NODE_IMAGE: "db-container-lib-docker-release-local.bahnhub.tech.rz.db.de/node:22-alpine"
# Azure subscriptions
SUBSCRIPTION_DEV: "245822e7-9f8b-4c8a-993b-e9ae726c9c42"
SUBSCRIPTION_IAT: "245822e7-9f8b-4c8a-993b-e9ae726c9c42"
# Proxy settings
HTTP_PROXY: "http://webproxy.comp.db.de:8080"
HTTPS_PROXY: "http://webproxy.comp.db.de:8080"
http_proxy: "http://webproxy.comp.db.de:8080"
https_proxy: "http://webproxy.comp.db.de:8080"
no_proxy: "172.30.0.1,127.0.0.1,localhost,cluster.local,db.de,169.254.169.254"
# ── Code quality gate (lint + unit tests) ─────────────────────
code_quality:
stage: code_analysis
image: $NODE_IMAGE
tags:
- dev
rules:
- if: $CI_MERGE_REQUEST_ID
- if: $CI_COMMIT_REF_PROTECTED == "true"
script:
- npm ci --ignore-scripts
- npm run test
# ── Bicep validation (MR only) ───────────────────────────────
validate_bicep:
stage: code_analysis
image: mcr-docker-remote.bahnhub.tech.rz.db.de/azure-cli:2.76.0
tags:
- dev
script:
- az login --federated-token "$(cat $AZURE_FEDERATED_TOKEN_FILE)" --service-principal -u $AZURE_CLIENT_ID -t $AZURE_TENANT_ID
- az account set --subscription $SUBSCRIPTION_DEV
- az deployment sub what-if --location westeurope --parameters infra/parameters/dev.bicepparam --parameters deployContainerApp=true --no-pretty-print
rules:
- if: $CI_MERGE_REQUEST_ID
# ── Job overrides (stages & runner tags) ──────────────────────
generate_version:
stage: init
tags:
- dev
submit_telemetry:
stage: init
tags:
- dev
scan_config_trivy:
stage: code_analysis
tags:
- dev
build_oci_image:
stage: build_image
tags:
- dev
needs:
- job: generate_version
optional: true
- job: code_quality
promote_oci_image:
stage: promote_image
tags:
- dev
rules:
- if: $CI_COMMIT_BRANCH == "main"
variables:
POI_USE_URI_ARTIFACT: "true"
POI_SOURCE_IMAGE_NAME: "generativeai-docker-stage-dev-local.bahnhub.tech.rz.db.de/db-planet-mcp-server"
POI_DESTINATION_IMAGE_NAME: "generativeai-docker-stage-iat-local.bahnhub.tech.rz.db.de/db-planet-mcp-server"
POI_DESTINATION_REGISTRY_CREDENTIALS_USER: "${GLOBAL_ARTIFACTORY_USER_STAGE}"
POI_DESTINATION_REGISTRY_CREDENTIALS_PASSWORD: "${GLOBAL_ARTIFACTORY_TOKEN_STAGE}"
- if: $CI_COMMIT_TAG
variables:
POI_SOURCE_IMAGE_NAME: "generativeai-docker-stage-dev-local.bahnhub.tech.rz.db.de/db-planet-mcp-server"
POI_DESTINATION_IMAGE_NAME: "generativeai-docker-prod-local.bahnhub.tech.rz.db.de/db-planet-mcp-server"
POI_SOURCE_IMAGE_VERSION: "${CI_PIPELINE_ID}"
POI_DESTINATION_IMAGE_VERSION: "${CI_COMMIT_TAG}"
POI_DESTINATION_REGISTRY_CREDENTIALS_USER: "${GLOBAL_ARTIFACTORY_USER_PROD}"
POI_DESTINATION_REGISTRY_CREDENTIALS_PASSWORD: "${GLOBAL_ARTIFACTORY_TOKEN_PROD}"
cleanup_artifactory_oci_nonprod:
stage: cleanup
tags:
- dev
# ── Deploy to Azure ───────────────────────────────────────────
.deploy_template:
image: mcr-docker-remote.bahnhub.tech.rz.db.de/azure-cli:2.76.0
stage: post_promote_image
tags:
- dev
script:
- az login --federated-token "$(cat $AZURE_FEDERATED_TOKEN_FILE)" --service-principal -u $AZURE_CLIENT_ID -t $AZURE_TENANT_ID
- az account set --subscription $DEPLOY_SUBSCRIPTION
- chmod +x ./infra/deploy.sh
- IMAGE_TAG=$(cat VERSION 2>/dev/null || echo "$CI_COMMIT_SHORT_SHA")
- ./infra/deploy.sh --environment $DEPLOY_ENVIRONMENT --image-tag "$IMAGE_TAG"
deploy_dev:
extends: .deploy_template
variables:
DEPLOY_ENVIRONMENT: "DEV"
DEPLOY_SUBSCRIPTION: $SUBSCRIPTION_DEV
rules:
- if: $CI_COMMIT_BRANCH == "develop"
needs:
- job: build_oci_image
artifacts: true
- job: generate_version
artifacts: true
optional: true
deploy_iat:
extends: .deploy_template
variables:
DEPLOY_ENVIRONMENT: "IAT"
DEPLOY_SUBSCRIPTION: $SUBSCRIPTION_IAT
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs:
- job: promote_oci_image
artifacts: true
- job: generate_version
artifacts: true
optional: true
@@ -0,0 +1 @@
{"specId": "3c666145-33fa-4fb9-ad25-9cb7a9d09ada", "workflowType": "requirements-first", "specType": "feature"}
@@ -0,0 +1,686 @@
# 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<br/>server.ts] -->|creates per session| B[McpServer<br/>getToolServer.ts]
A -->|extracts credentials| C[HaiiloClient<br/>haiiloClient.ts<br/>shared per session]
B -->|registers tools from| D[Tool Registry Modules<br/>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<T = any>(config: {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
params?: Record<string, any>;
data?: any;
}): Promise<T>;
// Existing methods (kept for Content_GetFull)
private async getAccessToken(): Promise<string>;
async search(term: string): Promise<any>;
private async getContentByType(token: string, item: any): Promise<string>;
private async fetchWidgetContent(token: string, url: string): Promise<string | null>;
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<CallToolResult> => {
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<string, any>; // 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<CallToolResult> => {
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
@@ -0,0 +1,165 @@
# Requirements Document
## Introduction
This document specifies the requirements for refactoring the DB Planet MCP Server from a single monolithic tool (SearchDBPlanet) into a suite of focused, single-responsibility MCP tools. The current server exposes one tool that combines search and full content fetching. The refactored server will expose ~27 tools organized across 8 capability areas, enabling AI agents to select the right tool for each task with clear inputs and outputs.
## Glossary
- **MCP_Server**: The Model Context Protocol server that registers and exposes tools to AI agents via the StreamableHTTP transport
- **Tool**: A single MCP tool registered on the MCP_Server with a unique name, description, input schema, and handler function
- **HaiiloClient**: The API client class responsible for authenticating with the Haiilo platform via OAuth2 and making HTTP requests
- **Tool_Registry**: The module that registers all tools on the MCP_Server instance, organized by capability area
- **Capability_Area**: A logical grouping of related tools (e.g., Search, Workspaces, People)
- **Haiilo_API**: The REST API exposed by the Haiilo (DB Planet) platform
## Requirements
### Requirement 1: Shared API Client Infrastructure
**User Story:** As a developer, I want all tools to share a single authenticated HaiiloClient instance per session, so that OAuth2 tokens are reused and connection setup is not duplicated.
#### Acceptance Criteria
1. THE MCP_Server SHALL create one HaiiloClient instance per session and share it across all registered tools
2. THE HaiiloClient SHALL cache the OAuth2 access token after the first successful authentication and reuse it for subsequent API calls within the same session
3. IF the HaiiloClient receives a 401 response from the Haiilo_API, THEN THE HaiiloClient SHALL re-authenticate and retry the request once
4. IF required credentials are missing, THEN THE Tool SHALL return an MCP error response with a descriptive message
### Requirement 2: Tool Registration Architecture
**User Story:** As a developer, I want tools organized into separate registration modules by capability area, so that the codebase is maintainable and each area can evolve independently.
#### Acceptance Criteria
1. THE Tool_Registry SHALL organize tool registration functions into separate modules, one per Capability_Area
2. THE MCP_Server SHALL register all tools from all Capability_Area modules during server initialization
3. THE Tool_Registry SHALL assign each Tool a unique, descriptive name following the pattern `{Area}_{Action}` (e.g., `Search_FullText`, `Workspace_List`)
4. THE Tool_Registry SHALL provide each Tool with a description that explains the tool's purpose, expected inputs, and output format
5. THE Tool_Registry SHALL define each Tool's input schema using Zod validation with descriptive parameter annotations
### Requirement 3: Information Discovery and Search Tools
**User Story:** As an AI agent, I want separate search tools for full-text search, quick entity lookup, and grouped search, so that I can choose the right search strategy for each query.
#### Acceptance Criteria
1. WHEN a full-text search query is provided, THE Search_FullText Tool SHALL call GET /api/search with the query term and return the list of search results with metadata (id, title, type, excerpt, modified date)
2. WHEN a full-text search query is provided, THE Search_FullText Tool SHALL accept optional pagination parameters (page number, page size)
3. WHEN a quick entity search query is provided, THE Search_QuickEntity Tool SHALL call GET /api/quick-entity-search with the query and return matching entities
4. WHEN a grouped search query is provided, THE Search_Grouped Tool SHALL call GET /api/search/grouped with the query and return results organized by content type
5. THE Search_FullText Tool SHALL return result metadata only, without fetching full content for each result
### Requirement 4: Workspace Management Tools
**User Story:** As an AI agent, I want tools to list, inspect, join, and create workspaces, so that I can help users manage their workspace memberships and discover workspace content.
#### Acceptance Criteria
1. WHEN workspace listing is requested, THE Workspace_List Tool SHALL call GET /api/workspaces and return the list of available workspaces
2. WHEN a workspace ID is provided, THE Workspace_Get Tool SHALL call GET /api/workspaces/{id} and return the workspace details including name, description, and settings
3. WHEN a workspace ID is provided, THE Workspace_GetMembers Tool SHALL call GET /api/workspaces/{id}/members and return the list of workspace members
4. WHEN a workspace ID is provided, THE Workspace_Join Tool SHALL call PUT /api/workspaces/{id}/members/join to add the current user as a member of the workspace
5. WHEN workspace creation parameters are provided, THE Workspace_Create Tool SHALL call POST /api/workspaces with the provided name and description to create a new workspace
### Requirement 5: News and Content Tools
**User Story:** As an AI agent, I want a tool to retrieve the news feed, so that I can help users stay informed about recent company news and blog posts.
#### Acceptance Criteria
1. WHEN the news feed is requested, THE News_GetFeed Tool SHALL call GET /web/blog/newsfeed and return the list of recent news items
2. THE News_GetFeed Tool SHALL accept optional pagination parameters (page number, page size)
3. THE News_GetFeed Tool SHALL return each news item with its title, author, publication date, and excerpt
### Requirement 6: People Directory Tools
**User Story:** As an AI agent, I want tools to look up the current user's profile, list users, and search for people, so that I can help users find colleagues and retrieve profile information.
#### Acceptance Criteria
1. WHEN the current user's profile is requested, THE People_GetMe Tool SHALL call GET /api/users/me and return the authenticated user's profile information
2. WHEN a user listing is requested, THE People_List Tool SHALL call GET /api/users and return a paginated list of users
3. WHEN a people search query is provided, THE People_Search Tool SHALL call GET /api/users/chooser/search with the query and return matching user profiles
4. THE People_List Tool SHALL accept optional pagination parameters (page number, page size)
### Requirement 7: Event Discovery Tools
**User Story:** As an AI agent, I want tools to view event details, check memberships, update attendance status, and create events, so that I can help users manage their event participation.
#### Acceptance Criteria
1. WHEN an event ID is provided, THE Event_Get Tool SHALL call GET /api/events/{id} and return the event details including title, description, date, and location
2. WHEN an event ID is provided, THE Event_GetMemberships Tool SHALL call GET /api/events/{id}/memberships and return the list of event attendees and their statuses
3. WHEN an event ID and attendance status are provided, THE Event_UpdateStatus Tool SHALL call PUT /api/events/{id}/memberships/status to update the current user's attendance status
4. WHEN event creation parameters are provided, THE Event_Create Tool SHALL call POST /api/events with the provided details to create a new event
### Requirement 8: Social Engagement Tools
**User Story:** As an AI agent, I want tools to read and post comments and manage likes, so that I can help users engage with content on the platform.
#### Acceptance Criteria
1. WHEN a target entity ID is provided, THE Social_GetComments Tool SHALL call GET /api/comments with the target ID and return the list of comments
2. WHEN a comment body and target entity ID are provided, THE Social_PostComment Tool SHALL call POST /api/comments to create a new comment on the target entity
3. WHEN a like target ID is provided, THE Social_GetLikes Tool SHALL call GET /api/like-targets/{targetType}/{targetId} and return the like count and whether the current user has liked the target
4. WHEN a like target ID is provided, THE Social_ToggleLike Tool SHALL call POST /api/like-targets/{targetType}/{targetId}/likes/{userId} to toggle the current user's like on the target
### Requirement 9: Notification Tools
**User Story:** As an AI agent, I want tools to retrieve notifications, check notification status, and mark notifications as seen, so that I can help users stay on top of platform activity.
#### Acceptance Criteria
1. WHEN notifications are requested, THE Notification_List Tool SHALL call GET /api/notifications and return the list of notifications with their read/unread status
2. THE Notification_List Tool SHALL accept optional pagination parameters (page number, page size)
3. WHEN notification status is requested, THE Notification_GetStatus Tool SHALL call GET /api/notifications/status and return the count of unseen notifications
4. WHEN a mark-seen request is made, THE Notification_MarkSeen Tool SHALL call PUT /api/notifications/mark-seen to mark all notifications as seen
### Requirement 10: Page Management Tools
**User Story:** As an AI agent, I want tools to list, view, inspect members of, and create pages, so that I can help users manage intranet page content.
#### Acceptance Criteria
1. WHEN page listing is requested, THE Page_List Tool SHALL call GET /api/pages and return the list of available pages
2. WHEN a page ID or slug is provided, THE Page_Get Tool SHALL call GET /api/pages/{id} and return the page details including title, content widgets, and metadata
3. WHEN a page ID is provided, THE Page_GetMembers Tool SHALL call GET /api/pages/{id}/members and return the list of page members and their roles
4. WHEN page creation parameters are provided, THE Page_Create Tool SHALL call POST /api/pages with the provided title and content to create a new page
### Requirement 11: Content Fetching Tool
**User Story:** As an AI agent, I want a dedicated tool to fetch the full rendered content of a specific item by its ID and type, so that I can retrieve detailed content on demand after discovering items via search.
#### Acceptance Criteria
1. WHEN a content ID and content type are provided, THE Content_GetFull Tool SHALL fetch the full rendered content for the specified item using the appropriate Haiilo widget API endpoint
2. THE Content_GetFull Tool SHALL support blog articles, wiki articles, pages, workspaces, apps, and timeline items as content types
3. THE Content_GetFull Tool SHALL extract text from widget HTML and return clean, readable text
4. IF the content type is not recognized, THEN THE Content_GetFull Tool SHALL return an error indicating the unsupported content type
5. IF the content cannot be fetched, THEN THE Content_GetFull Tool SHALL return an error with a descriptive message
### Requirement 12: Error Handling Consistency
**User Story:** As a developer, I want all tools to handle errors in a consistent manner, so that AI agents receive predictable error responses regardless of which tool is called.
#### Acceptance Criteria
1. IF a tool receives a 401 Unauthorized response from the Haiilo_API, THEN THE Tool SHALL return an MCP error response indicating authentication failure
2. IF a tool receives a 403 Forbidden response from the Haiilo_API, THEN THE Tool SHALL return an MCP error response indicating insufficient permissions
3. IF a tool receives a 404 Not Found response from the Haiilo_API, THEN THE Tool SHALL return an MCP error response indicating the requested resource was not found
4. IF a tool encounters a network timeout, THEN THE Tool SHALL return an MCP error response indicating a timeout occurred
5. THE Tool SHALL include the HTTP status code and a human-readable message in all error responses
### Requirement 13: Backward Compatibility
**User Story:** As a developer, I want the refactored server to maintain the same transport and authentication mechanism, so that existing clients can connect without changes.
#### Acceptance Criteria
1. THE MCP_Server SHALL continue to use the StreamableHTTP transport on the /servers/dbplanet/mcp endpoint
2. THE MCP_Server SHALL continue to accept credentials via the x-dbplanet-* HTTP headers
3. THE MCP_Server SHALL continue to listen on port 3000
4. THE MCP_Server SHALL continue to expose the /health endpoint returning a 200 status
@@ -0,0 +1,187 @@
# Implementation Plan: DB Planet MCP Tools Refactoring
## Overview
Refactor the DB Planet MCP Server from a single monolithic `SearchDBPlanet` tool into ~28 focused, single-responsibility MCP tools organized across 9 registration modules. The implementation proceeds bottom-up: shared infrastructure first (error handler, enhanced client), then tool registration modules by capability area, then wiring everything together in the barrel and server entry point.
## Tasks
- [x] 1. Set up test infrastructure and shared utilities
- [x] 1.1 Add vitest and fast-check dev dependencies
- Run `npm install --save-dev vitest fast-check`
- Add `"test": "vitest --run"` script to package.json
- Add vitest config if needed (ESM + TypeScript)
- _Requirements: N/A (infrastructure)_
- [x] 1.2 Create the shared error handler at `src/errorHandler.ts`
- Implement `handleApiError(error: unknown): CallToolResult`
- Map 401 → authentication failed, 403 → insufficient permissions, 404 → not found, timeout → timed out, other → status + message
- All responses: `{ isError: true, content: [{ type: 'text', text: '[{status}] {message}' }] }`
- _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5_
- [ ]* 1.3 Write property test for error handler (Property 3)
- **Property 3: Error handler maps HTTP status to correct MCP error**
- Generate arbitrary HTTP status codes and verify the output shape and message mapping
- **Validates: Requirements 12.1, 12.2, 12.3, 12.4, 12.5**
- [x] 2. Enhance HaiiloClient with generic request method and 401 retry
- [x] 2.1 Add generic `request<T>` method to `src/haiiloClient.ts`
- Accepts `{ method, path, params?, data? }` config
- Calls `getAccessToken()`, makes request with Bearer token, returns `response.data`
- _Requirements: 1.1, 1.2_
- [x] 2.2 Add Axios response interceptor for 401 retry
- On 401: clear `accessToken`, re-authenticate, retry once using `_retried` flag
- If retry also 401, propagate error
- _Requirements: 1.3_
- [ ]* 2.3 Write property test for token caching (Property 5)
- **Property 5: Token caching — single authentication per session**
- Mock OAuth endpoint, make N requests, verify exactly 1 token request
- **Validates: Requirements 1.1, 1.2**
- [ ]* 2.4 Write property test for 401 retry logic (Property 10)
- **Property 10: 401 retry — re-authenticate and retry once**
- Mock API returning 401 then success, verify retry behavior and token refresh
- **Validates: Requirements 1.3**
- [ ]* 2.5 Write property test for missing credentials (Property 6)
- **Property 6: Missing credentials produce error**
- Generate arbitrary subsets of missing credentials, verify MCP error response
- **Validates: Requirements 1.4**
- [x] 3. Checkpoint — Ensure shared infrastructure tests pass
- Ensure all tests pass, ask the user if questions arise.
- [x] 4. Implement search tool registration module
- [x] 4.1 Create `src/tools/searchTools.ts` with `registerSearchTools(server, client)`
- Register `Search_FullText`: GET /api/search with query, page, pageSize params; return metadata only (id, title, type, excerpt, modified date)
- Register `Search_QuickEntity`: GET /api/quick-entity-search with query param
- Register `Search_Grouped`: GET /api/search/grouped with query param
- All tools use Zod schemas with `.describe()` annotations and delegate errors to `handleApiError`
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
- [ ]* 4.2 Write property test for pagination forwarding (Property 2)
- **Property 2: Pagination parameter forwarding**
- Generate optional page/pageSize values, verify they are forwarded as query params when present and omitted when absent
- **Validates: Requirements 3.2, 5.2, 6.4, 9.2**
- [ ]* 4.3 Write property test for Search_FullText metadata only (Property 7)
- **Property 7: Search_FullText returns metadata only**
- Mock search response, verify result contains metadata fields and no full content
- **Validates: Requirements 3.5**
- [x] 5. Implement workspace tool registration module
- [x] 5.1 Create `src/tools/workspaceTools.ts` with `registerWorkspaceTools(server, client)`
- Register `Workspace_List`: GET /api/workspaces
- Register `Workspace_Get`: GET /api/workspaces/{id}
- Register `Workspace_GetMembers`: GET /api/workspaces/{id}/members
- Register `Workspace_Join`: PUT /api/workspaces/{id}/members/join
- Register `Workspace_Create`: POST /api/workspaces with name, description
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
- [x] 6. Implement news, people, and event tool registration modules
- [x] 6.1 Create `src/tools/newsTools.ts` with `registerNewsTools(server, client)`
- Register `News_GetFeed`: GET /web/blog/newsfeed with optional page, pageSize
- _Requirements: 5.1, 5.2, 5.3_
- [x] 6.2 Create `src/tools/peopleTools.ts` with `registerPeopleTools(server, client)`
- Register `People_GetMe`: GET /api/users/me
- Register `People_List`: GET /api/users with optional page, pageSize
- Register `People_Search`: GET /api/users/chooser/search with query
- _Requirements: 6.1, 6.2, 6.3, 6.4_
- [x] 6.3 Create `src/tools/eventTools.ts` with `registerEventTools(server, client)`
- Register `Event_Get`: GET /api/events/{id}
- Register `Event_GetMemberships`: GET /api/events/{id}/memberships
- Register `Event_UpdateStatus`: PUT /api/events/{id}/memberships/status with id, status
- Register `Event_Create`: POST /api/events with title, description, startDate, endDate, location
- _Requirements: 7.1, 7.2, 7.3, 7.4_
- [x] 7. Implement social, notification, and page tool registration modules
- [x] 7.1 Create `src/tools/socialTools.ts` with `registerSocialTools(server, client)`
- Register `Social_GetComments`: GET /api/comments with targetId
- Register `Social_PostComment`: POST /api/comments with targetId, body
- Register `Social_GetLikes`: GET /api/like-targets/{targetType}/{targetId}
- Register `Social_ToggleLike`: POST /api/like-targets/{targetType}/{targetId}/likes/{userId}
- _Requirements: 8.1, 8.2, 8.3, 8.4_
- [x] 7.2 Create `src/tools/notificationTools.ts` with `registerNotificationTools(server, client)`
- Register `Notification_List`: GET /api/notifications with optional page, pageSize
- Register `Notification_GetStatus`: GET /api/notifications/status
- Register `Notification_MarkSeen`: PUT /api/notifications/mark-seen
- _Requirements: 9.1, 9.2, 9.3, 9.4_
- [x] 7.3 Create `src/tools/pageTools.ts` with `registerPageTools(server, client)`
- Register `Page_List`: GET /api/pages
- Register `Page_Get`: GET /api/pages/{id}
- Register `Page_GetMembers`: GET /api/pages/{id}/members
- Register `Page_Create`: POST /api/pages with title, content
- _Requirements: 10.1, 10.2, 10.3, 10.4_
- [x] 8. Implement content fetching tool registration module
- [x] 8.1 Create `src/tools/contentTools.ts` with `registerContentTools(server, client)`
- Register `Content_GetFull`: uses existing `getContentByType`, `fetchWidgetContent`, `extractTextFromWidgets`, `cleanHtml` logic from HaiiloClient
- Accept contentId, contentType, optional appId, optional slug
- Support blog-article, wiki-article, page, workspace, app, timeline-item
- Return clean text extracted from widget HTML
- Return error for unsupported content types
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5_
- [ ]* 8.2 Write property test for HTML widget text extraction (Property 8)
- **Property 8: HTML widget text extraction removes all tags**
- Generate arbitrary HTML strings, verify output contains no `<...>` sequences and entities are decoded
- **Validates: Requirements 11.3**
- [ ]* 8.3 Write property test for unsupported content type (Property 9)
- **Property 9: Unsupported content type returns error**
- Generate arbitrary strings not in the supported set, verify MCP error response
- **Validates: Requirements 11.4**
- [ ] 9. Checkpoint — Ensure all tool module tests pass
- Ensure all tests pass, ask the user if questions arise.
- [x] 10. Wire everything together and update server entry point
- [x] 10.1 Create `src/tools/index.ts` barrel with `registerAllTools(server, client)`
- Import and call all 9 registration functions in order
- _Requirements: 2.1, 2.2_
- [x] 10.2 Update `src/getToolServer.ts` to use new architecture
- Create one `HaiiloClient` instance per session (not per tool call)
- Call `registerAllTools(server, client)` instead of `registerHaiiloTools`
- Remove old import of `registerHaiiloTools`
- _Requirements: 1.1, 2.2_
- [x] 10.3 Remove old `src/tools/haiiloTools.ts`
- Delete the monolithic tool registration file
- _Requirements: 2.1_
- [ ]* 10.4 Write property test for tool metadata completeness (Property 4)
- **Property 4: Tool metadata completeness**
- Instantiate McpServer, register all tools, verify each tool has `{Area}_{Action}` name pattern, non-empty description, and Zod schema
- **Validates: Requirements 2.3, 2.4, 2.5**
- [ ]* 10.5 Write property test for tool-to-endpoint routing (Property 1)
- **Property 1: Tool-to-endpoint routing correctness**
- For each registered tool with arbitrary valid input, mock `client.request`, invoke tool, verify correct HTTP method and path
- **Validates: Requirements 3.1, 3.3, 3.4, 4.14.5, 5.1, 6.16.3, 7.17.4, 8.18.4, 9.1, 9.3, 9.4, 10.110.4, 11.1**
- [ ] 11. Verify backward compatibility
- [ ] 11.1 Verify `server.ts` is unchanged
- Confirm StreamableHTTP transport on `/servers/dbplanet/mcp`, credentials via `x-dbplanet-*` headers, port 3000, `/health` endpoint
- _Requirements: 13.1, 13.2, 13.3, 13.4_
- [ ]* 11.2 Write unit tests for backward compatibility
- Test that server listens on port 3000, /health returns 200, all 28 tools are registered
- _Requirements: 13.1, 13.2, 13.3, 13.4, 2.2_
- [ ] 12. Final checkpoint — Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Property tests validate universal correctness properties from the design document
- The existing `server.ts` should remain unchanged — only `getToolServer.ts` and `src/tools/` are modified
- The `HaiiloClient` content-fetching methods (`getContentByType`, `fetchWidgetContent`, `extractTextFromWidgets`, `cleanHtml`) need to be made accessible (public or package-internal) for use by `contentTools.ts`
+23
View File
@@ -0,0 +1,23 @@
FROM db-container-lib-docker-release-local.bahnhub.tech.rz.db.de/node:22-alpine
ARG env="dev"
ENV DEPLOYMENT_ENV=$env
ENV NO_COLOR=true
WORKDIR /app
USER root
RUN apk update && apk upgrade
RUN apk add --no-cache ca-certificates curl
COPY package.json package-lock.json tsconfig.json ./
COPY src/ ./src/
RUN npm ci --ignore-scripts
RUN npm run build
EXPOSE 4000
ENV PORT=4000
RUN chown -R node:node /app
USER node
CMD ["node", "dist/server.js"]
+27
View File
@@ -0,0 +1,27 @@
DOCKER_REGISTRY ?= generativeai-docker-stage-dev-local.bahnhub.tech.rz.db.de
IMAGE_TAG ?= $(shell git describe --always --dirty --match "" 2>/dev/null || echo "latest")
DEPLOYMENT_ENV ?= dev
IMAGE_NAME = dbplanet-$(DEPLOYMENT_ENV)
.PHONY: build push deploy vars whatif
vars:
@echo "IMAGE_TAG=$(IMAGE_TAG)"
@echo "IMAGE_NAME=$(IMAGE_NAME)"
@echo "DOCKER_REGISTRY=$(DOCKER_REGISTRY)"
build:
docker build --platform linux/amd64 --build-arg env=$(DEPLOYMENT_ENV) -t $(DOCKER_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) .
push: build
docker login $(DOCKER_REGISTRY)
docker push $(DOCKER_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
whatif:
az deployment sub what-if \
--location westeurope \
--parameters infra/parameters/$(DEPLOYMENT_ENV).bicepparam \
--parameters deployContainerApp=true imageTag=$(IMAGE_TAG)
deploy: push
./infra/deploy.sh --environment $(shell echo $(DEPLOYMENT_ENV) | tr '[:lower:]' '[:upper:]') --image-tag $(IMAGE_TAG)
+217
View File
@@ -0,0 +1,217 @@
# DB Planet MCP Server
Model Context Protocol (MCP) server for DB Planet (Haiilo) integration, enabling LLMs to search and retrieve full content from the Deutsche Bahn internal knowledge platform.
## Overview
This MCP server provides seamless access to DB Planet content through a single search tool that:
- Searches across all content types (blog articles, wiki articles, pages, workspaces, timeline items, files, events)
- Automatically retrieves full content for each search result
- Cleans and formats content for optimal LLM consumption
- Handles OAuth2 authentication transparently
## Features
### Supported Content Types
- **Blog Articles**: Full article content with rich text and headlines
- **Wiki Articles**: Complete wiki pages with version support
- **Pages & Workspaces**: Full page content with widget extraction
- **Timeline Items**: Social posts and updates (snippet-based for performance)
- **Files**: File metadata and descriptions
- **Events**: Event details and information
- **Apps**: Standalone app content pages
### Content Extraction
- **Widget-based extraction**: Parses RTE (rich text editor) and teaser widgets
- **HTML cleaning**: Removes tags while preserving structure and readability
- **Entity decoding**: Converts HTML entities to readable text
- **Headline extraction**: Captures navigation headlines from teaser widgets
## Installation
```bash
cd servers/server-dbplanet
npm install
npm run build
```
## Running the Server
```bash
npm start
```
The server runs on port 3000 by default and exposes an MCP-over-HTTP endpoint.
## Configuration
### Required Credentials
The server requires DB Planet credentials passed via HTTP headers:
- `x-dbplanet-username`: Your DB Planet username
- `x-dbplanet-password`: Your DB Planet password
- `x-dbplanet-client-id`: OAuth2 client ID
- `x-dbplanet-client-secret`: OAuth2 client secret
- `x-dbplanet-base-url`: DB Planet base URL (e.g., `https://dbahn-staging.haiilo.cloud`)
### MCP Client Configuration
Add to your MCP client settings (e.g., Claude Desktop config):
```json
{
"mcpServers": {
"dbplanet": {
"url": "http://localhost:3000/servers/dbplanet/mcp",
"transport": "http",
"headers": {
"x-dbplanet-username": "your-username",
"x-dbplanet-password": "your-password",
"x-dbplanet-client-id": "your-client-id",
"x-dbplanet-client-secret": "your-client-secret",
"x-dbplanet-base-url": "https://dbahn-staging.haiilo.cloud"
}
}
}
}
```
## API Endpoints
### MCP Endpoint
- **Path**: `/servers/dbplanet/mcp`
- **Methods**: GET, POST, DELETE
- **Protocol**: MCP Streamable HTTP (2025-03-26)
### Health Check
- **Path**: `/health`
- **Method**: GET
- **Response**: `{"status": "ok"}`
## Tools
### SearchDBPlanet
Search DB Planet and retrieve full content for all results. Returns complete articles/pages formatted for LLM analysis.
**Parameters:**
- `query` (string, required): Search query term
**Returns:**
Formatted text containing:
- Total number of results
- For each result:
- Title and source
- Content type
- Full extracted content (cleaned and formatted)
**Example Usage:**
```typescript
{
"query": "Wie erstelle ich einen S3 Bucket?"
}
```
**Example Output:**
```
Found 3 results for "Wie erstelle ich einen S3 Bucket?":
[1] AWS S3 Bucket erstellen
Source: Cloud Platform Team (blog-article)
Content:
Schritt für Schritt Anleitung zum Erstellen eines S3 Buckets...
================================================================================
[2] S3 Best Practices
Source: DevOps Wiki (wiki-article)
Content:
Best Practices für die Verwendung von Amazon S3...
```
## How It Works
### Authentication Flow
1. Server receives credentials via HTTP headers
2. Obtains OAuth2 access token using password grant flow
3. Caches token for subsequent requests
4. Uses Bearer token for all API calls
### Search & Retrieval Flow
1. **Search**: Query DB Planet search API with user's search term
2. **Content Type Detection**: Identify content type for each result
3. **Content Retrieval**: Use type-specific endpoints to fetch full content:
- Blog articles: `/api/blog-article/{id}/widgets/app-blog-{appId}-{id}`
- Wiki articles: `/api/wiki-article/{id}/widgets/app-wiki-{appId}-{id}-{version}`
- Pages: `/api/pages/{slug}/widgets/default`
- Workspaces: `/api/workspaces/{slug}/widgets/default`
- Timeline items: `/api/timeline-items` (snippet only for performance)
- Apps: `/api/app/{id}/widgets/app-content-{id}`
4. **Content Extraction**: Parse widget data and extract text from RTE and teaser widgets
5. **Cleaning**: Remove HTML tags, decode entities, normalize whitespace
6. **Formatting**: Format as readable text optimized for LLM consumption
### Performance Optimizations
- **Selective fetching**: Timeline items use snippets to avoid timeout
- **Parallel processing**: Content retrieval happens sequentially but efficiently
- **Error handling**: Falls back to snippet if full content retrieval fails
- **Timeout**: 60-second timeout for API requests
## Development
### Project Structure
```
servers/server-dbplanet/
├── src/
│ ├── server.ts # Express server & MCP protocol handler
│ ├── haiiloClient.ts # DB Planet API client & content retrieval
│ └── haiiloTools.ts # MCP tool registration
├── package.json
└── tsconfig.json
```
### Testing
A Python test script is available in the project root:
```bash
cd ../../
python test_auth_api.py
```
This script tests authentication and content retrieval for all content types.
## Troubleshooting
### Authentication Errors
- Verify credentials are correct
- Check that base URL matches your DB Planet instance
- Ensure OAuth2 client has proper permissions
### Timeout Errors
- Reduce search result count by using more specific queries
- Timeline items automatically use snippets to prevent timeouts
- Check network connectivity to DB Planet instance
### Missing Content
- Some content types may require specific permissions
- Widget-based content requires proper layout names
- Wiki articles may need version number adjustment
## License
Internal Deutsche Bahn project
@@ -0,0 +1,202 @@
# DB Planet MCP Server — Tool Overview
29 tools across 9 components. Each tool maps to one or more Haiilo API endpoints.
---
## 1. Search (3 tools)
| Tool | API Endpoint | What it does |
|------|-------------|--------------|
| Search_FullText | `GET /api/search?term=` | Keyword search across all content. Returns metadata (id, type, excerpt). |
| Search_QuickEntity | `GET /api/quick-entity-search?term=` | Fast entity lookup by name (users, workspaces, pages, events). |
| Search_Grouped | `GET /api/search/grouped?term=` | Same as FullText but results grouped by content type. |
**Example user queries:**
- "Find articles about sustainability on DB Planet"
- "Search for GenAI content"
- "Is there a workspace called Innovation Hub?"
- "Find the Town Hall event"
**Limitations:**
- Keyword matching only, not semantic search. Short queries (1-2 words) work best.
- LLMs sometimes wrap queries in quotes — we strip them automatically.
---
## 2. Workspace Management (5 tools)
| Tool | API Endpoint | What it does |
|------|-------------|--------------|
| Workspace_List | `GET /api/workspaces` | List workspaces. `memberOnly=true` filters for user's own workspaces (client-side pagination + filter). |
| Workspace_Get | `GET /api/workspaces/{id}` | Get workspace details by UUID or slug. |
| Workspace_GetMembers | `GET /api/workspaces/{id}/members` | Get member list for a workspace. |
| Workspace_Join | `PUT /api/workspaces/{id}/users/join` | Join a workspace. |
| Workspace_Create | `POST /api/workspaces` | Create a new workspace. Auto-sets current user as admin. |
**Example user queries:**
- "What workspaces am I a member of?" → `Workspace_List(memberOnly=true)`
- "Show me the Innovation Hub workspace" → `Search_QuickEntity``Workspace_Get`
- "Who are the members of the BahnGPT workspace?" → `Workspace_GetMembers`
- "Join the Digital Transformation workspace" → `Search_QuickEntity``Workspace_Join`
- "Create a workspace called Q1 Planning" → `Workspace_Create`
**Limitations:**
- API has no server-side membership filter — `memberOnly` paginates all workspaces client-side.
- API defaults to 20 results per page.
---
## 3. News (1 tool)
| Tool | API Endpoint | What it does |
|------|-------------|--------------|
| News_GetFeed | `GET /web/blog/newsfeed` | Get news articles from subscribed pages. Filters: published/scheduled/drafts, date range. |
**Example user queries:**
- "What are the latest news on DB Planet?"
- "Show me articles from January 2026"
- "Are there any draft articles?"
**Limitations:**
- ⚠️ Returns 404 on staging — endpoint doesn't exist in staging environment.
- Only returns articles from pages the authenticated user is subscribed to.
---
## 4. People (4 tools)
| Tool | API Endpoint | What it does |
|------|-------------|--------------|
| People_GetMe | `GET /api/users/me` | Get current user's profile. |
| People_Get | `GET /api/users/{id}` | Get a user's profile by UUID. |
| People_List | `GET /api/users` | List all users with pagination. |
| People_Search | `GET /api/users/chooser/search?term=&types=user` | Search users by name only. |
| People_GetManaged | `GET /api/users/{id}/managed` | Get all users that a given user manages (direct reports). |
**Example user queries:**
- "Show me my DB Planet profile" → `People_GetMe`
- "Find Alexandra on DB Planet" → `People_Search`
- "How many users are on DB Planet?" → `People_List`
- "Who does Alexandra manage?" → `People_Search` + `People_GetManaged`
**Limitations:**
- People_Search only matches by name — cannot search by department, job title, or expertise.
- `properties.department` exists on profiles but is not queryable and often null on staging.
- No "find people in department X" capability via the API.
---
## 5. Events (4 tools)
| Tool | API Endpoint | What it does |
|------|-------------|--------------|
| Event_Get | `GET /api/events/{id}` | Get event details by UUID. |
| Event_GetMemberships | `GET /api/events/{id}/memberships` | Get attendee list. Only works if `showParticipants` is enabled. |
| Event_UpdateStatus | `PUT /api/events/{id}/status` | Update attendance: ATTENDING, NOT_ATTENDING, TENTATIVE. |
| Event_Create | `POST /api/events` | Create a new event. Auto-sets current user as host/admin. |
**Example user queries:**
- "Show me details about the Town Hall event" → `Search_QuickEntity``Event_Get`
- "Who is attending the Innovation Workshop?" → `Event_GetMemberships`
- "Register me for the team building event" → `Event_UpdateStatus(status=ATTENDING)`
- "Decline the Friday meeting" → `Event_UpdateStatus(status=NOT_ATTENDING)`
- "Create a team event on May 15th at Frankfurt HQ" → `Event_Create`
**Limitations:**
- No "list all events" endpoint — must use Search_QuickEntity to find events by name.
- Event_GetMemberships returns 404 if the event has `showParticipants=false`.
---
## 6. Social (4 tools)
| Tool | API Endpoint | What it does |
|------|-------------|--------------|
| Social_GetComments | `GET /api/comments?targetId=&targetType=` | Get comments on an entity. |
| Social_PostComment | `POST /api/comments` | Post a comment. Auto-sets current user as author. |
| Social_GetLikes | `GET /api/like-targets/{type}/{id}/likes` | Get like count and list. |
| Social_ToggleLike | `POST /api/like-targets/{type}/{id}/likes/{userId}` | Like or unlike content. |
**Example user queries:**
- "Show me comments on the latest blog post" → `Search_FullText``Social_GetComments`
- "How many likes does the sustainability article have?" → `Social_GetLikes`
- "Like the welcome post" → `People_GetMe` + `Search_FullText``Social_ToggleLike`
- "Comment 'Great work!' on the team update" → `Search_FullText``Social_PostComment`
**Limitations:**
- Valid targetTypes: `blog-article`, `timeline-item`, `wiki-article` only. Events are NOT supported.
- Requires content UUID — must search first to find it.
- ToggleLike requires the user's UUID (from People_GetMe).
---
## 7. Notifications (3 tools)
| Tool | API Endpoint | What it does |
|------|-------------|--------------|
| Notification_List | `GET /api/notifications` | List notifications. Filter by category: ACTIVITY, DISCUSSION. |
| Notification_GetStatus | `GET /api/notifications/status` | Get unseen notification count. |
| Notification_MarkSeen | `PUT /api/notifications/action/mark-seen` | Mark notifications as seen. Optional category filter. |
**Example user queries:**
- "Do I have unread notifications?" → `Notification_GetStatus`
- "Show me my activity notifications" → `Notification_List(category=ACTIVITY)`
- "Show me discussion notifications" → `Notification_List(category=DISCUSSION)`
- "Mark all notifications as read" → `Notification_MarkSeen`
**Limitations:**
- Category parameter may be required (API returns error without it on some environments).
---
## 8. Pages (4 tools)
| Tool | API Endpoint | What it does |
|------|-------------|--------------|
| Page_List | `GET /api/pages` | List all pages. |
| Page_Get | `GET /api/pages/{id}` | Get page details by UUID or slug. |
| Page_GetMembers | `GET /api/pages/{id}/members` | Get page members/admins. |
| Page_Create | `POST /api/pages` | Create a new page. Auto-sets current user as admin. |
**Example user queries:**
- "What pages are available on DB Planet?" → `Page_List`
- "Show me the HR Department page" → `Search_QuickEntity``Page_Get`
- "Who manages the Innovation page?" → `Page_GetMembers`
- "Create a page for the sustainability initiative" → `Page_Create`
**Limitations:**
- ⚠️ Page_Create returns 404 on staging — endpoint doesn't exist in staging environment.
---
## 9. Content (1 tool)
| Tool | API Endpoint | What it does |
|------|-------------|--------------|
| Content_GetFull | Various widget endpoints | Fetch full rendered content by ID and type. Supports: blog-article, wiki-article, page, workspace, app, timeline-item. |
**Example user queries:**
- "Show me the full text of that article" → `Content_GetFull` (after search)
- "Read the wiki page about onboarding" → `Search_FullText``Content_GetFull`
**Limitations:**
- Requires `appId` for blog and wiki articles (available from Search_FullText results).
- Uses internal widget layout endpoints — may break if Haiilo changes layout naming.
---
## Summary
| Component | Tools | Status |
|-----------|-------|--------|
| Search | 3 | ✅ Working |
| Workspaces | 5 | ✅ Working |
| News | 1 | ⚠️ 404 on staging |
| People | 5 | ✅ Working (name search only) |
| Events | 4 | ✅ Working |
| Social | 4 | ✅ Working |
| Notifications | 3 | ✅ Working |
| Pages | 4 | ✅ Working (Create 404 on staging) |
| Content | 1 | ✅ Working |
| **Total** | **30** | |
+105
View File
@@ -0,0 +1,105 @@
#!/bin/bash
# Deployment script for DB Planet MCP Server
# Usage: ./infra/deploy.sh --environment DEV|IAT|Prod [--image-tag TAG]
set -e
ENVIRONMENT=""
IMAGE_TAG=""
LOCATION="westeurope"
while [[ $# -gt 0 ]]; do
case $1 in
-e|--environment) ENVIRONMENT="$2"; shift 2 ;;
-t|--image-tag) IMAGE_TAG="$2"; shift 2 ;;
-h|--help)
echo "Usage: $0 -e ENV [-t IMAGE_TAG]"
echo " -e, --environment DEV, IAT, or Prod"
echo " -t, --image-tag Container image tag (optional, uses param file default)"
exit 0 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
if [[ ! "$ENVIRONMENT" =~ ^(DEV|IAT|Prod)$ ]]; then
echo "❌ Environment must be DEV, IAT, or Prod"
exit 1
fi
ENV_LOWER=$(echo "$ENVIRONMENT" | tr '[:upper:]' '[:lower:]')
PARAM_FILE="infra/parameters/${ENV_LOWER}.bicepparam"
if [[ ! -f "$PARAM_FILE" ]]; then
echo "❌ Parameter file not found: $PARAM_FILE"
exit 1
fi
echo "🚀 Deploying DB Planet MCP Server — $ENVIRONMENT"
echo " Parameter file: $PARAM_FILE"
[[ -n "$IMAGE_TAG" ]] && echo " Image tag: $IMAGE_TAG"
echo ""
# Step 1: Deploy infrastructure (RG, Identity, Key Vault, App Insights, RBAC)
DEPLOY_NAME="dbplanet-mcp-${ENV_LOWER}-$(date +%Y%m%d-%H%M%S)"
echo " Step 1: Deploying infrastructure..."
if ! az deployment sub create \
--location "$LOCATION" \
--parameters "$PARAM_FILE" \
--name "$DEPLOY_NAME"; then
echo "❌ Infrastructure deployment failed"
exit 1
fi
echo "✅ Infrastructure deployed"
echo ""
# Step 2: Deploy Container App
echo "📦 Step 2: Deploying Container App..."
DEPLOY_ARGS=("--location" "$LOCATION" "--parameters" "$PARAM_FILE"
"--parameters" "deployContainerApp=true"
"--name" "${DEPLOY_NAME}-app")
[[ -n "$IMAGE_TAG" ]] && DEPLOY_ARGS+=("--parameters" "imageTag=$IMAGE_TAG")
if ! az deployment sub create "${DEPLOY_ARGS[@]}"; then
echo "❌ Container App deployment failed"
exit 1
fi
echo "✅ Container App deployed"
echo ""
# Step 3: Verify
echo "🔍 Step 3: Verifying deployment..."
CA_NAME=$(az deployment sub show --name "${DEPLOY_NAME}-app" \
--query "properties.outputs.containerAppName.value" -o tsv)
CA_FQDN=$(az deployment sub show --name "${DEPLOY_NAME}-app" \
--query "properties.outputs.containerAppFqdn.value" -o tsv)
echo " Container App: $CA_NAME"
echo " FQDN: $CA_FQDN"
# Wait for healthy revision
RG_NAME="rg-dbplanet-mcp-${ENV_LOWER}"
for i in $(seq 1 10); do
STATE=$(az containerapp revision list --name "$CA_NAME" \
--resource-group "$RG_NAME" \
--query "[?properties.active].properties.runningState | [0]" -o tsv 2>/dev/null)
if [[ "$STATE" == "Running" || "$STATE" == "RunningAtMaxScale" ]]; then
echo " ✅ Revision is Running + Healthy"
break
fi
echo " ⏳ Waiting for revision ($STATE)... ($i/10)"
if [[ "$i" -eq 10 ]]; then
echo " ❌ Revision did not reach Running state after 10 attempts"
exit 1
fi
sleep 15
done
echo ""
echo "🎉 Deployment complete!"
echo " Health: https://${CA_FQDN}/health"
+243
View File
@@ -0,0 +1,243 @@
// DB Planet MCP Server Infrastructure
// Deploys: Resource Group, Managed Identity, Key Vault,
// Application Insights, Container App, Key Vault permissions
targetScope = 'subscription'
// ── Core parameters ──────────────────────────────────────────
@description('Environment name')
@allowed(['DEV', 'IAT', 'Prod'])
param environment string
@description('Azure region')
param location string = 'westeurope'
@description('Container image tag')
param imageTag string = 'latest'
@description('Set to true on second deployment after infra + permissions are in place')
param deployContainerApp bool = false
// ── Tags ─────────────────────────────────────────────────────
@description('Tags applied to all resources')
param tags object = {
CostReference: 'H-450007-52-01'
ReferenceID: 'A-113241'
ReferenceName: 'DB Planet MCP Server'
Environment: environment
}
// ── Existing infrastructure references ───────────────────────
@description('Central Key Vault name (shared secrets)')
param centralKeyVaultName string
@description('Central Key Vault resource group')
param centralKeyVaultResourceGroup string
@description('Container App Environment name')
param containerAppEnvironmentName string
@description('Container App Environment resource group')
param containerAppEnvironmentResourceGroup string
// ── Application configuration ────────────────────────────────
@description('Artifactory server URL')
param artifactoryServer string
@description('Registry username')
param registryUsername string
@description('DB Planet base URL (staging vs production)')
param dbplanetBaseUrl string
// ── Service Principal for Key Vault secrets management ───────
#disable-next-line secure-secrets-in-params // Object ID, not a secret
@description('Service Principal Object ID for Key Vault Secrets Officer role')
param secretsOfficerPrincipalId string = ''
// ── Custom domain (optional) ─────────────────────────────────
@description('Certificate name in Container App Environment')
param certificateName string = ''
@description('DNS zone name for custom domain')
param dnsZoneName string = ''
@description('DNS record name (subdomain)')
param dnsRecordName string = ''
@description('Resource group containing the DNS zone')
param dnsZoneResourceGroup string = 'Infrastructure'
// ── Derived values ───────────────────────────────────────────
var resourceGroupName = 'rg-dbplanet-mcp-${toLower(environment)}'
var resourceToken = toLower(uniqueString(subscription().id, resourceGroupName, location))
// ═══════════════════════════════════════════════════════════════
// 1. Resource Group
// ═══════════════════════════════════════════════════════════════
module rg 'modules/resource-group.bicep' = {
name: 'deploy-rg'
params: {
resourceGroupName: resourceGroupName
location: location
tags: tags
}
}
// ═══════════════════════════════════════════════════════════════
// 2. Managed Identity
// ═══════════════════════════════════════════════════════════════
module identity 'modules/managed-identity.bicep' = {
name: 'deploy-identity'
scope: resourceGroup(resourceGroupName)
dependsOn: [rg]
params: {
environment: environment
location: location
tags: tags
resourceToken: resourceToken
}
}
// ═══════════════════════════════════════════════════════════════
// 3. Key Vault (app-specific, public access)
// ═══════════════════════════════════════════════════════════════
module keyVault 'modules/keyvault.bicep' = {
name: 'deploy-keyvault'
scope: resourceGroup(resourceGroupName)
dependsOn: [rg]
params: {
environment: environment
location: location
tags: tags
resourceToken: resourceToken
}
}
// ═══════════════════════════════════════════════════════════════
// 4. Application Insights + Log Analytics
// ═══════════════════════════════════════════════════════════════
module appInsights 'modules/application-insights.bicep' = {
name: 'deploy-app-insights'
scope: resourceGroup(resourceGroupName)
params: {
environment: environment
location: location
tags: tags
resourceToken: resourceToken
keyVaultName: keyVault.outputs.keyVaultName
}
}
// ═══════════════════════════════════════════════════════════════
// 5. Container App Certificate (existing, optional)
// ═══════════════════════════════════════════════════════════════
module certificate 'modules/container-app-certificate.bicep' = if (!empty(certificateName)) {
name: 'deploy-certificate'
scope: resourceGroup(containerAppEnvironmentResourceGroup)
params: {
containerAppEnvironmentName: containerAppEnvironmentName
certificateName: certificateName
}
}
// ═══════════════════════════════════════════════════════════════
// 6. Container App
// ═══════════════════════════════════════════════════════════════
var customDomainName = !empty(dnsRecordName) && !empty(dnsZoneName) ? '${dnsRecordName}.${dnsZoneName}' : ''
module containerApp 'modules/container-app.bicep' = if (deployContainerApp) {
name: 'deploy-container-app'
scope: resourceGroup(resourceGroupName)
dependsOn: [rg]
params: {
environment: environment
location: location
tags: tags
resourceToken: resourceToken
centralKeyVaultName: centralKeyVaultName
centralKeyVaultResourceGroup: centralKeyVaultResourceGroup
appKeyVaultUri: keyVault.outputs.keyVaultUri
containerAppEnvironmentName: containerAppEnvironmentName
containerAppEnvironmentResourceGroup: containerAppEnvironmentResourceGroup
artifactoryServer: artifactoryServer
registryUsername: registryUsername
managedIdentityId: identity.outputs.managedIdentityId
imageTag: imageTag
dbplanetBaseUrl: dbplanetBaseUrl
customDomainName: !empty(certificateName) ? customDomainName : ''
containerAppCertificateId: !empty(certificateName) ? certificate.outputs.certificateId! : ''
}
}
// ═══════════════════════════════════════════════════════════════
// 7. DNS Record
// ═══════════════════════════════════════════════════════════════
module dnsRecord 'modules/dns-record.bicep' = if (deployContainerApp && !empty(dnsRecordName)) {
name: 'deploy-dns-record'
scope: resourceGroup(dnsZoneResourceGroup)
params: {
privateDnsZoneName: dnsZoneName
aRecordName: dnsRecordName
ipv4Address: containerApp.outputs.containerAppEnvironmentStaticIp!
}
}
// ═══════════════════════════════════════════════════════════════
// 8. Key Vault Permissions
// ═══════════════════════════════════════════════════════════════
// 6a. Managed Identity → central Key Vault (read registry secrets)
module centralKvPermissions 'modules/key-vault-permissions.bicep' = {
name: 'deploy-central-kv-permissions'
scope: resourceGroup(centralKeyVaultResourceGroup)
params: {
keyVaultName: centralKeyVaultName
principalId: identity.outputs.managedIdentityPrincipalId
roleName: 'Key Vault Secrets User'
}
}
// 6b. Managed Identity → app Key Vault (read app secrets)
module appKvIdentityPermissions 'modules/key-vault-permissions.bicep' = {
name: 'deploy-app-kv-identity-permissions'
scope: resourceGroup(resourceGroupName)
params: {
keyVaultName: keyVault.outputs.keyVaultName
principalId: identity.outputs.managedIdentityPrincipalId
roleName: 'Key Vault Secrets User'
}
}
// 6c. Container App system identity → app Key Vault (read app secrets)
module appKvContainerAppPermissions 'modules/key-vault-permissions.bicep' = if (deployContainerApp) {
name: 'deploy-app-kv-ca-permissions'
scope: resourceGroup(resourceGroupName)
params: {
keyVaultName: keyVault.outputs.keyVaultName
principalId: containerApp.outputs.systemIdentityPrincipalId!
roleName: 'Key Vault Secrets User'
}
}
// 6d. Service Principal → app Key Vault (read + write secrets for CI/CD)
module appKvOfficerPermissions 'modules/key-vault-permissions.bicep' = if (!empty(secretsOfficerPrincipalId)) {
name: 'deploy-app-kv-officer-permissions'
scope: resourceGroup(resourceGroupName)
params: {
keyVaultName: keyVault.outputs.keyVaultName
principalId: secretsOfficerPrincipalId
roleName: 'Key Vault Secrets Officer'
}
}
// ═══════════════════════════════════════════════════════════════
// Outputs
// ═══════════════════════════════════════════════════════════════
output resourceGroupName string = rg.outputs.resourceGroupName
output keyVaultName string = keyVault.outputs.keyVaultName
output keyVaultUri string = keyVault.outputs.keyVaultUri
output containerAppName string = deployContainerApp ? containerApp.outputs.containerAppName! : ''
output containerAppFqdn string = deployContainerApp ? containerApp.outputs.containerAppFqdn! : ''
output managedIdentityId string = identity.outputs.managedIdentityId
output appInsightsName string = appInsights.outputs.applicationInsightsName
@@ -0,0 +1,71 @@
// Application Insights module
@description('The environment name (DEV, IAT, Prod)')
param environment string
@description('The location for Application Insights')
param location string
@description('Tags to apply to resources')
param tags object
@description('Unique token for resource naming')
param resourceToken string
@description('Key Vault name for storing connection string')
param keyVaultName string
var appInsightsName = 'appi-dbp-${toLower(environment)}-${resourceToken}'
var logAnalyticsWorkspaceName = 'log-dbp-${toLower(environment)}-${resourceToken}'
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2025-02-01' = {
name: logAnalyticsWorkspaceName
location: location
tags: tags
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: environment == 'Prod' ? 90 : 30
features: {
enableLogAccessUsingOnlyResourcePermissions: true
}
}
}
resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = {
name: appInsightsName
location: location
tags: tags
kind: 'web'
properties: {
Application_Type: 'web'
WorkspaceResourceId: logAnalyticsWorkspace.id
IngestionMode: 'LogAnalytics'
publicNetworkAccessForIngestion: 'Enabled'
publicNetworkAccessForQuery: 'Enabled'
}
}
resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' existing = {
name: keyVaultName
}
resource connectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = {
parent: keyVault
name: 'applicationinsights-connection-string'
properties: {
value: applicationInsights.properties.ConnectionString
attributes: {
enabled: true
}
}
}
output applicationInsightsName string = applicationInsights.name
output applicationInsightsId string = applicationInsights.id
output connectionString string = applicationInsights.properties.ConnectionString
output instrumentationKey string = applicationInsights.properties.InstrumentationKey
output logAnalyticsWorkspaceId string = logAnalyticsWorkspace.id
output logAnalyticsWorkspaceName string = logAnalyticsWorkspace.name
@@ -0,0 +1,18 @@
// Container App Environment Certificate module (existing)
@description('Container App Environment name')
param containerAppEnvironmentName string
@description('Certificate name in the Container App Environment')
param certificateName string
resource containerAppEnvironment 'Microsoft.App/managedEnvironments@2025-01-01' existing = {
name: containerAppEnvironmentName
}
resource containerAppCertificate 'Microsoft.App/managedEnvironments/certificates@2025-01-01' existing = {
name: certificateName
parent: containerAppEnvironment
}
output certificateId string = containerAppCertificate.id
@@ -0,0 +1,206 @@
// Container App module for DB Planet MCP Server
@description('The environment name (DEV, IAT, Prod)')
param environment string
@description('The location for the Container App')
param location string
@description('Tags to apply to resources')
param tags object
@description('Unique token for resource naming')
param resourceToken string
@description('Existing central Key Vault name (shared secrets)')
param centralKeyVaultName string
@description('Existing central Key Vault resource group')
param centralKeyVaultResourceGroup string
@description('App-specific Key Vault URI (created by keyvault.bicep)')
param appKeyVaultUri string
@description('Existing Container App Environment name')
param containerAppEnvironmentName string
@description('Existing Container App Environment resource group')
param containerAppEnvironmentResourceGroup string
@description('Artifactory server URL for container registry')
param artifactoryServer string
@description('Registry username for container registry authentication')
param registryUsername string
@description('Managed Identity ID for Key Vault access')
param managedIdentityId string
@description('Container image tag/version')
param imageTag string = 'latest'
@description('DB Planet base URL (staging vs production)')
param dbplanetBaseUrl string
@description('Custom domain name for the Container App')
param customDomainName string = ''
@description('Container App Certificate ID (if custom domain is configured)')
param containerAppCertificateId string = ''
var containerAppName = 'ca-dbp-${toLower(environment)}-${take(resourceToken, 8)}'
// Custom domain configuration
var customDomain = !empty(customDomainName) && !empty(containerAppCertificateId) ? [
{
name: customDomainName
certificateId: containerAppCertificateId
bindingType: 'SniEnabled'
}
] : []
// Reference existing central Key Vault
resource centralKeyVault 'Microsoft.KeyVault/vaults@2024-11-01' existing = {
name: centralKeyVaultName
scope: resourceGroup(centralKeyVaultResourceGroup)
}
// Reference existing Container App Environment
resource containerAppEnvironment 'Microsoft.App/managedEnvironments@2025-01-01' existing = {
name: containerAppEnvironmentName
scope: resourceGroup(containerAppEnvironmentResourceGroup)
}
// Container App
resource containerApp 'Microsoft.App/containerApps@2025-01-01' = {
name: containerAppName
location: location
tags: tags
identity: {
type: 'SystemAssigned, UserAssigned'
userAssignedIdentities: {
'${managedIdentityId}': {}
}
}
properties: {
managedEnvironmentId: containerAppEnvironment.id
workloadProfileName: 'Consumption'
configuration: {
ingress: {
external: true
targetPort: 4000
allowInsecure: false
customDomains: customDomain
traffic: [
{
weight: 100
latestRevision: true
}
]
}
secrets: [
{
name: 'artifactory-token'
keyVaultUrl: '${centralKeyVault.properties.vaultUri}secrets/artifactory-registry-token'
identity: managedIdentityId
}
{
name: 'appinsights-connection-string'
keyVaultUrl: '${appKeyVaultUri}secrets/applicationinsights-connection-string'
identity: 'system'
}
{
name: 'dbplanet-username'
keyVaultUrl: '${appKeyVaultUri}secrets/DBPLANET-USERNAME'
identity: 'system'
}
{
name: 'dbplanet-password'
keyVaultUrl: '${appKeyVaultUri}secrets/DBPLANET-PASSWORD'
identity: 'system'
}
{
name: 'dbplanet-client-id'
keyVaultUrl: '${appKeyVaultUri}secrets/DBPLANET-CLIENT-ID'
identity: 'system'
}
{
name: 'dbplanet-client-secret'
keyVaultUrl: '${appKeyVaultUri}secrets/DBPLANET-CLIENT-SECRET'
identity: 'system'
}
]
registries: [
{
server: artifactoryServer
username: registryUsername
passwordSecretRef: 'artifactory-token'
}
]
}
template: {
containers: [
{
name: 'dbplanet-mcp'
image: '${artifactoryServer}/db-planet-mcp-server:${imageTag}'
resources: {
cpu: json(environment == 'Prod' ? '1.0' : '0.5')
memory: environment == 'Prod' ? '2Gi' : '1Gi'
}
env: [
{ name: 'DBPLANET_BASE_URL', value: dbplanetBaseUrl }
{ name: 'DBPLANET_USERNAME', secretRef: 'dbplanet-username' }
{ name: 'DBPLANET_PASSWORD', secretRef: 'dbplanet-password' }
{ name: 'DBPLANET_CLIENT_ID', secretRef: 'dbplanet-client-id' }
{ name: 'DBPLANET_CLIENT_SECRET', secretRef: 'dbplanet-client-secret' }
{ name: 'NODE_ENV', value: environment == 'Prod' ? 'production' : 'development' }
{ name: 'HTTP_PROXY', value: 'http://webproxy.comp.db.de:8080' }
{ name: 'HTTPS_PROXY', value: 'http://webproxy.comp.db.de:8080' }
{ name: 'http_proxy', value: 'http://webproxy.comp.db.de:8080' }
{ name: 'https_proxy', value: 'http://webproxy.comp.db.de:8080' }
{ name: 'NO_PROXY', value: '169.254.169.254,169.254.170.2,cognitiveservices.azure.com,internal.deutschebahn.com,db.de' }
{ name: 'no_proxy', value: '169.254.169.254,169.254.170.2,cognitiveservices.azure.com,internal.deutschebahn.com,db.de' }
{ name: 'STREAMABLE_HTTP', value: 'true' }
{ name: 'REMOTE_AUTHORIZATION', value: 'true' }
{ name: 'HOST', value: '0.0.0.0' }
{
name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
secretRef: 'appinsights-connection-string'
}
]
probes: [
{
type: 'Readiness'
httpGet: {
path: '/health'
port: 4000
}
initialDelaySeconds: 5
periodSeconds: 30
}
{
type: 'Liveness'
httpGet: {
path: '/health'
port: 4000
}
initialDelaySeconds: 10
periodSeconds: 60
}
]
}
]
scale: {
minReplicas: 1
maxReplicas: 1
}
}
}
}
output containerAppName string = containerApp.name
output containerAppId string = containerApp.id
output containerAppFqdn string = containerApp.properties.configuration.ingress.fqdn
output systemIdentityPrincipalId string = containerApp.identity.principalId
output containerAppEnvironmentStaticIp string = containerAppEnvironment.properties.staticIp
@@ -0,0 +1,30 @@
// DNS A Record module for Container App access
@description('Private DNS Zone name')
param privateDnsZoneName string
@description('A Record name (subdomain)')
param aRecordName string
@description('IPv4 address to point to')
param ipv4Address string
resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing = {
name: privateDnsZoneName
}
resource aRecord 'Microsoft.Network/privateDnsZones/A@2020-06-01' = {
name: aRecordName
parent: privateDnsZone
properties: {
ttl: 3600
aRecords: [
{
ipv4Address: ipv4Address
}
]
}
}
output recordName string = aRecord.name
output fqdn string = '${aRecordName}.${privateDnsZoneName}'
@@ -0,0 +1,59 @@
// Key Vault permissions module - reusable for different roles and principals
@description('Key Vault name')
param keyVaultName string
@description('Principal ID for Key Vault access')
param principalId string
@description('Principal type (User, Group, ServicePrincipal)')
@allowed(['User', 'Group', 'ServicePrincipal'])
param principalType string = 'ServicePrincipal'
@description('Key Vault role to assign')
@allowed([
'Key Vault Administrator'
'Key Vault Certificates Officer'
'Key Vault Crypto Officer'
'Key Vault Crypto Service Encryption User'
'Key Vault Crypto User'
'Key Vault Reader'
'Key Vault Secrets Officer'
'Key Vault Secrets User'
])
param roleName string = 'Key Vault Secrets User'
var roleDefinitions = {
// gitleaks:allow
'Key Vault Administrator': '00482a5a-887f-4fb3-b363-3b7fe8e74483'
'Key Vault Certificates Officer': 'a4417e6f-fecd-4de8-b567-7b0420556985'
'Key Vault Crypto Officer': '14b46e9e-c2b7-41b4-b07b-48a6ebf60603'
'Key Vault Crypto Service Encryption User': 'e147488a-f6f5-4113-8e2d-b22465e65bf6'
// gitleaks:allow
'Key Vault Crypto User': '12338af0-0e69-4776-bea7-57ae8d297424'
// gitleaks:allow
'Key Vault Reader': '21090545-7ca7-4776-b22c-e363652d74d2'
// gitleaks:allow
'Key Vault Secrets Officer': 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7'
// gitleaks:allow
'Key Vault Secrets User': '4633458b-17de-408a-b874-0445c86b69e6'
}
var roleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitions[roleName])
resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' existing = {
name: keyVaultName
}
resource keyVaultRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(keyVault.id, principalId, roleName)
scope: keyVault
properties: {
roleDefinitionId: roleDefinitionId
principalId: principalId
principalType: principalType
}
}
output roleAssignmentId string = keyVaultRoleAssignment.id
output roleAssignmentName string = keyVaultRoleAssignment.name
@@ -0,0 +1,47 @@
// Azure Key Vault module (public access, no Private Endpoint)
@description('The environment name (DEV, IAT, Prod)')
param environment string
@description('The location for the Key Vault')
param location string
@description('Tags to apply to resources')
param tags object
@description('Unique token for resource naming')
param resourceToken string
@description('The tenant ID for Key Vault access')
param tenantId string = subscription().tenantId
var keyVaultName = 'kv-dbp-${toLower(environment)}-${take(resourceToken, 8)}'
resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' = {
name: keyVaultName
location: location
tags: tags
properties: {
sku: {
family: 'A'
name: 'standard'
}
tenantId: tenantId
enabledForDeployment: false
enabledForDiskEncryption: true
enabledForTemplateDeployment: true
enableSoftDelete: true
softDeleteRetentionInDays: 7
enablePurgeProtection: true
enableRbacAuthorization: true
publicNetworkAccess: 'Enabled'
networkAcls: {
defaultAction: 'Allow'
bypass: 'AzureServices'
}
}
}
output keyVaultName string = keyVault.name
output keyVaultId string = keyVault.id
output keyVaultUri string = keyVault.properties.vaultUri
@@ -0,0 +1,26 @@
// User Managed Identity module
@description('The environment name (DEV, IAT, Prod)')
param environment string
@description('The location for the managed identity')
param location string
@description('Tags to apply to resources')
param tags object
@description('Unique token for resource naming')
param resourceToken string
var managedIdentityName = 'id-dbplanet-${toLower(environment)}-${resourceToken}'
resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = {
name: managedIdentityName
location: location
tags: tags
}
output managedIdentityName string = managedIdentity.name
output managedIdentityId string = managedIdentity.id
output managedIdentityPrincipalId string = managedIdentity.properties.principalId
output managedIdentityClientId string = managedIdentity.properties.clientId
@@ -0,0 +1,21 @@
// Resource Group module
targetScope = 'subscription'
@description('The resource group name')
param resourceGroupName string
@description('The location for the resource group')
param location string
@description('Tags to apply to the resource group')
param tags object
resource resourceGroup 'Microsoft.Resources/resourceGroups@2025-04-01' = {
name: resourceGroupName
location: location
tags: tags
}
output resourceGroupName string = resourceGroup.name
output resourceGroupId string = resourceGroup.id
@@ -0,0 +1,29 @@
// Development environment parameters for DB Planet MCP Server
using '../main.bicep'
param environment = 'DEV'
param location = 'westeurope'
param imageTag = 'latest'
// ── Existing infrastructure ──────────────────────────────────
// gitleaks:allow
param centralKeyVaultName = 'bahngpt-kv-c76j3yuqbfnmq'
param centralKeyVaultResourceGroup = 'DevSecOps-DEV'
param containerAppEnvironmentName = 'cae-devsecops-dev-c76j3yuq'
param containerAppEnvironmentResourceGroup = 'DevSecOps-DEV'
// ── Registry ─────────────────────────────────────────────────
param artifactoryServer = 'generativeai-docker-stage-dev-local.bahnhub.tech.rz.db.de'
param registryUsername = 'techuser-generativeai'
// ── Application ──────────────────────────────────────────────
param dbplanetBaseUrl = 'https://dbahn-staging.haiilo.cloud'
// ── Permissions ──────────────────────────────────────────────
param secretsOfficerPrincipalId = ''
// ── Custom domain ────────────────────────────────────────────
param certificateName = 'wildcard-cert'
param dnsZoneName = 'genai-test.comp.db.de'
param dnsRecordName = 'dbplanet-dev'
param dnsZoneResourceGroup = 'Infrastructure'
@@ -0,0 +1,29 @@
// IAT environment parameters for DB Planet MCP Server
using '../main.bicep'
param environment = 'IAT'
param location = 'westeurope'
param imageTag = 'latest'
// ── Existing infrastructure ──────────────────────────────────
// gitleaks:allow
param centralKeyVaultName = 'bahngpt-kv-6i2jt7tebagcq'
param centralKeyVaultResourceGroup = 'DevSecOps-IAT'
param containerAppEnvironmentName = 'cae-devsecops-iat-6i2jt7te'
param containerAppEnvironmentResourceGroup = 'DevSecOps-IAT'
// ── Registry ─────────────────────────────────────────────────
param artifactoryServer = 'generativeai-docker-stage-iat-local.bahnhub.tech.rz.db.de'
param registryUsername = 'techuser-generativeai'
// ── Application ──────────────────────────────────────────────
param dbplanetBaseUrl = 'https://dbahn-staging.haiilo.cloud'
// ── Permissions ──────────────────────────────────────────────
param secretsOfficerPrincipalId = ''
// ── Custom domain ────────────────────────────────────────────
param certificateName = 'wildcard-cert'
param dnsZoneName = 'genai-test.comp.db.de'
param dnsRecordName = 'dbplanet-iat'
param dnsZoneResourceGroup = 'Infrastructure'
@@ -0,0 +1,168 @@
# DB Planet MCP Server — Manual Test Plan
Based on the 8 component specs and actual tool implementations.
Test against staging: `https://dbahn-staging.haiilo.cloud/`
---
## Component 1: Information Discovery & Search
### Tools: Search_FullText, Search_QuickEntity, Search_Grouped
| # | Use Case | Tool | Test Input | Expected Result | Status | Notes |
|---|----------|------|------------|-----------------|--------|-------|
| 1.1 | Global full-text search | Search_FullText | `query: "sustainability"` | Returns result list with id, title, type, excerpt, modified | | |
| 1.2 | Full-text search with pagination | Search_FullText | `query: "test", page: 0, pageSize: 3` | Returns max 3 results | | |
| 1.3 | Full-text search — no results | Search_FullText | `query: "xyznonexistent999"` | Returns "No results found" message | | |
| 1.3a | Full-text search — quoted query (LLM edge case) | Search_FullText | `query: '"sustainability initiative"'` | Should strip quotes and return results | | Fixed: quote stripping added |
| 1.4 | Quick entity search (users, pages, etc.) | Search_QuickEntity | `query: "Alexandra"` | Returns matching entities | | |
| 1.5 | Quick entity search — no results | Search_QuickEntity | `query: "xyznonexistent999"` | Returns "No entities found" message | | |
| 1.6 | Grouped search by content type | Search_Grouped | `query: "GenAI"` | Returns results grouped by type | | |
| 1.7 | Search for events | Search_QuickEntity | `query: "Town Hall"` | Returns event entities | | |
| 1.8 | Search for workspaces | Search_QuickEntity | `query: "Innovation"` | Returns workspace entities | | |
---
## Component 2: Workspace Management & Collaboration
### Tools: Workspace_List, Workspace_Get, Workspace_GetMembers, Workspace_Join, Workspace_Create
| # | Use Case | Tool | Test Input | Expected Result | Status | Notes |
|---|----------|------|------------|-----------------|--------|-------|
| 2.1 | List all workspaces | Workspace_List | _(no params)_ | Returns list of workspaces | | |
| 2.2 | Get workspace details by ID | Workspace_Get | `id: "<UUID from 2.1>"` | Returns workspace name, description, visibility | | |
| 2.3 | Get workspace members | Workspace_GetMembers | `id: "<UUID from 2.1>"` | Returns member list with roles | | |
| 2.4 | Join a workspace | Workspace_Join | `id: "<UUID of a workspace you're not in>"` | Returns success message | | |
| 2.5 | Create a new workspace | Workspace_Create | `name: "Manual Test Workspace", visibility: "PUBLIC"` | Returns created workspace JSON | | |
| 2.6 | Create workspace with PRIVATE visibility | Workspace_Create | `name: "Private Test WS", visibility: "PRIVATE"` | Returns created workspace with visibility=PRIVATE | | |
| 2.7 | Get non-existent workspace | Workspace_Get | `id: "00000000-0000-0000-0000-000000000000"` | Returns 404 error | | |
---
## Component 3: News & Content Consumption
### Tools: News_GetFeed
### ⚠️ Known Issue: GET /web/blog/newsfeed returns 404 on staging
| # | Use Case | Tool | Test Input | Expected Result | Status | Notes |
|---|----------|------|------------|-----------------|--------|-------|
| 3.1 | Get news feed (default) | News_GetFeed | _(no params)_ | Returns articles OR 404 (staging limitation) | ⚠️ | Endpoint missing on staging |
| 3.2 | Get only published articles | News_GetFeed | `includePublished: true` | Returns published articles OR 404 | ⚠️ | |
| 3.3 | Get scheduled articles | News_GetFeed | `includeScheduled: true` | Returns scheduled articles OR 404 | ⚠️ | |
| 3.4 | Get drafts | News_GetFeed | `includeDrafts: true` | Returns draft articles OR 404 | ⚠️ | |
| 3.5 | Filter by date | News_GetFeed | `limitDate: "2025-01"` | Returns articles from Jan 2025 OR 404 | ⚠️ | |
---
## Component 4: People Directory & User Profiles
### Tools: People_GetMe, People_Get, People_List, People_Search
| # | Use Case | Tool | Test Input | Expected Result | Status | Notes |
|---|----------|------|------------|-----------------|--------|-------|
| 4.1 | Get current user profile | People_GetMe | _(no params)_ | Returns authenticated user's profile | | |
| 4.2 | List users with pagination | People_List | `page: 0, pageSize: 5` | Returns max 5 users | | |
| 4.3 | List users — default | People_List | _(no params)_ | Returns user list | | |
| 4.4 | Search people by name | People_Search | `query: "Alexandra"` | Returns matching users | | |
| 4.5 | Search people — no results | People_Search | `query: "xyznonexistent999"` | Returns "No people found" message | | |
| 4.6 | Get user by ID | People_Get | `id: "<UUID from 4.1 or 4.4>"` | Returns user profile details | | |
| 4.7 | Get non-existent user | People_Get | `id: "00000000-0000-0000-0000-000000000000"` | Returns 404 error | | |
---
## Component 5: Event Discovery & Participation
### Tools: Event_Get, Event_GetMemberships, Event_UpdateStatus, Event_Create
| # | Use Case | Tool | Test Input | Expected Result | Status | Notes |
|---|----------|------|------------|-----------------|--------|-------|
| 5.1 | Get event details | Event_Get | `id: "<UUID from Search_QuickEntity>"` | Returns event name, dates, location, description | | |
| 5.2 | Get event participants | Event_GetMemberships | `id: "<UUID with showParticipants=true>"` | Returns participant list | | |
| 5.3 | Get participants — hidden | Event_GetMemberships | `id: "<UUID with showParticipants=false>"` | Returns 404 | | |
| 5.4 | Accept event | Event_UpdateStatus | `id: "<UUID>", status: "ATTENDING"` | Returns success message | | |
| 5.5 | Decline event | Event_UpdateStatus | `id: "<UUID>", status: "NOT_ATTENDING"` | Returns success message | | |
| 5.6 | Tentative event | Event_UpdateStatus | `id: "<UUID>", status: "TENTATIVE"` | Returns success message | | |
| 5.7 | Create event | Event_Create | `title: "Test Event", startDate: "2026-05-01T10:00:00Z", endDate: "2026-05-01T12:00:00Z"` | Returns created event JSON | | |
| 5.8 | Create event with location | Event_Create | `title: "Office Event", startDate: "2026-05-01T10:00:00Z", endDate: "2026-05-01T12:00:00Z", location: "Frankfurt HQ"` | Returns event with place=Frankfurt HQ | | |
| 5.9 | Get non-existent event | Event_Get | `id: "00000000-0000-0000-0000-000000000000"` | Returns 404 error | | |
---
## Component 6: Social Engagement & Interaction
### Tools: Social_GetComments, Social_PostComment, Social_GetLikes, Social_ToggleLike
| # | Use Case | Tool | Test Input | Expected Result | Status | Notes |
|---|----------|------|------------|-----------------|--------|-------|
| 6.1 | Get comments on blog article | Social_GetComments | `targetId: "<blog-article UUID>", targetType: "blog-article"` | Returns comment list | | |
| 6.2 | Get comments on timeline item | Social_GetComments | `targetId: "<timeline-item UUID>", targetType: "timeline-item"` | Returns comment list | | |
| 6.3 | Get comments on wiki article | Social_GetComments | `targetId: "<wiki-article UUID>", targetType: "wiki-article"` | Returns comment list | | |
| 6.4 | Get comments — unsupported type | Social_GetComments | `targetId: "<UUID>", targetType: "event"` | Returns error or empty (API rejects unsupported types) | | |
| 6.5 | Post a comment | Social_PostComment | `targetId: "<blog-article UUID>", targetType: "blog-article", body: "Test comment from MCP"` | Returns success message | | |
| 6.6 | Get likes on blog article | Social_GetLikes | `targetType: "blog-article", targetId: "<blog-article UUID>"` | Returns like count and list | | |
| 6.7 | Toggle like | Social_ToggleLike | `targetType: "blog-article", targetId: "<blog-article UUID>", userId: "<your user UUID>"` | Returns "Like toggled successfully" | | |
| 6.8 | Toggle like again (unlike) | Social_ToggleLike | _(same as 6.7)_ | Toggles back — verify with Social_GetLikes | | |
| 6.9 | Get likes — unsupported type | Social_GetLikes | `targetType: "event", targetId: "<UUID>"` | Returns error | | |
---
## Component 7: Notification Management
### Tools: Notification_List, Notification_GetStatus, Notification_MarkSeen
| # | Use Case | Tool | Test Input | Expected Result | Status | Notes |
|---|----------|------|------------|-----------------|--------|-------|
| 7.1 | Get notification status | Notification_GetStatus | _(no params)_ | Returns unseen notification count | | |
| 7.2 | List ACTIVITY notifications | Notification_List | `category: "ACTIVITY"` | Returns activity notifications | | |
| 7.3 | List DISCUSSION notifications | Notification_List | `category: "DISCUSSION"` | Returns discussion notifications | | |
| 7.4 | List notifications with pagination | Notification_List | `category: "ACTIVITY", page: 0, pageSize: 3` | Returns max 3 notifications | | |
| 7.5 | List notifications — no category | Notification_List | _(no params)_ | Returns notifications or error if category required | | |
| 7.6 | Mark ACTIVITY as seen | Notification_MarkSeen | `category: "ACTIVITY"` | Returns success message | | |
| 7.7 | Mark all as seen | Notification_MarkSeen | _(no params)_ | Returns success message | | |
| 7.8 | Verify mark seen worked | Notification_GetStatus | _(no params)_ | Unseen count should be 0 or lower than 7.1 | | |
---
## Component 8: Page Management
### Tools: Page_List, Page_Get, Page_GetMembers, Page_Create
### ⚠️ Known Issue: POST /api/pages returns 404 on staging
| # | Use Case | Tool | Test Input | Expected Result | Status | Notes |
|---|----------|------|------------|-----------------|--------|-------|
| 8.1 | List all pages | Page_List | _(no params)_ | Returns list of pages | | |
| 8.2 | Get page details by ID | Page_Get | `id: "<UUID from 8.1>"` | Returns page name, visibility, details | | |
| 8.3 | Get page by slug | Page_Get | `id: "<slug from 8.1>"` | Returns page details | | |
| 8.4 | Get page members | Page_GetMembers | `id: "<UUID from 8.1>"` | Returns member/admin list | | |
| 8.5 | Create a page | Page_Create | `title: "Manual Test Page"` | Returns created page OR 404 (staging) | ⚠️ | Endpoint missing on staging |
| 8.6 | Create page with options | Page_Create | `title: "DE Test Page", visibility: "PUBLIC", defaultLanguage: "DE"` | Returns page OR 404 | ⚠️ | |
| 8.7 | Get non-existent page | Page_Get | `id: "00000000-0000-0000-0000-000000000000"` | Returns 404 error | | |
---
## Bonus: Content_GetFull (Cross-cutting)
| # | Use Case | Tool | Test Input | Expected Result | Status | Notes |
|---|----------|------|------------|-----------------|--------|-------|
| 9.1 | Get full blog article content | Content_GetFull | `contentType: "blog-article", contentId: "<UUID>", appId: "<appId from Search_FullText>"` | Returns rendered article text | | |
| 9.2 | Get full wiki article content | Content_GetFull | `contentType: "wiki-article", contentId: "<UUID>", appId: "<appId>"` | Returns rendered wiki text | | |
| 9.3 | Get page content | Content_GetFull | `contentType: "page", contentId: "<UUID>"` | Returns page content | | |
| 9.4 | Get workspace content | Content_GetFull | `contentType: "workspace", contentId: "<UUID>"` | Returns workspace content | | |
| 9.5 | Unsupported content type | Content_GetFull | `contentType: "unknown-type", contentId: "abc"` | Returns "Unsupported content type" error | | |
---
## Test Workflow Tips
1. Start with **People_GetMe** (4.1) to get your user UUID — needed for Social_ToggleLike and verifying create operations
2. Use **Search_FullText** (1.1) with `pageSize: 2` to get real content IDs, appIds, and slugs for other tests
3. Use **Search_QuickEntity** (1.4) to find event UUIDs for Component 5 tests
4. For Social tests (Component 6), first get a blog-article UUID from search results
5. Components 3 (News) and 8.5-8.6 (Page_Create) are known to 404 on staging — document but don't block on them
## Summary Template
After testing, fill in the Status column with:
- ✅ = Pass
- ❌ = Fail (add reason in Notes)
- ⚠️ = Known limitation / staging issue
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "mcpop-server-dbplanet",
"version": "1.0.0",
"description": "Standalone MCP Server for DB Planet (Haiilo)",
"type": "module",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "tsx watch src/server.ts",
"test": "vitest --run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.4",
"axios": "1.15.2",
"cors": "^2.8.5",
"express": "^4.18.2",
"https-proxy-agent": "^7.0.2",
"tslog": "^4.9.3",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.10.5",
"fast-check": "^4.6.0",
"nodemon": "^3.0.2",
"tsx": "^4.21.0",
"typescript": "^5.3.3",
"vitest": "^4.1.4"
}
}
+15
View File
@@ -0,0 +1,15 @@
---
version: v3
license: LicenseRef-DBISL
contacts:
- lars.palzer@deutschebahn.com
confidentiality: internal
reference-ids:
- none
protected-branches: []
usage-scenario:
- internal-service
custom:
integrity: normal
availability: normal
confidentiality: normal
@@ -0,0 +1,66 @@
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { AxiosError } from "axios";
export function handleApiError(error: unknown): CallToolResult {
if (error instanceof AxiosError) {
if (error.code === "ECONNABORTED") {
return {
isError: true,
content: [{ type: "text", text: "[TIMEOUT] Request timed out." }],
};
}
const status = error.response?.status;
if (status === 401) {
return {
isError: true,
content: [
{
type: "text",
text: "[401] Authentication failed. Check DB Planet credentials.",
},
],
};
}
if (status === 403) {
return {
isError: true,
content: [
{
type: "text",
text: "[403] Insufficient permissions to access this resource.",
},
],
};
}
if (status === 404) {
return {
isError: true,
content: [
{
type: "text",
text: "[404] The requested resource was not found.",
},
],
};
}
if (status) {
const message = error.response?.statusText || error.message;
return {
isError: true,
content: [{ type: "text", text: `[${status}] ${message}` }],
};
}
}
const message =
error instanceof Error ? error.message : "An unknown error occurred";
return {
isError: true,
content: [{ type: "text", text: `[ERROR] ${message}` }],
};
}
@@ -0,0 +1,39 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { HaiiloClient } from "./haiiloClient.js";
import { registerAllTools } from "./tools/index.js";
import { Logger } from 'tslog';
const log = new Logger();
interface HaiiloCredentials {
username?: string;
password?: string;
clientId?: string;
clientSecret?: string;
baseUrl?: string;
}
const getToolAPIServer = async (credentials: HaiiloCredentials) => {
const server = new McpServer({
name: 'DBPlanet-MCP-Server',
version: '0.1.0',
}, {
capabilities: {
logging: {}
}
});
const client = new HaiiloClient({
username: credentials.username || '',
password: credentials.password || '',
clientId: credentials.clientId || '',
clientSecret: credentials.clientSecret || '',
baseUrl: credentials.baseUrl || '',
});
registerAllTools(server, client);
return server;
};
export default getToolAPIServer;
@@ -0,0 +1,375 @@
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { Logger } from 'tslog';
import { HttpsProxyAgent } from 'https-proxy-agent';
// Extend Axios request config to support the _retried flag
declare module 'axios' {
interface InternalAxiosRequestConfig {
_retried?: boolean;
}
}
const log = new Logger();
export interface HaiiloCredentials {
username: string;
password: string;
clientId: string;
clientSecret: string;
baseUrl: string;
}
export class HaiiloClient {
private client: AxiosInstance;
private credentials: HaiiloCredentials;
private accessToken?: string;
constructor(credentials: HaiiloCredentials) {
this.credentials = credentials;
const axiosConfig: any = {
timeout: 60000,
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
}
};
// Configure proxy for HTTPS tunneling via CONNECT
if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY) {
const proxyUrl = (process.env.HTTPS_PROXY || process.env.HTTP_PROXY)!;
log.info('Using proxy:', proxyUrl);
axiosConfig.httpsAgent = new HttpsProxyAgent(proxyUrl);
axiosConfig.proxy = false;
}
this.client = axios.create(axiosConfig);
// 401 retry interceptor: on 401, clear token, re-authenticate, retry once.
// Skip retry for token requests to avoid infinite loops.
this.client.interceptors.response.use(
(response) => response,
async (error) => {
const isTokenRequest = error.config?.url?.includes('/api/oauth/token');
if (error.response?.status === 401 && !error.config._retried && !isTokenRequest) {
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);
}
);
}
private async getAccessToken(): Promise<string> {
if (this.accessToken) return this.accessToken;
if (!this.credentials.baseUrl) {
throw new Error('OAuth token request failed: baseUrl is not configured. Set the x-dbplanet-base-url header.');
}
const basicAuth = Buffer.from(`${this.credentials.clientId}:${this.credentials.clientSecret}`).toString('base64');
const tokenUrl = `${this.credentials.baseUrl}/api/oauth/token`;
try {
const params = new URLSearchParams();
params.append('grant_type', 'password');
params.append('username', this.credentials.username);
params.append('password', this.credentials.password);
const response = await this.client.post(
tokenUrl,
params.toString(),
{
headers: {
'Authorization': `Basic ${basicAuth}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
timeout: 120000
}
);
this.accessToken = response.data.access_token;
log.info('OAuth authentication successful');
return this.accessToken!;
} catch (error: any) {
const errMsg = error.code === 'ECONNABORTED'
? `timeout after 120s`
: `${error.response?.status || error.code} - ${error.response?.statusText || error.message}`;
log.error('OAuth authentication failed:', errMsg);
throw new Error(`OAuth token request failed: ${errMsg}`);
}
}
async getToken(): Promise<string> {
return this.getAccessToken();
}
getBaseUrl(): string {
return this.credentials.baseUrl;
}
async request<T = any>(config: {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
params?: Record<string, any>;
data?: any;
}): Promise<T> {
log.info(`[REQUEST] ${config.method} ${config.path} starting...`);
const start = Date.now();
try {
const token = await this.getAccessToken();
log.info(`[REQUEST] ${config.method} ${config.path} token acquired in ${Date.now() - start}ms`);
const response = await this.client.request<T>({
method: config.method,
url: `${this.credentials.baseUrl}${config.path}`,
params: config.params,
data: config.data,
headers: {
'Authorization': `Bearer ${token}`,
},
});
log.info(`[REQUEST] ${config.method} ${config.path} completed in ${Date.now() - start}ms, status=${response.status}`);
return response.data;
} catch (error: any) {
const errMsg = error.code === 'ECONNABORTED'
? `timeout after ${Date.now() - start}ms`
: `${error.response?.status || error.code} - ${error.response?.statusText || error.message}`;
log.error(`[REQUEST] ${config.method} ${config.path} failed after ${Date.now() - start}ms: ${errMsg}`);
throw error;
}
}
async search(term: string): Promise<any> {
const token = await this.getAccessToken();
const searchUrl = `${this.credentials.baseUrl}/api/search/`;
try {
const response = await this.client.get(searchUrl, {
headers: { 'Authorization': `Bearer ${token}` },
params: { term }
});
const items = response.data.content || [];
const results = [];
for (const item of items) {
const cleanExcerpt = this.cleanHtml(item.excerpt || '');
const result: any = {
id: item.id,
source: item.sender?.displayName || 'Unknown',
type: item.typeName || 'Unknown',
title: this.cleanHtml(item.displayName || 'Untitled'),
snippet: cleanExcerpt,
modified_date: item.modified
};
const contentType = (item.typeName || '').toLowerCase();
if (!contentType.includes('timeline')) {
try {
const content = await this.getContentByType(token, item);
result.fullContent = content;
} catch (error) {
result.fullContent = cleanExcerpt;
}
} else {
result.fullContent = cleanExcerpt;
}
results.push(result);
}
return { total_results: results.length, results };
} catch (error: any) {
log.error('Search failed:', error.message);
throw new Error(`Search failed: ${error.response?.status} ${error.response?.statusText}`);
}
}
async getContentByType(token: string, item: any): Promise<string> {
const contentType = (item.typeName || '').toLowerCase();
const contentId = item.id;
const targetParams = item.target?.params || {};
// Blog articles
if (contentType.includes('blog') || contentType.includes('article')) {
const appId = targetParams.appId;
if (appId) {
const layoutName = `app-blog-${appId}-${contentId}`;
const url = `${this.credentials.baseUrl}/api/blog-article/${contentId}/widgets/${layoutName}`;
const content = await this.fetchWidgetContent(token, url);
if (content) return content;
}
}
// Standalone app content
if (contentType === 'app') {
const layoutName = `app-content-${contentId}`;
const url = `${this.credentials.baseUrl}/api/app/${contentId}/widgets/${layoutName}`;
const content = await this.fetchWidgetContent(token, url);
if (content) return content;
}
// Pages
if (contentType.includes('page')) {
const slug = targetParams.slug || item.slug;
const pageId = targetParams.id || contentId;
if (slug) {
const url = `${this.credentials.baseUrl}/api/pages/${slug}/widgets/default`;
const content = await this.fetchWidgetContent(token, url);
if (content) return content;
}
const url = `${this.credentials.baseUrl}/api/pages/${pageId}/widgets/default`;
const content = await this.fetchWidgetContent(token, url);
if (content) return content;
}
// Workspaces
if (contentType.includes('workspace')) {
const slug = targetParams.slug || item.slug;
const workspaceId = targetParams.id || contentId;
if (slug) {
const url = `${this.credentials.baseUrl}/api/workspaces/${slug}/widgets/default`;
const content = await this.fetchWidgetContent(token, url);
if (content) return content;
}
const url = `${this.credentials.baseUrl}/api/workspaces/${workspaceId}/widgets/default`;
const content = await this.fetchWidgetContent(token, url);
if (content) return content;
}
// Timeline items
if (contentType.includes('timeline')) {
const senderId = item.sender?.id;
if (senderId) {
const url = `${this.credentials.baseUrl}/api/timeline-items`;
try {
const response = await this.client.get(url, {
headers: { 'Authorization': `Bearer ${token}` },
params: { type: 'sender', senderId, _page: 0, _pageSize: 100 }
});
const timelineItem = response.data.content?.find((ti: any) => ti.id === contentId);
if (timelineItem?.data?.message) {
const msg = timelineItem.data.message;
let cleanMsg = msg.replace(/<[^>]+>/g, ' ');
cleanMsg = this.cleanHtml(cleanMsg);
cleanMsg = cleanMsg.replace(/ +/g, ' ').trim();
return cleanMsg;
}
} catch (error: any) {
log.warn(`Timeline items endpoint failed for ${contentId}: ${error.message}`);
}
// Fallback: try direct timeline item endpoint
try {
const directUrl = `${this.credentials.baseUrl}/api/timeline-items/${contentId}`;
const response = await this.client.get(directUrl, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.data?.data?.message) {
const msg = response.data.data.message;
let cleanMsg = msg.replace(/<[^>]+>/g, ' ');
cleanMsg = this.cleanHtml(cleanMsg);
cleanMsg = cleanMsg.replace(/ +/g, ' ').trim();
return cleanMsg;
}
} catch (error: any) {
log.warn(`Direct timeline endpoint failed for ${contentId}: ${error.message}`);
}
}
}
// Wiki articles
if (contentType.includes('wiki')) {
const appId = targetParams.appId;
if (appId) {
// Try to get wiki article metadata first to find version
try {
const metaUrl = `${this.credentials.baseUrl}/api/wiki-article/${contentId}`;
const metaResp = await this.client.get(metaUrl, {
headers: { 'Authorization': `Bearer ${token}` }
});
const version = metaResp.data.version || 1;
const layoutName = `app-wiki-${appId}-${contentId}-${version}`;
const url = `${this.credentials.baseUrl}/api/wiki-article/${contentId}/widgets/${layoutName}`;
const content = await this.fetchWidgetContent(token, url);
if (content) return content;
} catch (error: any) {
log.warn(`Wiki metadata fetch failed for ${contentId}: ${error.message}`);
}
// Fallback: try multiple versions
for (const version of [1, 2, 3, 4, 5]) {
const layoutName = `app-wiki-${appId}-${contentId}-${version}`;
const url = `${this.credentials.baseUrl}/api/wiki-article/${contentId}/widgets/${layoutName}`;
const content = await this.fetchWidgetContent(token, url);
if (content) return content;
}
}
}
throw new Error('Content retrieval failed for type: ' + contentType);
}
async fetchWidgetContent(token: string, url: string): Promise<string | null> {
try {
const response = await this.client.get(url, {
headers: { 'Authorization': `Bearer ${token}` }
});
return this.extractTextFromWidgets(response.data);
} catch (error: any) {
log.debug(`Widget endpoint failed: ${error.message}`);
return null;
}
}
extractTextFromWidgets(widgetData: any): string {
const textParts: string[] = [];
const rows = widgetData.rows || [];
for (const row of rows) {
for (const column of row) {
for (const widget of column.widgets || []) {
if (widget.key === 'rte') {
const htmlContent = widget.settings?.html_content || '';
// Remove HTML tags but preserve structure
let cleanText = htmlContent.replace(/<[^>]+>/g, ' ');
cleanText = this.cleanHtml(cleanText);
// Clean up excessive whitespace but preserve line breaks
cleanText = cleanText.replace(/ +/g, ' ');
cleanText = cleanText.replace(/\n\s*\n/g, '\n\n');
cleanText = cleanText.trim();
if (cleanText) textParts.push(cleanText);
} else if (widget.key === 'teaser') {
const slides = widget.settings?.slides || [];
for (const slide of slides) {
const headline = slide.headline || '';
if (headline) textParts.push(`[${headline}]`);
}
}
}
}
}
return textParts.join('\n\n');
}
cleanHtml(text: string): string {
return text
.replace(/<[^>]*>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
}
+164
View File
@@ -0,0 +1,164 @@
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express, { Request, Response, NextFunction } from 'express';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js";
import getToolAPIServer from "./getToolServer.js";
import bodyParser from 'body-parser';
import { Logger } from "tslog";
import cors from 'cors';
// Load .env file manually (no dotenv dependency)
try {
const __dirname = dirname(fileURLToPath(import.meta.url));
const envPath = resolve(__dirname, '..', '.env');
const envContent = readFileSync(envPath, 'utf-8');
for (const line of envContent.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
const value = trimmed.slice(eqIndex + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
} catch (e) {
// .env file not found — rely on environment variables
}
const log = new Logger({stylePrettyLogs: false});
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(cors<Request>());
function getCredentials(req: Request): {
username?: string;
password?: string;
clientId?: string;
clientSecret?: string;
baseUrl?: string
} {
return {
username: (req.headers["x-dbplanet-username"] as string) || process.env.DBPLANET_USERNAME,
password: (req.headers["x-dbplanet-password"] as string) || process.env.DBPLANET_PASSWORD,
clientId: (req.headers["x-dbplanet-client-id"] as string) || process.env.DBPLANET_CLIENT_ID,
clientSecret: (req.headers["x-dbplanet-client-secret"] as string) || process.env.DBPLANET_CLIENT_SECRET,
baseUrl: (req.headers["x-dbplanet-base-url"] as string) || process.env.DBPLANET_BASE_URL
};
}
const transports: Record<string, StreamableHTTPServerTransport> = {};
app.get('/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'ok' });
});
app.all('/servers/dbplanet/mcp', async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
log.info(`Request to /mcp, session=${sessionId ?? "n/a"}, method=${req.method}`);
try {
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) {
const eventStore = new InMemoryEventStore();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore,
onsessioninitialized: (sessionId) => {
log.info('StreamableHTTP session initialized, session=', sessionId);
transports[sessionId] = transport;
}
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
log.info(`Transport closed, session=${sessionId}`);
delete transports[sid];
}
};
const credentials = getCredentials(req);
const server = await getToolAPIServer(credentials);
await server.connect(transport);
} else {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
});
return;
}
// For GET requests (SSE streams), send periodic keep-alive pings
// to prevent client-side read timeouts on idle connections.
let keepAliveInterval: ReturnType<typeof setInterval> | undefined;
if (req.method === 'GET') {
keepAliveInterval = setInterval(() => {
if (!res.writableEnded) {
res.write(':ping\n\n');
} else {
clearInterval(keepAliveInterval);
}
}, 15_000);
res.on('close', () => {
if (keepAliveInterval) clearInterval(keepAliveInterval);
});
}
await transport.handleRequest(req, res, req.body);
} catch (error) {
log.error('Error handling MCP request:', (error as Error).message);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
});
}
}
});
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
log.error('Server error:', err.message);
res.status(500).json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'production' ? undefined : err.message
});
});
const PORT = 4000;
app.listen(PORT, () => {
log.info(`DB Planet MCP server listening on port ${PORT}`);
log.info(`\nEndpoint: /servers/dbplanet/mcp\nMethods: GET, POST, DELETE\n`);
});
process.on('SIGINT', async () => {
log.info('Shutting down server...');
for (const sessionId in transports) {
try {
log.info(`Closing transport, session=${sessionId}`);
await transports[sessionId].close();
delete transports[sessionId];
} catch (error) {
log.error(`Error closing transport, session=${sessionId}:`, (error as Error).message);
}
}
log.info('Server shutdown complete');
process.exit(0);
});
@@ -0,0 +1,67 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { HaiiloClient } from "../haiiloClient.js";
import { handleApiError } from "../errorHandler.js";
const SUPPORTED_TYPES = ["blog-article", "wiki-article", "page", "workspace", "app", "timeline-item"];
export function registerContentTools(server: McpServer, client: HaiiloClient): void {
const s = server as any;
s.tool(
"Content_GetFull",
"Fetch the full rendered content of a specific item by its ID and type. Use ONLY for blog-article and wiki-article types — these are the only types with fetchable full content. Do NOT use for workspace, page, event, or timeline-item — use Workspace_Get, Page_Get, or Event_Get instead for those.",
{
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)"),
senderId: z.string().optional().describe("Sender/page ID (the page or workspace that contains this content)"),
slug: z.string().optional().describe("Slug (alternative identifier for pages and workspaces)"),
},
async ({ contentId, contentType, appId, senderId, slug }: any): Promise<CallToolResult> => {
try {
if (!SUPPORTED_TYPES.includes(contentType)) {
return {
isError: true,
content: [{ type: "text", text: `Unsupported content type: "${contentType}". Supported types: ${SUPPORTED_TYPES.join(", ")}` }],
};
}
const item = {
id: contentId,
typeName: contentType,
target: { params: { appId, slug, id: contentId, senderId } },
sender: { id: senderId || contentId },
slug,
};
const fullContent = await client.getContentByType(
await client.getToken(),
item
);
// Build link if possible
const baseUrl = client.getBaseUrl().replace(/\/+$/, '');
let link = '';
const ct = contentType.toLowerCase();
if (ct === 'blog-article' && senderId && appId) {
link = `${baseUrl}/pages/${senderId}/apps/blog/${appId}/view/${contentId}`;
} else if (ct === 'workspace') {
link = slug ? `${baseUrl}/workspaces/${slug}` : `${baseUrl}/workspaces/${contentId}`;
} else if (ct === 'page') {
link = slug ? `${baseUrl}/pages/${slug}` : `${baseUrl}/pages/${contentId}`;
}
let text = fullContent;
if (link) {
text += `\n\n[Auf DB Planet ansehen](${link})`;
}
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
}
@@ -0,0 +1,94 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { HaiiloClient } from "../haiiloClient.js";
import { handleApiError } from "../errorHandler.js";
export function registerEventTools(server: McpServer, client: HaiiloClient): void {
const s = server as any;
s.tool(
"Event_Get",
"Get event details by ID. NOT for discovering or listing events — use Search_FullText or Search_QuickEntity first to find events, then call this tool with the returned event UUID.",
{ id: z.string().describe("Event UUID (use Search_FullText or Search_QuickEntity to find it)") },
async ({ id }: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: `/api/events/${id}` });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Event_GetMemberships",
"Get attendees/participants for a specific event. NOT for discovering or listing events — use Search_FullText or Search_QuickEntity first to find events. The id must be an EVENT UUID, not a user or workspace ID. Only works if the event has showParticipants enabled; otherwise returns 404.",
{ id: z.string().describe("Event UUID (NOT a user or workspace ID — use Search_FullText or Search_QuickEntity to find events first)") },
async ({ id }: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: `/api/events/${id}/memberships` });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Event_UpdateStatus",
"Update the current user's attendance status for a specific event. NOT for discovering or listing events — use Search_FullText or Search_QuickEntity first to find events. Use this to accept (ATTENDING), decline/cancel (NOT_ATTENDING), or mark as tentative (MAYBE_ATTENDING). Requires the event UUID.",
{
id: z.string().describe("Event UUID (use Search_FullText or Search_QuickEntity to find it)"),
status: z.string().describe("Attendance status. Valid values: ATTENDING, NOT_ATTENDING, MAYBE_ATTENDING"),
},
async ({ id, status }: any): Promise<CallToolResult> => {
try {
await client.request<any>({ method: "PUT", path: `/api/events/${id}/status`, data: { status } });
return { content: [{ type: "text", text: `Attendance status updated to "${status}" for event ${id}.` }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Event_Create",
"Create a new event on DB Planet.",
{
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"),
},
async ({ title, description, startDate, endDate, location }: any): Promise<CallToolResult> => {
try {
const meData = await client.request<any>({ method: "GET", path: "/api/users/me" });
const data = await client.request<any>({
method: "POST",
path: "/api/events",
data: {
name: title,
description: description || "",
place: location || null,
startDate,
endDate,
fullDay: false,
public: true,
showParticipants: true,
requestDefiniteAnswer: false,
hostId: { id: meData.id, typeName: "user" },
adminIds: [meData.id],
},
});
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
}
@@ -0,0 +1,23 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { HaiiloClient } from "../haiiloClient.js";
import { registerSearchTools } from "./searchTools.js";
import { registerWorkspaceTools } from "./workspaceTools.js";
import { registerNewsTools } from "./newsTools.js";
import { registerPeopleTools } from "./peopleTools.js";
import { registerEventTools } from "./eventTools.js";
import { registerSocialTools } from "./socialTools.js";
import { registerNotificationTools } from "./notificationTools.js";
import { registerPageTools } from "./pageTools.js";
import { registerContentTools } from "./contentTools.js";
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);
}
@@ -0,0 +1,35 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { HaiiloClient } from "../haiiloClient.js";
import { handleApiError } from "../errorHandler.js";
export function registerNewsTools(server: McpServer, client: HaiiloClient): void {
const s = server as any;
s.tool(
"News_GetFeed",
"Retrieve the news feed from DB Planet. Returns blog articles from pages the user is subscribed to.",
{
includePublished: z.boolean().optional().describe("Filter for published articles, default true"),
includeScheduled: z.boolean().optional().describe("Filter for scheduled articles, default false"),
includeDrafts: z.boolean().optional().describe("Filter for draft articles, default false"),
limitDate: z.string().optional().describe("Limit results to time frame, format YYYY-MM or YYYY"),
},
async ({ includePublished, includeScheduled, includeDrafts, limitDate }: any): Promise<CallToolResult> => {
try {
const params: Record<string, any> = {};
if (includePublished !== undefined) params.includePublished = includePublished;
if (includeScheduled !== undefined) params.includeScheduled = includeScheduled;
if (includeDrafts !== undefined) params.includeDrafts = includeDrafts;
if (limitDate !== undefined) params.limitDate = limitDate;
const data = await client.request<any>({ method: "GET", path: "/web/blog/newsfeed", params });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
}
@@ -0,0 +1,66 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { HaiiloClient } from "../haiiloClient.js";
import { handleApiError } from "../errorHandler.js";
export function registerNotificationTools(server: McpServer, client: HaiiloClient): void {
const s = server as any;
s.tool(
"Notification_List",
"List notifications with their read/unread status.",
{
category: z.string().optional().describe("Notification category (e.g., ACTIVITY, DISCUSSION)"),
page: z.number().optional().describe("Page number (0-based)"),
pageSize: z.number().optional().describe("Notifications per page"),
},
async ({ category, page, pageSize }: any): Promise<CallToolResult> => {
try {
const params: Record<string, any> = {};
if (category !== undefined) params.category = category;
if (page !== undefined) params._page = page;
if (pageSize !== undefined) params._pageSize = pageSize;
const data = await client.request<any>({ method: "GET", path: "/api/notifications", params });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Notification_GetStatus",
"Get the count of unseen notifications.",
{},
async (_: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: "/api/notifications/status" });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Notification_MarkSeen",
"Mark all notifications as seen.",
{
category: z.string().optional().describe("Notification category to mark as seen (e.g., ACTIVITY, DISCUSSION). If omitted, marks all."),
},
async ({ category }: any): Promise<CallToolResult> => {
try {
const params: Record<string, any> = {};
if (category !== undefined) params.category = category;
await client.request<any>({ method: "PUT", path: "/api/notifications/action/mark-seen", params });
return { content: [{ type: "text", text: "Notifications marked as seen." }] };
} catch (error) {
return handleApiError(error);
}
}
);
}
@@ -0,0 +1,87 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { HaiiloClient } from "../haiiloClient.js";
import { handleApiError } from "../errorHandler.js";
export function registerPageTools(server: McpServer, client: HaiiloClient): void {
const s = server as any;
s.tool(
"Page_List",
"List available pages on DB Planet.",
{},
async (_: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: "/api/pages" });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Page_Get",
"Get page details by ID or slug. Use Page_List or Search_QuickEntity to find the ID first.",
{ id: z.string().describe("Page UUID or slug") },
async ({ id }: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: `/api/pages/${id}` });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Page_GetMembers",
"Get members and roles for a specific page. NOT for finding or listing pages — use Page_List or Search_QuickEntity first to find the page ID.",
{ id: z.string().describe("Page UUID (use Page_List or Search_QuickEntity to find it)") },
async ({ id }: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: `/api/pages/${id}/members` });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Page_Create",
"Create a new page on DB Planet.",
{
title: z.string().describe("Page title"),
content: z.string().optional().describe("Page content"),
visibility: z.string().optional().describe("Visibility: PUBLIC or PRIVATE. Defaults to PUBLIC."),
defaultLanguage: z.string().optional().describe("Default language (e.g., EN, DE). Defaults to EN."),
},
async ({ title, content, visibility, defaultLanguage }: any): Promise<CallToolResult> => {
try {
const meData = await client.request<any>({ method: "GET", path: "/api/users/me" });
const data = await client.request<any>({
method: "POST",
path: "/api/pages",
data: {
id: null,
slug: null,
name: title,
visibility: visibility || "PUBLIC",
defaultLanguage: defaultLanguage || "EN",
adminIds: [meData.id],
translations: {},
},
});
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
}
@@ -0,0 +1,118 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { HaiiloClient } from "../haiiloClient.js";
import { handleApiError } from "../errorHandler.js";
export function registerPeopleTools(server: McpServer, client: HaiiloClient): void {
const s = server as any;
s.tool(
"People_GetMe",
"Get the current authenticated user's profile information.",
{},
async (_: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: "/api/users/me" });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"People_Get",
"Get a user's profile by their UUID. NOT for searching or finding people — use People_Search (by name) or Search_QuickEntity first to find the user ID.",
{ id: z.string().describe("User UUID (use People_Search or Search_QuickEntity to find it)") },
async ({ id }: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: `/api/users/${id}` });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"People_List",
"List users on DB Planet with optional pagination.",
{
page: z.number().optional().describe("Page number (0-based)"),
pageSize: z.number().optional().describe("Users per page"),
},
async ({ page, pageSize }: any): Promise<CallToolResult> => {
try {
const params: Record<string, any> = {};
if (page !== undefined) params._page = page;
if (pageSize !== undefined) params._pageSize = pageSize;
const data = await client.request<any>({ method: "GET", path: "/api/users", params });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"People_Search",
"Search for people on DB Planet by name. This tool ONLY matches on user names — it cannot search by department, job title, or expertise. For topic-based people discovery, use Search_FullText instead.",
{ query: z.string().describe("Search query for people") },
async ({ query }: any): Promise<CallToolResult> => {
try {
const cleanQuery = query.replace(/^["']+|["']+$/g, '');
const data = await client.request<any>({ method: "GET", path: "/api/users/chooser/search", params: { term: cleanQuery, types: "user" } });
const items = Array.isArray(data) ? data : (data?.content || data?.results || []);
if (items.length === 0) {
return { content: [{ type: "text", text: `No people found matching "${query}". This tool only searches by name. Try Search_FullText to find people by topic, expertise, or content they authored.` }] };
}
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"People_GetManaged",
"Get all users that a given user manages (direct reports). Use People_Search or People_GetMe to find the user ID first. Returns an empty list if the user doesn't manage anyone.",
{
id: z.string().describe("User UUID of the manager"),
page: z.number().optional().describe("Page number (0-based)"),
pageSize: z.number().optional().describe("Results per page"),
},
async ({ id, page, pageSize }: any): Promise<CallToolResult> => {
try {
const params: Record<string, any> = {};
if (page !== undefined) params._page = page;
if (pageSize !== undefined) params._pageSize = pageSize;
const data = await client.request<any>({ method: "GET", path: `/api/users/${id}/managed`, params });
const items = data?.content || (Array.isArray(data) ? data : []);
if (items.length === 0) {
return { content: [{ type: "text", text: "This user does not manage any other users." }] };
}
let text = `This user manages ${items.length} direct report(s):\n\n`;
items.forEach((user: any, i: number) => {
text += `[${i + 1}] ${user.displayName || user.firstname + ' ' + user.lastname || "Unknown"}\n`;
text += ` ID: ${user.id}\n`;
if (user.email) text += ` Email: ${user.email}\n`;
if (user.properties?.department) text += ` Department: ${user.properties.department}\n`;
if (user.properties?.jobTitle) text += ` Job Title: ${user.properties.jobTitle}\n`;
text += '\n';
});
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
}
@@ -0,0 +1,155 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { HaiiloClient } from "../haiiloClient.js";
import { handleApiError } from "../errorHandler.js";
function buildLink(baseUrl: string, item: any): string {
const targetParams = item.target?.params || {};
const typeName = (item.typeName || "").toLowerCase();
const senderId = targetParams.senderId || item.sender?.id || '';
if (typeName === 'blog-article' || typeName.includes('blog')) {
const appId = targetParams.appId || '';
if (senderId && appId) return `${baseUrl}/pages/${senderId}/apps/blog/${appId}/view/${item.id}`;
} else if (typeName === 'wiki-article' || typeName.includes('wiki')) {
// Wiki article URL pattern unclear — skip link generation
return '';
} else if (typeName === 'workspace') {
const slug = targetParams.slug || '';
return slug ? `${baseUrl}/workspaces/${slug}` : `${baseUrl}/workspaces/${item.id}`;
} else if (typeName === 'page') {
const slug = targetParams.slug || '';
return slug ? `${baseUrl}/pages/${slug}` : `${baseUrl}/pages/${item.id}`;
} else if (typeName === 'event') {
const slug = targetParams.slug || '';
return slug ? `${baseUrl}/events/${slug}` : `${baseUrl}/events/${item.id}`;
} else if (typeName === 'app') {
const senderSlug = targetParams.senderSlug || item.sender?.slug || '';
if (senderSlug) return `${baseUrl}/pages/${senderSlug}/apps/content/${item.id}`;
} else if (typeName === 'user') {
const slug = targetParams.slug || '';
return slug ? `${baseUrl}/users/${slug}` : '';
} else if (typeName === 'file') {
// File URLs require an app ID we don't have — skip link generation
return '';
} else if (typeName === 'timeline-item') {
const senderSlug = item.sender?.slug || '';
const senderType = item.sender?.typeName || targetParams.senderType || '';
if (senderSlug) {
if (senderType === 'workspace') return `${baseUrl}/workspaces/${senderSlug}/apps/timeline/timeline`;
if (senderType === 'page') return `${baseUrl}/pages/${senderSlug}/apps/timeline/timeline`;
}
}
return '';
}
function formatItem(baseUrl: string, item: any, index: number): string {
const targetParams = item.target?.params || {};
const title = (item.displayName || "Untitled").replace(/<\/?em>/g, '');
const link = buildLink(baseUrl, item);
let text = `[${index}] ${link ? `[${title}](${link})` : title}\n`;
text += ` ID: ${item.id}\n`;
text += ` Type: ${item.typeName || "Unknown"}\n`;
if (targetParams.appId) text += ` AppID: ${targetParams.appId}\n`;
if (targetParams.senderId || item.sender?.id) text += ` SenderID: ${targetParams.senderId || item.sender?.id}\n`;
text += ` Excerpt: ${item.excerpt || "—"}\n`;
text += ` Modified: ${item.modified || "—"}\n\n`;
return text;
}
export function registerSearchTools(server: McpServer, client: HaiiloClient): void {
const s = server as any;
s.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. SEARCH TIP: Use short, broad keyword queries (1-2 core terms) for best results. Avoid long phrases or full sentences — the API performs keyword matching, not semantic search.",
{
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 }: any): Promise<CallToolResult> => {
try {
// Strip surrounding quotes that LLMs sometimes add to search terms
const cleanQuery = query.replace(/^["']+|["']+$/g, '');
const params: Record<string, any> = { term: cleanQuery };
if (page !== undefined) params._page = page;
if (pageSize !== undefined) params._pageSize = pageSize;
const data = await client.request<any>({ method: "GET", path: "/api/search", params });
const items = data.content || [];
if (items.length === 0) {
return { content: [{ type: "text", text: `No results found for "${query}". Try a different search tool or rephrase the query.` }] };
}
let text = `Found ${items.length} results for "${query}":\n\n`;
const baseUrl = client.getBaseUrl().replace(/\/+$/, '');
items.forEach((item: any, i: number) => {
text += formatItem(baseUrl, item, i + 1);
});
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Search_QuickEntity",
"Quick entity search across DB Planet. Returns matching entities for fast lookups by name (e.g. people, workspaces, pages). Best for finding specific known entities. For broader topic-based search, use Search_FullText instead.",
{ query: z.string().describe("Quick search query") },
async ({ query }: any): Promise<CallToolResult> => {
try {
const cleanQuery = query.replace(/^["']+|["']+$/g, '');
const data = await client.request<any>({ method: "GET", path: "/api/quick-entity-search", params: { term: cleanQuery } });
const items = Array.isArray(data) ? data : (data?.content || data?.results || []);
if (items.length === 0) {
return { content: [{ type: "text", text: `No entities found for "${query}". Try Search_FullText for a broader content search.` }] };
}
const baseUrl = client.getBaseUrl().replace(/\/+$/, '');
let text = `Found ${items.length} entities for "${query}":\n\n`;
items.forEach((item: any, i: number) => {
text += formatItem(baseUrl, item, i + 1);
});
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Search_Grouped",
"Search DB Planet and return results grouped by content type (e.g. blog articles, wiki pages, workspaces).",
{ query: z.string().describe("Search query term") },
async ({ query }: any): Promise<CallToolResult> => {
try {
const cleanQuery = query.replace(/^["']+|["']+$/g, '');
const data = await client.request<any>({ method: "GET", path: "/api/search/grouped", params: { term: cleanQuery } });
const groups = data?.groups || {};
const totalElements = data?.totalElements || 0;
if (Object.keys(groups).length === 0) {
return { content: [{ type: "text", text: `No grouped results found for "${query}".` }] };
}
const baseUrl = client.getBaseUrl().replace(/\/+$/, '');
let text = `Found ${totalElements} results for "${query}" grouped by type:\n\n`;
let globalIndex = 1;
for (const [groupName, groupItems] of Object.entries(groups)) {
const items = groupItems as any[];
text += `--- ${groupName} (${items.length}) ---\n\n`;
items.forEach((item: any) => {
text += formatItem(baseUrl, item, globalIndex++);
});
}
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
}
@@ -0,0 +1,93 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { HaiiloClient } from "../haiiloClient.js";
import { handleApiError } from "../errorHandler.js";
export function registerSocialTools(server: McpServer, client: HaiiloClient): void {
const s = server as any;
s.tool(
"Social_GetComments",
"Get comments for a specific entity on DB Planet.",
{
targetId: z.string().describe("UUID of the entity (use Search_QuickEntity or Search_FullText to find it)"),
targetType: z.string().describe("Type name of the target. Only known valid types: blog-article, timeline-item, wiki-article. Other types like event are NOT supported."),
},
async ({ targetId, targetType }: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: "/api/comments", params: { targetId, targetType } });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Social_PostComment",
"Post a comment on a specific entity on DB Planet.",
{
targetId: z.string().describe("UUID of the entity (use Search_QuickEntity or Search_FullText to find it)"),
targetType: z.string().describe("Type name of the target. Only known valid types: blog-article, timeline-item, wiki-article. Other types like event are NOT supported."),
body: z.string().describe("Comment text"),
},
async ({ targetId, targetType, body }: any): Promise<CallToolResult> => {
try {
const meData = await client.request<any>({ method: "GET", path: "/api/users/me" });
const data = await client.request<any>({
method: "POST",
path: "/api/comments",
data: {
authorId: meData.id,
targetId,
targetType,
message: body,
attachments: [],
fileLibraryAttachments: [],
},
});
return { content: [{ type: "text", text: "Comment posted successfully." }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Social_GetLikes",
"Get like count and whether the current user has liked a specific target.",
{
targetType: z.string().describe("Type of the like target. Only known valid types: blog-article, timeline-item, wiki-article. Other types like event are NOT supported."),
targetId: z.string().describe("UUID of the target (use Search_QuickEntity or Search_FullText to find it)"),
},
async ({ targetType, targetId }: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: `/api/like-targets/${targetType}/${targetId}/likes` });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Social_ToggleLike",
"Toggle a like on a specific target for a user.",
{
targetType: z.string().describe("Type of the like target. Only known valid types: blog-article, timeline-item, wiki-article. Other types like event are NOT supported."),
targetId: z.string().describe("UUID of the target (use Search_QuickEntity or Search_FullText to find it)"),
userId: z.string().describe("User UUID (use People_GetMe or People_Search to find it)"),
},
async ({ targetType, targetId, userId }: any): Promise<CallToolResult> => {
try {
await client.request<any>({ method: "POST", path: `/api/like-targets/${targetType}/${targetId}/likes/${userId}` });
return { content: [{ type: "text", text: "Like toggled successfully." }] };
} catch (error) {
return handleApiError(error);
}
}
);
}
@@ -0,0 +1,156 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { HaiiloClient } from "../haiiloClient.js";
import { handleApiError } from "../errorHandler.js";
export function registerWorkspaceTools(server: McpServer, client: HaiiloClient): void {
const s = server as any;
s.tool(
"Workspace_List",
"List workspaces on DB Planet. Use memberOnly=true to show only workspaces the current user is a member of (recommended for 'my workspaces' queries). Without the filter, returns one page of workspaces.",
{
memberOnly: z.boolean().optional().describe("If true, only return workspaces where the current user is a member or admin. Defaults to false."),
page: z.number().optional().describe("Page number (0-based). Defaults to 0."),
pageSize: z.number().optional().describe("Number of workspaces per page. Defaults to 20."),
},
async ({ memberOnly, page, pageSize }: any): Promise<CallToolResult> => {
try {
if (memberOnly) {
// Paginate through all workspaces to find the user's memberships
const allMyWorkspaces: any[] = [];
let currentPage = 0;
const batchSize = 100;
let hasMore = true;
while (hasMore) {
const data = await client.request<any>({
method: "GET",
path: "/api/workspaces",
params: { _page: currentPage, _pageSize: batchSize },
});
const items = data?.content || (Array.isArray(data) ? data : []);
for (const ws of items) {
if (ws.membershipStatus && ws.membershipStatus !== "NONE") {
allMyWorkspaces.push(ws);
}
}
hasMore = items.length === batchSize;
currentPage++;
}
if (allMyWorkspaces.length === 0) {
return { content: [{ type: "text", text: "You are not a member of any workspaces." }] };
}
let text = `You are a member of ${allMyWorkspaces.length} workspace(s):\n\n`;
allMyWorkspaces.forEach((ws: any, i: number) => {
text += `[${i + 1}] ${ws.name || ws.displayName || "Untitled"}\n`;
text += ` ID: ${ws.id}\n`;
text += ` Slug: ${ws.slug || "—"}\n`;
text += ` Role: ${ws.membershipStatus}\n`;
text += ` Visibility: ${ws.visibility || "—"}\n`;
text += ` Members: ${ws.memberCount || 0}\n`;
text += ` Description: ${(ws.description || "—").substring(0, 150)}\n\n`;
});
return { content: [{ type: "text", text }] };
}
// Default: return a single page
const params: Record<string, any> = {};
if (page !== undefined) params._page = page;
if (pageSize !== undefined) params._pageSize = pageSize;
const data = await client.request<any>({ method: "GET", path: "/api/workspaces", params });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Workspace_Get",
"Get workspace details by ID or slug. The id parameter must be a WORKSPACE UUID or slug, NOT a user ID. Use Workspace_List or Search_QuickEntity to find workspace IDs first.",
{ id: z.string().describe("Workspace UUID or slug (NOT a user ID)") },
async ({ id }: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: `/api/workspaces/${id}` });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Workspace_GetMembers",
"Get members of a specific workspace. NOT for finding or listing workspaces — use Workspace_List or Search_QuickEntity first to find workspace IDs. The id parameter must be a WORKSPACE UUID, NOT a user ID.",
{ id: z.string().describe("Workspace UUID (NOT a user ID — use Workspace_List or Search_QuickEntity to find it)") },
async ({ id }: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "GET", path: `/api/workspaces/${id}/members` });
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Workspace_Join",
"Join a workspace as the current user. Use Workspace_List or Search_QuickEntity to find the ID first.",
{ id: z.string().describe("Workspace UUID") },
async ({ id }: any): Promise<CallToolResult> => {
try {
const data = await client.request<any>({ method: "PUT", path: `/api/workspaces/${id}/users/join` });
return { content: [{ type: "text", text: `Successfully joined workspace ${id}.` }] };
} catch (error) {
return handleApiError(error);
}
}
);
s.tool(
"Workspace_Create",
"Create a new workspace on DB Planet.",
{
name: z.string().describe("Workspace name"),
visibility: z.string().optional().describe("Visibility: PUBLIC, CLOSED, or PRIVATE. Defaults to PUBLIC."),
},
async ({ name, visibility }: any): Promise<CallToolResult> => {
try {
const meData = await client.request<any>({ method: "GET", path: "/api/users/me" });
const data = await client.request<any>({
method: "POST",
path: "/api/workspaces",
data: {
id: null,
slug: null,
name,
description: "",
categoryIds: [],
adminIds: [meData.id],
adminGroupIds: [],
memberIds: [],
memberGroupIds: [],
visibility: visibility || "PUBLIC",
defaultLanguage: null,
translations: {},
archived: false,
},
});
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
return { content: [{ type: "text", text }] };
} catch (error) {
return handleApiError(error);
}
}
);
}
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import { AxiosError, AxiosHeaders } from "axios";
import { handleApiError } from "../../src/errorHandler.js";
function makeAxiosError(
status: number,
statusText: string = "Error"
): AxiosError {
const headers = new AxiosHeaders();
const error = new AxiosError("Request failed", "ERR_BAD_REQUEST", undefined, undefined, {
status,
statusText,
headers,
config: { headers },
data: {},
});
return error;
}
function makeTimeoutError(): AxiosError {
const error = new AxiosError("timeout of 5000ms exceeded", "ECONNABORTED");
return error;
}
describe("handleApiError", () => {
it("maps 401 to authentication failed message", () => {
const result = handleApiError(makeAxiosError(401));
expect(result.isError).toBe(true);
expect(result.content[0]).toEqual({
type: "text",
text: "[401] Authentication failed. Check DB Planet credentials.",
});
});
it("maps 403 to insufficient permissions message", () => {
const result = handleApiError(makeAxiosError(403));
expect(result.isError).toBe(true);
expect(result.content[0]).toEqual({
type: "text",
text: "[403] Insufficient permissions to access this resource.",
});
});
it("maps 404 to not found message", () => {
const result = handleApiError(makeAxiosError(404));
expect(result.isError).toBe(true);
expect(result.content[0]).toEqual({
type: "text",
text: "[404] The requested resource was not found.",
});
});
it("maps timeout (ECONNABORTED) to timed out message", () => {
const result = handleApiError(makeTimeoutError());
expect(result.isError).toBe(true);
expect(result.content[0]).toEqual({
type: "text",
text: "[TIMEOUT] Request timed out.",
});
});
it("maps other HTTP status codes to status + statusText", () => {
const result = handleApiError(makeAxiosError(500, "Internal Server Error"));
expect(result.isError).toBe(true);
expect(result.content[0]).toEqual({
type: "text",
text: "[500] Internal Server Error",
});
});
it("handles non-Axios errors with message", () => {
const result = handleApiError(new Error("Something broke"));
expect(result.isError).toBe(true);
expect(result.content[0]).toEqual({
type: "text",
text: "[ERROR] Something broke",
});
});
it("handles unknown non-Error values", () => {
const result = handleApiError("string error");
expect(result.isError).toBe(true);
expect(result.content[0]).toEqual({
type: "text",
text: "[ERROR] An unknown error occurred",
});
});
});
@@ -0,0 +1,54 @@
# DB Planet MCP Tools — Test Results
| # | Tool | Status | Test Query | API Call | Notes |
|---|------|--------|------------|----------|-------|
| 1 | Search_FullText | ✅ | `query: "test", pageSize: 2` | GET /api/search?term=test&_pageSize=2 | Returns metadata with AppID and Slug |
| 2 | Search_QuickEntity | ✅ | `query: "test"` | GET /api/quick-entity-search?term=test | Returns users, events, entities (91 total) |
| 3 | Search_Grouped | ✅ | `query: "test"` | GET /api/search/grouped?term=test | Returns results grouped by type (1322 total) |
| 4 | Workspace_List | ✅ | — | GET /api/workspaces | Returns 1156 workspaces |
| 5 | Workspace_Get | ✅ | `id: "2c47c013-..."` | GET /api/workspaces/{id} | Returns workspace details |
| 6 | Workspace_GetMembers | ✅ | `id: "2c47c013-..."` | GET /api/workspaces/{id}/members | Returns 2 members with roles |
| 7 | Workspace_Join | ✅ | `id: "36e6cd0b-..."` | PUT /api/workspaces/{id}/users/join | Fixed path from /members/join to /users/join |
| 8 | Workspace_Create | ✅ | `name: "MCP Test Workspace", visibility: "PUBLIC"` | POST /api/workspaces | Fixed: added all required null/empty fields |
| 9 | News_GetFeed | ⚠️ 404 | — | GET /web/blog/newsfeed | Staging limitation — endpoint doesn't exist |
| 10 | People_GetMe | ✅ | — | GET /api/users/me | Returns user profile (Team BahnGPT) |
| 11 | People_List | ✅ | `pageSize: 2` | GET /api/users | Returns 67 users |
| 12 | People_Search | ✅ | `query: "Alexandra"` | GET /api/users/chooser/search?term=Alexandra&types=user | Fixed: param `query``term`, added `types=user` |
| 13 | Event_Get | ✅ | `id: "b59a52a7-..."` | GET /api/events/{id} | Returns event details (test 6, FFM) |
| 14 | Event_GetMemberships | ✅ | `id: "b59a52a7-..."` | GET /api/events/{id}/memberships | Returns 0 attendees (empty event) |
| 15 | Event_UpdateStatus | ✅ | `id: "de7a9f75-...", status: "ATTENDING"` | PUT /api/events/{id}/status | Fixed: path from /memberships/status to /status. Valid statuses: ATTENDING, NOT_ATTENDING, TENTATIVE |
| 16 | Event_Create | ✅ | `title: "MCP Test Event"` | POST /api/events | Fixed: field names title→name, location→place, added hostId and adminIds |
| 17 | Social_GetComments | ✅ | `targetId: "53193ed9-...", targetType: "blog-article"` | GET /api/comments?targetId=...&targetType=blog-article | Fixed: added required `targetType` param. Returns 10 comments |
| 18 | Social_PostComment | ✅ | `targetId: "53193ed9-...", targetType: "blog-article", body: "Test comment"` | POST /api/comments | Fixed: added authorId, targetType, renamed body→message |
| 19 | Social_GetLikes | ✅ | `targetType: "blog-article", targetId: "20bd15ca-..."` | GET /api/like-targets/{type}/{id}/likes | Returns likes list |
| 20 | Social_ToggleLike | ✅ | `targetType: "blog-article", targetId: "53193ed9-...", userId: "dd5ee53a-..."` | POST /api/like-targets/{type}/{id}/likes/{userId} | Like toggled successfully |
| 21 | Notification_List | ✅ | `category: "ACTIVITY", pageSize: 3` | GET /api/notifications?category=ACTIVITY | Fixed: added required `category` param. Returns 1 notification |
| 22 | Notification_GetStatus | ✅ | — | GET /api/notifications/status | Returns unseen count |
| 23 | Notification_MarkSeen | ✅ | `category: "ACTIVITY"` | PUT /api/notifications/action/mark-seen | Fixed: path `/mark-seen``/action/mark-seen`, added `category` param |
| 24 | Page_List | ✅ | — | GET /api/pages | Returns 358 pages |
| 25 | Page_Get | ✅ | `id: "966b680c-..."` | GET /api/pages/{id} | Returns DB Fernverkehr page |
| 26 | Page_GetMembers | ✅ | `id: "966b680c-..."` | GET /api/pages/{id}/members | Returns 1 admin ID |
| 27 | Page_Create | ⚠️ 404 | `title: "MCP Test Page"` | POST /api/pages | Staging limitation — endpoint doesn't exist |
| 28 | Content_GetFull | ✅ | `contentType: "blog-article", contentId: "20bd15ca-...", appId: "ec11b7d7-..."` | GET (varies) | Works with appId from Search_FullText |
## Summary
- ✅ Working: 26 tools
- ⚠️ Staging limitation: 2 tools (News_GetFeed, Page_Create — endpoints don't exist on staging)
## All Fixes Applied
1. **Search_QuickEntity**: param `query``term`
2. **Search_Grouped**: param `query``term`
3. **Search_FullText**: added AppID and Slug to output for Content_GetFull
4. **People_Search**: param `query``term`, added `types=user`
5. **Social_GetComments**: added required `targetType` param
6. **Social_PostComment**: added `authorId`, `targetType`, renamed `body``message`
7. **Social_GetLikes**: path added `/likes` suffix
8. **Notification_List**: added `category` param
9. **Notification_MarkSeen**: path `/mark-seen``/action/mark-seen`, added `category` param
10. **Workspace_Join**: path `/members/join``/users/join`
11. **Workspace_Create**: added all required null/empty fields matching browser payload
12. **Event_Create**: field names `title``name`, `location``place`, added `hostId` and `adminIds`
13. **Event_UpdateStatus**: path `/memberships/status``/status`
14. **Content_GetFull**: works with appId now included in Search_FullText output
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"lib": ["ES2020"],
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts', 'tests/**/*.property.test.ts'],
},
});