panda-mcp 0.3.1
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/README.md +65 -0
- data/exe/panda-mcp +6 -0
- data/lib/panda_mcp/client.rb +315 -0
- data/lib/panda_mcp/server.rb +70 -0
- data/lib/panda_mcp/tools.rb +1293 -0
- data/lib/panda_mcp/version.rb +5 -0
- data/lib/panda_mcp.rb +4 -0
- metadata +94 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a3f0f46edbc22c1c994ba3c6327b58dc5e7689d5a2367292a57b5116d5acc487
|
|
4
|
+
data.tar.gz: 0ef7c056456594d90d4563632c1ae5c333009a01ad22741c255f5fa57033f5da
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: da193e6069352b733ef7226cad5c2e93129d5fdd7f744e906901e05629725e8411b53ce25942197a5f1d5243cad9d72030bf14fb2a92044759849ec4ca93b634
|
|
7
|
+
data.tar.gz: e8844bf0e478b216b83753388d8eb93f0f1a31b9781629f7d4953c895117f58fbb388604f616eb8ce93feffdad8ebf313ae56ddaf1473133dc108e944fc66c72
|
data/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Panda CMS MCP Server
|
|
2
|
+
|
|
3
|
+
Ruby MCP server for Panda CMS. Provides tools for creating and updating draft pages, posts, and collection items, plus read-only access to templates, page hierarchy, and content listings.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install panda-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
1. Install the gem:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem install panda-mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
2. Get your API token from Panda CMS admin at `/manage/my_profile/api_tokens`
|
|
20
|
+
|
|
21
|
+
3. Configure Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"panda-cms": {
|
|
27
|
+
"command": "panda-mcp",
|
|
28
|
+
"env": {
|
|
29
|
+
"PANDA_CMS_API_URL": "https://your-site.com",
|
|
30
|
+
"PANDA_CMS_API_TOKEN": "your_token_here"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
4. Restart Claude Desktop
|
|
38
|
+
|
|
39
|
+
## Available Tools
|
|
40
|
+
|
|
41
|
+
- `list_templates`
|
|
42
|
+
- `list_page_tree`
|
|
43
|
+
- `list_pages`
|
|
44
|
+
- `get_page`
|
|
45
|
+
- `create_draft_page`
|
|
46
|
+
- `update_page`
|
|
47
|
+
- `submit_page_for_review`
|
|
48
|
+
- `list_posts`
|
|
49
|
+
- `get_post`
|
|
50
|
+
- `create_draft_post`
|
|
51
|
+
- `update_draft_post`
|
|
52
|
+
- `submit_post_for_review`
|
|
53
|
+
- `list_collections`
|
|
54
|
+
- `list_collection_items`
|
|
55
|
+
- `get_collection_item`
|
|
56
|
+
- `create_collection_item_draft`
|
|
57
|
+
- `update_collection_item_draft`
|
|
58
|
+
- `search_content`
|
|
59
|
+
|
|
60
|
+
## Notes
|
|
61
|
+
|
|
62
|
+
- Markdown content is converted to EditorJS via the Panda CMS API.
|
|
63
|
+
- Collection items default to `visible=false` when created via the MCP server.
|
|
64
|
+
|
|
65
|
+
See `../docs/api.md` for the full API reference.
|
data/exe/panda-mcp
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module PandaMCP
|
|
9
|
+
class ApiError < StandardError
|
|
10
|
+
attr_reader :status, :body
|
|
11
|
+
|
|
12
|
+
def initialize(message, status, body)
|
|
13
|
+
super(message)
|
|
14
|
+
@status = status
|
|
15
|
+
@body = body
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Client
|
|
20
|
+
OPEN_TIMEOUT = 10
|
|
21
|
+
READ_TIMEOUT = 120
|
|
22
|
+
WRITE_TIMEOUT = 120
|
|
23
|
+
|
|
24
|
+
def initialize(base_url, token)
|
|
25
|
+
@base_url = base_url.to_s.sub(%r{/+$}, "")
|
|
26
|
+
@token = token
|
|
27
|
+
@debug = ENV["PANDA_MCP_DEBUG"] == "1"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def list_templates
|
|
31
|
+
request(:get, "/api/v1/templates")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def list_pages(params = {})
|
|
35
|
+
request(:get, "/api/v1/pages", query: compact_params(params))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def get_page(id)
|
|
39
|
+
request(:get, "/api/v1/pages/#{id}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def list_page_tree
|
|
43
|
+
request(:get, "/api/v1/pages/tree")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def create_page(payload)
|
|
47
|
+
request(:post, "/api/v1/pages", body: {page: payload})
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def update_page(id, payload)
|
|
51
|
+
request(:patch, "/api/v1/pages/#{id}", body: {page: payload})
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def submit_page_for_review(id)
|
|
55
|
+
request(:post, "/api/v1/pages/#{id}/submit_for_review", body: {})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def approve_page(id)
|
|
59
|
+
request(:post, "/api/v1/pages/#{id}/approve", body: {})
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def publish_page(id)
|
|
63
|
+
request(:post, "/api/v1/pages/#{id}/publish", body: {})
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def list_posts(params = {})
|
|
67
|
+
request(:get, "/api/v1/posts", query: compact_params(params))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def get_post(id)
|
|
71
|
+
request(:get, "/api/v1/posts/#{id}")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def create_post(payload)
|
|
75
|
+
request(:post, "/api/v1/posts", body: {post: payload})
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def update_post(id, payload)
|
|
79
|
+
request(:patch, "/api/v1/posts/#{id}", body: {post: payload})
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def submit_post_for_review(id)
|
|
83
|
+
request(:post, "/api/v1/posts/#{id}/submit_for_review", body: {})
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def approve_post(id)
|
|
87
|
+
request(:post, "/api/v1/posts/#{id}/approve", body: {})
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def publish_post(id)
|
|
91
|
+
request(:post, "/api/v1/posts/#{id}/publish", body: {})
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Version history
|
|
95
|
+
|
|
96
|
+
def list_page_versions(page_id)
|
|
97
|
+
request(:get, "/api/v1/pages/#{page_id}/versions")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def get_page_version(page_id, version_number)
|
|
101
|
+
request(:get, "/api/v1/pages/#{page_id}/versions/#{version_number}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def diff_page_version(page_id, version_number)
|
|
105
|
+
request(:get, "/api/v1/pages/#{page_id}/versions/#{version_number}/diff")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def restore_page_version(page_id, version_number)
|
|
109
|
+
request(:post, "/api/v1/pages/#{page_id}/versions/#{version_number}/restore", body: {})
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def list_post_versions(post_id)
|
|
113
|
+
request(:get, "/api/v1/posts/#{post_id}/versions")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def get_post_version(post_id, version_number)
|
|
117
|
+
request(:get, "/api/v1/posts/#{post_id}/versions/#{version_number}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def diff_post_version(post_id, version_number)
|
|
121
|
+
request(:get, "/api/v1/posts/#{post_id}/versions/#{version_number}/diff")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def restore_post_version(post_id, version_number)
|
|
125
|
+
request(:post, "/api/v1/posts/#{post_id}/versions/#{version_number}/restore", body: {})
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Content suggestions
|
|
129
|
+
|
|
130
|
+
def list_content_suggestions(params = {})
|
|
131
|
+
request(:get, "/api/v1/content_suggestions", query: compact_params(params))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def get_content_suggestion(id)
|
|
135
|
+
request(:get, "/api/v1/content_suggestions/#{id}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def approve_content_suggestion(id, notes: nil)
|
|
139
|
+
request(:post, "/api/v1/content_suggestions/#{id}/approve", body: compact_params({notes: notes}))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def reject_content_suggestion(id, notes:)
|
|
143
|
+
request(:post, "/api/v1/content_suggestions/#{id}/reject", body: {notes: notes})
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def list_collections
|
|
147
|
+
request(:get, "/api/v1/collections")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def get_collection(id)
|
|
151
|
+
request(:get, "/api/v1/collections/#{id}")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def create_collection(payload)
|
|
155
|
+
request(:post, "/api/v1/collections", body: {collection: payload})
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def update_collection(id, payload)
|
|
159
|
+
request(:patch, "/api/v1/collections/#{id}", body: {collection: payload})
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def delete_collection(id)
|
|
163
|
+
request(:delete, "/api/v1/collections/#{id}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def list_collection_items(collection_id, params = {})
|
|
167
|
+
request(:get, "/api/v1/collections/#{collection_id}/items", query: compact_params(params))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def get_collection_item(collection_id, id)
|
|
171
|
+
request(:get, "/api/v1/collections/#{collection_id}/items/#{id}")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def create_collection_item(collection_id, payload)
|
|
175
|
+
request(:post, "/api/v1/collections/#{collection_id}/items", body: {collection_item: payload})
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def update_collection_item(collection_id, id, payload)
|
|
179
|
+
request(:patch, "/api/v1/collections/#{collection_id}/items/#{id}", body: {collection_item: payload})
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def delete_collection_item(collection_id, id)
|
|
183
|
+
request(:delete, "/api/v1/collections/#{collection_id}/items/#{id}")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def convert_markdown(markdown)
|
|
187
|
+
request(:post, "/api/v1/markdown/convert", body: {markdown: markdown})
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# File management
|
|
191
|
+
|
|
192
|
+
def list_files(params = {})
|
|
193
|
+
request(:get, "/api/v1/files", query: compact_params(params))
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def get_file(id)
|
|
197
|
+
request(:get, "/api/v1/files/#{id}")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def upload_file(data:, filename:, content_type:, category_slug: nil, description: nil)
|
|
201
|
+
payload = {
|
|
202
|
+
data: data,
|
|
203
|
+
filename: filename,
|
|
204
|
+
content_type: content_type
|
|
205
|
+
}
|
|
206
|
+
payload[:category_slug] = category_slug if category_slug
|
|
207
|
+
payload[:description] = description if description
|
|
208
|
+
|
|
209
|
+
request(:post, "/api/v1/files", body: {file: payload})
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def update_file(id, payload)
|
|
213
|
+
request(:patch, "/api/v1/files/#{id}", body: {file: payload})
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def delete_file(id)
|
|
217
|
+
request(:delete, "/api/v1/files/#{id}")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def list_file_categories
|
|
221
|
+
request(:get, "/api/v1/file_categories")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
def request(method, path, body: nil, query: nil)
|
|
227
|
+
uri = URI.join(@base_url + "/", path.delete_prefix("/"))
|
|
228
|
+
if query&.any?
|
|
229
|
+
uri.query = URI.encode_www_form(query)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
233
|
+
http.use_ssl = uri.scheme == "https"
|
|
234
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
235
|
+
http.read_timeout = READ_TIMEOUT
|
|
236
|
+
http.write_timeout = WRITE_TIMEOUT
|
|
237
|
+
|
|
238
|
+
request = build_request(method, uri)
|
|
239
|
+
request["Authorization"] = "Token #{@token}"
|
|
240
|
+
request["Content-Type"] = "application/json"
|
|
241
|
+
request.body = JSON.dump(body) if body
|
|
242
|
+
|
|
243
|
+
body_size = request.body&.bytesize || 0
|
|
244
|
+
log("#{method.upcase} #{uri} (#{body_size} bytes)")
|
|
245
|
+
|
|
246
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
247
|
+
response = http.request(request)
|
|
248
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
249
|
+
log("#{response.code} (#{response.body&.bytesize || 0} bytes, #{elapsed_ms}ms)")
|
|
250
|
+
|
|
251
|
+
parsed_body = parse_json(response.body)
|
|
252
|
+
|
|
253
|
+
if response.code.to_i >= 200 && response.code.to_i < 300
|
|
254
|
+
parsed_body
|
|
255
|
+
else
|
|
256
|
+
message = parsed_body["error"] || parsed_body["message"] || "HTTP #{response.code}"
|
|
257
|
+
if parsed_body["messages"].is_a?(Array) && parsed_body["messages"].any?
|
|
258
|
+
message = "#{message}: #{parsed_body["messages"].join("; ")}"
|
|
259
|
+
end
|
|
260
|
+
raise ApiError.new(message, response.code.to_i, parsed_body)
|
|
261
|
+
end
|
|
262
|
+
rescue Net::OpenTimeout
|
|
263
|
+
raise ApiError.new("Connection timed out after #{OPEN_TIMEOUT}s connecting to #{uri.host}", 0, {})
|
|
264
|
+
rescue Net::ReadTimeout
|
|
265
|
+
raise ApiError.new("Request timed out after #{READ_TIMEOUT}s waiting for response (body was #{body_size} bytes)", 0, {})
|
|
266
|
+
rescue Net::WriteTimeout
|
|
267
|
+
raise ApiError.new("Write timed out after #{WRITE_TIMEOUT}s sending request (body was #{body_size} bytes)", 0, {})
|
|
268
|
+
rescue Errno::ECONNREFUSED
|
|
269
|
+
raise ApiError.new("Connection refused to #{uri.host}:#{uri.port} — is the server running?", 0, {})
|
|
270
|
+
rescue Errno::ECONNRESET
|
|
271
|
+
raise ApiError.new("Connection reset by #{uri.host} — the server may have dropped the connection", 0, {})
|
|
272
|
+
rescue SocketError => e
|
|
273
|
+
raise ApiError.new("DNS/socket error for #{uri.host}: #{e.message}", 0, {})
|
|
274
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
275
|
+
raise ApiError.new("SSL error connecting to #{uri.host}: #{e.message}", 0, {})
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def build_request(method, uri)
|
|
279
|
+
case method
|
|
280
|
+
when :get
|
|
281
|
+
Net::HTTP::Get.new(uri)
|
|
282
|
+
when :post
|
|
283
|
+
Net::HTTP::Post.new(uri)
|
|
284
|
+
when :patch
|
|
285
|
+
Net::HTTP::Patch.new(uri)
|
|
286
|
+
when :put
|
|
287
|
+
Net::HTTP::Put.new(uri)
|
|
288
|
+
when :delete
|
|
289
|
+
Net::HTTP::Delete.new(uri)
|
|
290
|
+
else
|
|
291
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def parse_json(body)
|
|
296
|
+
return {} if body.to_s.strip.empty?
|
|
297
|
+
|
|
298
|
+
JSON.parse(body)
|
|
299
|
+
rescue JSON::ParserError
|
|
300
|
+
{"raw" => body}
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def compact_params(params)
|
|
304
|
+
params.each_with_object({}) do |(key, value), acc|
|
|
305
|
+
next if value.nil?
|
|
306
|
+
|
|
307
|
+
acc[key] = value
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def log(message)
|
|
312
|
+
warn "[PandaMCP] #{message}" if @debug
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
require_relative "client"
|
|
6
|
+
require_relative "tools"
|
|
7
|
+
|
|
8
|
+
module PandaMCP
|
|
9
|
+
# Some MCP clients (e.g. Cowork) wrap tool arguments inside a "data" key
|
|
10
|
+
# instead of passing them as top-level properties. This module unwraps
|
|
11
|
+
# that before the mcp gem's schema validation runs.
|
|
12
|
+
module DataUnwrapper
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def call_tool(request)
|
|
16
|
+
arguments = request[:arguments]
|
|
17
|
+
|
|
18
|
+
if arguments.is_a?(Hash)
|
|
19
|
+
data_key = if arguments.key?(:data)
|
|
20
|
+
:data
|
|
21
|
+
elsif arguments.key?("data")
|
|
22
|
+
"data"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if data_key && arguments.keys == [data_key]
|
|
26
|
+
tool = tools[request[:name]]
|
|
27
|
+
if tool && !tool.input_schema_value.to_h.dig(:properties, :data)
|
|
28
|
+
request = request.merge(arguments: arguments[data_key])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class Server
|
|
38
|
+
def self.start!
|
|
39
|
+
api_url = ENV.fetch("PANDA_CMS_API_URL", "http://localhost:3000")
|
|
40
|
+
api_token = ENV["PANDA_CMS_API_TOKEN"]
|
|
41
|
+
|
|
42
|
+
if api_token.to_s.strip.empty?
|
|
43
|
+
warn "Error: PANDA_CMS_API_TOKEN environment variable is required"
|
|
44
|
+
exit(1)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
client = PandaMCP::Client.new(api_url, api_token)
|
|
48
|
+
|
|
49
|
+
# Pin to 2025-06-18 protocol version as Claude Desktop doesn't yet support
|
|
50
|
+
# the mcp gem's default (2025-11-25), causing an immediate disconnect after
|
|
51
|
+
# the initialize handshake. Override via PANDA_MCP_PROTOCOL_VERSION if needed.
|
|
52
|
+
configuration = MCP::Configuration.new(
|
|
53
|
+
protocol_version: ENV.fetch("PANDA_MCP_PROTOCOL_VERSION", "2025-06-18")
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
MCP::Server.prepend(DataUnwrapper)
|
|
57
|
+
|
|
58
|
+
server = MCP::Server.new(
|
|
59
|
+
name: "panda-cms",
|
|
60
|
+
version: PandaMCP::VERSION,
|
|
61
|
+
tools: PandaMCP::Tools.all,
|
|
62
|
+
server_context: {client: client},
|
|
63
|
+
configuration: configuration
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
67
|
+
transport.open
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|