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.
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module BandMCP
6
+ VERSION = "1.0.0" #: String
7
+ 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: []