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,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);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user