mcpeasy 0.1.0 → 0.2.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.
@@ -0,0 +1,607 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "json"
6
+ require_relative "service"
7
+
8
+ module Notion
9
+ class MCPServer
10
+ def initialize
11
+ # Defer Service initialization until actually needed
12
+ @notion_tool = nil
13
+ @tools = {
14
+ "test_connection" => {
15
+ name: "test_connection",
16
+ description: "Test the Notion API connection",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {},
20
+ required: []
21
+ }
22
+ },
23
+ "search_pages" => {
24
+ name: "search_pages",
25
+ description: "Search for pages in Notion workspace",
26
+ inputSchema: {
27
+ type: "object",
28
+ properties: {
29
+ query: {
30
+ type: "string",
31
+ description: "Search query to find pages (optional, searches all pages if empty)"
32
+ },
33
+ page_size: {
34
+ type: "number",
35
+ description: "Maximum number of results to return (default: 10, max: 100)"
36
+ }
37
+ },
38
+ required: []
39
+ }
40
+ },
41
+ "search_databases" => {
42
+ name: "search_databases",
43
+ description: "Search for databases in Notion workspace",
44
+ inputSchema: {
45
+ type: "object",
46
+ properties: {
47
+ query: {
48
+ type: "string",
49
+ description: "Search query to find databases (optional, searches all databases if empty)"
50
+ },
51
+ page_size: {
52
+ type: "number",
53
+ description: "Maximum number of results to return (default: 10, max: 100)"
54
+ }
55
+ },
56
+ required: []
57
+ }
58
+ },
59
+ "get_page" => {
60
+ name: "get_page",
61
+ description: "Get details of a specific Notion page",
62
+ inputSchema: {
63
+ type: "object",
64
+ properties: {
65
+ page_id: {
66
+ type: "string",
67
+ description: "The ID of the Notion page to retrieve"
68
+ }
69
+ },
70
+ required: ["page_id"]
71
+ }
72
+ },
73
+ "get_page_content" => {
74
+ name: "get_page_content",
75
+ description: "Get the text content of a Notion page",
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ page_id: {
80
+ type: "string",
81
+ description: "The ID of the Notion page to get content from"
82
+ }
83
+ },
84
+ required: ["page_id"]
85
+ }
86
+ },
87
+ "query_database" => {
88
+ name: "query_database",
89
+ description: "Query entries in a Notion database",
90
+ inputSchema: {
91
+ type: "object",
92
+ properties: {
93
+ database_id: {
94
+ type: "string",
95
+ description: "The ID of the Notion database to query"
96
+ },
97
+ page_size: {
98
+ type: "number",
99
+ description: "Maximum number of results to return (default: 100, max: 100)"
100
+ },
101
+ start_cursor: {
102
+ type: "string",
103
+ description: "Cursor for pagination. Use the next_cursor from previous response to get next page"
104
+ }
105
+ },
106
+ required: ["database_id"]
107
+ }
108
+ },
109
+ "list_users" => {
110
+ name: "list_users",
111
+ description: "List all users in the Notion workspace",
112
+ inputSchema: {
113
+ type: "object",
114
+ properties: {
115
+ page_size: {
116
+ type: "number",
117
+ description: "Maximum number of results to return (default: 100, max: 100)"
118
+ },
119
+ start_cursor: {
120
+ type: "string",
121
+ description: "Cursor for pagination. Use the next_cursor from previous response to get next page"
122
+ }
123
+ },
124
+ required: []
125
+ }
126
+ },
127
+ "get_user" => {
128
+ name: "get_user",
129
+ description: "Get details of a specific Notion user",
130
+ inputSchema: {
131
+ type: "object",
132
+ properties: {
133
+ user_id: {
134
+ type: "string",
135
+ description: "The ID of the Notion user to retrieve"
136
+ }
137
+ },
138
+ required: ["user_id"]
139
+ }
140
+ },
141
+ "get_bot_user" => {
142
+ name: "get_bot_user",
143
+ description: "Get information about the integration bot user",
144
+ inputSchema: {
145
+ type: "object",
146
+ properties: {},
147
+ required: []
148
+ }
149
+ }
150
+ }
151
+ end
152
+
153
+ def run
154
+ # Disable stdout buffering for immediate response
155
+ $stdout.sync = true
156
+
157
+ # Log startup to file instead of stdout to avoid protocol interference
158
+ Mcpeasy::Config.ensure_config_dirs
159
+ File.write(Mcpeasy::Config.log_file_path("notion", "startup"), "#{Time.now}: Notion MCP Server starting on stdio\n", mode: "a")
160
+ while (line = $stdin.gets)
161
+ handle_request(line.strip)
162
+ end
163
+ rescue Interrupt
164
+ # Silent shutdown
165
+ rescue => e
166
+ # Log to a file instead of stderr to avoid protocol interference
167
+ File.write(Mcpeasy::Config.log_file_path("notion", "error"), "#{Time.now}: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
168
+ end
169
+
170
+ private
171
+
172
+ def handle_request(line)
173
+ return if line.empty?
174
+
175
+ begin
176
+ request = JSON.parse(line)
177
+ response = process_request(request)
178
+ if response
179
+ puts JSON.generate(response)
180
+ $stdout.flush
181
+ end
182
+ rescue JSON::ParserError => e
183
+ error_response = {
184
+ jsonrpc: "2.0",
185
+ id: nil,
186
+ error: {
187
+ code: -32700,
188
+ message: "Parse error",
189
+ data: e.message
190
+ }
191
+ }
192
+ puts JSON.generate(error_response)
193
+ $stdout.flush
194
+ rescue => e
195
+ File.write(Mcpeasy::Config.log_file_path("notion", "error"), "#{Time.now}: Error handling request: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
196
+ error_response = {
197
+ jsonrpc: "2.0",
198
+ id: request&.dig("id"),
199
+ error: {
200
+ code: -32603,
201
+ message: "Internal error",
202
+ data: e.message
203
+ }
204
+ }
205
+ puts JSON.generate(error_response)
206
+ $stdout.flush
207
+ end
208
+ end
209
+
210
+ def process_request(request)
211
+ id = request["id"]
212
+ method = request["method"]
213
+ params = request["params"] || {}
214
+
215
+ case method
216
+ when "notifications/initialized"
217
+ # Client acknowledgment - no response needed
218
+ nil
219
+ when "initialize"
220
+ initialize_response(id, params)
221
+ when "tools/list"
222
+ tools_list_response(id, params)
223
+ when "tools/call"
224
+ tools_call_response(id, params)
225
+ else
226
+ {
227
+ jsonrpc: "2.0",
228
+ id: id,
229
+ error: {
230
+ code: -32601,
231
+ message: "Method not found",
232
+ data: "Unknown method: #{method}"
233
+ }
234
+ }
235
+ end
236
+ end
237
+
238
+ def initialize_response(id, params)
239
+ {
240
+ jsonrpc: "2.0",
241
+ id: id,
242
+ result: {
243
+ protocolVersion: "2024-11-05",
244
+ capabilities: {
245
+ tools: {}
246
+ },
247
+ serverInfo: {
248
+ name: "notion-mcp-server",
249
+ version: "1.0.0"
250
+ }
251
+ }
252
+ }
253
+ end
254
+
255
+ def tools_list_response(id, params)
256
+ {
257
+ jsonrpc: "2.0",
258
+ id: id,
259
+ result: {
260
+ tools: @tools.values
261
+ }
262
+ }
263
+ end
264
+
265
+ def tools_call_response(id, params)
266
+ tool_name = params["name"]
267
+ arguments = params["arguments"] || {}
268
+
269
+ unless @tools.key?(tool_name)
270
+ return {
271
+ jsonrpc: "2.0",
272
+ id: id,
273
+ error: {
274
+ code: -32602,
275
+ message: "Unknown tool",
276
+ data: "Tool '#{tool_name}' not found"
277
+ }
278
+ }
279
+ end
280
+
281
+ begin
282
+ result = call_tool(tool_name, arguments)
283
+ {
284
+ jsonrpc: "2.0",
285
+ id: id,
286
+ result: {
287
+ content: [
288
+ {
289
+ type: "text",
290
+ text: result
291
+ }
292
+ ],
293
+ isError: false
294
+ }
295
+ }
296
+ rescue => e
297
+ File.write(Mcpeasy::Config.log_file_path("notion", "error"), "#{Time.now}: Tool error: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
298
+ {
299
+ jsonrpc: "2.0",
300
+ id: id,
301
+ result: {
302
+ content: [
303
+ {
304
+ type: "text",
305
+ text: "❌ Error: #{e.message}"
306
+ }
307
+ ],
308
+ isError: true
309
+ }
310
+ }
311
+ end
312
+ end
313
+
314
+ def call_tool(tool_name, arguments)
315
+ # Initialize Service only when needed
316
+ @notion_tool ||= Service.new
317
+
318
+ case tool_name
319
+ when "test_connection"
320
+ test_connection
321
+ when "search_pages"
322
+ search_pages(arguments)
323
+ when "search_databases"
324
+ search_databases(arguments)
325
+ when "get_page"
326
+ get_page(arguments)
327
+ when "get_page_content"
328
+ get_page_content(arguments)
329
+ when "query_database"
330
+ query_database(arguments)
331
+ when "list_users"
332
+ list_users(arguments)
333
+ when "get_user"
334
+ get_user(arguments)
335
+ when "get_bot_user"
336
+ get_bot_user
337
+ else
338
+ raise "Unknown tool: #{tool_name}"
339
+ end
340
+ end
341
+
342
+ def test_connection
343
+ response = @notion_tool.test_connection
344
+ if response[:ok]
345
+ "✅ Successfully connected to Notion. User: #{response[:user]}, Type: #{response[:type]}"
346
+ else
347
+ raise "Authentication failed: #{response[:error]}"
348
+ end
349
+ end
350
+
351
+ def search_pages(arguments)
352
+ query = arguments["query"]&.to_s || ""
353
+ page_size = [arguments["page_size"]&.to_i || 10, 100].min
354
+
355
+ pages = @notion_tool.search_pages(query: query, page_size: page_size)
356
+
357
+ query_text = query.empty? ? "" : " for query '#{query}'"
358
+ pages_list = pages.map.with_index do |page, i|
359
+ <<~PAGE
360
+ #{i + 1}. **#{page[:title]}**
361
+ - ID: `#{page[:id]}`
362
+ - URL: #{page[:url]}
363
+ - Last edited: #{page[:last_edited_time]}
364
+ PAGE
365
+ end.join("\n")
366
+
367
+ <<~OUTPUT
368
+ 📄 Found #{pages.count} pages#{query_text}:
369
+
370
+ #{pages_list}
371
+ OUTPUT
372
+ end
373
+
374
+ def search_databases(arguments)
375
+ query = arguments["query"]&.to_s || ""
376
+ page_size = [arguments["page_size"]&.to_i || 10, 100].min
377
+
378
+ databases = @notion_tool.search_databases(query: query, page_size: page_size)
379
+
380
+ query_text = query.empty? ? "" : " for query '#{query}'"
381
+ databases_list = databases.map.with_index do |database, i|
382
+ <<~DATABASE
383
+ #{i + 1}. **#{database[:title]}**
384
+ - ID: `#{database[:id]}`
385
+ - URL: #{database[:url]}
386
+ - Last edited: #{database[:last_edited_time]}
387
+ DATABASE
388
+ end.join("\n")
389
+
390
+ <<~OUTPUT
391
+ 🗃️ Found #{databases.count} databases#{query_text}:
392
+
393
+ #{databases_list}
394
+ OUTPUT
395
+ end
396
+
397
+ def get_page(arguments)
398
+ unless arguments["page_id"]
399
+ raise "Missing required argument: page_id"
400
+ end
401
+
402
+ page_id = arguments["page_id"].to_s
403
+ page = @notion_tool.get_page(page_id)
404
+
405
+ properties_section = if page[:properties]&.any?
406
+ properties_lines = page[:properties].map do |name, prop|
407
+ formatted_value = format_property_for_mcp(prop)
408
+ "- **#{name}:** #{formatted_value}"
409
+ end
410
+ "\n**Properties:**\n#{properties_lines.join("\n")}\n"
411
+ else
412
+ ""
413
+ end
414
+
415
+ <<~OUTPUT
416
+ 📄 **Page Details**
417
+
418
+ **Title:** #{page[:title]}
419
+ **ID:** `#{page[:id]}`
420
+ **URL:** #{page[:url]}
421
+ **Created:** #{page[:created_time]}
422
+ **Last edited:** #{page[:last_edited_time]}#{properties_section}
423
+ OUTPUT
424
+ end
425
+
426
+ def get_page_content(arguments)
427
+ unless arguments["page_id"]
428
+ raise "Missing required argument: page_id"
429
+ end
430
+
431
+ page_id = arguments["page_id"].to_s
432
+ content = @notion_tool.get_page_content(page_id)
433
+
434
+ if content && !content.empty?
435
+ "📝 **Page Content:**\n\n#{content}"
436
+ else
437
+ "📝 No content found for this page"
438
+ end
439
+ end
440
+
441
+ def query_database(arguments)
442
+ unless arguments["database_id"]
443
+ raise "Missing required argument: database_id"
444
+ end
445
+
446
+ database_id = arguments["database_id"].to_s
447
+ page_size = [arguments["page_size"]&.to_i || 100, 100].min
448
+ start_cursor = arguments["start_cursor"]
449
+
450
+ # Keep track of current page for display
451
+ @query_database_page ||= {}
452
+ @query_database_page[database_id] ||= 0
453
+ @query_database_page[database_id] = start_cursor ? @query_database_page[database_id] + 1 : 1
454
+
455
+ result = @notion_tool.query_database(database_id, page_size: page_size, start_cursor: start_cursor)
456
+ entries = result[:entries]
457
+
458
+ # Calculate record range
459
+ page_num = @query_database_page[database_id]
460
+ start_index = (page_num - 1) * page_size
461
+ end_index = start_index + entries.count - 1
462
+
463
+ entries_list = entries.map.with_index do |entry, i|
464
+ <<~ENTRY
465
+ #{start_index + i + 1}. **#{entry[:title]}**
466
+ - ID: `#{entry[:id]}`
467
+ - URL: #{entry[:url]}
468
+ - Last edited: #{entry[:last_edited_time]}
469
+ ENTRY
470
+ end.join("\n")
471
+
472
+ pagination_info = if result[:has_more]
473
+ <<~INFO
474
+
475
+ 📄 **Page #{page_num}** | Showing records #{start_index + 1}-#{end_index + 1}
476
+ _More entries available. Use `start_cursor: "#{result[:next_cursor]}"` to get the next page._
477
+ INFO
478
+ else
479
+ # Try to estimate total if we're on last page
480
+ estimated_total = start_index + entries.count
481
+ <<~INFO
482
+
483
+ 📄 **Page #{page_num}** | Showing records #{start_index + 1}-#{end_index + 1} of #{estimated_total} total
484
+ INFO
485
+ end
486
+
487
+ <<~OUTPUT
488
+ 🗃️ Found #{entries.count} entries in database:
489
+
490
+ #{entries_list}#{pagination_info}
491
+ OUTPUT
492
+ end
493
+
494
+ def list_users(arguments)
495
+ page_size = [arguments["page_size"]&.to_i || 100, 100].min
496
+ start_cursor = arguments["start_cursor"]
497
+
498
+ # Keep track of current page for display
499
+ @list_users_page ||= 0
500
+ @list_users_page = start_cursor ? @list_users_page + 1 : 1
501
+
502
+ result = @notion_tool.list_users(page_size: page_size, start_cursor: start_cursor)
503
+ users = result[:users]
504
+
505
+ # Calculate record range
506
+ start_index = (@list_users_page - 1) * page_size
507
+ end_index = start_index + users.count - 1
508
+
509
+ users_list = users.map.with_index do |user, i|
510
+ email_line = user[:email] ? "\n - Email: #{user[:email]}" : ""
511
+ avatar_line = user[:avatar_url] ? "\n - Avatar: #{user[:avatar_url]}" : ""
512
+
513
+ <<~USER
514
+ #{start_index + i + 1}. **#{user[:name] || "Unnamed"}** (#{user[:type]})
515
+ - ID: `#{user[:id]}`#{email_line}#{avatar_line}
516
+ USER
517
+ end.join("\n")
518
+
519
+ pagination_info = if result[:has_more]
520
+ <<~INFO
521
+
522
+ 📄 **Page #{@list_users_page}** | Showing records #{start_index + 1}-#{end_index + 1}
523
+ _More users available. Use `start_cursor: "#{result[:next_cursor]}"` to get the next page._
524
+ INFO
525
+ else
526
+ # Try to estimate total if we're on last page
527
+ estimated_total = start_index + users.count
528
+ <<~INFO
529
+
530
+ 📄 **Page #{@list_users_page}** | Showing records #{start_index + 1}-#{end_index + 1} of #{estimated_total} total
531
+ INFO
532
+ end
533
+
534
+ <<~OUTPUT
535
+ 👥 Found #{users.count} users in workspace:
536
+
537
+ #{users_list}#{pagination_info}
538
+ OUTPUT
539
+ end
540
+
541
+ def get_user(arguments)
542
+ unless arguments["user_id"]
543
+ raise "Missing required argument: user_id"
544
+ end
545
+
546
+ user_id = arguments["user_id"].to_s
547
+ user = @notion_tool.get_user(user_id)
548
+
549
+ email_line = user[:email] ? "\n**Email:** #{user[:email]}" : ""
550
+ avatar_line = user[:avatar_url] ? "\n**Avatar:** #{user[:avatar_url]}" : ""
551
+
552
+ <<~OUTPUT
553
+ 👤 **User Details**
554
+
555
+ **Name:** #{user[:name] || "Unnamed"}
556
+ **Type:** #{user[:type]}
557
+ **ID:** `#{user[:id]}`#{email_line}#{avatar_line}
558
+ OUTPUT
559
+ end
560
+
561
+ def get_bot_user
562
+ bot = @notion_tool.get_bot_user
563
+
564
+ workspace_line = bot[:bot][:workspace_name] ? "\n**Workspace:** #{bot[:bot][:workspace_name]}" : ""
565
+ owner_line = bot[:bot][:owner] ? "\n**Owner:** #{bot[:bot][:owner]}" : ""
566
+
567
+ <<~OUTPUT
568
+ 🤖 **Bot User Details**
569
+
570
+ **Name:** #{bot[:name] || "Unnamed"}
571
+ **Type:** #{bot[:type]}
572
+ **ID:** `#{bot[:id]}`#{workspace_line}#{owner_line}
573
+ OUTPUT
574
+ end
575
+
576
+ def format_property_for_mcp(prop)
577
+ case prop["type"]
578
+ when "title"
579
+ prop["title"]&.map { |t| t["plain_text"] }&.join || ""
580
+ when "rich_text"
581
+ prop["rich_text"]&.map { |t| t["plain_text"] }&.join || ""
582
+ when "number"
583
+ prop["number"]&.to_s || ""
584
+ when "select"
585
+ prop["select"]&.dig("name") || ""
586
+ when "multi_select"
587
+ prop["multi_select"]&.map { |s| s["name"] }&.join(", ") || ""
588
+ when "date"
589
+ prop["date"]&.dig("start") || ""
590
+ when "checkbox"
591
+ prop["checkbox"] ? "☑" : "☐"
592
+ when "url"
593
+ prop["url"] || ""
594
+ when "email"
595
+ prop["email"] || ""
596
+ when "phone_number"
597
+ prop["phone_number"] || ""
598
+ else
599
+ "[#{prop["type"]}]"
600
+ end
601
+ end
602
+ end
603
+ end
604
+
605
+ if __FILE__ == $0
606
+ Notion::MCPServer.new.run
607
+ end