mcpeasy 0.1.0 → 0.3.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.claudeignore +0 -3
  3. data/.mcp.json +19 -1
  4. data/CHANGELOG.md +59 -0
  5. data/CLAUDE.md +19 -5
  6. data/README.md +19 -3
  7. data/lib/mcpeasy/cli.rb +62 -10
  8. data/lib/mcpeasy/config.rb +22 -1
  9. data/lib/mcpeasy/setup.rb +1 -0
  10. data/lib/mcpeasy/version.rb +1 -1
  11. data/lib/utilities/gcal/README.md +11 -3
  12. data/lib/utilities/gcal/cli.rb +110 -108
  13. data/lib/utilities/gcal/mcp.rb +463 -308
  14. data/lib/utilities/gcal/service.rb +312 -0
  15. data/lib/utilities/gdrive/README.md +3 -3
  16. data/lib/utilities/gdrive/cli.rb +98 -96
  17. data/lib/utilities/gdrive/mcp.rb +290 -288
  18. data/lib/utilities/gdrive/service.rb +293 -0
  19. data/lib/utilities/gmail/README.md +278 -0
  20. data/lib/utilities/gmail/cli.rb +264 -0
  21. data/lib/utilities/gmail/mcp.rb +846 -0
  22. data/lib/utilities/gmail/service.rb +547 -0
  23. data/lib/utilities/gmeet/cli.rb +131 -129
  24. data/lib/utilities/gmeet/mcp.rb +374 -372
  25. data/lib/utilities/gmeet/service.rb +411 -0
  26. data/lib/utilities/notion/README.md +287 -0
  27. data/lib/utilities/notion/cli.rb +245 -0
  28. data/lib/utilities/notion/mcp.rb +607 -0
  29. data/lib/utilities/notion/service.rb +327 -0
  30. data/lib/utilities/slack/README.md +3 -3
  31. data/lib/utilities/slack/cli.rb +69 -54
  32. data/lib/utilities/slack/mcp.rb +277 -226
  33. data/lib/utilities/slack/service.rb +134 -0
  34. data/mcpeasy.gemspec +6 -1
  35. metadata +87 -10
  36. data/env.template +0 -11
  37. data/lib/utilities/gcal/gcal_tool.rb +0 -308
  38. data/lib/utilities/gdrive/gdrive_tool.rb +0 -291
  39. data/lib/utilities/gmeet/gmeet_tool.rb +0 -407
  40. data/lib/utilities/slack/slack_tool.rb +0 -119
  41. data/logs/.keep +0 -0
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "net/http"
5
+ require "json"
6
+ require "uri"
7
+ require "mcpeasy/config"
8
+
9
+ module Notion
10
+ class Service
11
+ BASE_URI = "https://api.notion.com/v1"
12
+
13
+ def initialize
14
+ ensure_env!
15
+ end
16
+
17
+ def search_pages(query: "", page_size: 10)
18
+ body = {
19
+ query: query.to_s.strip,
20
+ page_size: [page_size.to_i, 100].min,
21
+ filter: {
22
+ value: "page",
23
+ property: "object"
24
+ }
25
+ }
26
+
27
+ response = post_request("/search", body)
28
+ parse_search_results(response, "page")
29
+ rescue => e
30
+ log_error("search_pages", e)
31
+ raise e
32
+ end
33
+
34
+ def search_databases(query: "", page_size: 10)
35
+ body = {
36
+ query: query.to_s.strip,
37
+ page_size: [page_size.to_i, 100].min,
38
+ filter: {
39
+ value: "database",
40
+ property: "object"
41
+ }
42
+ }
43
+
44
+ response = post_request("/search", body)
45
+ parse_search_results(response, "database")
46
+ rescue => e
47
+ log_error("search_databases", e)
48
+ raise e
49
+ end
50
+
51
+ def get_page(page_id)
52
+ clean_id = clean_notion_id(page_id)
53
+ response = get_request("/pages/#{clean_id}")
54
+
55
+ {
56
+ id: response["id"],
57
+ title: extract_title(response),
58
+ url: response["url"],
59
+ created_time: response["created_time"],
60
+ last_edited_time: response["last_edited_time"],
61
+ properties: response["properties"]
62
+ }
63
+ rescue => e
64
+ log_error("get_page", e)
65
+ raise e
66
+ end
67
+
68
+ def get_page_content(page_id)
69
+ clean_id = clean_notion_id(page_id)
70
+ response = get_request("/blocks/#{clean_id}/children")
71
+
72
+ blocks = response["results"] || []
73
+ extract_text_content(blocks)
74
+ rescue => e
75
+ log_error("get_page_content", e)
76
+ raise e
77
+ end
78
+
79
+ def query_database(database_id, filters: {}, sorts: [], page_size: 100, start_cursor: nil)
80
+ clean_id = clean_notion_id(database_id)
81
+
82
+ body = {
83
+ page_size: [page_size.to_i, 100].min
84
+ }
85
+ body[:filter] = filters unless filters.empty?
86
+ body[:sorts] = sorts unless sorts.empty?
87
+ body[:start_cursor] = start_cursor if start_cursor
88
+
89
+ response = post_request("/databases/#{clean_id}/query", body)
90
+
91
+ entries = response["results"].map do |page|
92
+ {
93
+ id: page["id"],
94
+ title: extract_title(page),
95
+ url: page["url"],
96
+ created_time: page["created_time"],
97
+ last_edited_time: page["last_edited_time"],
98
+ properties: page["properties"]
99
+ }
100
+ end
101
+
102
+ {
103
+ entries: entries,
104
+ has_more: response["has_more"],
105
+ next_cursor: response["next_cursor"]
106
+ }
107
+ rescue => e
108
+ log_error("query_database", e)
109
+ raise e
110
+ end
111
+
112
+ def test_connection
113
+ response = get_request("/users/me")
114
+ {
115
+ ok: true,
116
+ user: response["name"] || response["id"],
117
+ type: response["type"]
118
+ }
119
+ rescue => e
120
+ log_error("test_connection", e)
121
+ {ok: false, error: e.message}
122
+ end
123
+
124
+ def list_users(page_size: 100, start_cursor: nil)
125
+ params = {page_size: [page_size.to_i, 100].min}
126
+ params[:start_cursor] = start_cursor if start_cursor
127
+
128
+ response = get_request("/users", params)
129
+
130
+ users = response["results"].map do |user|
131
+ {
132
+ id: user["id"],
133
+ type: user["type"],
134
+ name: user["name"],
135
+ avatar_url: user["avatar_url"],
136
+ email: user.dig("person", "email")
137
+ }
138
+ end
139
+
140
+ {
141
+ users: users,
142
+ has_more: response["has_more"],
143
+ next_cursor: response["next_cursor"]
144
+ }
145
+ rescue => e
146
+ log_error("list_users", e)
147
+ raise e
148
+ end
149
+
150
+ def get_user(user_id)
151
+ response = get_request("/users/#{user_id}")
152
+
153
+ {
154
+ id: response["id"],
155
+ type: response["type"],
156
+ name: response["name"],
157
+ avatar_url: response["avatar_url"],
158
+ email: response.dig("person", "email")
159
+ }
160
+ rescue => e
161
+ log_error("get_user", e)
162
+ raise e
163
+ end
164
+
165
+ def get_bot_user
166
+ response = get_request("/users/me")
167
+
168
+ {
169
+ id: response["id"],
170
+ type: response["type"],
171
+ name: response["name"],
172
+ bot: {
173
+ owner: response.dig("bot", "owner"),
174
+ workspace_name: response.dig("bot", "workspace_name")
175
+ }
176
+ }
177
+ rescue => e
178
+ log_error("get_bot_user", e)
179
+ raise e
180
+ end
181
+
182
+ private
183
+
184
+ def get_request(path, params = {})
185
+ uri = URI("#{BASE_URI}#{path}")
186
+ uri.query = URI.encode_www_form(params) unless params.empty?
187
+
188
+ http = Net::HTTP.new(uri.host, uri.port)
189
+ http.use_ssl = true
190
+ http.read_timeout = 10
191
+ http.open_timeout = 5
192
+
193
+ request = Net::HTTP::Get.new(uri)
194
+ add_headers(request)
195
+
196
+ response = http.request(request)
197
+ handle_response(response)
198
+ end
199
+
200
+ def post_request(path, body)
201
+ uri = URI("#{BASE_URI}#{path}")
202
+ http = Net::HTTP.new(uri.host, uri.port)
203
+ http.use_ssl = true
204
+ http.read_timeout = 10
205
+ http.open_timeout = 5
206
+
207
+ request = Net::HTTP::Post.new(uri)
208
+ add_headers(request)
209
+ request.body = body.to_json
210
+
211
+ response = http.request(request)
212
+ handle_response(response)
213
+ end
214
+
215
+ def add_headers(request)
216
+ request["Authorization"] = "Bearer #{Mcpeasy::Config.notion_api_key}"
217
+ request["Content-Type"] = "application/json"
218
+ request["Notion-Version"] = "2022-06-28"
219
+ end
220
+
221
+ def handle_response(response)
222
+ case response.code.to_i
223
+ when 200..299
224
+ JSON.parse(response.body)
225
+ when 401
226
+ raise "Authentication failed. Please check your Notion API key."
227
+ when 403
228
+ raise "Access forbidden. Check your integration permissions."
229
+ when 404
230
+ raise "Resource not found. Check the ID and ensure the integration has access."
231
+ when 429
232
+ raise "Rate limit exceeded. Please try again later."
233
+ else
234
+ error_data = begin
235
+ JSON.parse(response.body)
236
+ rescue
237
+ {}
238
+ end
239
+ error_msg = error_data["message"] || "Unknown error"
240
+ raise "Notion API Error (#{response.code}): #{error_msg}"
241
+ end
242
+ end
243
+
244
+ def parse_search_results(response, type)
245
+ response["results"].map do |item|
246
+ {
247
+ id: item["id"],
248
+ title: extract_title(item),
249
+ url: item["url"],
250
+ created_time: item["created_time"],
251
+ last_edited_time: item["last_edited_time"]
252
+ }
253
+ end
254
+ end
255
+
256
+ def extract_title(object)
257
+ return "Untitled" unless object["properties"]
258
+
259
+ # Try to find title property
260
+ title_prop = object["properties"].find { |_, prop| prop["type"] == "title" }
261
+ if title_prop
262
+ title_content = title_prop[1]["title"]
263
+ return title_content.map { |t| t["plain_text"] }.join if title_content&.any?
264
+ end
265
+
266
+ # Fallback: look for any text in properties
267
+ object["properties"].each do |_, prop|
268
+ case prop["type"]
269
+ when "rich_text"
270
+ text = prop["rich_text"]&.map { |t| t["plain_text"] }&.join
271
+ return text if text && !text.empty?
272
+ end
273
+ end
274
+
275
+ "Untitled"
276
+ end
277
+
278
+ def extract_text_content(blocks)
279
+ text_content = []
280
+
281
+ blocks.each do |block|
282
+ case block["type"]
283
+ when "paragraph"
284
+ text = block["paragraph"]["rich_text"]&.map { |t| t["plain_text"] }&.join
285
+ text_content << text if text && !text.empty?
286
+ when "heading_1", "heading_2", "heading_3"
287
+ heading_type = block["type"]
288
+ text = block[heading_type]["rich_text"]&.map { |t| t["plain_text"] }&.join
289
+ text_content << text if text && !text.empty?
290
+ when "bulleted_list_item", "numbered_list_item"
291
+ list_type = block["type"]
292
+ text = block[list_type]["rich_text"]&.map { |t| t["plain_text"] }&.join
293
+ text_content << "• #{text}" if text && !text.empty?
294
+ when "to_do"
295
+ text = block["to_do"]["rich_text"]&.map { |t| t["plain_text"] }&.join
296
+ checked = block["to_do"]["checked"] ? "☑" : "☐"
297
+ text_content << "#{checked} #{text}" if text && !text.empty?
298
+ end
299
+ end
300
+
301
+ text_content.join("\n\n")
302
+ end
303
+
304
+ def clean_notion_id(id)
305
+ # Remove hyphens and ensure it's a valid UUID format
306
+ id.to_s.delete("-")
307
+ end
308
+
309
+ def log_error(method, error)
310
+ Mcpeasy::Config.ensure_config_dirs
311
+ File.write(
312
+ Mcpeasy::Config.log_file_path("notion", "error"),
313
+ "#{Time.now}: NotionTool##{method} error: #{error.class}: #{error.message}\n#{error.backtrace.join("\n")}\n",
314
+ mode: "a"
315
+ )
316
+ end
317
+
318
+ def ensure_env!
319
+ unless Mcpeasy::Config.notion_api_key
320
+ raise <<~ERROR
321
+ Notion API key is not configured!
322
+ Please run: mcpz notion set_api_key YOUR_API_KEY
323
+ ERROR
324
+ end
325
+ end
326
+ end
327
+ end
@@ -175,14 +175,14 @@ The server provides these tools to Claude Code:
175
175
  lib/utilities/slack/
