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:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
.kiro/settings/mcp.json
|
||||
debug-output.md
|
||||
@@ -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.1–4.5, 5.1, 6.1–6.3, 7.1–7.4, 8.1–8.4, 9.1, 9.3, 9.4, 10.1–10.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`
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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** | |
|
||||
@@ -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"
|
||||
@@ -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
|
||||
+4053
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user