memory-journal-mcp 4.4.2 → 4.5.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 (73) hide show
  1. package/.github/workflows/lint-and-test.yml +1 -1
  2. package/.github/workflows/security-update.yml +1 -1
  3. package/CHANGELOG.md +81 -1
  4. package/DOCKER_README.md +57 -7
  5. package/Dockerfile +17 -17
  6. package/README.md +65 -6
  7. package/SECURITY.md +27 -35
  8. package/dist/cli.js +10 -0
  9. package/dist/cli.js.map +1 -1
  10. package/dist/constants/ServerInstructions.d.ts +5 -1
  11. package/dist/constants/ServerInstructions.d.ts.map +1 -1
  12. package/dist/constants/ServerInstructions.js +137 -83
  13. package/dist/constants/ServerInstructions.js.map +1 -1
  14. package/dist/database/SqliteAdapter.d.ts +2 -1
  15. package/dist/database/SqliteAdapter.d.ts.map +1 -1
  16. package/dist/database/SqliteAdapter.js +15 -8
  17. package/dist/database/SqliteAdapter.js.map +1 -1
  18. package/dist/handlers/resources/index.d.ts +3 -1
  19. package/dist/handlers/resources/index.d.ts.map +1 -1
  20. package/dist/handlers/resources/index.js +5 -2
  21. package/dist/handlers/resources/index.js.map +1 -1
  22. package/dist/handlers/tools/index.d.ts.map +1 -1
  23. package/dist/handlers/tools/index.js +63 -16
  24. package/dist/handlers/tools/index.js.map +1 -1
  25. package/dist/server/McpServer.d.ts +2 -0
  26. package/dist/server/McpServer.d.ts.map +1 -1
  27. package/dist/server/McpServer.js +43 -2
  28. package/dist/server/McpServer.js.map +1 -1
  29. package/dist/server/Scheduler.d.ts +91 -0
  30. package/dist/server/Scheduler.d.ts.map +1 -0
  31. package/dist/server/Scheduler.js +201 -0
  32. package/dist/server/Scheduler.js.map +1 -0
  33. package/dist/utils/logger.d.ts.map +1 -1
  34. package/dist/utils/logger.js +6 -3
  35. package/dist/utils/logger.js.map +1 -1
  36. package/dist/utils/security-utils.d.ts +0 -21
  37. package/dist/utils/security-utils.d.ts.map +1 -1
  38. package/dist/utils/security-utils.js +0 -47
  39. package/dist/utils/security-utils.js.map +1 -1
  40. package/hooks/README.md +107 -0
  41. package/hooks/cursor/hooks.json +10 -0
  42. package/hooks/cursor/memory-journal.mdc +22 -0
  43. package/hooks/cursor/session-end.sh +19 -0
  44. package/hooks/kilo-code/session-end-mode.json +11 -0
  45. package/hooks/kiro/session-end.md +13 -0
  46. package/package.json +8 -8
  47. package/releases/v4.5.0.md +116 -0
  48. package/scripts/generate-server-instructions.ts +176 -0
  49. package/scripts/server-instructions-function-body.ts +77 -0
  50. package/server.json +3 -3
  51. package/src/cli.ts +26 -0
  52. package/src/constants/ServerInstructions.ts +137 -83
  53. package/src/constants/server-instructions.md +262 -0
  54. package/src/database/SqliteAdapter.ts +22 -8
  55. package/src/handlers/resources/index.ts +8 -2
  56. package/src/handlers/tools/index.ts +70 -20
  57. package/src/server/McpServer.ts +60 -2
  58. package/src/server/Scheduler.ts +278 -0
  59. package/src/utils/logger.ts +6 -3
  60. package/src/utils/security-utils.ts +0 -52
  61. package/tests/constants/server-instructions.test.ts +26 -0
  62. package/tests/database/sqlite-adapter.test.ts +84 -0
  63. package/tests/filtering/tool-filter.test.ts +46 -0
  64. package/tests/handlers/github-resource-handlers.test.ts +453 -0
  65. package/tests/handlers/github-tool-handlers.test.ts +899 -0
  66. package/tests/handlers/prompt-handlers.test.ts +40 -0
  67. package/tests/handlers/resource-handlers.test.ts +32 -0
  68. package/tests/handlers/tool-handlers.test.ts +13 -2
  69. package/tests/security/sql-injection.test.ts +3 -54
  70. package/tests/server/mcp-server.test.ts +491 -5
  71. package/tests/server/scheduler.test.ts +400 -0
  72. package/tests/vector/vector-search-manager.test.ts +60 -0
  73. package/.vscode/settings.json +0 -84
