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.
data/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # ruby-band-mcp-server
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server for the [BAND Open API](https://developers.band.us/develop/guide/api),
4
+ written in Ruby on the official [`mcp` gem](https://github.com/modelcontextprotocol/ruby-sdk) and served over stdio.
5
+
6
+ ## Tools
7
+
8
+ | Tool | What it does |
9
+ | ------------------- | ---------------------------------------------------------------------------------------------- |
10
+ | `ping` | Reports that the server is up and whether `BAND_ACCESS_TOKEN` is configured (never its value). |
11
+ | `list_bands` | Lists the bands you have joined, with the `band_key` the other tools need. |
12
+ | `create_post` | Publishes a text post to a band. Members are push-notified only if `do_push` is true. |
13
+ | `get_post` | Fetches one post by `band_key` and `post_key`, e.g. to confirm a `create_post` landed. |
14
+ | `list_posts` | Lists a band's recent posts, newest-modified first, paged with an `after` cursor. |
15
+ | `check_permissions` | Reports which of `posting`, `commenting` and `contents_deletion` the user has in a band. |
16
+ | `remove_post` | Permanently deletes a post by `band_key` and `post_key`. It cannot be undone. |
17
+ | `list_comments` | Lists a post's comments, oldest first unless `newest_first`, paged with `after`. |
18
+ | `create_comment` | Adds a comment to a post. BAND does not return its key; find it with `list_comments`. |
19
+ | `remove_comment` | Permanently deletes a comment by `comment_key`. It cannot be undone. |
20
+
21
+ A typical flow is `list_bands` to find the band, `create_post` to publish, then `get_post` with the returned
22
+ `post_key` to confirm. `create_post` is marked as not read-only, so MCP clients such as Claude Desktop can ask before
23
+ running it.
24
+
25
+ To browse what is already in a band, use `list_posts`. BAND picks the page size (20 posts); when the result's
26
+ `next_after` is not null, pass it back as `after` to get the next page.
27
+
28
+ To read the discussion on a post, use `list_comments` with the post's `band_key` and `post_key`. Comments come oldest
29
+ first; set `newest_first` to reverse that. BAND chose a page size of 20 when tested. Pass `next_after` back as `after` exactly as
30
+ returned: it remembers the order, so a page after the first needs no `newest_first`.
31
+
32
+ To join in, use `create_comment`. BAND confirms the comment without saying what its key is, so call `list_comments`
33
+ afterwards to confirm it landed and to get its `comment_key`.
34
+
35
+ To take a post down, use `remove_post`; it is marked destructive, so clients can ask before running it. Run
36
+ `check_permissions` with `contents_deletion` first. BAND's docs say that permission does not check authorship, so
37
+ removing someone else's post can still fail. `remove_comment` works the same way on a `comment_key` from
38
+ `list_comments`, and BAND's docs add that it can only remove comments made through this app.
39
+
40
+ ## Requirements
41
+
42
+ Ruby 4.0 or newer.
43
+
44
+ ## Install
45
+
46
+ ```sh
47
+ gem install band-mcp-server
48
+ ```
49
+
50
+ This puts a `band-mcp-server` executable on your `PATH`. To run it without installing, RubyGems can fetch and run it
51
+ on demand (the gem and executable share a name, but `gem exec` needs the gem named explicitly):
52
+
53
+ ```sh
54
+ gem exec -g band-mcp-server band-mcp-server
55
+ ```
56
+
57
+ ### Getting a BAND access token
58
+
59
+ Register an app on the [BAND developer site](https://developers.band.us) and get an access token for it; the
60
+ [API guide](https://developers.band.us/develop/guide/api) covers the details. The server only consumes the token and
61
+ sends it to BAND as an `Authorization: Bearer` header. It does not run any OAuth flow itself.
62
+
63
+ ### Configuring the token
64
+
65
+ Put the token in `~/.config/band-mcp-server/config` (or `$XDG_CONFIG_HOME/band-mcp-server/config` if you set that):
66
+
67
+ ```sh
68
+ mkdir -p ~/.config/band-mcp-server
69
+ echo 'BAND_ACCESS_TOKEN=your-token' > ~/.config/band-mcp-server/config
70
+ chmod 600 ~/.config/band-mcp-server/config
71
+ ```
72
+
73
+ A value already set in the environment (for example through the MCP client's `env` setting) takes precedence over
74
+ the file.
75
+
76
+ ## Running
77
+
78
+ Register it with Claude Code:
79
+
80
+ ```sh
81
+ claude mcp add band -- band-mcp-server
82
+ ```
83
+
84
+ The server speaks MCP over stdio, so it is normally launched by a client rather than run by hand.
85
+
86
+ ### Claude Desktop
87
+
88
+ Claude Desktop starts servers with a minimal environment, so a version manager such as chruby is not active and
89
+ the installed executable's `#!/usr/bin/env ruby` finds the wrong Ruby (the system Ruby, if any). With chruby, launch
90
+ the server through [`chruby-exec`](https://github.com/postmodern/chruby#chruby-exec), which selects the Ruby and its
91
+ `GEM_HOME` for you.
92
+
93
+ `chruby-exec` does that by running the command in a login shell (`$SHELL -l -c`), so two things must hold. chruby has
94
+ to be loaded by a startup file that a non-interactive login shell reads (for zsh, `~/.zprofile` or `~/.zshenv`, not
95
+ only `~/.zshrc`). And those startup files must not print anything to stdout: it would land in front of the protocol
96
+ stream and Desktop could not parse the handshake.
97
+
98
+ 1. Find where `chruby-exec` is installed with `which chruby-exec`. That depends on how you installed chruby, and the
99
+ path has to be absolute because Desktop's `PATH` is minimal.
100
+ 2. Open Settings → Developer → Edit Config (on macOS this is
101
+ `~/Library/Application Support/Claude/claude_desktop_config.json`) and add a `band` entry to `mcpServers`:
102
+
103
+ ```json
104
+ {
105
+ "mcpServers": {
106
+ "band": {
107
+ "command": "/path/to/chruby-exec",
108
+ "args": ["ruby-4", "--", "band-mcp-server"]
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ chruby matches `ruby-4` against the installed Rubies by name, so it picks a 4.x Ruby; use a fuller name such as
115
+ `ruby-4.0.5` to pin one. Run `gem install band-mcp-server` under that same Ruby.
116
+
117
+ Keep the token in the config file described above; it is loaded by absolute path, so the working directory does not
118
+ matter. Setting `BAND_ACCESS_TOKEN` in an `env` map instead also works and takes precedence, but puts the secret in
119
+ Desktop's config file.
120
+
121
+ Without chruby, use the absolute path of the installed executable (`which band-mcp-server`) as `command`. If that
122
+ Ruby is not the first `ruby` on Desktop's minimal `PATH`, use the absolute path of a Ruby 4.0 or newer as `command`
123
+ instead, put the executable in `args`, and set `GEM_HOME` to the directory holding the installed gems
124
+ (`gem env gemdir` prints it):
125
+
126
+ ```json
127
+ {
128
+ "mcpServers": {
129
+ "band": {
130
+ "command": "/path/to/ruby",
131
+ "args": ["/path/to/band-mcp-server"],
132
+ "env": { "GEM_HOME": "/path/to/gem/dir" }
133
+ }
134
+ }
135
+ }
136
+ ```
137
+ 3. Quit Claude Desktop completely (Cmd-Q) and reopen it. The `band` tools appear in the tools menu of a new chat.
138
+
139
+ Then ask Claude to run `ping`, which reports whether the token is set without showing it, and try "Which bands am I
140
+ in?" to confirm the connection. Desktop asks before it runs a tool unless you have set that tool to always allow.
141
+ Keep the prompt on for `create_post` and `create_comment`: they publish to every member of the band. Read the band and
142
+ the wording in the request before approving. For `create_post`, members are push-notified only if the request sets
143
+ `do_push`. Keep the prompt on for `remove_post` and `remove_comment` too: what they delete is gone for every member and
144
+ cannot be restored, so check the keys before approving.
145
+
146
+ To check the launch command outside Desktop, run it with a bare environment. A JSON `initialize` response means the
147
+ server starts under that Ruby with its gems; it does not check the token, which `ping` does:
148
+
149
+ ```sh
150
+ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' |
151
+ env -i HOME="$HOME" PATH=/usr/bin:/bin "$(which chruby-exec)" ruby-4 -- band-mcp-server
152
+ ```
153
+
154
+ If the tools do not show up, read `~/Library/Logs/Claude/mcp-server-band.log`:
155
+
156
+ | Symptom in the log or tool result | Cause |
157
+ | ----------------------------------------------- | ------------------------------------------------------------------------------ |
158
+ | `spawn … ENOENT` | `command` is not an absolute path to an existing `chruby-exec` (or Ruby). |
159
+ | `chruby: unknown Ruby: …` | No installed Ruby matches the name in `args`; compare with the output of `chruby`. |
160
+ | `Could not find dotenv-…, mcp-…` (gems missing) | `gem install` was run under a different Ruby; run it under the one in args. |
161
+ | `requires Ruby version >= 4.0` | The chosen Ruby is older than 4.0; point `args` (or `command`) at a 4.x Ruby. |
162
+ | `BAND_ACCESS_TOKEN is not set` | No config file with the token, and no `BAND_ACCESS_TOKEN` in an `env` map. |
163
+ | `command not found: chruby` | chruby is only loaded in `~/.zshrc`; load it in `~/.zprofile` or `~/.zshenv`. |
164
+ | Desktop cannot parse the server's output | A shell startup file prints to stdout; `chruby-exec` runs a login shell. |
165
+ | `BAND API error 10401` | BAND's "Unauthorized" code: the token is missing, invalid or expired. |
166
+
167
+ ## Development
168
+
169
+ From a checkout, use Bundler instead of the installed gem. A `.env` in the checkout root takes priority over the
170
+ user config file, and `bin/band-mcp-server` (which sets up Bundler itself) replaces `band-mcp-server`:
171
+
172
+ ```sh
173
+ chruby ruby-4.0.5 # or any manager that reads .ruby-version
174
+ bundle install
175
+ cp .env.example .env # then set BAND_ACCESS_TOKEN
176
+ claude mcp add band -- /path/to/ruby-band-mcp-server/bin/band-mcp-server
177
+ ```
178
+
179
+ ```sh
180
+ bundle exec rake # rubocop, rbs drift check, steep, rspec
181
+ bundle exec rake -T # every task, including audit
182
+ ```
183
+
184
+ `lib/` is typed with [rbs-inline](https://github.com/soutaro/rbs-inline) annotations and checked by
185
+ [Steep](https://github.com/soutaro/steep); regenerate `sig/generated/` with `bundle exec rake rbs:generate` after
186
+ changing them. See [AGENTS.md](AGENTS.md) for conventions and architecture.
187
+
188
+ `main` is protected: changes land through a pull request with green CI.
189
+
190
+ ## License
191
+
192
+ GPL-3.0. See [LICENSE](LICENSE).
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "band_mcp"
5
+
6
+ BandMCP.run
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require "json"
6
+ require "net/http"
7
+ require "uri"
8
+
9
+ require_relative "config"
10
+ require_relative "error"
11
+
12
+ module BandMCP
13
+ # Minimal BAND Open API client. Callers pass the full versioned path (`/v2.1/bands`) because BAND versions each
14
+ # endpoint separately. Both verbs return the response's `result_data` and raise BandMCP::Error on any failure.
15
+ class Client
16
+ BASE_URL = "https://openapi.band.us" #: String
17
+ SUCCESS_CODE = 1 #: Integer
18
+ OPEN_TIMEOUT = 10 #: Integer
19
+ READ_TIMEOUT = 30 #: Integer
20
+ NETWORK_ERRORS = [
21
+ SocketError, SystemCallError, IOError, EOFError, Timeout::Error, OpenSSL::SSL::SSLError,
22
+ Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError
23
+ ].freeze #: Array[Class]
24
+
25
+ # @rbs @access_token: String
26
+
27
+ #: (?access_token: String?) -> void
28
+ def initialize(access_token: Config.access_token)
29
+ raise Error, "BAND_ACCESS_TOKEN is not set" if access_token.nil?
30
+
31
+ @access_token = access_token
32
+ end
33
+
34
+ #: (String path, ?Hash[String, untyped] params) -> Hash[String, untyped]
35
+ def get(path, params = {})
36
+ uri = uri_for(path)
37
+ uri.query = URI.encode_www_form(params) unless params.empty?
38
+ perform(uri, Net::HTTP::Get.new(uri))
39
+ end
40
+
41
+ #: (String path, ?Hash[String, untyped] params) -> Hash[String, untyped]
42
+ def post(path, params = {})
43
+ uri = uri_for(path)
44
+ request = Net::HTTP::Post.new(uri)
45
+ request.set_form_data(params)
46
+ perform(uri, request)
47
+ end
48
+
49
+ private
50
+
51
+ #: (String path) -> URI::Generic
52
+ def uri_for(path)
53
+ URI("#{BASE_URL}#{path}")
54
+ end
55
+
56
+ #: (URI::Generic uri, Net::HTTPRequest request) -> Hash[String, untyped]
57
+ def perform(uri, request)
58
+ request["Authorization"] = "Bearer #{@access_token}"
59
+ response = Net::HTTP.start(uri.hostname.to_s, uri.port, use_ssl: true, open_timeout: OPEN_TIMEOUT,
60
+ read_timeout: READ_TIMEOUT) do |http|
61
+ http.request(request)
62
+ end
63
+ parse(response)
64
+ rescue *NETWORK_ERRORS => e
65
+ # Only the exception class: the message can echo the request URL, which must stay free of credentials.
66
+ raise NetworkError, "BAND request failed (#{e.class})"
67
+ end
68
+
69
+ # BAND reports failures as JSON with a non-1 result_code, often alongside a 4xx status, so the body is parsed
70
+ # before the HTTP status is consulted. A body that isn't BAND's answer is a plain error for a 4xx (a rejection) but
71
+ # an uncertain outcome for a 5xx.
72
+ #: (Net::HTTPResponse response) -> Hash[String, untyped]
73
+ def parse(response)
74
+ body = JSON.parse(response.body.to_s)
75
+ code = body["result_code"] if body.is_a?(Hash)
76
+ raise unreadable_response(response, "an unexpected response") unless code.is_a?(Integer)
77
+
78
+ data = body["result_data"]
79
+ data = empty_data unless data.is_a?(Hash)
80
+ return data if code == SUCCESS_CODE
81
+
82
+ raise APIError.new(code, error_message(data))
83
+ rescue JSON::ParserError
84
+ raise unreadable_response(response, "a non-JSON response")
85
+ end
86
+
87
+ #: (Net::HTTPResponse response, String what) -> Error
88
+ def unreadable_response(response, what)
89
+ error_class = response.code.to_i >= 500 ? UncertainResponseError : Error
90
+ error_class.new("BAND returned #{what} (HTTP #{response.code})")
91
+ end
92
+
93
+ #: () -> Hash[String, untyped]
94
+ def empty_data
95
+ {}
96
+ end
97
+
98
+ #: (Hash[String, untyped] data) -> String
99
+ def error_message(data)
100
+ [data["message"], data["detail"]].compact.join(": ").then { |text| text.empty? ? "unknown error" : text }
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require "dotenv"
6
+
7
+ module BandMCP
8
+ module Config
9
+ ROOT = File.expand_path("../..", __dir__.to_s) #: String
10
+
11
+ # The per-user config file an installed gem reads, following the XDG base directory convention. It uses .env
12
+ # syntax (KEY=value) but is named for its role.
13
+ #: () -> String
14
+ def self.user_config_path
15
+ config_home = ENV.fetch("XDG_CONFIG_HOME", "")
16
+ # The XDG spec says to ignore a relative path, which would otherwise resolve against the client's cwd.
17
+ config_home = File.join(Dir.home, ".config") unless config_home.start_with?("/")
18
+ File.join(config_home, "band-mcp-server", "config")
19
+ end
20
+
21
+ # Loads .env files by absolute path so the server works from any cwd. Dotenv.load never overrides values already
22
+ # in ENV (which lets an MCP client's `env` setting win) or set by an earlier file, so the order is the priority:
23
+ # the checkout's own .env, which an installed gem does not have, then the user's.
24
+ #: () -> void
25
+ def self.load_dotenv
26
+ Dotenv.load(File.join(ROOT, ".env"), user_config_path)
27
+ end
28
+
29
+ #: () -> String?
30
+ def self.access_token
31
+ token = ENV.fetch("BAND_ACCESS_TOKEN", nil)
32
+ token unless token.nil? || token.empty?
33
+ end
34
+
35
+ #: () -> bool
36
+ def self.access_token?
37
+ !access_token.nil?
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module BandMCP
6
+ # Raised for anything that stops a BAND request from producing a result. The message is safe to show to the model:
7
+ # it never contains the access token.
8
+ class Error < StandardError; end
9
+
10
+ # The request failed in transit, so it may or may not have reached BAND. Matters for writes, which must not be blindly
11
+ # retried.
12
+ class NetworkError < Error; end
13
+
14
+ # A 5xx whose body isn't BAND's own answer (an HTML error page, say). A gateway in front of BAND can forward a write
15
+ # and then fail, so like a dropped connection it says nothing about whether the write happened.
16
+ class UncertainResponseError < NetworkError; end
17
+
18
+ # BAND answered, but with a failure `result_code`. See https://developers.band.us/develop/guide/api/handle_errors.
19
+ class APIError < Error
20
+ attr_reader :code #: Integer
21
+
22
+ #: (Integer code, String message) -> void
23
+ def initialize(code, message)
24
+ @code = code
25
+ super("BAND API error #{code}: #{message}")
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,56 @@
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 CheckPermissions < MCP::Tool
12
+ extend Helpers
13
+
14
+ PERMISSIONS = %w[posting commenting contents_deletion].freeze #: Array[String]
15
+
16
+ tool_name "check_permissions"
17
+ description "Check what the user is allowed to do in a BAND before trying it: posting (create_post), " \
18
+ "commenting, and contents_deletion (removing posts and comments). Band settings can deny these " \
19
+ "even to members. Returns the granted and denied permissions. contents_deletion says nothing " \
20
+ "about authorship, so removing someone else's post can still fail. Find band_key with list_bands."
21
+ input_schema(
22
+ properties: {
23
+ band_key: { type: "string", minLength: 1, description: "Band to check, from list_bands." },
24
+ permissions: {
25
+ type: "array", minItems: 1, uniqueItems: true, items: { type: "string", enum: PERMISSIONS },
26
+ description: "Permissions to check."
27
+ }
28
+ },
29
+ required: %w[band_key permissions]
30
+ )
31
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true)
32
+
33
+ #: (band_key: String, permissions: Array[String], **untyped) -> untyped
34
+ def self.call(band_key:, permissions:, **)
35
+ with_client do |client|
36
+ data = client.get("/v2/band/permissions", { "band_key" => band_key, "permissions" => permissions.join(",") })
37
+ granted = granted_permissions(data)
38
+ if granted
39
+ json_response({ "granted" => permissions & granted, "denied" => permissions - granted })
40
+ else
41
+ unexpected_format("permissions")
42
+ end
43
+ end
44
+ end
45
+
46
+ # BAND's docs show the result key as `permission` in an example, but the live API sends `permissions` (see
47
+ # API_DISCREPANCIES.md). Returns nil when that isn't a list, so a changed format isn't reported as everything
48
+ # denied. Entries that aren't strings can't match a requested permission and are dropped.
49
+ #: (Hash[String, untyped] data) -> Array[String]?
50
+ def self.granted_permissions(data)
51
+ granted = data["permissions"]
52
+ granted.grep(String) if granted.is_a?(Array)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,44 @@
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 CreateComment < MCP::Tool
12
+ extend Helpers
13
+
14
+ # A blind retry would add a duplicate comment for every member to see.
15
+ UNCERTAIN_OUTCOME = "The comment may still have been added; check with list_comments before retrying." #: String
16
+
17
+ tool_name "create_comment"
18
+ description "Add a comment to a post in a BAND as the user. Visible to the band's members, so confirm the " \
19
+ "post and wording with the user first. BAND does not return the new comment's key; call " \
20
+ "list_comments to find it. Check the commenting permission with check_permissions if unsure. " \
21
+ "Find band_key with list_bands and post_key with list_posts. BAND can answer 'Cool down time " \
22
+ "restriction' (1003) when comments come in quick succession; leave about ten seconds between " \
23
+ "comments, or wait that long 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 comment on, from list_posts." },
28
+ content: { type: "string", pattern: "\\S", description: "Text of the comment. Must not be blank." }
29
+ },
30
+ required: %w[band_key post_key content]
31
+ )
32
+ annotations(read_only_hint: false, destructive_hint: false, idempotent_hint: false)
33
+
34
+ #: (band_key: String, post_key: String, content: String, **untyped) -> untyped
35
+ def self.call(band_key:, post_key:, content:, **)
36
+ with_write_client(UNCERTAIN_OUTCOME) do |client|
37
+ client.post("/v2/band/post/comment/create",
38
+ { "band_key" => band_key, "post_key" => post_key, "body" => content })
39
+ json_response({ "band_key" => band_key, "post_key" => post_key, "created" => true })
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,43 @@
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 CreatePost < MCP::Tool
12
+ extend Helpers
13
+
14
+ # A blind retry would duplicate the post for every member.
15
+ UNCERTAIN_OUTCOME = "The post may still have been published; check the band before retrying." #: String
16
+
17
+ tool_name "create_post"
18
+ description "Publish a text post to a BAND as the user. Visible to the band's members, so confirm the band " \
19
+ "and wording with the user first. Returns the post_key; pass it to get_post to confirm the post " \
20
+ "landed. Find band_key with list_bands. Members are only push-notified if do_push is true."
21
+ input_schema(
22
+ properties: {
23
+ band_key: { type: "string", minLength: 1, description: "Band to post to, from list_bands." },
24
+ content: { type: "string", pattern: "\\S", description: "Body of the post. Must not be blank." },
25
+ do_push: { type: "boolean", description: "Push-notify subscribed members. Defaults to false." }
26
+ },
27
+ required: %w[band_key content]
28
+ )
29
+ annotations(read_only_hint: false, destructive_hint: false, idempotent_hint: false)
30
+
31
+ #: (band_key: String, content: String, ?do_push: bool, **untyped) -> untyped
32
+ def self.call(band_key:, content:, do_push: false, **)
33
+ with_write_client(UNCERTAIN_OUTCOME) do |client|
34
+ data = client.post(
35
+ "/v2.2/band/post/create",
36
+ { "band_key" => band_key, "content" => content, "do_push" => do_push }
37
+ )
38
+ json_response(data.slice("band_key", "post_key"))
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
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 GetPost < MCP::Tool
12
+ extend Helpers
13
+
14
+ tool_name "get_post"
15
+ description "Fetch one post from a BAND by band_key and post_key, e.g. to confirm that a post made with " \
16
+ "create_post was published. Returns the text, author, creation time and comment/emotion counts."
17
+ input_schema(
18
+ properties: {
19
+ band_key: { type: "string", minLength: 1, description: "Band the post belongs to, from list_bands." },
20
+ post_key: { type: "string", minLength: 1, description: "Post to fetch, e.g. from create_post." }
21
+ },
22
+ required: %w[band_key post_key]
23
+ )
24
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true)
25
+
26
+ #: (band_key: String, post_key: String, **untyped) -> untyped
27
+ def self.call(band_key:, post_key:, **)
28
+ with_client do |client|
29
+ data = client.get("/v2.1/band/post", { "band_key" => band_key, "post_key" => post_key })
30
+ post = data["post"]
31
+ if post.is_a?(Hash)
32
+ json_response(summarize_post(post))
33
+ else
34
+ text_response("BAND returned no post for that band_key and post_key", error: true)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require "json"
6
+ require "mcp"
7
+ require "time"
8
+
9
+ require_relative "../client"
10
+
11
+ module BandMCP
12
+ module Tools
13
+ # Shared plumbing for tools that call BAND. Tool classes `extend` this so their `self.call` stays a one-liner and
14
+ # every failure reaches the model as a tool error rather than crashing the server.
15
+ module Helpers
16
+ POST_FIELDS = %w[post_key band_key content comment_count emotion_count].freeze #: Array[String]
17
+ COMMENT_FIELDS = %w[comment_key post_key content emotion_count].freeze #: Array[String]
18
+
19
+ # Yields a client and turns BandMCP::Error into an MCP error response.
20
+ #: () { (Client) -> untyped } -> untyped
21
+ def with_client
22
+ yield Client.new
23
+ rescue Error => e
24
+ text_response(e.message, error: true)
25
+ end
26
+
27
+ #: (untyped data) -> untyped
28
+ def json_response(data)
29
+ text_response(JSON.generate(data))
30
+ end
31
+
32
+ #: (String text, ?error: bool) -> untyped
33
+ def text_response(text, error: false)
34
+ MCP::Tool::Response.new([{ type: "text", text: text }], error: error)
35
+ end
36
+
37
+ # `with_client` for a write. When the connection drops, that says nothing about whether the write took effect,
38
+ # and a blind retry could repeat it, so the model is told to check first. `advice` says how.
39
+ #: (String advice) { (Client) -> untyped } -> untyped
40
+ def with_write_client(advice)
41
+ with_client do |client|
42
+ yield client
43
+ rescue NetworkError => e
44
+ text_response("#{e.message}. #{advice}", error: true)
45
+ end
46
+ end
47
+
48
+ # For a response whose shape BAND changed. Reporting it as an error keeps it from reading as an empty or
49
+ # all-denied answer.
50
+ #: (String what) -> untyped
51
+ def unexpected_format(what)
52
+ text_response("BAND returned #{what} in an unexpected format", error: true)
53
+ end
54
+
55
+ #: (Hash[String, untyped] post) -> Hash[String, untyped]
56
+ def summarize_post(post)
57
+ summarize_record(post, POST_FIELDS)
58
+ end
59
+
60
+ #: (Hash[String, untyped] comment) -> Hash[String, untyped]
61
+ def summarize_comment(comment)
62
+ summarize_record(comment, COMMENT_FIELDS)
63
+ end
64
+
65
+ # Answers a BAND page of items as `{key => [...], "next_after" => cursor}`, yielding each item to summarize it.
66
+ # A missing list means an empty page, but a list that isn't an array means BAND changed shape; saying "none"
67
+ # there would mislead the model.
68
+ # `prefix` is put in front of the cursor for tools that encode more than BAND's cursor in it.
69
+ #: (Hash[String, untyped] data, String key, ?prefix: String) { (Hash[String, untyped]) -> untyped } -> untyped
70
+ def paged_response(data, key, prefix: "", &)
71
+ items = data["items"]
72
+ return unexpected_format(key) unless items.nil? || items.is_a?(Array)
73
+
74
+ cursor = next_after(data)
75
+ json_response({ key => Array(items).grep(Hash).map(&),
76
+ "next_after" => cursor && "#{prefix}#{cursor}" })
77
+ end
78
+
79
+ # BAND's next_params also carries the access token, so only the cursor is passed on. Nothing else from it may
80
+ # reach the model.
81
+ #: (Hash[String, untyped] data) -> String?
82
+ def next_after(data)
83
+ paging = data["paging"]
84
+ next_params = paging["next_params"] if paging.is_a?(Hash)
85
+ after = next_params["after"] if next_params.is_a?(Hash)
86
+ after if after.is_a?(String) && !after.empty?
87
+ end
88
+
89
+ # Keeps the listed fields and drops the photo/video payloads and author profile details, and turns BAND's epoch
90
+ # milliseconds into ISO 8601 so the model doesn't have to. Anything missing or oddly shaped is omitted rather
91
+ # than raised on.
92
+ #: (Hash[String, untyped] record, Array[String] fields) -> Hash[String, untyped]
93
+ def summarize_record(record, fields)
94
+ summary = record.slice(*fields)
95
+ author = record["author"]
96
+ summary["author"] = author["name"] if author.is_a?(Hash)
97
+ created_at = record["created_at"]
98
+ summary["created_at"] = Time.at(created_at / 1000, in: "UTC").iso8601 if created_at.is_a?(Integer)
99
+ summary
100
+ end
101
+ end
102
+ end
103
+ end