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 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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "panda_mcp"
5
+
6
+ PandaMCP::Server.start!
@@ -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