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
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).
|
data/exe/band-mcp-server
ADDED
|
@@ -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
|