diff --git a/Gemfile b/Gemfile index e254ea9..0442eb3 100644 --- a/Gemfile +++ b/Gemfile @@ -74,3 +74,5 @@ gem "marksmith", "~> 0.4.5" gem "commonmarker", "~> 2.3" gem "appsignal" + +gem "mcp" diff --git a/Gemfile.lock b/Gemfile.lock index 83a9155..76aa9db 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -221,6 +221,9 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) json (2.12.2) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) language_server-protocol (3.17.0.5) lint_roller (1.1.0) llhttp-ffi (0.5.1) @@ -238,6 +241,8 @@ GEM marcel (1.0.4) marksmith (0.4.5) activesupport + mcp (0.9.1) + json-schema (>= 4.1) meta-tags (2.22.1) actionpack (>= 6.0.0, < 8.1) method_source (1.1.0) @@ -456,6 +461,7 @@ DEPENDENCIES importmap-rails (~> 2.1) jbuilder marksmith (~> 0.4.5) + mcp nokogiri (~> 1.18) pdf-reader (~> 2.12) pg (~> 1.1) diff --git a/README.md b/README.md index 36306c8..71bbd0b 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,13 @@ For new developers joining the project, we provide a streamlined onboarding proc - Production database is automatically dumped weekly (every Monday at 2 AM UTC) - Dumps are stored as GitHub Actions artifacts for 30 days - Dumps use PostgreSQL's custom archive format for efficient storage and restore + +### MCP Server + +The application exposes a [Model Context Protocol](https://modelcontextprotocol.io/) endpoint +for AI agent integration at `https://www.buildcanada.com/tracker/mcp`. + +This provides 10 read-only tools covering commitments, bills, departments, ministers, the +activity feed, and dashboard summaries. Tool classes are POROs under `app/models/mcp_tools/`. + +To connect from Claude Desktop: Settings > Connectors > Add custom connector > paste the URL. diff --git a/agent/CLAUDE.md b/agent/CLAUDE.md index 9b26739..6a29adb 100644 --- a/agent/CLAUDE.md +++ b/agent/CLAUDE.md @@ -2,6 +2,13 @@ You are the Build Canada commitment evaluation agent. Do NOT search the filesystem for project structure — everything you need is documented here. +## MCP Server + +The application exposes an MCP (Model Context Protocol) server at `https://www.buildcanada.com/tracker/mcp` (POST). +Available read-only tools: `list_commitments`, `get_commitment`, `list_bills`, `get_bill`, `list_departments`, +`get_department`, `list_ministers`, `list_activity`, `get_commitment_summary`, `get_commitment_progress`. +These tools proxy to the existing REST API endpoints and return JSON. Tool classes live under `app/models/mcp_tools/`. + ## Rails API Reference Base URL: provided in system prompt. Auth: `Authorization: Bearer ` (also in system prompt). diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb new file mode 100644 index 0000000..548e536 --- /dev/null +++ b/app/controllers/mcp_controller.rb @@ -0,0 +1,24 @@ +class McpController < ActionController::API + TOOLS = [ + McpTools::ListCommitments, + McpTools::GetCommitment, + McpTools::ListBills, + McpTools::GetBill, + McpTools::ListDepartments, + McpTools::GetDepartment, + McpTools::ListMinisters, + McpTools::ListActivity, + McpTools::GetCommitmentSummary, + McpTools::GetCommitmentProgress + ].freeze + + def create + server = MCP::Server.new(name: "build-canada-tracker", version: "1.0.0", tools: TOOLS) + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) + server.transport = transport + + resp_status, resp_headers, resp_body = transport.handle_request(request) + resp_headers&.each { |key, value| response.headers[key] = value } + render json: resp_body&.first, status: resp_status + end +end diff --git a/app/models/concerns/mcp_rack_tool.rb b/app/models/concerns/mcp_rack_tool.rb new file mode 100644 index 0000000..3ac6888 --- /dev/null +++ b/app/models/concerns/mcp_rack_tool.rb @@ -0,0 +1,40 @@ +module McpRackTool + extend ActiveSupport::Concern + + class_methods do + def path_template(value = nil) + if value + @path_template = value + @path_params = value.scan(/:(\w+)/).flatten.map(&:to_sym) + else + @path_template + end + end + + def path_params + @path_params || [] + end + + def call(server_context:, **params) + path = path_template.gsub(/:(\w+)/) { params[$1.to_sym] } + query_params = params.except(*path_params) + response = rack_get(path, query_params) + MCP::Tool::Response.new([{ type: "text", text: response }]) + end + + private + + def rack_get(path, params = {}) + query_string = params.compact.to_query + url = query_string.empty? ? path : "#{path}?#{query_string}" + + env = Rack::MockRequest.env_for(url, "REQUEST_METHOD" => "GET", "HTTP_ACCEPT" => "application/json", "HTTP_HOST" => "localhost") + status, headers, body = Rails.application.call(env) + + chunks = [] + body.each { |chunk| chunks << chunk } + body.close if body.respond_to?(:close) + chunks.join + end + end +end diff --git a/app/models/mcp_tools/get_bill.rb b/app/models/mcp_tools/get_bill.rb new file mode 100644 index 0000000..24a5bc3 --- /dev/null +++ b/app/models/mcp_tools/get_bill.rb @@ -0,0 +1,16 @@ +class McpTools::GetBill < MCP::Tool + include McpRackTool + + description <<~DESC.strip + Get full details for a parliamentary bill. Returns all fields including stage + dates and the complete raw data from the Parliament of Canada API (sponsor, + type, session info). + DESC + + input_schema( + properties: { id: { type: "integer", description: "The bill database ID" } }, + required: [ "id" ] + ) + + path_template "/bills/:id" +end diff --git a/app/models/mcp_tools/get_commitment.rb b/app/models/mcp_tools/get_commitment.rb new file mode 100644 index 0000000..55d2a51 --- /dev/null +++ b/app/models/mcp_tools/get_commitment.rb @@ -0,0 +1,17 @@ +class McpTools::GetCommitment < MCP::Tool + include McpRackTool + + description <<~DESC.strip + Get full details for a single commitment. Returns all nested data: sources + (original government documents), criteria (completion/success/progress/failure + with assessment history), departments, timeline events, status_history, + and recent_feed items. + DESC + + input_schema( + properties: { id: { type: "integer", description: "The commitment ID" } }, + required: [ "id" ] + ) + + path_template "/commitments/:id" +end diff --git a/app/models/mcp_tools/get_commitment_progress.rb b/app/models/mcp_tools/get_commitment_progress.rb new file mode 100644 index 0000000..9e53df1 --- /dev/null +++ b/app/models/mcp_tools/get_commitment_progress.rb @@ -0,0 +1,23 @@ +class McpTools::GetCommitmentProgress < MCP::Tool + include McpRackTool + + description <<~DESC.strip + Commitment progress over time — daily time-series of how many commitments have been + scoped, started, completed, or broken throughout a government's mandate. Useful for + trend analysis and charting. Returns { date, scope, started, completed, broken } per + day, plus mandate_start/end dates. Tracks COMMITMENTS (not promises). + The government_id for the current Government of Canada is 1. + DESC + + input_schema( + properties: { + government_id: { type: "integer", description: "Government ID (1 = current Government of Canada)" }, + source_type: { type: "string", description: "Filter by source type" }, + policy_area_slug: { type: "string", description: "Filter by policy area slug" }, + department_slug: { type: "string", description: "Filter by lead department slug" } + }, + required: [ "government_id" ] + ) + + path_template "/api/burndown/:government_id" +end diff --git a/app/models/mcp_tools/get_commitment_summary.rb b/app/models/mcp_tools/get_commitment_summary.rb new file mode 100644 index 0000000..9b314e6 --- /dev/null +++ b/app/models/mcp_tools/get_commitment_summary.rb @@ -0,0 +1,20 @@ +class McpTools::GetCommitmentSummary < MCP::Tool + include McpRackTool + + description <<~DESC.strip + Overall commitment status summary — how many commitments are not started, in progress, + completed, or broken, broken down by policy area. This aggregates COMMITMENTS (not + promises). If no commitments exist yet, totals will be zero even if promises are loaded. + The government_id for the current Government of Canada is 1. + DESC + + input_schema( + properties: { + government_id: { type: "integer", description: "Government ID (1 = current Government of Canada)" }, + source_type: { type: "string", description: "Filter by source type (e.g. 'platform_document', 'mandate_letter')" } + }, + required: [ "government_id" ] + ) + + path_template "/api/dashboard/:government_id/at_a_glance" +end diff --git a/app/models/mcp_tools/get_department.rb b/app/models/mcp_tools/get_department.rb new file mode 100644 index 0000000..04e8981 --- /dev/null +++ b/app/models/mcp_tools/get_department.rb @@ -0,0 +1,16 @@ +class McpTools::GetDepartment < MCP::Tool + include McpRackTool + + description <<~DESC.strip + Get department details including minister info (hill office, constituency offices) + and the department's lead commitments. Accepts numeric ID or + slug (e.g. 'finance-canada', 'national-defence'). + DESC + + input_schema( + properties: { id_or_slug: { type: "string", description: "Department ID or slug" } }, + required: [ "id_or_slug" ] + ) + + path_template "/departments/:id_or_slug" +end diff --git a/app/models/mcp_tools/list_activity.rb b/app/models/mcp_tools/list_activity.rb new file mode 100644 index 0000000..5a0299e --- /dev/null +++ b/app/models/mcp_tools/list_activity.rb @@ -0,0 +1,25 @@ +class McpTools::ListActivity < MCP::Tool + include McpRackTool + + description <<~DESC.strip + Chronological feed of government activity on tracked commitments. Best tool for + "what's happening" or "what changed recently." Returns: event_type, title, summary, + occurred_at, linked commitment, and policy_area. Paginated, most recent first. + Note: only populated when the evaluation agent has assessed commitments and + created events — will be empty if no commitments have been evaluated yet. + DESC + + input_schema( + properties: { + commitment_id: { type: "integer", description: "Filter to one commitment's activity" }, + event_type: { type: "string", description: "Filter by event type" }, + policy_area_id: { type: "integer", description: "Filter by policy area ID" }, + since: { type: "string", description: "Activity after this date (ISO 8601, e.g. '2025-06-01')" }, + until: { type: "string", description: "Activity before this date (ISO 8601)" }, + page: { type: "integer", description: "Page number (default: 1)" }, + per_page: { type: "integer", description: "Results per page, max 100 (default: 50)" } + } + ) + + path_template "/feed" +end diff --git a/app/models/mcp_tools/list_bills.rb b/app/models/mcp_tools/list_bills.rb new file mode 100644 index 0000000..0995074 --- /dev/null +++ b/app/models/mcp_tools/list_bills.rb @@ -0,0 +1,12 @@ +class McpTools::ListBills < MCP::Tool + include McpRackTool + + description <<~DESC.strip + List Canadian parliamentary bills (45th Parliament). Returns bill_number_formatted + (e.g. 'C-2', 'S-201'), short_title, long_title, latest_activity, and all stage + dates tracking progress through Parliament: House 1st/2nd/3rd reading, Senate + 1st/2nd/3rd reading, and Royal Assent. + DESC + + path_template "/bills" +end diff --git a/app/models/mcp_tools/list_commitments.rb b/app/models/mcp_tools/list_commitments.rb new file mode 100644 index 0000000..3f7e837 --- /dev/null +++ b/app/models/mcp_tools/list_commitments.rb @@ -0,0 +1,29 @@ +class McpTools::ListCommitments < MCP::Tool + include McpRackTool + + description <<~DESC.strip + Search and filter government commitments — the core accountability tracking unit. + The Build Canada data model: Promises (raw political pledges) → Commitments + (specific, measurable outcomes with status tracking) → Evidence (bills, events, sources). + Each commitment has a status (not_started/in_progress/completed/broken), a type, + a policy area, and a lead department. Returns paginated results with + meta { total_count, page, per_page }. + Use get_commitment_summary to discover valid policy area slugs. + DESC + + input_schema( + properties: { + q: { type: "string", description: "Full-text search on title and description" }, + status: { type: "string", enum: %w[not_started in_progress completed broken], description: "Filter by commitment status" }, + policy_area: { type: "string", description: "Policy area slug (e.g. 'defence', 'healthcare', 'economy')" }, + commitment_type: { type: "string", enum: %w[legislative spending procedural institutional diplomatic aspirational outcome], description: "Filter by commitment type" }, + department: { type: "string", description: "Department slug (e.g. 'finance-canada')" }, + sort: { type: "string", enum: %w[title date_promised last_assessed_at status], description: "Sort field (default: created_at desc)" }, + direction: { type: "string", enum: %w[asc desc], description: "Sort direction (default: desc)" }, + page: { type: "integer", description: "Page number (default: 1)" }, + per_page: { type: "integer", description: "Results per page, max 1000 (default: 50)" } + } + ) + + path_template "/commitments" +end diff --git a/app/models/mcp_tools/list_departments.rb b/app/models/mcp_tools/list_departments.rb new file mode 100644 index 0000000..dd21758 --- /dev/null +++ b/app/models/mcp_tools/list_departments.rb @@ -0,0 +1,11 @@ +class McpTools::ListDepartments < MCP::Tool + include McpRackTool + + description <<~DESC.strip + List all ~32 federal government departments. Returns: id, display_name, slug, + official_name, priority, and minister info (name, title, contact details, + hill office) if a minister is assigned. + DESC + + path_template "/departments" +end diff --git a/app/models/mcp_tools/list_ministers.rb b/app/models/mcp_tools/list_ministers.rb new file mode 100644 index 0000000..37303b0 --- /dev/null +++ b/app/models/mcp_tools/list_ministers.rb @@ -0,0 +1,10 @@ +class McpTools::ListMinisters < MCP::Tool + include McpRackTool + + description <<~DESC.strip + List current cabinet ministers and officials. Returns: name, title, avatar_url, + email, phone, website, constituency, province, and their department assignment. + DESC + + path_template "/ministers" +end diff --git a/config/routes.rb b/config/routes.rb index cb8c417..6ff8835 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -62,5 +62,7 @@ # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check + post "/mcp", to: "mcp#create" + root "application#root" end diff --git a/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb new file mode 100644 index 0000000..3063b9d --- /dev/null +++ b/test/controllers/mcp_controller_test.rb @@ -0,0 +1,257 @@ +require "test_helper" + +class McpControllerTest < ActionDispatch::IntegrationTest + MCP_HEADERS = { "Accept" => "application/json, text/event-stream", "Content-Type" => "application/json" } + + def mcp_initialize + post "/mcp", params: { + jsonrpc: "2.0", id: 1, method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1.0" } } + }.to_json, headers: MCP_HEADERS + end + + def mcp_call(tool_name, arguments = {}) + post "/mcp", params: { + jsonrpc: "2.0", id: 2, method: "tools/call", + params: { name: tool_name, arguments: arguments } + }.to_json, headers: MCP_HEADERS + end + + # Extract the parsed JSON data from an MCP tool call response. + # MCP wraps results as: { result: { content: [{ type: "text", text: "...json..." }] } } + def tool_response_data + body = JSON.parse(response.body) + text = body.dig("result", "content", 0, "text") + JSON.parse(text) + end + + # -- Protocol -- + + test "initialize returns server capabilities" do + mcp_initialize + assert_response :success + end + + test "tools/list returns exactly the expected tools with correct schemas" do + mcp_initialize + post "/mcp", params: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }.to_json, headers: MCP_HEADERS + assert_response :success + + tools = JSON.parse(response.body).dig("result", "tools") + tool_map = tools.index_by { |t| t["name"] } + + expected_tools = { + "list_commitments" => { properties: %w[q status policy_area commitment_type department sort direction page per_page], required: nil }, + "get_commitment" => { properties: %w[id], required: %w[id] }, + "list_bills" => { properties: [], required: nil }, + "get_bill" => { properties: %w[id], required: %w[id] }, + "list_departments" => { properties: [], required: nil }, + "get_department" => { properties: %w[id_or_slug], required: %w[id_or_slug] }, + "list_ministers" => { properties: [], required: nil }, + "list_activity" => { properties: %w[commitment_id event_type policy_area_id since until page per_page], required: nil }, + "get_commitment_summary" => { properties: %w[government_id source_type], required: %w[government_id] }, + "get_commitment_progress" => { properties: %w[government_id source_type policy_area_slug department_slug], required: %w[government_id] } + } + + assert_equal expected_tools.keys.sort, tool_map.keys.sort, "Tool names mismatch" + + expected_tools.each do |name, expected| + schema = tool_map[name]["inputSchema"] + actual_props = (schema["properties"] || {}).keys.sort + assert_equal expected[:properties].sort, actual_props, "Properties mismatch for #{name}" + + if expected[:required] + assert_equal expected[:required].sort, (schema["required"] || []).sort, "Required mismatch for #{name}" + end + end + end + + # -- Commitments -- + + test "list_commitments with no filters" do + mcp_initialize + mcp_call("list_commitments") + assert_response :success + + data = tool_response_data + assert data.key?("commitments"), "Expected commitments key" + assert data.key?("meta"), "Expected meta key" + assert_kind_of Array, data["commitments"] + end + + test "list_commitments filtered by status" do + mcp_initialize + mcp_call("list_commitments", status: "in_progress") + assert_response :success + end + + test "list_commitments filtered by policy_area" do + mcp_initialize + mcp_call("list_commitments", policy_area: "defence") + assert_response :success + end + + test "list_commitments filtered by department" do + mcp_initialize + mcp_call("list_commitments", department: "finance") + assert_response :success + end + + test "list_commitments with search query" do + mcp_initialize + mcp_call("list_commitments", q: "defence") + assert_response :success + end + + test "list_commitments with pagination" do + mcp_initialize + mcp_call("list_commitments", page: 1, per_page: 2) + assert_response :success + end + + test "get_commitment" do + mcp_initialize + mcp_call("get_commitment", id: commitments(:defence_spending).id) + assert_response :success + + data = tool_response_data + assert_equal commitments(:defence_spending).id, data["id"] + assert data.key?("title"), "Expected title key" + assert data.key?("status"), "Expected status key" + end + + test "get_commitment not found" do + mcp_initialize + mcp_call("get_commitment", id: 999999) + assert_response :success + end + + # -- Departments -- + + test "list_departments" do + mcp_initialize + mcp_call("list_departments") + assert_response :success + + data = tool_response_data + assert_kind_of Array, data + assert data.any?, "Expected at least one department" + end + + test "get_department by slug" do + mcp_initialize + mcp_call("get_department", id_or_slug: "finance") + assert_response :success + + data = tool_response_data + assert_equal "finance", data["slug"] + end + + test "get_department by id" do + mcp_initialize + mcp_call("get_department", id_or_slug: departments(:finance).id.to_s) + assert_response :success + end + + test "get_department not found" do + mcp_initialize + mcp_call("get_department", id_or_slug: "nonexistent-slug") + assert_response :success + end + + # -- Bills -- + + test "list_bills" do + mcp_initialize + mcp_call("list_bills") + assert_response :success + + data = tool_response_data + assert_kind_of Array, data + end + + test "get_bill" do + mcp_initialize + mcp_call("get_bill", id: bills(:one).id) + assert_response :success + + data = tool_response_data + assert_equal bills(:one).id, data["id"] + end + + test "get_bill not found" do + mcp_initialize + mcp_call("get_bill", id: 999999) + assert_response :success + end + + # -- Ministers -- + + test "list_ministers" do + mcp_initialize + mcp_call("list_ministers") + assert_response :success + + data = tool_response_data + assert_kind_of Array, data + end + + # -- Feed Items -- + + test "list_activity with no filters" do + mcp_initialize + mcp_call("list_activity") + assert_response :success + end + + test "list_activity filtered by commitment" do + mcp_initialize + mcp_call("list_activity", commitment_id: commitments(:defence_spending).id) + assert_response :success + end + + test "list_activity filtered by date range" do + mcp_initialize + mcp_call("list_activity", since: "2025-01-01", until: "2025-12-31") + assert_response :success + end + + # -- Dashboard & Burndown -- + + test "get_commitment_summary" do + mcp_initialize + mcp_call("get_commitment_summary", government_id: governments(:canada).id) + assert_response :success + + data = tool_response_data + assert data.key?("government"), "Expected government key" + assert data.key?("policy_areas"), "Expected policy_areas key" + end + + test "get_commitment_summary not found" do + mcp_initialize + mcp_call("get_commitment_summary", government_id: 999999) + assert_response :success + end + + test "get_commitment_progress" do + mcp_initialize + mcp_call("get_commitment_progress", government_id: governments(:canada).id) + assert_response :success + + data = tool_response_data + assert data.key?("government"), "Expected government key" + end + + test "get_commitment_progress with filters" do + mcp_initialize + mcp_call("get_commitment_progress", government_id: governments(:canada).id, policy_area_slug: "defence") + assert_response :success + end + + test "get_commitment_progress not found" do + mcp_initialize + mcp_call("get_commitment_progress", government_id: 999999) + assert_response :success + end +end