Skip to content

Commit fe7ec34

Browse files
committed
feat: MCPサーバー
記事の確認と投稿をClaudeCodeから直接行えるようにする
1 parent 0d21481 commit fe7ec34

File tree

14 files changed

+521
-55
lines changed

14 files changed

+521
-55
lines changed

.devcontainer/devcontainer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
// Mount host's ~/.claude if it exists
4040
"mounts": [
4141
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached",
42-
"source=${localEnv:HOME}/.claude.json,target=/home/vscode/.claude.json,type=bind,consistency=cached"
42+
"source=${localEnv:HOME}/.claude.json,target=/home/vscode/.claude.json,type=bind,consistency=cached",
43+
"source=${localEnv:HOME}/.mcp.json,target=/home/vscode/.mcp.json,type=bind,consistency=cached"
4344
]
4445
}

.mcp.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"mcpServers": {
3+
"spotlight-rails": {}
4+
}
5+
}

Gemfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,6 @@ gem "metainspector", "~> 5.15"
7575
gem "rswag-api"
7676
gem "rswag-ui"
7777
gem "rswag-specs", group: [ :development, :test ]
78+
79+
# Model Context Protocol (MCP) for LLM integration
80+
gem "mcp"

Gemfile.lock

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ GEM
163163
json-schema (5.1.1)
164164
addressable (~> 2.8)
165165
bigdecimal (~> 3.1)
166+
json_rpc_handler (0.1.1)
166167
kamal (2.5.2)
167168
activesupport (>= 7.0)
168169
base64 (~> 0.2)
@@ -186,6 +187,8 @@ GEM
186187
net-pop
187188
net-smtp
188189
marcel (1.0.4)
190+
mcp (0.1.0)
191+
json_rpc_handler (~> 0.1)
189192
metainspector (5.15.0)
190193
addressable (~> 2.8.4)
191194
faraday (~> 2.5)
@@ -457,6 +460,7 @@ DEPENDENCIES
457460
importmap-rails
458461
jbuilder
459462
kamal
463+
mcp
460464
metainspector (~> 5.15)
461465
propshaft
462466
puma (>= 5.0)

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,21 @@ bundle exec rails rswag:specs:swaggerize
4141
```
4242

4343
http://localhost:3000/api-docs/
44+
45+
## MCP サーバー
46+
記事の更新のためのMCPサーバーを実装しています。
47+
48+
```json:~/.mcp.json
49+
{
50+
"mcpServers": {
51+
"spotlight-rails": {
52+
"type": "http",
53+
"url": "https://takeyuweb.co.jp/api/mcp",
54+
"method": "POST",
55+
"headers": {
56+
"Authorization": "Bearer token"
57+
}
58+
}
59+
}
60+
}
61+
```

app/controllers/api/mcp_controller.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
class McpController < ApplicationController
5+
skip_before_action :verify_authenticity_token
6+
before_action :authenticate_mcp_request
7+
8+
def handle
9+
# Create MCP server instance
10+
server = ArticleServer.create
11+
12+
# Handle the JSON-RPC request
13+
response = server.handle_json(request.raw_post)
14+
15+
# Return response
16+
render json: response
17+
rescue => e
18+
Rails.logger.error "MCP Error: #{e.message}"
19+
Rails.logger.error e.backtrace.join("\n")
20+
21+
render json: {
22+
jsonrpc: "2.0",
23+
error: {
24+
code: -32603,
25+
message: "Internal error",
26+
data: { details: e.message }
27+
},
28+
id: nil
29+
}, status: :internal_server_error
30+
end
31+
32+
private
33+
34+
def authenticate_mcp_request
35+
token = request.headers["Authorization"]&.gsub(/^Bearer\s+/, "")
36+
expected_token = Rails.application.credentials.dig(:mcp, :api_token)
37+
38+
unless token.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected_token)
39+
render json: {
40+
jsonrpc: "2.0",
41+
error: {
42+
code: -32603,
43+
message: "Unauthorized",
44+
data: { details: "Invalid or missing authentication token" }
45+
},
46+
id: params[:id]
47+
}, status: :unauthorized
48+
end
49+
end
50+
end
51+
end

app/mcp/article_server.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
class ArticleServer
4+
def self.create
5+
MCP::Server.new(
6+
name: "spotlight-rails-articles",
7+
version: "1.0.0",
8+
tools: [
9+
Tools::CreateArticleTool,
10+
Tools::UpdateArticleTool,
11+
Tools::FindArticleTool
12+
],
13+
server_context: {}
14+
)
15+
end
16+
end

app/mcp/tools/create_article_tool.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# frozen_string_literal: true
2+
3+
module Tools
4+
class CreateArticleTool < MCP::Tool
5+
description "Create a new article from markdown content with YAML frontmatter"
6+
7+
input_schema(
8+
properties: {
9+
content: {
10+
type: "string",
11+
description: "The markdown content with YAML frontmatter (including title, slug, description, published_date, tags, etc.)"
12+
}
13+
},
14+
required: [ "content" ]
15+
)
16+
17+
def self.call(content:, server_context:)
18+
article = Article.import_from_markdown(content)
19+
20+
if article
21+
MCP::Tool::Response.new([ {
22+
type: "text",
23+
text: "Article created successfully:\n" \
24+
"- Title: #{article.title}\n" \
25+
"- Slug: #{article.slug}\n" \
26+
"- Published at: #{article.published_at}\n" \
27+
"- Tags: #{article.tags.pluck(:name).join(', ')}"
28+
} ])
29+
else
30+
MCP::Tool::Response.new([ {
31+
type: "text",
32+
text: "Failed to create article. Please check the markdown content and ensure it has valid YAML frontmatter with required fields (title, slug, description, published_date)."
33+
} ])
34+
end
35+
rescue => e
36+
MCP::Tool::Response.new([ {
37+
type: "text",
38+
text: "Error creating article: #{e.message}"
39+
} ])
40+
end
41+
end
42+
end

app/mcp/tools/find_article_tool.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
module Tools
4+
class FindArticleTool < MCP::Tool
5+
description "Find an article by slug and return its details"
6+
7+
input_schema(
8+
properties: {
9+
slug: {
10+
type: "string",
11+
description: "The slug of the article to find"
12+
}
13+
},
14+
required: [ "slug" ]
15+
)
16+
17+
def self.call(slug:, server_context:)
18+
article = Article.find_by(slug: slug)
19+
20+
if article
21+
MCP::Tool::Response.new([ {
22+
type: "text",
23+
text: "Article found:\n" \
24+
"- Title: #{article.title}\n" \
25+
"- Slug: #{article.slug}\n" \
26+
"- Description: #{article.description}\n" \
27+
"- Published at: #{article.published_at}\n" \
28+
"- Tags: #{article.tags.pluck(:name).join(', ')}\n" \
29+
"- Created at: #{article.created_at}\n" \
30+
"- Updated at: #{article.updated_at}"
31+
} ])
32+
else
33+
MCP::Tool::Response.new([ {
34+
type: "text",
35+
text: "Article not found with slug: #{slug}"
36+
} ])
37+
end
38+
rescue => e
39+
MCP::Tool::Response.new([ {
40+
type: "text",
41+
text: "Error finding article: #{e.message}"
42+
} ])
43+
end
44+
end
45+
end

app/mcp/tools/update_article_tool.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
module Tools
4+
class UpdateArticleTool < MCP::Tool
5+
description "Update an existing article by slug with new markdown content"
6+
7+
input_schema(
8+
properties: {
9+
slug: {
10+
type: "string",
11+
description: "The slug of the article to update"
12+
},
13+
content: {
14+
type: "string",
15+
description: "The new markdown content with YAML frontmatter"
16+
}
17+
},
18+
required: [ "slug", "content" ]
19+
)
20+
21+
def self.call(slug:, content:, server_context:)
22+
# Find the existing article
23+
article = Article.find_by(slug: slug)
24+
25+
unless article
26+
return MCP::Tool::Response.new([ {
27+
type: "text",
28+
text: "Article not found with slug: #{slug}"
29+
} ])
30+
end
31+
32+
# Update the article with new content
33+
updated_article = Article.import_from_markdown(content)
34+
35+
if updated_article
36+
MCP::Tool::Response.new([ {
37+
type: "text",
38+
text: "Article updated successfully:\n" \
39+
"- Title: #{updated_article.title}\n" \
40+
"- Slug: #{updated_article.slug}\n" \
41+
"- Published at: #{updated_article.published_at}\n" \
42+
"- Tags: #{updated_article.tags.pluck(:name).join(', ')}"
43+
} ])
44+
else
45+
MCP::Tool::Response.new([ {
46+
type: "text",
47+
text: "Failed to update article. Please check the markdown content and ensure it has valid YAML frontmatter."
48+
} ])
49+
end
50+
rescue => e
51+
MCP::Tool::Response.new([ {
52+
type: "text",
53+
text: "Error updating article: #{e.message}"
54+
} ])
55+
end
56+
end
57+
end

0 commit comments

Comments
 (0)