dav-mcp 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +260 -0
  4. package/package.json +80 -0
  5. package/src/error-handler.js +215 -0
  6. package/src/formatters.js +754 -0
  7. package/src/logger.js +144 -0
  8. package/src/server-http.js +402 -0
  9. package/src/server-stdio.js +225 -0
  10. package/src/tool-call-logger.js +148 -0
  11. package/src/tools/calendar/calendar-multi-get.js +38 -0
  12. package/src/tools/calendar/calendar-query.js +98 -0
  13. package/src/tools/calendar/create-event.js +79 -0
  14. package/src/tools/calendar/delete-calendar.js +36 -0
  15. package/src/tools/calendar/delete-event.js +38 -0
  16. package/src/tools/calendar/index.js +16 -0
  17. package/src/tools/calendar/list-calendars.js +21 -0
  18. package/src/tools/calendar/list-events.js +43 -0
  19. package/src/tools/calendar/make-calendar.js +80 -0
  20. package/src/tools/calendar/update-calendar.js +106 -0
  21. package/src/tools/calendar/update-event-fields.js +119 -0
  22. package/src/tools/calendar/update-event-raw.js +45 -0
  23. package/src/tools/contacts/addressbook-multi-get.js +38 -0
  24. package/src/tools/contacts/addressbook-query.js +85 -0
  25. package/src/tools/contacts/create-contact.js +84 -0
  26. package/src/tools/contacts/delete-contact.js +38 -0
  27. package/src/tools/contacts/index.js +13 -0
  28. package/src/tools/contacts/list-addressbooks.js +21 -0
  29. package/src/tools/contacts/list-contacts.js +32 -0
  30. package/src/tools/contacts/update-contact-fields.js +135 -0
  31. package/src/tools/contacts/update-contact-raw.js +45 -0
  32. package/src/tools/index.js +57 -0
  33. package/src/tools/shared/helpers.js +132 -0
  34. package/src/tools/todos/create-todo.js +101 -0
  35. package/src/tools/todos/delete-todo.js +38 -0
  36. package/src/tools/todos/index.js +12 -0
  37. package/src/tools/todos/list-todos.js +30 -0
  38. package/src/tools/todos/todo-multi-get.js +37 -0
  39. package/src/tools/todos/todo-query.js +112 -0
  40. package/src/tools/todos/update-todo-fields.js +119 -0
  41. package/src/tools/todos/update-todo-raw.js +46 -0
  42. package/src/tsdav-client.js +199 -0
  43. package/src/utils/tool-helpers.js +388 -0
  44. package/src/validation.js +245 -0