@@ -9,7 +9,11 @@ import initSqlJs, { type Database } from 'sql.js'
9
9
  import * as fs from 'node:fs'
10
10
  import * as path from 'node:path'
11
11
  import { logger } from '../utils/logger.js'
12
- import { validateDateFormatPattern } from '../utils/security-utils.js'
12
+ import {
13
+ validateDateFormatPattern,
14
+ sanitizeSearchQuery,
15
+ assertNoPathTraversal,
16
+ } from '../utils/security-utils.js'
13
17
  import type {
14
18
  JournalEntry,
15
19
  Tag,
@@ -168,6 +172,11 @@ export class SqliteAdapter {
168
172
 
169
173
  // Initialize schema
170
174
  this.db.run(SCHEMA_SQL)
175
+
176
+ // Enable foreign key enforcement (SQLite disables by default)
177
+ // Required for ON DELETE CASCADE in entry_tags, relationships, embeddings
178
+ this.db.run('PRAGMA foreign_keys = ON')
179
+
171
180
  this.initialized = true
172
181
 
173
182
  logger.info('Database opened', { module: 'SqliteAdapter', dbPath: this.dbPath })
@@ -582,9 +591,9 @@ export class SqliteAdapter {
582
591
 
583
592
  let sql = `
584
593
  SELECT * FROM memory_journal
585
- WHERE deleted_at IS NULL AND content LIKE ?
594
+ WHERE deleted_at IS NULL AND content LIKE ? ESCAPE '\\'
586
595
  `
587
- const params: unknown[] = [`%${query}%`]
596
+ const params: unknown[] = [`%${sanitizeSearchQuery(query)}%`]
588
597
 
589
598
  if (isPersonal !== undefined) {
590
599
  sql += ` AND is_personal = ?`
@@ -1037,6 +1046,11 @@ export class SqliteAdapter {
1037
1046
  const db = this.ensureDb()
1038
1047
  const backupsDir = this.getBackupsDir()
1039
1048
 
1049
+ // Validate backup name against path traversal before sanitization
1050
+ if (backupName) {
1051
+ assertNoPathTraversal(backupName)
1052
+ }
1053
+
1040
1054
  // Ensure backups directory exists
1041
1055
  if (!fs.existsSync(backupsDir)) {
1042
1056
  fs.mkdirSync(backupsDir, { recursive: true })
@@ -1118,7 +1132,7 @@ export class SqliteAdapter {
1118
1132
  deleteOldBackups(keepCount: number): { deleted: string[]; kept: number } {
1119
1133
  const backups = this.listBackups() // Already sorted newest-first
1120
1134
 
1121
- if (keepCount < 1) {
1135
+ if (keepCount < 1 || Number.isNaN(keepCount)) {
1122
1136
  throw new Error('keepCount must be at least 1')
1123
1137
  }
1124
1138
 
@@ -1155,9 +1169,7 @@ export class SqliteAdapter {
1155
1169
  newEntryCount: number
1156
1170
  }> {
1157
1171
  // Validate filename (prevent path traversal)
1158
- if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
1159
- throw new Error('Invalid backup filename: path separators not allowed')
1160
- }
1172
+ assertNoPathTraversal(filename)
1161
1173
 
1162
1174
  const backupsDir = this.getBackupsDir()
1163
1175
  const backupPath = path.join(backupsDir, filename)
@@ -1187,6 +1199,7 @@ export class SqliteAdapter {
1187
1199
  // Initialize new database from backup
1188
1200
  const SQL = await import('sql.js').then((m) => m.default())
1189
1201
  this.db = new SQL.Database(backupBuffer)
1202
+ this.db.run('PRAGMA foreign_keys = ON')
1190
1203
  this.initialized = true
1191
1204
 
1192
1205
  // Get new entry count
@@ -1329,7 +1342,8 @@ export class SqliteAdapter {
1329
1342
  }
1330
1343
 
1331
1344
  /**
1332
- * Get raw database for advanced operations
1345
+ * Get raw sql.js Database handle for advanced queries.
1346
+ * @internal Callers MUST use parameterized queries — never concatenate user input into SQL.
1333
1347
  */
1334
1348
  getRawDb(): Database {
1335
1349
  return this.ensureDb()
@@ -10,6 +10,7 @@ import type { ToolFilterConfig } from '../../filtering/ToolFilter.js'
10
10
  import { getAllToolNames } from '../../filtering/ToolFilter.js'
11
11
  import type { Tag, McpIcon } from '../../types/index.js'
12
12
  import type { GitHubIntegration } from '../../github/GitHubIntegration.js'
13
+ import type { Scheduler } from '../../server/Scheduler.js'
13
14
  import { generateInstructions, type InstructionLevel } from '../../constants/ServerInstructions.js'
14
15
  import { getPrompts } from '../prompts/index.js'
15
16
  import {
@@ -36,6 +37,7 @@ export interface ResourceContext {
36
37
  vectorManager?: VectorSearchManager
37
38
  filterConfig?: ToolFilterConfig | null
38
39
  github?: GitHubIntegration | null
40
+ scheduler?: Scheduler | null
39
41
  }
40
42
 
41
43
  /**
@@ -131,10 +133,11 @@ export async function readResource(
131
133
  db: SqliteAdapter,
132
134
  vectorManager?: VectorSearchManager,
133
135
  filterConfig?: ToolFilterConfig | null,
134
- github?: GitHubIntegration | null
136
+ github?: GitHubIntegration | null,
137
+ scheduler?: Scheduler | null
135
138
  ): Promise<{ data: unknown; annotations?: { lastModified?: string } }> {
136
139
  const resources = getAllResourceDefinitions()
137
- const context: ResourceContext = { db, vectorManager, filterConfig, github }
140
+ const context: ResourceContext = { db, vectorManager, filterConfig, github, scheduler }
138
141
 
139
142
  // Strip query parameters for matching, but pass full URI to handler
140
143
  const baseUri = getBaseUri(uri)
@@ -1172,6 +1175,9 @@ I have project memory access and will create entries for significant work.`,
1172
1175
  ...dbHealth,
1173
1176
  vectorIndex,
1174
1177
  toolFilter,
1178
+ scheduler: context.scheduler
1179
+ ? context.scheduler.getStatus()
1180
+ : { active: false, jobs: [] },
1175
1181
  timestamp: lastModified,
1176
1182
  },
1177
1183
  annotations: { lastModified },
@@ -37,12 +37,48 @@ export interface ToolContext {
37
37
  // Zod Schemas for Input Validation
38
38
  // ============================================================================
39
39
 
40
+ /**
41
+ * Valid entry types (matches EntryType union in types/index.ts)
42
+ */
43
+ const ENTRY_TYPES = [
44
+ 'personal_reflection',
45
+ 'project_decision',
46
+ 'technical_achievement',
47
+ 'bug_fix',
48
+ 'feature_implementation',
49
+ 'code_review',
50
+ 'meeting_notes',
51
+ 'learning',
52
+ 'research',
53
+ 'planning',
54
+ 'retrospective',
55
+ 'standup',
56
+ 'other',
57
+ ] as const
58
+
59
+ /**
60
+ * Valid significance types (matches SignificanceType union in types/index.ts)
61
+ */
62
+ const SIGNIFICANCE_TYPES = [
63
+ 'milestone',
64
+ 'breakthrough',
65
+ 'technical_breakthrough',
66
+ 'decision',
67
+ 'lesson_learned',
68
+ 'blocker_resolved',
69
+ 'release',
70
+ ] as const
71
+
72
+ /** YYYY-MM-DD date format regex */
73
+ const DATE_FORMAT_REGEX = /^\d{4}-\d{2}-\d{2}$/
74
+ const DATE_FORMAT_MESSAGE = 'Date must be YYYY-MM-DD format'
75
+
40
76
  const CreateEntrySchema = z.object({
41
77
  content: z.string().min(1).max(50000),
42
- entry_type: z.string().optional().default('personal_reflection'),
78
+ entry_type: z.enum(ENTRY_TYPES).optional().default('personal_reflection'),
43
79
  tags: z.array(z.string()).optional().default([]),
44
80
  is_personal: z.boolean().optional().default(true),
45
- significance_type: z.string().optional(),
81
+ significance_type: z.enum(SIGNIFICANCE_TYPES).optional(),
46
82
  auto_context: z.boolean().optional().default(true),
47
83
  project_number: z.number().optional(),
48
84
  project_owner: z.string().optional(),
@@ -87,9 +123,9 @@ const SearchEntriesSchema = z.object({
87
123
  })
88
124
 
89
125
  const SearchByDateRangeSchema = z.object({
90
- start_date: z.string(),
91
- end_date: z.string(),
92
- entry_type: z.string().optional(),
126
+ start_date: z.string().regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE),
127
+ end_date: z.string().regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE),
128
+ entry_type: z.enum(ENTRY_TYPES).optional(),
93
129
  tags: z.array(z.string()).optional(),
94
130
  is_personal: z.boolean().optional(),
95
131
  project_number: z.number().optional(),
@@ -112,8 +148,8 @@ const SemanticSearchSchema = z.object({
112
148
 
113
149
  const GetStatisticsSchema = z.object({
114
150
  group_by: z.enum(['day', 'week', 'month']).optional().default('week'),
115
- start_date: z.string().optional(),
116
- end_date: z.string().optional(),
151
+ start_date: z.string().regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE).optional(),
152
+ end_date: z.string().regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE).optional(),
117
153
  project_breakdown: z.boolean().optional().default(false),
118
154
  })
119
155
 
@@ -138,9 +174,9 @@ const LinkEntriesSchema = z.object({
138
174
 
139
175
  const ExportEntriesSchema = z.object({
140
176
  format: z.enum(['json', 'markdown']).optional().default('json'),
141
- start_date: z.string().optional(),
142
- end_date: z.string().optional(),
143
- entry_types: z.array(z.string()).optional(),
177
+ start_date: z.string().regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE).optional(),
178
+ end_date: z.string().regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE).optional(),
179
+ entry_types: z.array(z.enum(ENTRY_TYPES)).optional(),
144
180
  tags: z.array(z.string()).optional(),
145
181
  limit: z.number().optional().default(100).describe('Maximum entries to export (default: 100)'),
146
182
  })
@@ -148,7 +184,7 @@ const ExportEntriesSchema = z.object({
148
184
  const UpdateEntrySchema = z.object({
149
185
  entry_id: z.number(),
150
186
  content: z.string().optional(),
151
- entry_type: z.string().optional(),
187
+ entry_type: z.enum(ENTRY_TYPES).optional(),
152
188
  is_personal: z.boolean().optional(),
153
189
  tags: z.array(z.string()).optional(),
154
190
  })
@@ -1036,10 +1072,10 @@ function getAllToolDefinitions(context: ToolContext): ToolDefinition[] {
1036
1072
 
1037
1073
  const entry = db.createEntry({
1038
1074
  content: input.content,
1039
- entryType: input.entry_type as EntryType,
1075
+ entryType: input.entry_type,
1040
1076
  tags: input.tags,
1041
- isPersonal: input.is_personal,
1042
- significanceType: (input.significance_type as SignificanceType) ?? null,
1077
+ isPersonal: input.share_with_team ? false : input.is_personal,
1078
+ significanceType: input.significance_type ?? null,
1043
1079
  projectNumber: input.project_number,
1044
1080
  projectOwner: input.project_owner,
1045
1081
  issueNumber: input.issue_number,
@@ -1184,7 +1220,7 @@ function getAllToolDefinitions(context: ToolContext): ToolDefinition[] {
1184
1220
  handler: (params: unknown) => {
1185
1221
  const input = SearchByDateRangeSchema.parse(params)
1186
1222
  const entries = db.searchByDateRange(input.start_date, input.end_date, {
1187
- entryType: input.entry_type as EntryType | undefined,
1223
+ entryType: input.entry_type,
1188
1224
  tags: input.tags,
1189
1225
  isPersonal: input.is_personal,
1190
1226
  projectNumber: input.project_number,
@@ -1277,8 +1313,16 @@ function getAllToolDefinitions(context: ToolContext): ToolDefinition[] {
1277
1313
  description: 'Analyze patterns across all GitHub Projects tracked in journal entries',
1278
1314
  group: 'analytics',
1279
1315
  inputSchema: z.object({
1280
- start_date: z.string().optional().describe('Start date (YYYY-MM-DD)'),
1281
- end_date: z.string().optional().describe('End date (YYYY-MM-DD)'),
1316
+ start_date: z
1317
+ .string()
1318
+ .regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE)
1319
+ .optional()
1320
+ .describe('Start date (YYYY-MM-DD)'),
1321
+ end_date: z
1322
+ .string()
1323
+ .regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE)
1324
+ .optional()
1325
+ .describe('End date (YYYY-MM-DD)'),
1282
1326
  min_entries: z
1283
1327
  .number()
1284
1328
  .optional()
@@ -1290,8 +1334,14 @@ function getAllToolDefinitions(context: ToolContext): ToolDefinition[] {
1290
1334
  handler: (params: unknown) => {
1291
1335
  const input = z
1292
1336
  .object({
1293
- start_date: z.string().optional(),
1294
- end_date: z.string().optional(),
1337
+ start_date: z
1338
+ .string()
1339
+ .regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE)
1340
+ .optional(),
1341
+ end_date: z
1342
+ .string()
1343
+ .regex(DATE_FORMAT_REGEX, DATE_FORMAT_MESSAGE)
1344
+ .optional(),
1295
1345
  min_entries: z.number().optional().default(3),
1296
1346
  })
1297
1347
  .parse(params)
@@ -1744,7 +1794,7 @@ function getAllToolDefinitions(context: ToolContext): ToolDefinition[] {
1744
1794
  const input = UpdateEntrySchema.parse(params)
1745
1795
  const entry = db.updateEntry(input.entry_id, {
1746
1796
  content: input.content,
1747
- entryType: input.entry_type as EntryType | undefined,
1797
+ entryType: input.entry_type,
1748
1798
  isPersonal: input.is_personal,
1749
1799
  tags: input.tags,
1750
1800
  })
@@ -13,6 +13,7 @@ import { randomUUID } from 'node:crypto'
13
13
  import type { IncomingMessage, ServerResponse } from 'node:http'
14
14
  import express from 'express'
15
15
  import type { Express, Request, Response } from 'express'
16
+ import rateLimit from 'express-rate-limit'
16
17
  import { z } from 'zod'
17
18
 
18
19
  import { SqliteAdapter } from '../database/SqliteAdapter.js'
@@ -29,6 +30,7 @@ import { getTools, callTool } from '../handlers/tools/index.js'
29
30
  import { getResources, readResource } from '../handlers/resources/index.js'
30
31
  import { getPrompts, getPrompt } from '../handlers/prompts/index.js'
31
32
  import { generateInstructions } from '../constants/ServerInstructions.js'
33
+ import { Scheduler, type SchedulerOptions } from './Scheduler.js'
32
34
  import pkg from '../../package.json' with { type: 'json' }
33
35
 
34
36
  /** Session timeout for stateful HTTP mode (30 minutes) */
@@ -47,6 +49,7 @@ export interface ServerOptions {
47
49
  autoRebuildIndex?: boolean
48
50
  statelessHttp?: boolean
49
51
  corsOrigin?: string
52
+ scheduler?: SchedulerOptions
50
53
  }
51
54
 
52
55
  /**
@@ -290,7 +293,8 @@ export async function createServer(options: ServerOptions): Promise<void> {
290
293
  db,
291
294
  vectorManager,
292
295
  filterConfig,
293
- github
296
+ github,
297
+ scheduler
294
298
  )
295
299
  const dataStr =
296
300
  typeof result.data === 'string'
@@ -324,7 +328,8 @@ export async function createServer(options: ServerOptions): Promise<void> {
324
328
  db,
325
329
  vectorManager,
326
330
  filterConfig,
327
- github
331
+ github,
332
+ scheduler
328
333
  )
329
334
  const dataStr =
330
335
  typeof result.data === 'string'
@@ -388,6 +393,25 @@ export async function createServer(options: ServerOptions): Promise<void> {
388
393
  )
389
394
  }
390
395
 
396
+ // Initialize scheduler (HTTP/SSE only)
397
+ let scheduler: Scheduler | null = null
398
+ if (options.scheduler) {
399
+ const hasAnyJob =
400
+ options.scheduler.backupIntervalMinutes > 0 ||
401
+ options.scheduler.vacuumIntervalMinutes > 0 ||
402
+ options.scheduler.rebuildIndexIntervalMinutes > 0
403
+
404
+ if (hasAnyJob && transport === 'stdio') {
405
+ logger.warning(
406
+ 'Scheduler options ignored for stdio transport (session is ephemeral). ' +
407
+ 'Use HTTP/SSE transport for automated scheduling.',
408
+ { module: 'Scheduler' }
409
+ )
410
+ } else if (hasAnyJob) {
411
+ scheduler = new Scheduler(options.scheduler, db, vectorManager)
412
+ }
413
+ }
414
+
391
415
  // Start server based on transport
392
416
  if (transport === 'stdio') {
393
417
  const stdioTransport = new StdioServerTransport()
@@ -405,12 +429,24 @@ export async function createServer(options: ServerOptions): Promise<void> {
405
429
  const port = options.port ?? 3000
406
430
  const host = options.host ?? 'localhost'
407
431
  const corsOrigin = options.corsOrigin ?? process.env['MCP_CORS_ORIGIN'] ?? '*'
432
+
433
+ if (corsOrigin === '*') {
434
+ logger.warning(
435
+ 'CORS origin is set to "*" (all origins). ' +
436
+ 'Set --cors-origin or MCP_CORS_ORIGIN for production deployments.',
437
+ { module: 'McpServer' }
438
+ )
439
+ }
440
+
408
441
  const app: Express = express()
409
442
 
410
443
  // Security headers middleware
411
444
  app.use((_req: Request, res: Response, next: () => void) => {
412
445
  res.setHeader('X-Content-Type-Options', 'nosniff')
413
446
  res.setHeader('X-Frame-Options', 'DENY')
447
+ res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")
448
+ res.setHeader('Cache-Control', 'no-store')
449
+ res.setHeader('Referrer-Policy', 'no-referrer')
414
450
  next()
415
451
  })
416
452
 
@@ -437,6 +473,19 @@ export async function createServer(options: ServerOptions): Promise<void> {
437
473
  // JSON body parser with size limit to prevent memory exhaustion (DoS)
438
474
  app.use(express.json({ limit: '1mb' }))
439
475
 
476
+ // Rate limiting to prevent abuse (100 requests/minute per IP)
477
+ const limiter = rateLimit({
478
+ windowMs: 60 * 1000,
479
+ limit: 100,
480
+ standardHeaders: 'draft-8',
481
+ legacyHeaders: false,
482
+ message: { error: 'Too many requests, please try again later' },
483
+ })
484
+ app.use(limiter)
485
+ logger.info('Rate limiting enabled: 100 requests/minute per IP', {
486
+ module: 'McpServer',
487
+ })
488
+
440
489
  // Explicit OPTIONS handler for /mcp - MUST be before other /mcp routes
441
490
  // Using app.all to intercept before Express 5's auto-OPTIONS
442
491
  app.all('/mcp', (req: Request, res: Response, next: () => void) => {
@@ -498,6 +547,9 @@ export async function createServer(options: ServerOptions): Promise<void> {
498
547
  })
499
548
  })
500
549
 
550
+ // Start scheduler after HTTP server is listening
551
+ scheduler?.start()
552
+
501
553
  httpServer.on('close', () => {
502
554
  logger.info('HTTP server closed', { module: 'McpServer' })
503
555
  })
@@ -506,6 +558,7 @@ export async function createServer(options: ServerOptions): Promise<void> {
506
558
  process.on('SIGINT', () => {
507
559
  logger.info('Shutting down HTTP server...', { module: 'McpServer' })
508
560
  void (async () => {
561
+ scheduler?.stop()
509
562
  try {
510
563
  await statelessTransport.close()
511
564
  } catch (error) {
@@ -711,6 +764,9 @@ export async function createServer(options: ServerOptions): Promise<void> {
711
764
  })
712
765
  })
713
766
 
767
+ // Start scheduler after HTTP server is listening
768
+ scheduler?.start()
769
+
714
770
  // Keep process alive - httpServer keeps the event loop active
715
771
  // but we also ensure it doesn't close prematurely
716
772
  httpServer.on('close', () => {
@@ -722,6 +778,8 @@ export async function createServer(options: ServerOptions): Promise<void> {
722
778
  logger.info('Shutting down HTTP server...', { module: 'McpServer' })
723
779
 
724
780
  void (async () => {
781
+ scheduler?.stop()
782
+
725
783
  // Close all active transports
726
784
  for (const [sessionId, httpTransport] of transports) {
727
785
  try {