band-mcp-server 1.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.
- checksums.yaml +7 -0
- data/API_DISCREPANCIES.md +119 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE +674 -0
- data/README.md +192 -0
- data/exe/band-mcp-server +6 -0
- data/lib/band_mcp/client.rb +103 -0
- data/lib/band_mcp/config.rb +40 -0
- data/lib/band_mcp/error.rb +28 -0
- data/lib/band_mcp/tools/check_permissions.rb +56 -0
- data/lib/band_mcp/tools/create_comment.rb +44 -0
- data/lib/band_mcp/tools/create_post.rb +43 -0
- data/lib/band_mcp/tools/get_post.rb +40 -0
- data/lib/band_mcp/tools/helpers.rb +103 -0
- data/lib/band_mcp/tools/list_bands.rb +31 -0
- data/lib/band_mcp/tools/list_comments.rb +69 -0
- data/lib/band_mcp/tools/list_posts.rb +41 -0
- data/lib/band_mcp/tools/ping.rb +21 -0
- data/lib/band_mcp/tools/remove_comment.rb +46 -0
- data/lib/band_mcp/tools/remove_post.rb +42 -0
- data/lib/band_mcp/version.rb +7 -0
- data/lib/band_mcp.rb +39 -0
- metadata +95 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
require "mcp"
|
|
6
|
+
|
|
7
|
+
require_relative "helpers"
|
|
8
|
+
|
|
9
|
+
module BandMCP
|
|
10
|
+
module Tools
|
|
11
|
+
class ListBands < MCP::Tool
|
|
12
|
+
extend Helpers
|
|
13
|
+
|
|
14
|
+
FIELDS = %w[name band_key member_count].freeze #: Array[String]
|
|
15
|
+
|
|
16
|
+
tool_name "list_bands"
|
|
17
|
+
description "List the BANDs the user has joined. Returns each band's name, band_key and member_count; use the " \
|
|
18
|
+
"band_key to refer to a band in other tools."
|
|
19
|
+
input_schema(properties: {})
|
|
20
|
+
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true)
|
|
21
|
+
|
|
22
|
+
#: (**untyped) -> untyped
|
|
23
|
+
def self.call(**)
|
|
24
|
+
with_client do |client|
|
|
25
|
+
bands = client.get("/v2.1/bands")["bands"]
|
|
26
|
+
json_response(bands.is_a?(Array) ? bands.map { |band| band.slice(*FIELDS) } : [])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
require "mcp"
|
|
6
|
+
|
|
7
|
+
require_relative "helpers"
|
|
8
|
+
|
|
9
|
+
module BandMCP
|
|
10
|
+
module Tools
|
|
11
|
+
class ListComments < MCP::Tool
|
|
12
|
+
extend Helpers
|
|
13
|
+
|
|
14
|
+
NEWEST_FIRST_SORT = "-created_at" #: String
|
|
15
|
+
|
|
16
|
+
# BAND's cursor only works with the sort it came from; paired with the wrong one it silently skips and repeats
|
|
17
|
+
# comments. So next_after carries the order as a tag, and a cursor without one is refused.
|
|
18
|
+
ORDER_TAGS = { false => "oldest", true => "newest" }.freeze #: Hash[bool, String]
|
|
19
|
+
TAGGED_CURSOR = /\A(?<tag>#{ORDER_TAGS.values.join('|')}):(?<cursor>.+)\z/m #: Regexp
|
|
20
|
+
|
|
21
|
+
BAD_CURSOR = "after must be a next_after value from a previous list_comments result, passed back unchanged " \
|
|
22
|
+
"(it starts with oldest: or newest:). Leave after out to start from the first page." #: String
|
|
23
|
+
|
|
24
|
+
tool_name "list_comments"
|
|
25
|
+
description "List the comments on a post in a BAND, oldest first unless newest_first is true, with each " \
|
|
26
|
+
"comment's comment_key, text, author, time and emotion count. BAND chooses the page size (20 when " \
|
|
27
|
+
"tested). If next_after in the result is not null, there are more comments: pass it unchanged as " \
|
|
28
|
+
"`after` to get the next page; it remembers the order, so newest_first only matters for the " \
|
|
29
|
+
"first page. Find band_key with " \
|
|
30
|
+
"list_bands and post_key with list_posts."
|
|
31
|
+
input_schema(
|
|
32
|
+
properties: {
|
|
33
|
+
band_key: { type: "string", minLength: 1, description: "Band the post belongs to, from list_bands." },
|
|
34
|
+
post_key: { type: "string", minLength: 1, description: "Post whose comments to read, from list_posts." },
|
|
35
|
+
after: { type: "string", minLength: 1,
|
|
36
|
+
description: "The next_after value from a previous result, unchanged." },
|
|
37
|
+
newest_first: { type: "boolean",
|
|
38
|
+
description: "List the newest comments first. Defaults to false. Ignored when `after` " \
|
|
39
|
+
"is given." }
|
|
40
|
+
},
|
|
41
|
+
required: %w[band_key post_key]
|
|
42
|
+
)
|
|
43
|
+
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true)
|
|
44
|
+
|
|
45
|
+
#: (band_key: String, post_key: String, ?after: String?, ?newest_first: bool, **untyped) -> untyped
|
|
46
|
+
def self.call(band_key:, post_key:, after: nil, newest_first: false, **)
|
|
47
|
+
if after
|
|
48
|
+
newest_first, cursor = parse_cursor(after)
|
|
49
|
+
return text_response(BAD_CURSOR, error: true) unless cursor
|
|
50
|
+
end
|
|
51
|
+
with_client do |client|
|
|
52
|
+
sort = NEWEST_FIRST_SORT if newest_first
|
|
53
|
+
params = { "band_key" => band_key, "post_key" => post_key, "after" => cursor, "sort" => sort }.compact
|
|
54
|
+
data = client.get("/v2/band/post/comments", params)
|
|
55
|
+
paged_response(data, "comments", prefix: "#{ORDER_TAGS[newest_first]}:") { |item| summarize_comment(item) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The order and BAND's cursor out of a next_after value, or nil for the cursor when it isn't one of ours.
|
|
60
|
+
#: (String after) -> [bool, String?]
|
|
61
|
+
def self.parse_cursor(after)
|
|
62
|
+
match = TAGGED_CURSOR.match(after)
|
|
63
|
+
return [false, nil] unless match
|
|
64
|
+
|
|
65
|
+
[ORDER_TAGS.key(match[:tag]) || false, match[:cursor]]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
require "mcp"
|
|
6
|
+
|
|
7
|
+
require_relative "helpers"
|
|
8
|
+
|
|
9
|
+
module BandMCP
|
|
10
|
+
module Tools
|
|
11
|
+
class ListPosts < MCP::Tool
|
|
12
|
+
extend Helpers
|
|
13
|
+
|
|
14
|
+
DEFAULT_LOCALE = "en_US" #: String
|
|
15
|
+
|
|
16
|
+
tool_name "list_posts"
|
|
17
|
+
description "List recent posts in a BAND, most recently modified first, with each post's text, author, time " \
|
|
18
|
+
"and comment/emotion counts. BAND chooses the page size. If next_after in the result is not " \
|
|
19
|
+
"null, pass it as `after` to get the next page. Find band_key with list_bands."
|
|
20
|
+
input_schema(
|
|
21
|
+
properties: {
|
|
22
|
+
band_key: { type: "string", minLength: 1, description: "Band to read, from list_bands." },
|
|
23
|
+
after: { type: "string", minLength: 1, description: "The next_after value from a previous result." },
|
|
24
|
+
locale: { type: "string", minLength: 1,
|
|
25
|
+
description: "Region and language for BAND's response. Defaults to en_US." }
|
|
26
|
+
},
|
|
27
|
+
required: %w[band_key]
|
|
28
|
+
)
|
|
29
|
+
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true)
|
|
30
|
+
|
|
31
|
+
#: (band_key: String, ?after: String?, ?locale: String, **untyped) -> untyped
|
|
32
|
+
def self.call(band_key:, after: nil, locale: DEFAULT_LOCALE, **)
|
|
33
|
+
with_client do |client|
|
|
34
|
+
params = { "band_key" => band_key, "locale" => locale, "after" => after }.compact
|
|
35
|
+
data = client.get("/v2/band/posts", params)
|
|
36
|
+
paged_response(data, "posts") { |item| summarize_post(item) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
require "mcp"
|
|
6
|
+
|
|
7
|
+
module BandMCP
|
|
8
|
+
module Tools
|
|
9
|
+
# Connectivity check. Makes no network call and never reveals the token, only whether one is configured.
|
|
10
|
+
class Ping < MCP::Tool
|
|
11
|
+
tool_name "ping"
|
|
12
|
+
description "Check that the server is up and whether BAND_ACCESS_TOKEN is configured."
|
|
13
|
+
input_schema(properties: {})
|
|
14
|
+
|
|
15
|
+
#: (**untyped) -> untyped
|
|
16
|
+
def self.call(**)
|
|
17
|
+
MCP::Tool::Response.new([{ type: "text", text: "pong (BAND_ACCESS_TOKEN set: #{Config.access_token?})" }])
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
require "mcp"
|
|
6
|
+
|
|
7
|
+
require_relative "helpers"
|
|
8
|
+
|
|
9
|
+
module BandMCP
|
|
10
|
+
module Tools
|
|
11
|
+
class RemoveComment < MCP::Tool
|
|
12
|
+
extend Helpers
|
|
13
|
+
|
|
14
|
+
UNCERTAIN_OUTCOME = "The comment may already have been removed. Check with list_comments before retrying, " \
|
|
15
|
+
"paging through with `after` if the post has many comments: if its comment_key is " \
|
|
16
|
+
"gone, the removal worked." #: String
|
|
17
|
+
|
|
18
|
+
tool_name "remove_comment"
|
|
19
|
+
description "Permanently delete a comment from a post in a BAND. It disappears for every member and cannot " \
|
|
20
|
+
"be undone, so confirm the comment with the user first, e.g. by showing them list_comments' " \
|
|
21
|
+
"result. BAND's docs say only comments made through this app can be removed, and " \
|
|
22
|
+
"contents_deletion (check_permissions) does not cover authorship, so removing someone else's " \
|
|
23
|
+
"comment can still fail. comment_key is only meaningful together with its post_key; find it " \
|
|
24
|
+
"with list_comments. BAND can answer 'Cool down time restriction' (1003) right after another " \
|
|
25
|
+
"write; wait a minute and retry."
|
|
26
|
+
input_schema(
|
|
27
|
+
properties: {
|
|
28
|
+
band_key: { type: "string", minLength: 1, description: "Band the post belongs to, from list_bands." },
|
|
29
|
+
post_key: { type: "string", minLength: 1, description: "Post the comment is on, from list_posts." },
|
|
30
|
+
comment_key: { type: "string", minLength: 1, description: "Comment to delete, from list_comments." }
|
|
31
|
+
},
|
|
32
|
+
required: %w[band_key post_key comment_key]
|
|
33
|
+
)
|
|
34
|
+
annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: true)
|
|
35
|
+
|
|
36
|
+
#: (band_key: String, post_key: String, comment_key: String, **untyped) -> untyped
|
|
37
|
+
def self.call(band_key:, post_key:, comment_key:, **)
|
|
38
|
+
with_write_client(UNCERTAIN_OUTCOME) do |client|
|
|
39
|
+
keys = { "band_key" => band_key, "post_key" => post_key, "comment_key" => comment_key }
|
|
40
|
+
client.post("/v2/band/post/comment/remove", keys)
|
|
41
|
+
json_response(keys.merge("removed" => true))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
require "mcp"
|
|
6
|
+
|
|
7
|
+
require_relative "helpers"
|
|
8
|
+
|
|
9
|
+
module BandMCP
|
|
10
|
+
module Tools
|
|
11
|
+
class RemovePost < MCP::Tool
|
|
12
|
+
extend Helpers
|
|
13
|
+
|
|
14
|
+
UNCERTAIN_OUTCOME = "The post may already have been removed. Check with get_post before retrying: BAND " \
|
|
15
|
+
"reporting it as deleted or not found means the removal worked." #: String
|
|
16
|
+
|
|
17
|
+
tool_name "remove_post"
|
|
18
|
+
description "Permanently delete a post from a BAND. It disappears for every member and cannot be undone, so " \
|
|
19
|
+
"confirm the band and post with the user first, e.g. by showing them get_post's result. " \
|
|
20
|
+
"Check contents_deletion with check_permissions beforehand; that permission does not cover " \
|
|
21
|
+
"authorship, so removing someone else's post can still fail. Find band_key with list_bands and " \
|
|
22
|
+
"post_key with list_posts. BAND can answer 'Cool down time restriction' (1003) right after another " \
|
|
23
|
+
"write; wait a minute and retry."
|
|
24
|
+
input_schema(
|
|
25
|
+
properties: {
|
|
26
|
+
band_key: { type: "string", minLength: 1, description: "Band the post belongs to, from list_bands." },
|
|
27
|
+
post_key: { type: "string", minLength: 1, description: "Post to delete, from list_posts or create_post." }
|
|
28
|
+
},
|
|
29
|
+
required: %w[band_key post_key]
|
|
30
|
+
)
|
|
31
|
+
annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: true)
|
|
32
|
+
|
|
33
|
+
#: (band_key: String, post_key: String, **untyped) -> untyped
|
|
34
|
+
def self.call(band_key:, post_key:, **)
|
|
35
|
+
with_write_client(UNCERTAIN_OUTCOME) do |client|
|
|
36
|
+
client.post("/v2/band/post/remove", { "band_key" => band_key, "post_key" => post_key })
|
|
37
|
+
json_response({ "band_key" => band_key, "post_key" => post_key, "removed" => true })
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/band_mcp.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
require "mcp"
|
|
6
|
+
|
|
7
|
+
require_relative "band_mcp/version"
|
|
8
|
+
require_relative "band_mcp/config"
|
|
9
|
+
require_relative "band_mcp/error"
|
|
10
|
+
require_relative "band_mcp/client"
|
|
11
|
+
require_relative "band_mcp/tools/ping"
|
|
12
|
+
require_relative "band_mcp/tools/list_bands"
|
|
13
|
+
require_relative "band_mcp/tools/create_post"
|
|
14
|
+
require_relative "band_mcp/tools/get_post"
|
|
15
|
+
require_relative "band_mcp/tools/list_posts"
|
|
16
|
+
require_relative "band_mcp/tools/check_permissions"
|
|
17
|
+
require_relative "band_mcp/tools/remove_post"
|
|
18
|
+
require_relative "band_mcp/tools/list_comments"
|
|
19
|
+
require_relative "band_mcp/tools/create_comment"
|
|
20
|
+
require_relative "band_mcp/tools/remove_comment"
|
|
21
|
+
|
|
22
|
+
module BandMCP
|
|
23
|
+
TOOLS = [
|
|
24
|
+
Tools::Ping, Tools::ListBands, Tools::CreatePost, Tools::GetPost, Tools::ListPosts, Tools::CheckPermissions,
|
|
25
|
+
Tools::RemovePost, Tools::ListComments, Tools::CreateComment, Tools::RemoveComment
|
|
26
|
+
].freeze #: Array[singleton(MCP::Tool)]
|
|
27
|
+
|
|
28
|
+
#: () -> untyped
|
|
29
|
+
def self.build_server
|
|
30
|
+
MCP::Server.new(name: "band-mcp-server", version: VERSION, tools: TOOLS)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Entry point shared by the gem's executable and the checkout's launcher.
|
|
34
|
+
#: () -> untyped
|
|
35
|
+
def self.run
|
|
36
|
+
Config.load_dotenv
|
|
37
|
+
MCP::Server::Transports::StdioTransport.new(build_server).open
|
|
38
|
+
end
|
|
39
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: band-mcp-server
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Keith T. Garner
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: dotenv
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: mcp
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.8'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.8'
|
|
40
|
+
description: A Model Context Protocol server, served over stdio, that lets MCP clients
|
|
41
|
+
list bands and read, post, and comment on BAND posts through the BAND Open API.
|
|
42
|
+
email:
|
|
43
|
+
- kgarner@kgarner.com
|
|
44
|
+
executables:
|
|
45
|
+
- band-mcp-server
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- API_DISCREPANCIES.md
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- LICENSE
|
|
52
|
+
- README.md
|
|
53
|
+
- exe/band-mcp-server
|
|
54
|
+
- lib/band_mcp.rb
|
|
55
|
+
- lib/band_mcp/client.rb
|
|
56
|
+
- lib/band_mcp/config.rb
|
|
57
|
+
- lib/band_mcp/error.rb
|
|
58
|
+
- lib/band_mcp/tools/check_permissions.rb
|
|
59
|
+
- lib/band_mcp/tools/create_comment.rb
|
|
60
|
+
- lib/band_mcp/tools/create_post.rb
|
|
61
|
+
- lib/band_mcp/tools/get_post.rb
|
|
62
|
+
- lib/band_mcp/tools/helpers.rb
|
|
63
|
+
- lib/band_mcp/tools/list_bands.rb
|
|
64
|
+
- lib/band_mcp/tools/list_comments.rb
|
|
65
|
+
- lib/band_mcp/tools/list_posts.rb
|
|
66
|
+
- lib/band_mcp/tools/ping.rb
|
|
67
|
+
- lib/band_mcp/tools/remove_comment.rb
|
|
68
|
+
- lib/band_mcp/tools/remove_post.rb
|
|
69
|
+
- lib/band_mcp/version.rb
|
|
70
|
+
homepage: https://github.com/ktgeek/ruby-band-mcp-server
|
|
71
|
+
licenses:
|
|
72
|
+
- GPL-3.0-only
|
|
73
|
+
metadata:
|
|
74
|
+
source_code_uri: https://github.com/ktgeek/ruby-band-mcp-server
|
|
75
|
+
changelog_uri: https://github.com/ktgeek/ruby-band-mcp-server/blob/main/CHANGELOG.md
|
|
76
|
+
bug_tracker_uri: https://github.com/ktgeek/ruby-band-mcp-server/issues
|
|
77
|
+
rubygems_mfa_required: 'true'
|
|
78
|
+
rdoc_options: []
|
|
79
|
+
require_paths:
|
|
80
|
+
- lib
|
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '4.0'
|
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: '0'
|
|
91
|
+
requirements: []
|
|
92
|
+
rubygems_version: 4.0.10
|
|
93
|
+
specification_version: 4
|
|
94
|
+
summary: MCP server for the BAND Open API
|
|
95
|
+
test_files: []
|