176
176
  ├── cli.rb # Thor-based CLI interface
177
177
  ├── mcp.rb # MCP server implementation
178
- ├── slack_tool.rb # Slack Web API wrapper
178
+ ├── service.rb # Slack Web API wrapper
179
179
  └── README.md # This file
180
180
  ```
181
181
 
182
182
  ### Adding New Features
183
183
 
184
- 1. **New API methods**: Add to `SlackTool` class
185
- 2. **New CLI commands**: Add to `SlackCLI` class
184
+ 1. **New API methods**: Add to `Service` class
185
+ 2. **New CLI commands**: Add to `CLI` class
186
186
  3. **New MCP tools**: Add to `MCPServer` class
187
187
 
188
188
  ### Testing
@@ -2,73 +2,88 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "thor"
5
- require_relative "slack_tool"
5
+ require_relative "service"
6
6
 
7
- class SlackCLI < Thor
8
- desc "test", "Test the Slack API connection"
9
- def test
10
- response = tool.test_connection
7
+ module Slack
8
+ class CLI < Thor
9
+ desc "test", "Test the Slack API connection"
10
+ def test
11
+ response = tool.test_connection
11
12
 
12
- if response["ok"]
13
- puts "✅ Successfully connected to Slack"
14
- puts " Bot name: #{response["user"]}"
15
- puts " Team: #{response["team"]}"
16
- else
17
- warn "❌ Authentication failed: #{response["error"]}"
13
+ if response["ok"]
14
+ puts "✅ Successfully connected to Slack"
15
+ puts " Bot name: #{response["user"]}"
16
+ puts " Team: #{response["team"]}"
17
+ else
18
+ warn "❌ Authentication failed: #{response["error"]}"
19
+ end
20
+ rescue RuntimeError => e
21
+ puts "❌ Failed to connect to Slack: #{e.message}"
22
+ exit 1
18
23
  end
19
- rescue RuntimeError => e
20
- puts "❌ Failed to connect to Slack: #{e.message}"
21
- exit 1
22
- end
23
24
 
24
- desc "list", "List available Slack channels"
25
- def list
26
- channels = tool.list_channels
25
+ desc "list", "List available Slack channels"
26
+ method_option :limit, type: :numeric, default: 1000, desc: "Maximum number of channels to retrieve"
27
+ method_option :include_archived, type: :boolean, default: false, desc: "Include archived channels in results"
28
+ def list
29
+ all_channels = []
30
+ cursor = nil
31
+ limit = options[:limit]
32
+ exclude_archived = !options[:include_archived]
33
+
34
+ loop do
35
+ result = tool.list_channels(limit: limit, cursor: cursor, exclude_archived: exclude_archived)
36
+ all_channels.concat(result[:channels])
37
+
38
+ break unless result[:has_more] && all_channels.count < limit
39
+ cursor = result[:next_cursor]
40
+ end
27
41
 
28
- if channels && !channels.empty?
29
- puts "📋 Available channels:"
30
- channels.each do |channel|
31
- puts " ##{channel[:name]} (ID: #{channel[:id]})"
42
+ if all_channels && !all_channels.empty?
43
+ puts "📋 Available channels (#{all_channels.count} total):"
44
+ all_channels.each do |channel|
45
+ puts " ##{channel[:name]} (ID: #{channel[:id]})"
46
+ end
32
47
  end
48
+ rescue RuntimeError => e
49
+ warn "❌ Failed to list channels: #{e.message}"
50
+ exit 1
33
51
  end
34
- rescue RuntimeError => e
35
- warn "❌ Failed to list channels: #{e.message}"
36
- exit 1
37
- end
38
52
 
39
- desc "post", "Post a message to a Slack channel"
40
- method_option :channel, required: true, type: :string, aliases: "-c"
41
- method_option :message, required: true, type: :string, aliases: "-m"
42
- method_option :username, type: :string, aliases: "-u"
43
- method_option :timestamp, type: :string, aliases: "-t"
44
- def post
45
- channel = options[:channel]
46
- text = options[:message]
47
- username = options[:username]
48
- thread_ts = options[:timestamp]
53
+ desc "post", "Post a message to a Slack channel"
54
+ method_option :channel, required: true, type: :string, aliases: "-c"
55
+ method_option :message, required: true, type: :string, aliases: "-m"
56
+ method_option :username, type: :string, aliases: "-u"
57
+ method_option :timestamp, type: :string, aliases: "-t"
58
+ def post
59
+ channel = options[:channel]
60
+ text = options[:message]
61
+ username = options[:username]
62
+ thread_ts = options[:timestamp]
49
63
 
50
- response = tool.post_message(
51
- channel: channel,
52
- text: text,
53
- username: username,
54
- thread_ts: thread_ts
55
- )
64
+ response = tool.post_message(
65
+ channel: channel,
66
+ text: text,
67
+ username: username,
68
+ thread_ts: thread_ts
69
+ )
56
70
 
57
- if response["ok"]
58
- puts "✅ Message posted successfully to ##{channel}"
59
- puts " Message timestamp: #{response["ts"]}"
60
- else
61
- warn "❌ Failed to post message: #{response["error"]}"
71
+ if response["ok"]
72
+ puts "✅ Message posted successfully to ##{channel}"
73
+ puts " Message timestamp: #{response["ts"]}"
74
+ else
75
+ warn "❌ Failed to post message: #{response["error"]}"
76
+ exit 1
77
+ end
78
+ rescue RuntimeError => e
79
+ warn "❌ Unexpected error: #{e.message}"
62
80
  exit 1
63
81
  end
64
- rescue RuntimeError => e
65
- warn "❌ Unexpected error: #{e.message}"
66
- exit 1
67
- end
68
82
 
69
- private
83
+ private
70
84
 
71
- def tool
72
- @tool ||= SlackTool.new
85
+ def tool
86
+ @tool ||= Service.new
87
+ end
73
88
  end
74
89
  end