diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 614dc3c..2cc905a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -39,6 +39,7 @@ // Mount host's ~/.claude if it exists "mounts": [ "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached", - "source=${localEnv:HOME}/.claude.json,target=/home/vscode/.claude.json,type=bind,consistency=cached" + "source=${localEnv:HOME}/.claude.json,target=/home/vscode/.claude.json,type=bind,consistency=cached", + "source=${localEnv:HOME}/.mcp.json,target=/home/vscode/.mcp.json,type=bind,consistency=cached" ] } diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..c509dd4 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,5 @@ +{ + "mcpServers": { + "spotlight-rails": {} + } +} \ No newline at end of file diff --git a/Gemfile b/Gemfile index 10e3f82..6ca5bc7 100644 --- a/Gemfile +++ b/Gemfile @@ -75,3 +75,6 @@ gem "metainspector", "~> 5.15" gem "rswag-api" gem "rswag-ui" gem "rswag-specs", group: [ :development, :test ] + +# Model Context Protocol (MCP) for LLM integration +gem "mcp" diff --git a/Gemfile.lock b/Gemfile.lock index 4490a7a..a4e8821 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,10 +75,10 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) - base64 (0.2.0) + base64 (0.3.0) bcrypt_pbkdf (1.1.1) - benchmark (0.4.0) - bigdecimal (3.1.9) + benchmark (0.4.1) + bigdecimal (3.2.2) bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) @@ -86,7 +86,7 @@ GEM racc builder (3.3.0) concurrent-ruby (1.3.5) - connection_pool (2.5.0) + connection_pool (2.5.3) crass (1.0.6) date (3.4.1) debug (1.10.0) @@ -94,9 +94,9 @@ GEM reline (>= 0.3.8) diff-lcs (1.6.1) domain_name (0.6.20240107) - dotenv (3.1.7) - drb (2.2.1) - ed25519 (1.3.0) + dotenv (3.1.8) + drb (2.2.3) + ed25519 (1.4.0) erubi (1.13.1) et-orbi (1.2.11) tzinfo @@ -163,20 +163,21 @@ GEM json-schema (5.1.1) addressable (~> 2.8) bigdecimal (~> 3.1) - kamal (2.5.2) + json_rpc_handler (0.1.1) + kamal (2.7.0) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) concurrent-ruby (~> 1.2) dotenv (~> 3.1) - ed25519 (~> 1.2) + ed25519 (~> 1.4) net-ssh (~> 7.3) sshkit (>= 1.23.0, < 2.0) thor (~> 1.3) zeitwerk (>= 2.6.18, < 3.0) language_server-protocol (3.17.0.4) lint_roller (1.1.0) - logger (1.6.6) + logger (1.7.0) loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -186,6 +187,8 @@ GEM net-pop net-smtp marcel (1.0.4) + mcp (0.1.0) + json_rpc_handler (~> 0.1) metainspector (5.15.0) addressable (~> 2.8.4) faraday (~> 2.5) @@ -203,7 +206,7 @@ GEM benchmark logger mini_mime (1.1.5) - minitest (5.25.4) + minitest (5.25.5) msgpack (1.8.0) nesty (1.0.2) net-http (0.6.0) @@ -235,7 +238,7 @@ GEM racc (~> 1.4) nokogiri (1.18.3-x86_64-linux-musl) racc (~> 1.4) - ostruct (0.6.1) + ostruct (0.6.2) parallel (1.26.3) parser (3.3.7.1) ast (~> 2.4.1) @@ -419,7 +422,7 @@ GEM unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) - uri (1.0.2) + uri (1.0.3) useragent (0.16.11) view_component (3.21.0) activesupport (>= 5.2.0, < 8.1) @@ -434,7 +437,7 @@ GEM base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.7.2) + zeitwerk (2.7.3) zlib (2.1.1) PLATFORMS @@ -457,6 +460,7 @@ DEPENDENCIES importmap-rails jbuilder kamal + mcp metainspector (~> 5.15) propshaft puma (>= 5.0) diff --git a/README.md b/README.md index b852569..64cdb26 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,21 @@ bundle exec rails rswag:specs:swaggerize ``` http://localhost:3000/api-docs/ + +## MCP サーバー +記事の更新のためのMCPサーバーを実装しています。 + +```json:~/.mcp.json +{ + "mcpServers": { + "spotlight-rails": { + "type": "http", + "url": "https://takeyuweb.co.jp/api/mcp", + "method": "POST", + "headers": { + "Authorization": "Bearer token" + } + } + } +} +``` diff --git a/app/controllers/api/mcp_controller.rb b/app/controllers/api/mcp_controller.rb new file mode 100644 index 0000000..e080a89 --- /dev/null +++ b/app/controllers/api/mcp_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Api + class McpController < ApplicationController + skip_before_action :verify_authenticity_token + before_action :authenticate_mcp_request + + def handle + # Create MCP server instance + server = ArticleServer.create + + # Handle the JSON-RPC request + response = server.handle_json(request.raw_post) + + # Return response + render json: response + rescue => e + Rails.logger.error "MCP Error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + render json: { + jsonrpc: "2.0", + error: { + code: -32603, + message: "Internal error", + data: { details: e.message } + }, + id: nil + }, status: :internal_server_error + end + + private + + def authenticate_mcp_request + token = request.headers["Authorization"]&.gsub(/^Bearer\s+/, "") + expected_token = Rails.application.credentials.dig(:mcp, :api_token) + + unless token.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected_token) + render json: { + jsonrpc: "2.0", + error: { + code: -32603, + message: "Unauthorized", + data: { details: "Invalid or missing authentication token" } + }, + id: params[:id] + }, status: :unauthorized + end + end + end +end diff --git a/app/mcp/article_server.rb b/app/mcp/article_server.rb new file mode 100644 index 0000000..a2b212e --- /dev/null +++ b/app/mcp/article_server.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ArticleServer + def self.create + MCP::Server.new( + name: "spotlight-rails-articles", + version: "1.0.0", + tools: [ + Tools::CreateArticleTool, + Tools::UpdateArticleTool, + Tools::FindArticleTool + ], + server_context: {} + ) + end +end diff --git a/app/mcp/tools/create_article_tool.rb b/app/mcp/tools/create_article_tool.rb new file mode 100644 index 0000000..e211b56 --- /dev/null +++ b/app/mcp/tools/create_article_tool.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Tools + class CreateArticleTool < MCP::Tool + description "Create a new article from markdown content with YAML frontmatter" + + input_schema( + properties: { + content: { + type: "string", + description: "The markdown content with YAML frontmatter (including title, slug, description, published_date, tags, etc.)" + } + }, + required: [ "content" ] + ) + + def self.call(content:, server_context:) + article = Article.import_from_markdown(content) + + if article + MCP::Tool::Response.new([ { + type: "text", + text: "Article created successfully:\n" \ + "- Title: #{article.title}\n" \ + "- Slug: #{article.slug}\n" \ + "- Published at: #{article.published_at}\n" \ + "- Tags: #{article.tags.pluck(:name).join(', ')}" + } ]) + else + MCP::Tool::Response.new([ { + type: "text", + 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)." + } ]) + end + rescue => e + MCP::Tool::Response.new([ { + type: "text", + text: "Error creating article: #{e.message}" + } ]) + end + end +end diff --git a/app/mcp/tools/find_article_tool.rb b/app/mcp/tools/find_article_tool.rb new file mode 100644 index 0000000..e76436c --- /dev/null +++ b/app/mcp/tools/find_article_tool.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Tools + class FindArticleTool < MCP::Tool + description "Find an article by slug and return its details" + + input_schema( + properties: { + slug: { + type: "string", + description: "The slug of the article to find" + } + }, + required: [ "slug" ] + ) + + def self.call(slug:, server_context:) + article = Article.find_by(slug: slug) + + if article + MCP::Tool::Response.new([ { + type: "text", + text: "Article found:\n" \ + "- Title: #{article.title}\n" \ + "- Slug: #{article.slug}\n" \ + "- Description: #{article.description}\n" \ + "- Published at: #{article.published_at}\n" \ + "- Tags: #{article.tags.pluck(:name).join(', ')}\n" \ + "- Created at: #{article.created_at}\n" \ + "- Updated at: #{article.updated_at}" + } ]) + else + MCP::Tool::Response.new([ { + type: "text", + text: "Article not found with slug: #{slug}" + } ]) + end + rescue => e + MCP::Tool::Response.new([ { + type: "text", + text: "Error finding article: #{e.message}" + } ]) + end + end +end diff --git a/app/mcp/tools/update_article_tool.rb b/app/mcp/tools/update_article_tool.rb new file mode 100644 index 0000000..4eebdb9 --- /dev/null +++ b/app/mcp/tools/update_article_tool.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Tools + class UpdateArticleTool < MCP::Tool + description "Update an existing article by slug with new markdown content" + + input_schema( + properties: { + slug: { + type: "string", + description: "The slug of the article to update" + }, + content: { + type: "string", + description: "The new markdown content with YAML frontmatter" + } + }, + required: [ "slug", "content" ] + ) + + def self.call(slug:, content:, server_context:) + # Find the existing article + article = Article.find_by(slug: slug) + + unless article + return MCP::Tool::Response.new([ { + type: "text", + text: "Article not found with slug: #{slug}" + } ]) + end + + # Update the article with new content + updated_article = Article.import_from_markdown(content) + + if updated_article + MCP::Tool::Response.new([ { + type: "text", + text: "Article updated successfully:\n" \ + "- Title: #{updated_article.title}\n" \ + "- Slug: #{updated_article.slug}\n" \ + "- Published at: #{updated_article.published_at}\n" \ + "- Tags: #{updated_article.tags.pluck(:name).join(', ')}" + } ]) + else + MCP::Tool::Response.new([ { + type: "text", + text: "Failed to update article. Please check the markdown content and ensure it has valid YAML frontmatter." + } ]) + end + rescue => e + MCP::Tool::Response.new([ { + type: "text", + text: "Error updating article: #{e.message}" + } ]) + end + end +end diff --git a/app/models/article.rb b/app/models/article.rb index 9d59a08..bc0af48 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -15,10 +15,10 @@ def to_param slug end - # Import articles from markdown files - # @param source_dir [String] Path to the directory containing article markdown files - # @return [Integer] Number of articles imported - def self.import_from_docs(source_dir) + # Import a single article from markdown content + # @param markdown_content [String] The markdown content with YAML frontmatter + # @return [Article, nil] The created/updated article, or nil if failed + def self.import_from_markdown(markdown_content) require "redcarpet" # Initialize Markdown renderer with custom renderer for special syntax @@ -34,60 +34,75 @@ def self.import_from_docs(source_dir) quote: true }) + # Parse metadata using MetadataParser service + parsed_data = MetadataParser.parse(markdown_content) + metadata = parsed_data[:metadata] + content = parsed_data[:content] + + # Only process articles + return nil unless metadata[:category] == "article" + + # Convert markdown to HTML + html_content = markdown.render(content) + + # Find or create article by slug + article = find_or_initialize_by(slug: metadata[:slug]) + + # Update article attributes from parsed metadata + article.title = metadata[:title] + article.description = metadata[:description] + article.published_at = metadata[:published_date] + article.content = html_content + + # Save the article + if article.save + # Process tags if present + if metadata[:tags] + article.tags.clear # Remove existing tags + + metadata[:tags].each do |tag_name| + next if tag_name.blank? + + tag = Tag.find_or_create_by(name: tag_name) + article.tags << tag unless article.tags.include?(tag) + end + end + + article + else + Rails.logger.error "Error saving article: #{article.errors.full_messages.join(', ')}" + nil + end + rescue MetadataParser::MetadataParseError => e + Rails.logger.error "Metadata parsing error: #{e.message}" + nil + rescue => e + Rails.logger.error "Error processing article: #{e.message}" + nil + end + + # Import articles from markdown files + # @param source_dir [String] Path to the directory containing article markdown files + # @return [Integer] Number of articles imported + def self.import_from_docs(source_dir) # Find all markdown files in the source directory article_files = Dir.glob(File.join(source_dir, "**", "*.md")) imported_count = 0 article_files.each do |file_path| - begin - puts "Processing article: #{file_path}" - - # Read the file content - file_content = File.read(file_path) - - # Parse metadata using MetadataParser service - parsed_data = MetadataParser.parse(file_content) - metadata = parsed_data[:metadata] - markdown_content = parsed_data[:content] - - # Skip if not an article - next unless metadata[:category] == "article" - - # Convert markdown to HTML - html_content = markdown.render(markdown_content) - - # Find or create article by slug - article = find_or_initialize_by(slug: metadata[:slug]) - - # Update article attributes from parsed metadata - article.title = metadata[:title] - article.description = metadata[:description] - article.published_at = metadata[:published_date] - article.content = html_content - - # Save the article - if article.save - # Process tags if present - if metadata[:tags] - article.tags.clear # Remove existing tags - - metadata[:tags].each do |tag_name| - next if tag_name.blank? - - tag = Tag.find_or_create_by(name: tag_name) - article.tags << tag unless article.tags.include?(tag) - end - end - - puts " Saved article: #{article.title}" - imported_count += 1 - else - puts " Error saving article: #{article.errors.full_messages.join(', ')}" - end - rescue MetadataParser::MetadataParseError => e - puts " Metadata parsing error for #{file_path}: #{e.message}" - rescue => e - puts " Error processing article #{file_path}: #{e.message}" + puts "Processing article: #{file_path}" + + # Read the file content + file_content = File.read(file_path) + + # Import the article from markdown content + article = import_from_markdown(file_content) + + if article + puts " Saved article: #{article.title}" + imported_count += 1 + else + puts " Failed to import article from #{file_path}" end end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index b9c9453..e57c020 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -gJp5CkjZLgWrgjnR67NLeWT2vszA9OUbt0eJVMOVjKhGigfYc5+H8lvIWNBuozd/NhOuSMCIYxHKG83DdrX+JUUYtPwatJBhF7e/7Pw7JXs5tvxhOh3/pDY9Lpk43Sd2W9b51cP3iglQqlOUOYNaHPFKe1sNKF5cLLYmoaBKER6wwz720pPH7To99bRc36iqJX6DraIeaZ8jCZwbsM4W7fQY+Fg6DhYP+bpZbhuqfNF9Ga4ENQomQuLHDtOVLtdOAU4RxXWaFwfivLvGKlrOPUc2L6QTx+uAt4EdD0RgMcyurzBwRll0iRc+dZNwjpGlBDRPPWEbmYDpAS1moBBZWVdodvuSUHkEJfBDlCTpAvKKclDqYGPq8dEqxC5UUUXYaKGqfPyf/I6pt6+asPz0Ogm8sjI0Ie1kET5Ff5dRNHmS1AivbD0RTTFhJdpVeDVdNT0oH/i7fXCykG81pl4vx1lUsfJxMP5Sq/oaHtn7h8goNcO/hvsP1hIO--/6H6KbJ8Pqm5ML3Q--3VoTdSjm9dagLNK0v1bUuw== \ No newline at end of file +iRH2ATbq8mnn+A4Dvv4+JaqoZgGwXymotR5065v76ZfSkpQuf6av4dizjUn4hVpk5TBNZZh62Fvx8UIhuHgFymQeDBAGmI75t0HEsUoHE3XSvmGXuNwzL0HXPJYCeohlViEB5/lBev2FyG5AtaLXJ/4ET0bkdpyq5FPTV6bMqEbHaTOvwDrxOyE9mz1uP0cr7AJq35b+8JD+9gFsWxyk73zayM41MGnHDwesFgBYcnBwrW3M7r/rI75J9AjolUsPZyLAng1GR1nB7Ss7Te0uWdEU5exsBEZLNr7t1/blE99HEA1YDNxduMq1/KJM3BJmhDvtNbE5kDMyAqbU1nm6aQnbtfeJQcVTSeSsbvW+pjFW5XABWpVOes+6fb1mC+0R5P35cBNAa5bPwyibeT0FAJwPJ9raVt1qPzHIOQV/Wuc4O9U5Ein0HEdsOufxXrzuU4WAXG5u3JK6YjPu9EJC2WTR4i8mxVpeurMDMBWDK8sXG/ilwaoIPbVQb92K7SflnRZRSVEx99KdyTIHKgvcvyUkIV98nbOsXnhEaUwjWIPq3PYz+l5n4cL4xxnYla0UnWx2KYF+bMC6my14X3mxiMqFYXCYo9SGT7CwVi/MHknapQ==--70ibUpgcNuA7ZOM9--JCSrfQ+x80/ucEXXSzUPqA== \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 990fbba..cee1160 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -30,6 +30,9 @@ get :metadata end end + + # MCP endpoint for article management + post "mcp", to: "mcp#handle" end # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) diff --git a/spec/requests/api/mcp_spec.rb b/spec/requests/api/mcp_spec.rb new file mode 100644 index 0000000..6ee367c --- /dev/null +++ b/spec/requests/api/mcp_spec.rb @@ -0,0 +1,206 @@ +require 'rails_helper' + +RSpec.describe "Api::Mcp", type: :request do + describe "POST /api/mcp" do + let(:valid_token) { Rails.application.credentials.dig(:mcp, :api_token) } + let(:headers) { { "Authorization" => "Bearer #{valid_token}", "Content-Type" => "application/json" } } + + context "with valid authentication" do + context "when handling server info request" do + let(:request_body) do + { + jsonrpc: "2.0", + method: "initialize", + id: 1 + }.to_json + end + + it "returns server information" do + post api_mcp_path, params: request_body, headers: headers + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response["jsonrpc"]).to eq("2.0") + expect(json_response["id"]).to eq(1) + expect(json_response["result"]["protocolVersion"]).to eq("2024-11-05") + expect(json_response["result"]["serverInfo"]["name"]).to eq("spotlight-rails-articles") + end + end + + context "when handling tools list request" do + let(:request_body) do + { + jsonrpc: "2.0", + method: "tools/list", + id: 2 + }.to_json + end + + it "returns available tools" do + post api_mcp_path, params: request_body, headers: headers + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response["jsonrpc"]).to eq("2.0") + expect(json_response["id"]).to eq(2) + + tools = json_response["result"]["tools"] + expect(tools).to be_an(Array) + expect(tools.map { |t| t["name"] }).to include("create_article_tool", "update_article_tool", "find_article_tool") + end + end + + context "when calling create_article_tool" do + let(:request_body) do + { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "create_article_tool", + arguments: { + content: <<~MARKDOWN + --- + title: "Test Article" + slug: test-article + category: article + description: "A test article" + published_date: 2025-01-12 + tags: + - Rails + - Testing + --- + + ## Test Content + + This is a test article. + MARKDOWN + } + }, + id: 3 + }.to_json + end + + it "creates a new article" do + expect { + post api_mcp_path, params: request_body, headers: headers + }.to change(Article, :count).by(1) + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response["jsonrpc"]).to eq("2.0") + expect(json_response["id"]).to eq(3) + expect(json_response["result"]["content"][0]["text"]).to include("Article created successfully") + + article = Article.last + expect(article.title).to eq("Test Article") + expect(article.slug).to eq("test-article") + expect(article.tags.map(&:name)).to match_array([ "Rails", "Testing" ]) + end + end + + context "when handling invalid JSON-RPC request" do + let(:request_body) { "invalid json" } + + it "returns JSON-RPC parse error" do + post api_mcp_path, params: request_body, headers: headers + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response["error"]["code"]).to eq(-32700) + expect(json_response["error"]["message"]).to eq("Parse error") + end + end + + context "when server raises an error" do + let(:request_body) do + { + jsonrpc: "2.0", + method: "unknown_method", + id: 4 + }.to_json + end + + before do + allow_any_instance_of(MCP::Server).to receive(:handle_json).and_raise(StandardError.new("Test error")) + end + + it "returns internal server error" do + post api_mcp_path, params: request_body, headers: headers + + expect(response).to have_http_status(:internal_server_error) + + json_response = JSON.parse(response.body) + expect(json_response["error"]["code"]).to eq(-32603) + expect(json_response["error"]["message"]).to eq("Internal error") + expect(json_response["error"]["data"]["details"]).to eq("Test error") + end + end + end + + context "with invalid authentication" do + context "when token is missing" do + let(:headers) { { "Content-Type" => "application/json" } } + let(:request_body) do + { + jsonrpc: "2.0", + method: "initialize", + id: 1 + }.to_json + end + + it "returns unauthorized error" do + post api_mcp_path, params: request_body, headers: headers + + expect(response).to have_http_status(:unauthorized) + + json_response = JSON.parse(response.body) + expect(json_response["error"]["code"]).to eq(-32603) + expect(json_response["error"]["message"]).to eq("Unauthorized") + expect(json_response["error"]["data"]["details"]).to eq("Invalid or missing authentication token") + end + end + + context "when token is invalid" do + let(:headers) { { "Authorization" => "Bearer invalid-token", "Content-Type" => "application/json" } } + let(:request_body) do + { + jsonrpc: "2.0", + method: "initialize", + id: 1 + }.to_json + end + + it "returns unauthorized error" do + post api_mcp_path, params: request_body, headers: headers + + expect(response).to have_http_status(:unauthorized) + + json_response = JSON.parse(response.body) + expect(json_response["error"]["code"]).to eq(-32603) + expect(json_response["error"]["message"]).to eq("Unauthorized") + end + end + end + + context "with non-Bearer authentication scheme" do + let(:headers) { { "Authorization" => "Basic #{valid_token}", "Content-Type" => "application/json" } } + let(:request_body) do + { + jsonrpc: "2.0", + method: "initialize", + id: 1 + }.to_json + end + + it "returns unauthorized error" do + post api_mcp_path, params: request_body, headers: headers + + expect(response).to have_http_status(:unauthorized) + end + end + end +end