package/src/logger.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Simple JSON logger with millisecond precision
3
+ * Outputs structured JSON logs to stdout (HTTP) or stderr (STDIO)
4
+ *
5
+ * CRITICAL: In STDIO mode, all output MUST go to stderr to avoid
6
+ * corrupting JSON-RPC messages on stdout.
7
+ */
8
+
9
+ // Detect STDIO transport mode - must write to stderr to preserve stdout for JSON-RPC
10
+ const isStdioMode = process.env.MCP_TRANSPORT === 'stdio';
11
+
12
+ class JSONLogger {
13
+ constructor(context = {}, level = 'info') {
14
+ this.context = context;
15
+ this.level = level;
16
+ this.levels = { error: 0, warn: 1, info: 2, debug: 3 };
17
+ this.minLevel = this.levels[process.env.LOG_LEVEL?.toLowerCase() || 'info'] || 2;
18
+ }
19
+
20
+ /**
21
+ * Format timestamp with milliseconds (HH:MM:ss.mmm)
22
+ */
23
+ formatTimestamp() {
24
+ const now = new Date();
25
+ const hours = String(now.getHours()).padStart(2, '0');
26
+ const minutes = String(now.getMinutes()).padStart(2, '0');
27
+ const seconds = String(now.getSeconds()).padStart(2, '0');
28
+ const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
29
+ return `${hours}:${minutes}:${seconds}.${milliseconds}`;
30
+ }
31
+
32
+ /**
33
+ * Log a message at the specified level
34
+ */
35
+ log(level, objOrMsg, msg) {
36
+ // Check if level should be logged
37
+ if (this.levels[level] > this.minLevel) {
38
+ return;
39
+ }
40
+
41
+ const timestamp = this.formatTimestamp();
42
+ let logData = {
43
+ time: timestamp,
44
+ level: level.toUpperCase(),
45
+ ...this.context,
46
+ };
47
+
48
+ // Handle both log(level, obj, msg) and log(level, msg) patterns
49
+ if (typeof objOrMsg === 'string') {
50
+ logData.msg = objOrMsg;
51
+ } else if (typeof objOrMsg === 'object' && objOrMsg !== null) {
52
+ logData = { ...logData, ...objOrMsg };
53
+ if (msg) {
54
+ logData.msg = msg;
55
+ }
56
+ }
57
+
58
+ // Output function: stderr for STDIO mode, stdout for HTTP mode
59
+ const output = isStdioMode
60
+ ? (msg) => process.stderr.write(msg + '\n')
61
+ : (msg) => console.log(msg);
62
+
63
+ // Pretty-print in development, single-line JSON in production
64
+ if (process.env.NODE_ENV !== 'production') {
65
+ // Development: colored output with readable format
66
+ const colorMap = {
67
+ error: '\x1b[31m', // Red
68
+ warn: '\x1b[33m', // Yellow
69
+ info: '\x1b[32m', // Green
70
+ debug: '\x1b[36m', // Cyan
71
+ };
72
+ const reset = '\x1b[0m';
73
+ const color = colorMap[level] || '';
74
+
75
+ const { time, level: lvl, msg: message, ...rest } = logData;
76
+ const extraFields = Object.keys(rest).length > 0
77
+ ? ' ' + JSON.stringify(rest)
78
+ : '';
79
+
80
+ output(`[${time}] ${color}${lvl}${reset}: ${message || ''}${extraFields}`);
81
+ } else {
82
+ // Production: single-line JSON
83
+ output(JSON.stringify(logData));
84
+ }
85
+ }
86
+
87
+ info(objOrMsg, msg) {
88
+ this.log('info', objOrMsg, msg);
89
+ }
90
+
91
+ warn(objOrMsg, msg) {
92
+ this.log('warn', objOrMsg, msg);
93
+ }
94
+
95
+ error(objOrMsg, msg) {
96
+ this.log('error', objOrMsg, msg);
97
+ }
98
+
99
+ debug(objOrMsg, msg) {
100
+ this.log('debug', objOrMsg, msg);
101
+ }
102
+
103
+ /**
104
+ * Create child logger with additional context
105
+ */
106
+ child(additionalContext) {
107
+ return new JSONLogger(
108
+ { ...this.context, ...additionalContext },
109
+ this.level
110
+ );
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Main logger instance
116
+ */
117
+ export const logger = new JSONLogger();
118
+
119
+ /**
120
+ * Create child logger with context
121
+ */
122
+ export function createContextLogger(context) {
123
+ return logger.child(context);
124
+ }
125
+
126
+ /**
127
+ * Create logger with request ID for tracing
128
+ */
129
+ export function createRequestLogger(requestId, additionalContext = {}) {
130
+ return logger.child({
131
+ requestId,
132
+ ...additionalContext,
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Create logger with session ID for tracing
138
+ */
139
+ export function createSessionLogger(sessionId, additionalContext = {}) {
140
+ return logger.child({
141
+ sessionId,
142
+ ...additionalContext,
143
+ });
144
+ }
@@ -0,0 +1,402 @@
1
+ /**
2
+ * dav-mcp Streamable HTTP Server (Stateless)
3
+ *
4
+ * Modern MCP transport for remote clients (n8n, cloud deployments)
5
+ * Implements the MCP Streamable HTTP specification in stateless mode.
6
+ *
7
+ * Each request is independent - no session state maintained.
8
+ * Suitable for horizontal scaling and multi-node deployments.
9
+ *
10
+ * Usage:
11
+ * node src/server-http.js
12
+ *
13
+ * Configuration via environment variables:
14
+ * - PORT: Server port (default: 3000)
15
+ * - BEARER_TOKEN: Required for authentication
16
+ * - CORS_ALLOWED_ORIGINS: Comma-separated list of allowed origins
17
+ * - CALDAV_SERVER_URL, CALDAV_USERNAME, CALDAV_PASSWORD: CalDAV credentials
18
+ */
19
+
20
+ import express from 'express';
21
+ import cors from 'cors';
22
+ import crypto from 'crypto';
23
+ import dotenv from 'dotenv';
24
+ import rateLimit from 'express-rate-limit';
25
+
26
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
27
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
28
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
29
+
30
+ import { tsdavManager } from './tsdav-client.js';
31
+ import { tools } from './tools/index.js';
32
+ import { createToolErrorResponse, MCP_ERROR_CODES } from './error-handler.js';
33
+ import { logger, createRequestLogger } from './logger.js';
34
+ import { initializeToolCallLogger, getToolCallLogger } from './tool-call-logger.js';
35
+
36
+ // Load environment variables
37
+ dotenv.config();
38
+
39
+ const app = express();
40
+ const PORT = process.env.PORT || 3000;
41
+
42
+ // CORS Configuration
43
+ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
44
+ ? process.env.CORS_ALLOWED_ORIGINS.split(',')
45
+ : ['http://localhost:5678', 'http://localhost:3000'];
46
+
47
+ app.use(cors({
48
+ origin: (origin, callback) => {
49
+ if (!origin) return callback(null, true);
50
+ if (allowedOrigins.indexOf(origin) !== -1 || allowedOrigins.includes('*')) {
51
+ callback(null, true);
52
+ } else {
53
+ callback(new Error('Not allowed by CORS'));
54
+ }
55
+ },
56
+ credentials: true,
57
+ }));
58
+
59
+ // Body parser
60
+ app.use(express.json());
61
+
62
+ // Rate Limiting
63
+ const limiter = rateLimit({
64
+ windowMs: 15 * 60 * 1000,
65
+ max: (req) => {
66
+ const ip = req.ip || req.connection.remoteAddress;
67
+ if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1' || ip?.startsWith('::ffff:172.')) {
68
+ return 10000;
69
+ }
70
+ return 100;
71
+ },
72
+ message: 'Too many requests from this IP, please try again later.',
73
+ standardHeaders: true,
74
+ legacyHeaders: false,
75
+ });
76
+
77
+ app.use('/mcp', limiter);
78
+
79
+ /**
80
+ * Bearer token authentication middleware
81
+ */
82
+ function authenticateBearer(req, res, next) {
83
+ const bearerToken = process.env.BEARER_TOKEN;
84
+
85
+ if (!bearerToken) {
86
+ logger.error('Server misconfiguration: BEARER_TOKEN not set');
87
+ return res.status(500).json({
88
+ jsonrpc: '2.0',
89
+ error: { code: -32603, message: 'Server misconfiguration: BEARER_TOKEN not set' },
90
+ id: null,
91
+ });
92
+ }
93
+
94
+ const authHeader = req.headers.authorization;
95
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
96
+ logger.warn({ ip: req.ip }, 'Unauthorized: Bearer token required');
97
+ return res.status(401).json({
98
+ jsonrpc: '2.0',
99
+ error: { code: -32001, message: 'Unauthorized: Bearer token required' },
100
+ id: null,
101
+ });
102
+ }
103
+
104
+ const token = authHeader.substring(7);
105
+
106
+ // Timing-safe comparison
107
+ const tokenBuffer = Buffer.from(token);
108
+ const secretBuffer = Buffer.from(bearerToken);
109
+
110
+ if (tokenBuffer.length !== secretBuffer.length || !crypto.timingSafeEqual(tokenBuffer, secretBuffer)) {
111
+ logger.warn({ ip: req.ip }, 'Unauthorized: Invalid token');
112
+ return res.status(401).json({
113
+ jsonrpc: '2.0',
114
+ error: { code: -32001, message: 'Unauthorized: Invalid token' },
115
+ id: null,
116
+ });
117
+ }
118
+
119
+ next();
120
+ }
121
+
122
+ /**
123
+ * Initialize tsdav clients
124
+ */
125
+ async function initializeTsdav() {
126
+ try {
127
+ const authMethod = process.env.AUTH_METHOD || 'Basic';
128
+
129
+ if (authMethod === 'OAuth' || authMethod === 'Oauth') {
130
+ logger.info('Initializing with OAuth2 authentication');
131
+
132
+ if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET || !process.env.GOOGLE_REFRESH_TOKEN) {
133
+ throw new Error('OAuth2 requires GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REFRESH_TOKEN');
134
+ }
135
+
136
+ await tsdavManager.initialize({
137
+ serverUrl: process.env.GOOGLE_SERVER_URL || 'https://apidata.googleusercontent.com/caldav/v2/',
138
+ authMethod: 'OAuth',
139
+ username: process.env.GOOGLE_USER,
140
+ clientId: process.env.GOOGLE_CLIENT_ID,
141
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
142
+ refreshToken: process.env.GOOGLE_REFRESH_TOKEN,
143
+ tokenUrl: process.env.GOOGLE_TOKEN_URL || 'https://accounts.google.com/o/oauth2/token',
144
+ });
145
+
146
+ logger.info('OAuth2 clients initialized successfully');
147
+ } else {
148
+ logger.info('Initializing with Basic authentication');
149
+
150
+ if (!process.env.CALDAV_SERVER_URL || !process.env.CALDAV_USERNAME || !process.env.CALDAV_PASSWORD) {
151
+ throw new Error('Basic Auth requires CALDAV_SERVER_URL, CALDAV_USERNAME, and CALDAV_PASSWORD');
152
+ }
153
+
154
+ await tsdavManager.initialize({
155
+ serverUrl: process.env.CALDAV_SERVER_URL,
156
+ authMethod: 'Basic',
157
+ username: process.env.CALDAV_USERNAME,
158
+ password: process.env.CALDAV_PASSWORD,
159
+ });
160
+
161
+ logger.info('Basic Auth clients initialized successfully');
162
+ }
163
+ } catch (error) {
164
+ logger.error({ error: error.message }, 'Failed to initialize tsdav clients');
165
+ process.exit(1);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Create MCP Server instance for a request
171
+ */
172
+ function createMCPServer(requestId) {
173
+ const requestLogger = createRequestLogger(requestId);
174
+
175
+ const server = new Server(
176
+ {
177
+ name: process.env.MCP_SERVER_NAME || 'dav-mcp',
178
+ version: process.env.MCP_SERVER_VERSION || '3.0.0',
179
+ },
180
+ {
181
+ capabilities: {
182
+ tools: {},
183
+ },
184
+ }
185
+ );
186
+
187
+ // Register tools/list handler
188
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
189
+ requestLogger.debug({ count: tools.length }, 'tools/list request');
190
+ return {
191
+ tools: tools.map(t => ({
192
+ name: t.name,
193
+ description: t.description,
194
+ inputSchema: t.inputSchema,
195
+ })),
196
+ };
197
+ });
198
+
199
+ // Register tools/call handler
200
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
201
+ const toolCallLogger = getToolCallLogger();
202
+ const toolName = request.params.name;
203
+ const args = request.params.arguments || {};
204
+
205
+ requestLogger.info({ tool: toolName }, 'tools/call request');
206
+
207
+ const tool = tools.find(t => t.name === toolName);
208
+ if (!tool) {
209
+ requestLogger.error({ tool: toolName }, 'Tool not found');
210
+ const error = new Error(`Unknown tool: ${toolName}`);
211
+ error.code = MCP_ERROR_CODES.METHOD_NOT_FOUND;
212
+ throw error;
213
+ }
214
+
215
+ const startTime = Date.now();
216
+ toolCallLogger.logToolCallStart(toolName, args, {
217
+ requestId,
218
+ transport: 'http',
219
+ });
220
+
221
+ try {
222
+ const result = await tool.handler(args);
223
+ const duration = Date.now() - startTime;
224
+
225
+ requestLogger.info({ tool: toolName, duration }, 'Tool executed successfully');
226
+ toolCallLogger.logToolCallSuccess(toolName, args, result, {
227
+ requestId,
228
+ transport: 'http',
229
+ duration,
230
+ });
231
+
232
+ return result;
233
+ } catch (error) {
234
+ const duration = Date.now() - startTime;
235
+
236
+ requestLogger.error({ tool: toolName, error: error.message }, 'Tool execution error');
237
+ toolCallLogger.logToolCallError(toolName, args, error, {
238
+ requestId,
239
+ transport: 'http',
240
+ duration,
241
+ });
242
+
243
+ return createToolErrorResponse(error, process.env.NODE_ENV === 'development');
244
+ }
245
+ });
246
+
247
+ return server;
248
+ }
249
+
250
+ /**
251
+ * POST /mcp - Handle MCP requests (stateless)
252
+ */
253
+ app.post('/mcp', authenticateBearer, async (req, res) => {
254
+ const requestId = crypto.randomUUID();
255
+
256
+ try {
257
+ // Stateless: create new transport and server for each request
258
+ const transport = new StreamableHTTPServerTransport({
259
+ sessionIdGenerator: undefined, // Stateless mode
260
+ });
261
+
262
+ const server = createMCPServer(requestId);
263
+
264
+ // Cleanup on request close
265
+ res.on('close', () => {
266
+ transport.close();
267
+ server.close();
268
+ });
269
+
270
+ await server.connect(transport);
271
+ await transport.handleRequest(req, res, req.body);
272
+ } catch (error) {
273
+ logger.error({ requestId, error: error.message }, 'Error handling MCP request');
274
+ if (!res.headersSent) {
275
+ res.status(500).json({
276
+ jsonrpc: '2.0',
277
+ error: { code: -32603, message: 'Internal server error' },
278
+ id: null,
279
+ });
280
+ }
281
+ }
282
+ });
283
+
284
+ /**
285
+ * GET /mcp - Not supported in stateless mode
286
+ */
287
+ app.get('/mcp', (req, res) => {
288
+ res.status(405).json({
289
+ jsonrpc: '2.0',
290
+ error: { code: -32000, message: 'Method not allowed. Use POST for MCP requests.' },
291
+ id: null,
292
+ });
293
+ });
294
+
295
+ /**
296
+ * DELETE /mcp - Not supported in stateless mode
297
+ */
298
+ app.delete('/mcp', (req, res) => {
299
+ res.status(405).json({
300
+ jsonrpc: '2.0',
301
+ error: { code: -32000, message: 'Method not allowed. Use POST for MCP requests.' },
302
+ id: null,
303
+ });
304
+ });
305
+
306
+ /**
307
+ * Health check endpoint
308
+ */
309
+ app.get('/health', (req, res) => {
310
+ res.json({
311
+ status: 'healthy',
312
+ server: process.env.MCP_SERVER_NAME || 'dav-mcp',
313
+ version: process.env.MCP_SERVER_VERSION || '3.0.0',
314
+ transport: 'http-stateless',
315
+ timestamp: new Date().toISOString(),
316
+ tools: tools.length,
317
+ uptime: process.uptime(),
318
+ });
319
+ });
320
+
321
+ /**
322
+ * Info endpoint
323
+ */
324
+ app.get('/', (req, res) => {
325
+ res.json({
326
+ name: process.env.MCP_SERVER_NAME || 'dav-mcp',
327
+ version: process.env.MCP_SERVER_VERSION || '3.0.0',
328
+ transport: 'http-stateless',
329
+ description: 'MCP Streamable HTTP Server for CalDAV/CardDAV integration (stateless)',
330
+ endpoints: {
331
+ mcp: '/mcp (POST only)',
332
+ health: '/health (GET)',
333
+ },
334
+ tools: tools.map(t => ({
335
+ name: t.name,
336
+ description: t.description,
337
+ })),
338
+ });
339
+ });
340
+
341
+ /**
342
+ * Graceful shutdown handler
343
+ */
344
+ async function gracefulShutdown(signal) {
345
+ logger.info({ signal }, 'Received shutdown signal, shutting down...');
346
+
347
+ if (httpServer) {
348
+ httpServer.close(() => {
349
+ logger.info('HTTP server closed');
350
+ });
351
+ }
352
+
353
+ logger.info('Shutdown completed');
354
+ process.exit(0);
355
+ }
356
+
357
+ // Register shutdown handlers
358
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
359
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
360
+
361
+ // Handle uncaught errors
362
+ process.on('uncaughtException', (error) => {
363
+ logger.error({ error: error.message, stack: error.stack }, 'Uncaught exception');
364
+ });
365
+
366
+ process.on('unhandledRejection', (reason) => {
367
+ logger.error({ reason }, 'Unhandled promise rejection');
368
+ });
369
+
370
+ let httpServer;
371
+
372
+ /**
373
+ * Start server
374
+ */
375
+ async function start() {
376
+ logger.info('Starting dav-mcp HTTP Server (stateless)...');
377
+
378
+ // Initialize tsdav clients
379
+ await initializeTsdav();
380
+
381
+ // Initialize tool call logger
382
+ initializeToolCallLogger();
383
+ logger.info('Tool call logger initialized');
384
+
385
+ // Start Express server
386
+ httpServer = app.listen(PORT, () => {
387
+ logger.info({
388
+ port: PORT,
389
+ url: `http://localhost:${PORT}`,
390
+ mcpEndpoint: `http://localhost:${PORT}/mcp`,
391
+ mode: 'stateless',
392
+ }, 'HTTP Server running');
393
+
394
+ logger.info({ count: tools.length }, 'Available tools');
395
+ });
396
+ }
397
+
398
+ // Start the server
399
+ start().catch(error => {
400
+ logger.error({ error: error.message, stack: error.stack }, 'Failed to start server');
401
+ process.exit(1);
402
+ });