From 130ef5832723fd4e9b47a9c186a174f69cebdb21 Mon Sep 17 00:00:00 2001 From: Matt Helm Date: Wed, 25 Mar 2026 12:07:34 -0700 Subject: [PATCH 1/2] Add remote MCP server for AI-assisted access to tracker data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Build Canada tracker already makes government accountability data publicly available through a REST API. This PR adds a Model Context Protocol (MCP) server so AI assistants can query that data directly. Anyone using Claude Desktop, Claude Code, Cursor, or any MCP client can connect with a single URL: https://www.buildcanada.com/mcp ## MCP server Uses the official `mcp` Ruby SDK with stateless Streamable HTTP transport. Thirteen read-only tools, no auth required: - list_policy_areas — discover valid policy area slugs - list_commitments / get_commitment — search and inspect commitments - list_promises / get_promise — platform promises with progress scores - list_bills / get_bill — parliamentary bills with stage tracking - list_departments / get_department — departments with ministers - list_ministers — cabinet officials by portfolio - list_activity — chronological feed of government activity - get_commitment_summary — status overview by policy area - get_commitment_progress — time-series for trend analysis Each tool dispatches internally to the existing REST endpoints via Rack — zero duplicated query or serialization logic. The entire MCP surface is a single controller with a declarative config array. Adding a new tool is ~8 lines. No existing files are modified — this is purely additive (new controller, new policy_areas endpoint, routes, and tests). ## Testing locally 1. Start Rails: `bin/rails server -p 3099` 2. Verify: `curl -X POST http://localhost:3099/mcp -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'` 3. To test with Claude Desktop, expose via ngrok: ngrok http 3099 4. In Claude Desktop: Settings > Connectors > Add custom connector, then enter the ngrok URL with /mcp appended. You may need to allow the ngrok host in Rails: # config/initializers/allow_tunnel.rb (do not commit) Rails.application.config.hosts.clear 5. Run tests: `bin/rails test test/controllers/mcp_controller_test.rb` --- Gemfile | 2 + Gemfile.lock | 6 + app/controllers/mcp_controller.rb | 217 ++++++++++++++++++++++++ config/routes.rb | 2 + test/controllers/mcp_controller_test.rb | 208 +++++++++++++++++++++++ 5 files changed, 435 insertions(+) create mode 100644 app/controllers/mcp_controller.rb create mode 100644 test/controllers/mcp_controller_test.rb 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/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb new file mode 100644 index 0000000..f6ad0b0 --- /dev/null +++ b/app/controllers/mcp_controller.rb @@ -0,0 +1,217 @@ +class McpController < ActionController::API + # Each MCP tool maps 1:1 to an existing REST endpoint. All data access and + # serialization is handled by the existing controllers and Jbuilder views. + TOOLS_CONFIG = [ + { + name: "list_commitments", + path: "/commitments", + 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 + 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)" } + } + }, + { + name: "get_commitment", + path: "/commitments/:id", + 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 + properties: { id: { type: "integer", description: "The commitment ID" } }, + required: [ "id" ] + }, + { + name: "list_promises", + path: "/promises", + description: <<~DESC.strip, + List all platform promises — the original political pledges from the 2025 campaign + platform, mandate letters, and budgets. There are ~350 promises. Each has a + concise_title, progress_score (1-5), and bc_promise_rank. Promises are upstream + of commitments: a promise like "increase defence spending" may generate several + specific commitments with measurable criteria. Returns all promises (no pagination). + DESC + }, + { + name: "get_promise", + path: "/promises/:id", + description: <<~DESC.strip, + Get full details for a single promise. Returns: text, description, + what_it_means_for_canadians, commitment_history_rationale, progress_score, + progress_summary, source_url, and evidences — impactful evidence links with + impact assessment and linked government activity details. + DESC + properties: { id: { type: "integer", description: "The promise ID" } }, + required: [ "id" ] + }, + { + name: "list_bills", + path: "/bills", + 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 + }, + { + name: "get_bill", + path: "/bills/:id", + 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 + properties: { id: { type: "integer", description: "The bill database ID" } }, + required: [ "id" ] + }, + { + name: "list_departments", + path: "/departments", + 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 + }, + { + name: "get_department", + path: "/departments/:id_or_slug", + description: <<~DESC.strip, + Get department details including minister info (hill office, constituency offices) + and the department's lead promises with progress scores. Accepts numeric ID or + slug (e.g. 'finance-canada', 'national-defence'). + DESC + properties: { id_or_slug: { type: "string", description: "Department ID or slug" } }, + required: [ "id_or_slug" ] + }, + { + name: "list_ministers", + path: "/ministers", + description: <<~DESC.strip, + List current cabinet ministers and officials. Returns: name, title, avatar_url, + email, phone, website, constituency, province, and their department assignment. + DESC + }, + { + name: "list_activity", + path: "/feed", + 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 + 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)" } + } + }, + { + name: "get_commitment_summary", + path: "/api/dashboard/:government_id/at_a_glance", + 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 + 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" ] + }, + { + name: "get_commitment_progress", + path: "/api/burndown/:government_id", + 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 + 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" ] + } + ].freeze + + # Generate MCP::Tool subclasses from the config above. + TOOLS = TOOLS_CONFIG.map do |config| + path_template = config[:path] + path_params = path_template.scan(/:(\w+)/).flatten.map(&:to_sym) + + klass = Class.new(MCP::Tool) do + description config[:description] + schema = { properties: config.fetch(:properties, {}) } + schema[:required] = config[:required] if config[:required]&.any? + input_schema(**schema) + + define_singleton_method(:call) do |server_context:, **params| + path = path_template.gsub(/:(\w+)/) { params[$1.to_sym] } + query_params = params.except(*path_params) + response = McpController.internal_get(path, query_params) + MCP::Tool::Response.new([{ type: "text", text: response }]) + end + end + + # Register as a top-level constant so the mcp gem derives the tool name from the class name. + const_name = config[:name].to_s.camelize + Object.const_set(const_name, klass) unless Object.const_defined?(const_name) + klass + end.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 + + # Internal Rack dispatch — lets MCP tools call existing REST endpoints + # without duplicating any controller or query logic. + def self.internal_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 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..e5c01c9 --- /dev/null +++ b/test/controllers/mcp_controller_test.rb @@ -0,0 +1,208 @@ +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 + + # -- Protocol -- + + test "initialize returns server capabilities" do + mcp_initialize + assert_response :success + end + + test "tools/list returns registered tools" do + mcp_initialize + post "/mcp", params: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }.to_json, headers: MCP_HEADERS + assert_response :success + end + + # -- Commitments -- + + test "list_commitments with no filters" do + mcp_initialize + mcp_call("list_commitments") + assert_response :success + 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 + 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 + end + + test "get_department by slug" do + mcp_initialize + mcp_call("get_department", id_or_slug: "finance") + assert_response :success + 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 + + # -- Promises -- + + test "list_promises" do + mcp_initialize + mcp_call("list_promises") + assert_response :success + end + + test "get_promise" do + mcp_initialize + mcp_call("get_promise", id: promises(:one).id) + assert_response :success + end + + test "get_promise not found" do + mcp_initialize + mcp_call("get_promise", id: 999999) + assert_response :success + end + + # -- Bills -- + + test "list_bills" do + mcp_initialize + mcp_call("list_bills") + assert_response :success + end + + test "get_bill" do + mcp_initialize + mcp_call("get_bill", id: bills(:one).id) + assert_response :success + 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 + 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 + 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 + 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 From 5a404b51e8ce02365f8f3c35793e56f4ecbb6d77 Mon Sep 17 00:00:00 2001 From: Matt Helm Date: Thu, 26 Mar 2026 10:23:30 -0700 Subject: [PATCH 2/2] Refactor MCP tools into POROs and address PR feedback - Extract each MCP tool into its own class under app/models/mcp_tools/ - Move shared Rack dispatch logic into McpRackTool concern - Remove list_promises and get_promise (commitments replace promises) - Document MCP endpoint in README and agent/CLAUDE.md --- README.md | 10 + agent/CLAUDE.md | 7 + app/controllers/mcp_controller.rb | 215 +----------------- app/models/concerns/mcp_rack_tool.rb | 40 ++++ app/models/mcp_tools/get_bill.rb | 16 ++ app/models/mcp_tools/get_commitment.rb | 17 ++ .../mcp_tools/get_commitment_progress.rb | 23 ++ .../mcp_tools/get_commitment_summary.rb | 20 ++ app/models/mcp_tools/get_department.rb | 16 ++ app/models/mcp_tools/list_activity.rb | 25 ++ app/models/mcp_tools/list_bills.rb | 12 + app/models/mcp_tools/list_commitments.rb | 29 +++ app/models/mcp_tools/list_departments.rb | 11 + app/models/mcp_tools/list_ministers.rb | 10 + test/controllers/mcp_controller_test.rb | 91 ++++++-- 15 files changed, 317 insertions(+), 225 deletions(-) create mode 100644 app/models/concerns/mcp_rack_tool.rb create mode 100644 app/models/mcp_tools/get_bill.rb create mode 100644 app/models/mcp_tools/get_commitment.rb create mode 100644 app/models/mcp_tools/get_commitment_progress.rb create mode 100644 app/models/mcp_tools/get_commitment_summary.rb create mode 100644 app/models/mcp_tools/get_department.rb create mode 100644 app/models/mcp_tools/list_activity.rb create mode 100644 app/models/mcp_tools/list_bills.rb create mode 100644 app/models/mcp_tools/list_commitments.rb create mode 100644 app/models/mcp_tools/list_departments.rb create mode 100644 app/models/mcp_tools/list_ministers.rb 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 index f6ad0b0..548e536 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -1,195 +1,17 @@ class McpController < ActionController::API - # Each MCP tool maps 1:1 to an existing REST endpoint. All data access and - # serialization is handled by the existing controllers and Jbuilder views. - TOOLS_CONFIG = [ - { - name: "list_commitments", - path: "/commitments", - 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 - 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)" } - } - }, - { - name: "get_commitment", - path: "/commitments/:id", - 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 - properties: { id: { type: "integer", description: "The commitment ID" } }, - required: [ "id" ] - }, - { - name: "list_promises", - path: "/promises", - description: <<~DESC.strip, - List all platform promises — the original political pledges from the 2025 campaign - platform, mandate letters, and budgets. There are ~350 promises. Each has a - concise_title, progress_score (1-5), and bc_promise_rank. Promises are upstream - of commitments: a promise like "increase defence spending" may generate several - specific commitments with measurable criteria. Returns all promises (no pagination). - DESC - }, - { - name: "get_promise", - path: "/promises/:id", - description: <<~DESC.strip, - Get full details for a single promise. Returns: text, description, - what_it_means_for_canadians, commitment_history_rationale, progress_score, - progress_summary, source_url, and evidences — impactful evidence links with - impact assessment and linked government activity details. - DESC - properties: { id: { type: "integer", description: "The promise ID" } }, - required: [ "id" ] - }, - { - name: "list_bills", - path: "/bills", - 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 - }, - { - name: "get_bill", - path: "/bills/:id", - 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 - properties: { id: { type: "integer", description: "The bill database ID" } }, - required: [ "id" ] - }, - { - name: "list_departments", - path: "/departments", - 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 - }, - { - name: "get_department", - path: "/departments/:id_or_slug", - description: <<~DESC.strip, - Get department details including minister info (hill office, constituency offices) - and the department's lead promises with progress scores. Accepts numeric ID or - slug (e.g. 'finance-canada', 'national-defence'). - DESC - properties: { id_or_slug: { type: "string", description: "Department ID or slug" } }, - required: [ "id_or_slug" ] - }, - { - name: "list_ministers", - path: "/ministers", - description: <<~DESC.strip, - List current cabinet ministers and officials. Returns: name, title, avatar_url, - email, phone, website, constituency, province, and their department assignment. - DESC - }, - { - name: "list_activity", - path: "/feed", - 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 - 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)" } - } - }, - { - name: "get_commitment_summary", - path: "/api/dashboard/:government_id/at_a_glance", - 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 - 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" ] - }, - { - name: "get_commitment_progress", - path: "/api/burndown/:government_id", - 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 - 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" ] - } + TOOLS = [ + McpTools::ListCommitments, + McpTools::GetCommitment, + McpTools::ListBills, + McpTools::GetBill, + McpTools::ListDepartments, + McpTools::GetDepartment, + McpTools::ListMinisters, + McpTools::ListActivity, + McpTools::GetCommitmentSummary, + McpTools::GetCommitmentProgress ].freeze - # Generate MCP::Tool subclasses from the config above. - TOOLS = TOOLS_CONFIG.map do |config| - path_template = config[:path] - path_params = path_template.scan(/:(\w+)/).flatten.map(&:to_sym) - - klass = Class.new(MCP::Tool) do - description config[:description] - schema = { properties: config.fetch(:properties, {}) } - schema[:required] = config[:required] if config[:required]&.any? - input_schema(**schema) - - define_singleton_method(:call) do |server_context:, **params| - path = path_template.gsub(/:(\w+)/) { params[$1.to_sym] } - query_params = params.except(*path_params) - response = McpController.internal_get(path, query_params) - MCP::Tool::Response.new([{ type: "text", text: response }]) - end - end - - # Register as a top-level constant so the mcp gem derives the tool name from the class name. - const_name = config[:name].to_s.camelize - Object.const_set(const_name, klass) unless Object.const_defined?(const_name) - klass - end.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) @@ -199,19 +21,4 @@ def create resp_headers&.each { |key, value| response.headers[key] = value } render json: resp_body&.first, status: resp_status end - - # Internal Rack dispatch — lets MCP tools call existing REST endpoints - # without duplicating any controller or query logic. - def self.internal_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 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/test/controllers/mcp_controller_test.rb b/test/controllers/mcp_controller_test.rb index e5c01c9..3063b9d 100644 --- a/test/controllers/mcp_controller_test.rb +++ b/test/controllers/mcp_controller_test.rb @@ -17,6 +17,14 @@ def mcp_call(tool_name, 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 @@ -24,10 +32,38 @@ def mcp_call(tool_name, arguments = {}) assert_response :success end - test "tools/list returns registered tools" do + 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 -- @@ -36,6 +72,11 @@ def mcp_call(tool_name, arguments = {}) 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 @@ -72,6 +113,11 @@ def mcp_call(tool_name, arguments = {}) 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 @@ -86,12 +132,19 @@ def mcp_call(tool_name, arguments = {}) 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 @@ -106,38 +159,24 @@ def mcp_call(tool_name, arguments = {}) assert_response :success end - # -- Promises -- - - test "list_promises" do - mcp_initialize - mcp_call("list_promises") - assert_response :success - end - - test "get_promise" do - mcp_initialize - mcp_call("get_promise", id: promises(:one).id) - assert_response :success - end - - test "get_promise not found" do - mcp_initialize - mcp_call("get_promise", id: 999999) - 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 @@ -152,6 +191,9 @@ def mcp_call(tool_name, arguments = {}) mcp_initialize mcp_call("list_ministers") assert_response :success + + data = tool_response_data + assert_kind_of Array, data end # -- Feed Items -- @@ -180,6 +222,10 @@ def mcp_call(tool_name, arguments = {}) 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 @@ -192,6 +238,9 @@ def mcp_call(tool_name, arguments = {}) 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