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.
- checksums.yaml +4 -4
- data/.claudeignore +0 -3
- data/.mcp.json +10 -1
- data/CHANGELOG.md +50 -0
- data/CLAUDE.md +19 -5
- data/README.md +19 -3
- data/lib/mcpeasy/cli.rb +33 -10
- data/lib/mcpeasy/config.rb +22 -1
- data/lib/mcpeasy/setup.rb +1 -0
- data/lib/mcpeasy/version.rb +1 -1
- data/lib/utilities/gcal/README.md +11 -3
- data/lib/utilities/gcal/cli.rb +110 -108
- data/lib/utilities/gcal/mcp.rb +463 -308
- data/lib/utilities/gcal/service.rb +312 -0
- data/lib/utilities/gdrive/README.md +3 -3
- data/lib/utilities/gdrive/cli.rb +98 -96
- data/lib/utilities/gdrive/mcp.rb +290 -288
- data/lib/utilities/gdrive/service.rb +293 -0
- data/lib/utilities/gmeet/cli.rb +131 -129
- data/lib/utilities/gmeet/mcp.rb +374 -372
- data/lib/utilities/gmeet/service.rb +409 -0
- data/lib/utilities/notion/README.md +287 -0
- data/lib/utilities/notion/cli.rb +245 -0
- data/lib/utilities/notion/mcp.rb +607 -0
- data/lib/utilities/notion/service.rb +327 -0
- data/lib/utilities/slack/README.md +3 -3
- data/lib/utilities/slack/cli.rb +69 -54
- data/lib/utilities/slack/mcp.rb +277 -226
- data/lib/utilities/slack/service.rb +134 -0
- metadata +11 -8
- data/env.template +0 -11
- data/lib/utilities/gcal/gcal_tool.rb +0 -308
- data/lib/utilities/gdrive/gdrive_tool.rb +0 -291
- data/lib/utilities/gmeet/gmeet_tool.rb +0 -407
- data/lib/utilities/slack/slack_tool.rb +0 -119
- 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
|
-
├──
|
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 `
|
185
|
-
2. **New CLI commands**: Add to `
|
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
|
data/lib/utilities/slack/cli.rb
CHANGED
@@ -2,73 +2,88 @@
|
|
2
2
|
|
3
3
|
require "bundler/setup"
|
4
4
|
require "thor"
|
5
|
-
require_relative "
|
5
|
+
require_relative "service"
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
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
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
25
|
-
|
26
|
-
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
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
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
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
64
|
+
response = tool.post_message(
|
65
|
+
channel: channel,
|
66
|
+
text: text,
|
67
|
+
username: username,
|
68
|
+
thread_ts: thread_ts
|
69
|
+
)
|
56
70
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
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
|
-
|
83
|
+
private
|
70
84
|
|
71
|
-
|
72
|
-
|
85
|
+
def tool
|
86
|
+
@tool ||= Service.new
|
87
|
+
end
|
73
88
|
end
|
74
89
|
end
|