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 88b32c42e92d673f9560b0fdb7fe20a674038a0b Mon Sep 17 00:00:00 2001 From: Matt Helm Date: Wed, 25 Mar 2026 12:56:28 -0700 Subject: [PATCH 2/2] Refactor agent to read tracker data via remote MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The evaluation agent previously read commitments, bills, and other tracker data via ~300 lines of raw SQL in db_read.py. It now connects to the remote MCP server (POST /mcp) for those reads, removing the duplicated query logic. The agent uses two MCP servers: - "tracker" (remote) — read-only tools served by the Rails app - "agent" (local) — entry processing and government page fetching The CLI scan-all command now reads via the REST API with correct stale-first ordering (sort=last_assessed_at, direction=asc). ## REST API enhancements (to support agent and MCP tools) - GET /commitments: stale_days filter, criteria_count/matches_count in response, last_assessed_at in listing, NULLS FIRST ordering - GET /commitments/:id: evidence matches (linked bills/entries) - GET /bills: parliament_number and government_bills filters - GET /bills/:id: Jbuilder view with linked_commitments - GET /policy_areas: new endpoint (id, name, slug, description) ## New MCP tools (added to the controller from PR 1) - list_policy_areas — discover valid policy area slugs - Updated list_commitments with stale_days, criteria_count, matches_count - Updated list_bills with parliament_number and government_bills filters - Updated get_commitment with evidence matches - Updated get_bill with linked_commitments Depends on: feat/mcp-server (the MCP endpoint must be deployed first) --- agent/.env.example | 7 +- agent/CLAUDE.md | 3 +- agent/src/agent/evaluator.py | 135 +++------- agent/src/agent/main.py | 20 +- agent/src/agent/tools/db_read.py | 277 ++------------------- app/controllers/bills_controller.rb | 3 +- app/controllers/commitments_controller.rb | 13 +- app/controllers/mcp_controller.rb | 34 ++- app/controllers/policy_areas_controller.rb | 5 + app/views/bills/show.json.jbuilder | 26 ++ app/views/commitments/index.json.jbuilder | 4 +- app/views/commitments/show.json.jbuilder | 12 + config/routes.rb | 1 + 13 files changed, 158 insertions(+), 382 deletions(-) create mode 100644 app/controllers/policy_areas_controller.rb create mode 100644 app/views/bills/show.json.jbuilder diff --git a/agent/.env.example b/agent/.env.example index 58934cc..5c8e1c1 100644 --- a/agent/.env.example +++ b/agent/.env.example @@ -1,13 +1,16 @@ # Claude API ANTHROPIC_API_KEY=sk-ant-... -# Read-only Postgres connection for the agent +# Read-only Postgres connection (only needed for entry processing and batch runs) AGENT_DATABASE_URL=postgresql://agent_reader:password@localhost:5432/outcome_tracker_development -# Rails API for write operations +# Rails API for write operations and REST reads RAILS_API_URL=http://localhost:3000 RAILS_API_KEY=agent-secret-key +# Remote MCP server URL (defaults to RAILS_API_URL/mcp) +# MCP_SERVER_URL=https://www.buildcanada.com/mcp + # Agent configuration AGENT_MODEL=claude-opus-4-6 AGENT_MAX_COMMITMENTS_PER_RUN=100 diff --git a/agent/CLAUDE.md b/agent/CLAUDE.md index 9b26739..e5d8f39 100644 --- a/agent/CLAUDE.md +++ b/agent/CLAUDE.md @@ -112,7 +112,8 @@ Base URL: provided in system prompt. Auth: `Authorization: Bearer ` (also i ## Rules - Do NOT use Read, Glob, Grep, or filesystem tools to explore the Rails codebase. Everything you need is above. -- Use the MCP tools (get_commitment, get_bill, etc.) for reading data. +- Use the remote MCP tools (mcp__tracker__get_commitment, mcp__tracker__list_bills, etc.) for reading tracker data. +- Use the local MCP tools (mcp__agent__get_entry, mcp__agent__list_unprocessed_entries, mcp__agent__fetch_government_page) for entry processing and page fetching. - Use curl via Bash for ALL write operations. - Every judgement (assess_criterion, create_commitment_event, update_commitment_status) MUST include a source_url that was previously fetched via fetch_government_page. - Fetch pages BEFORE referencing them. The fetch auto-registers them as Sources in the DB. diff --git a/agent/src/agent/evaluator.py b/agent/src/agent/evaluator.py index e35d6d3..0bb00c2 100644 --- a/agent/src/agent/evaluator.py +++ b/agent/src/agent/evaluator.py @@ -26,15 +26,7 @@ SYSTEM_PROMPT, WEEKLY_SCAN_PROMPT, ) -from agent.tools.db_read import ( - get_bill, - get_bills_for_parliament, - get_commitment, - get_commitment_sources, - get_entry, - list_commitments, - list_unprocessed_entries, -) +from agent.tools.db_read import get_entry, list_unprocessed_entries from agent.tools.web_search import fetch_government_page from agent.tools.rails_write import register_source @@ -63,58 +55,7 @@ def _tool_error(e: Exception, tool_name: str, args: dict) -> dict[str, Any]: } -# ── Read-only DB tools (via MCP) ─────────────────────────────────────────── - -@tool( - "get_commitment", - "Fetch a commitment with its criteria, matches, events, linked bills, departments, and source documents.", - {"commitment_id": int}, - annotations=ToolAnnotations(readOnlyHint=True), -) -async def get_commitment_tool(args: dict[str, Any]) -> dict[str, Any]: - _tool_log("get_commitment", f"Loading commitment {args['commitment_id']}") - try: - return _tool_result(get_commitment(args["commitment_id"]), "get_commitment") - except Exception as e: - return _tool_error(e, "get_commitment", args) - - -@tool( - "list_commitments", - "List commitments with optional filters. Params: status, policy_area, commitment_type, stale_days, limit.", - { - "type": "object", - "properties": { - "status": {"type": "string", "enum": ["not_started", "in_progress", "completed", "broken"]}, - "policy_area": {"type": "string", "description": "Policy area slug"}, - "commitment_type": {"type": "string"}, - "stale_days": {"type": "integer"}, - "limit": {"type": "integer"}, - }, - }, - annotations=ToolAnnotations(readOnlyHint=True), -) -async def list_commitments_tool(args: dict[str, Any]) -> dict[str, Any]: - _tool_log("list_commitments", f"filters: {args}") - try: - return _tool_result(list_commitments(**args), "list_commitments") - except Exception as e: - return _tool_error(e, "list_commitments", args) - - -@tool( - "get_bill", - "Fetch a bill with all stage dates (House/Senate readings, Royal Assent) and linked commitments.", - {"bill_id": int}, - annotations=ToolAnnotations(readOnlyHint=True), -) -async def get_bill_tool(args: dict[str, Any]) -> dict[str, Any]: - _tool_log("get_bill", f"Loading bill {args['bill_id']}") - try: - return _tool_result(get_bill(args["bill_id"]), "get_bill") - except Exception as e: - return _tool_error(e, "get_bill", args) - +# ── Agent-local tools (not served by the remote MCP server) ──────────────── @tool( "get_entry", @@ -144,35 +85,6 @@ async def list_unprocessed_entries_tool(args: dict[str, Any]) -> dict[str, Any]: return _tool_error(e, "list_unprocessed_entries", args) -@tool( - "get_commitment_sources", - "Get the source documents (platform, Speech from the Throne, budget) for a commitment. Use this to determine where a commitment originated for the budget evidence rule.", - {"commitment_id": int}, - annotations=ToolAnnotations(readOnlyHint=True), -) -async def get_commitment_sources_tool(args: dict[str, Any]) -> dict[str, Any]: - _tool_log("get_commitment_sources", f"commitment {args['commitment_id']}") - try: - return _tool_result(get_commitment_sources(args["commitment_id"]), "get_commitment_sources") - except Exception as e: - return _tool_error(e, "get_commitment_sources", args) - - -@tool( - "get_bills_for_parliament", - "Get all government bills for a parliament session with their stage dates.", - {"type": "object", "properties": {"parliament_number": {"type": "integer"}}}, - annotations=ToolAnnotations(readOnlyHint=True), -) -async def get_bills_for_parliament_tool(args: dict[str, Any]) -> dict[str, Any]: - pn = args.get("parliament_number", 45) - _tool_log("get_bills_for_parliament", f"parliament {pn}") - try: - return _tool_result(get_bills_for_parliament(pn), "get_bills_for_parliament") - except Exception as e: - return _tool_error(e, "get_bills_for_parliament", args) - - @tool( "fetch_government_page", "Fetch and parse content from an official Canadian government webpage (*.canada.ca / *.gc.ca only). " @@ -225,27 +137,22 @@ async def fetch_government_page_tool(args: dict[str, Any]) -> dict[str, Any]: return _tool_error(e, "fetch_government_page", args) -# ── MCP Server (read tools + fetch only) ─────────────────────────────────── +# ── MCP Servers ──────────────────────────────────────────────────────────── -ALL_TOOLS = [ - get_commitment_tool, - list_commitments_tool, - get_bill_tool, +# Agent-local tools that need direct DB access or are side-effecting. +# Read-only tracker tools are served by the remote MCP server (POST /mcp). +LOCAL_TOOLS = [ get_entry_tool, list_unprocessed_entries_tool, - get_commitment_sources_tool, - get_bills_for_parliament_tool, fetch_government_page_tool, ] -tracker_server = create_sdk_mcp_server( - name="tracker", +agent_server = create_sdk_mcp_server( + name="agent", version="1.0.0", - tools=ALL_TOOLS, + tools=LOCAL_TOOLS, ) -ALLOWED_TOOLS = [f"mcp__tracker__{t.name}" for t in ALL_TOOLS] + ["Bash", "WebSearch"] - # ── Agent runner ──────────────────────────────────────────────────────────── @@ -263,11 +170,31 @@ def _build_options() -> ClaudeAgentOptions: model = os.environ.get("AGENT_MODEL", "claude-sonnet-4-6") + # Remote MCP server for read-only tracker tools (commitments, departments, + # bills, promises, ministers, feed items, dashboard, burndown). + # Agent-local MCP server for entry tools and fetch_government_page. + tracker_url = os.environ.get("MCP_SERVER_URL", f"{rails_url}/mcp") + + remote_tools = [ + "mcp__tracker__list_policy_areas", + "mcp__tracker__list_commitments", "mcp__tracker__get_commitment", + "mcp__tracker__list_departments", "mcp__tracker__get_department", + "mcp__tracker__list_promises", "mcp__tracker__get_promise", + "mcp__tracker__list_bills", "mcp__tracker__get_bill", + "mcp__tracker__list_ministers", "mcp__tracker__list_activity", + "mcp__tracker__get_commitment_summary", "mcp__tracker__get_commitment_progress", + ] + local_tools = [f"mcp__agent__{t.name}" for t in LOCAL_TOOLS] + allowed_tools = remote_tools + local_tools + ["Bash", "WebSearch"] + return ClaudeAgentOptions( model=model, system_prompt=SYSTEM_PROMPT + api_context, - mcp_servers={"tracker": tracker_server}, - allowed_tools=ALLOWED_TOOLS, + mcp_servers={ + "tracker": {"type": "url", "url": tracker_url}, + "agent": agent_server, + }, + allowed_tools=allowed_tools, permission_mode="bypassPermissions", cwd=str(pathlib.Path(__file__).resolve().parent.parent.parent), # agent/ dir where CLAUDE.md lives setting_sources=["project"], diff --git a/agent/src/agent/main.py b/agent/src/agent/main.py index 1edb4df..3f28fd6 100644 --- a/agent/src/agent/main.py +++ b/agent/src/agent/main.py @@ -1,10 +1,11 @@ """CLI entry point for the commitment evaluation agent.""" -import sys +import os import time import uuid import click +import httpx from agent.evaluator import ( evaluate_commitment, @@ -12,7 +13,20 @@ process_entry, weekly_scan_commitment, ) -from agent.tools.db_read import list_commitments + + +def _fetch_commitments(status=None, policy_area=None, limit=100): + """Fetch commitments from the Rails REST API, oldest-assessed first.""" + rails_url = os.environ.get("RAILS_API_URL", "http://localhost:3000") + params = {"per_page": limit, "sort": "last_assessed_at", "direction": "asc"} + if status: + params["status"] = status + if policy_area: + params["policy_area"] = policy_area + + resp = httpx.get(f"{rails_url}/commitments", params=params, timeout=30.0) + resp.raise_for_status() + return resp.json().get("commitments", []) @click.group() @@ -70,7 +84,7 @@ def scan_all(limit: int, status: str | None, policy_area: str | None, as_of: str click.echo("Starting weekly scan...") run_id = str(uuid.uuid4()) - commitments = list_commitments( + commitments = _fetch_commitments( status=status, policy_area=policy_area, limit=limit, diff --git a/agent/src/agent/tools/db_read.py b/agent/src/agent/tools/db_read.py index 251d63f..63a33a9 100644 --- a/agent/src/agent/tools/db_read.py +++ b/agent/src/agent/tools/db_read.py @@ -1,211 +1,26 @@ -"""Database read tools — direct read-only Postgres queries.""" - -import json +"""Database read tools for agent-internal queries not served by the remote MCP server.""" from agent.db import query, query_one -def get_commitment(commitment_id: int) -> dict: - """Fetch a commitment with its criteria, matches, events, linked bills, and sources.""" - commitment = query_one( - """ - SELECT c.*, pa.name AS policy_area_name, pa.slug AS policy_area_slug, - g.name AS government_name - FROM commitments c - LEFT JOIN policy_areas pa ON pa.id = c.policy_area_id - LEFT JOIN governments g ON g.id = c.government_id - WHERE c.id = %s - """, - (commitment_id,), - ) - if not commitment: - return {"error": f"Commitment {commitment_id} not found"} - - criteria = query( - """ - SELECT id, category, description, verification_method, status, - evidence_notes, assessed_at, position - FROM criteria - WHERE commitment_id = %s - ORDER BY category, position - """, - (commitment_id,), - ) - - matches = query( - """ - SELECT cm.id, cm.matchable_type, cm.matchable_id, cm.relevance_score, - cm.relevance_reasoning, cm.matched_at, cm.assessed, - CASE - WHEN cm.matchable_type = 'Bill' THEN b.bill_number_formatted - WHEN cm.matchable_type = 'Entry' THEN e.title - ELSE NULL - END AS matchable_title, - CASE - WHEN cm.matchable_type = 'Bill' THEN b.short_title - WHEN cm.matchable_type = 'Entry' THEN e.url - ELSE NULL - END AS matchable_detail - FROM commitment_matches cm - LEFT JOIN bills b ON cm.matchable_type = 'Bill' AND b.id = cm.matchable_id - LEFT JOIN entries e ON cm.matchable_type = 'Entry' AND e.id = cm.matchable_id - WHERE cm.commitment_id = %s - ORDER BY cm.relevance_score DESC - """, - (commitment_id,), - ) - - events = query( - """ - SELECT id, event_type, action_type, title, description, occurred_at, metadata - FROM commitment_events - WHERE commitment_id = %s - ORDER BY occurred_at DESC - LIMIT 50 - """, - (commitment_id,), - ) - - sources = query( - """ - SELECT cs.id, cs.section, cs.reference, cs.excerpt, cs.relevance_note, - s.title AS source_title, s.source_type, s.url AS source_url, s.date AS source_date - FROM commitment_sources cs - JOIN sources s ON s.id = cs.source_id - WHERE cs.commitment_id = %s - """, - (commitment_id,), - ) - - departments = query( - """ - SELECT d.id, d.slug, d.display_name, d.official_name, cd.is_lead - FROM commitment_departments cd - JOIN departments d ON d.id = cd.department_id - WHERE cd.commitment_id = %s - ORDER BY cd.is_lead DESC - """, - (commitment_id,), - ) - - status_changes = query( - """ - SELECT previous_status, new_status, changed_at, reason - FROM commitment_status_changes - WHERE commitment_id = %s - ORDER BY changed_at DESC - LIMIT 10 - """, - (commitment_id,), - ) - - commitment["criteria"] = criteria - commitment["matches"] = matches - commitment["events"] = events - commitment["sources"] = sources - commitment["departments"] = departments - commitment["status_changes"] = status_changes - - return _serialize(commitment) - - -def list_commitments( - status: str | None = None, - policy_area: str | None = None, - commitment_type: str | None = None, - stale_days: int | None = None, - government_id: int | None = None, - limit: int = 50, - offset: int = 0, -) -> list[dict]: - """List commitments with optional filters.""" - conditions = [] - params = [] - - if status: - # Map status name to integer - status_map = {"not_started": 0, "in_progress": 1, "completed": 2, "broken": 4} - if status in status_map: - conditions.append("c.status = %s") - params.append(status_map[status]) - - if policy_area: - conditions.append("pa.slug = %s") - params.append(policy_area) - - if commitment_type: - type_map = { - "legislative": 0, "spending": 1, "procedural": 2, - "institutional": 3, "diplomatic": 4, "aspirational": 5, "outcome": 6, - } - if commitment_type in type_map: - conditions.append("c.commitment_type = %s") - params.append(type_map[commitment_type]) - - if stale_days: - conditions.append( - "(c.last_assessed_at IS NULL OR c.last_assessed_at < NOW() - INTERVAL '%s days')" - ) - params.append(stale_days) - - if government_id: - conditions.append("c.government_id = %s") - params.append(government_id) - - where = "WHERE " + " AND ".join(conditions) if conditions else "" - - params.extend([limit, offset]) - - results = query( - f""" - SELECT c.id, c.title, c.description, c.commitment_type, c.status, - c.target_date, c.date_promised, c.last_assessed_at, - pa.name AS policy_area_name, pa.slug AS policy_area_slug, - (SELECT COUNT(*) FROM criteria cr WHERE cr.commitment_id = c.id) AS criteria_count, - (SELECT COUNT(*) FROM commitment_matches cm WHERE cm.commitment_id = c.id) AS matches_count - FROM commitments c - LEFT JOIN policy_areas pa ON pa.id = c.policy_area_id - {where} - ORDER BY c.last_assessed_at ASC NULLS FIRST, c.id - LIMIT %s OFFSET %s - """, - tuple(params), - ) - return [_serialize(r) for r in results] - - -def get_bill(bill_id: int) -> dict: - """Fetch a bill with stage dates and linked commitments.""" - bill = query_one( - """ - SELECT id, bill_id, bill_number_formatted, parliament_number, - short_title, long_title, latest_activity, - passed_house_first_reading_at, passed_house_second_reading_at, - passed_house_third_reading_at, - passed_senate_first_reading_at, passed_senate_second_reading_at, - passed_senate_third_reading_at, - received_royal_assent_at, latest_activity_at - FROM bills - WHERE id = %s - """, - (bill_id,), - ) - if not bill: - return {"error": f"Bill {bill_id} not found"} +def _serialize(obj: dict) -> dict: + """Serialize datetime and other non-JSON-serializable types.""" + from datetime import date, datetime + from decimal import Decimal - linked_commitments = query( - """ - SELECT cm.commitment_id, cm.relevance_score, cm.relevance_reasoning, - c.title AS commitment_title, c.status AS commitment_status - FROM commitment_matches cm - JOIN commitments c ON c.id = cm.commitment_id - WHERE cm.matchable_type = 'Bill' AND cm.matchable_id = %s - ORDER BY cm.relevance_score DESC - """, - (bill_id,), - ) - bill["linked_commitments"] = linked_commitments - return _serialize(bill) + result = {} + for key, value in obj.items(): + if isinstance(value, (datetime, date)): + result[key] = value.isoformat() + elif isinstance(value, Decimal): + result[key] = float(value) + elif isinstance(value, list): + result[key] = [_serialize(v) if isinstance(v, dict) else v for v in value] + elif isinstance(value, dict): + result[key] = _serialize(value) + else: + result[key] = value + return result def get_entry(entry_id: int) -> dict: @@ -252,59 +67,3 @@ def list_unprocessed_entries(government_id: int | None = None, limit: int = 50) tuple(params), ) return [_serialize(r) for r in results] - - -def get_commitment_sources(commitment_id: int) -> list[dict]: - """Get the source documents (platform, SFT, budget) for a commitment.""" - results = query( - """ - SELECT cs.section, cs.reference, cs.excerpt, cs.relevance_note, - s.title, s.source_type, s.url, s.date - FROM commitment_sources cs - JOIN sources s ON s.id = cs.source_id - WHERE cs.commitment_id = %s - """, - (commitment_id,), - ) - return [_serialize(r) for r in results] - - -def get_bills_for_parliament(parliament_number: int = 45) -> list[dict]: - """Get all government bills for a parliament session.""" - results = query( - """ - SELECT id, bill_id, bill_number_formatted, short_title, long_title, - latest_activity, latest_activity_at, - passed_house_first_reading_at, passed_house_second_reading_at, - passed_house_third_reading_at, - passed_senate_first_reading_at, passed_senate_second_reading_at, - passed_senate_third_reading_at, - received_royal_assent_at - FROM bills - WHERE parliament_number = %s - AND data->>'BillTypeEn' IN ('House Government Bill', 'Senate Government Bill') - ORDER BY latest_activity_at DESC NULLS LAST - """, - (parliament_number,), - ) - return [_serialize(r) for r in results] - - -def _serialize(obj: dict) -> dict: - """Serialize datetime and other non-JSON-serializable types.""" - from datetime import date, datetime - from decimal import Decimal - - result = {} - for key, value in obj.items(): - if isinstance(value, (datetime, date)): - result[key] = value.isoformat() - elif isinstance(value, Decimal): - result[key] = float(value) - elif isinstance(value, list): - result[key] = [_serialize(v) if isinstance(v, dict) else v for v in value] - elif isinstance(value, dict): - result[key] = _serialize(value) - else: - result[key] = value - return result diff --git a/app/controllers/bills_controller.rb b/app/controllers/bills_controller.rb index 5a0f0f8..1d91ca5 100644 --- a/app/controllers/bills_controller.rb +++ b/app/controllers/bills_controller.rb @@ -4,13 +4,14 @@ class BillsController < ApplicationController # GET /bills def index @bills = Bill.all + @bills = @bills.where(parliament_number: params[:parliament_number]) if params[:parliament_number].present? + @bills = @bills.government_bills if params[:government_bills] == "true" render json: @bills end # GET /bills/1 def show - render json: @bill end private diff --git a/app/controllers/commitments_controller.rb b/app/controllers/commitments_controller.rb index c5f1abf..41605d5 100644 --- a/app/controllers/commitments_controller.rb +++ b/app/controllers/commitments_controller.rb @@ -1,6 +1,6 @@ class CommitmentsController < ApplicationController def index - @commitments = Commitment.includes(:policy_area, :lead_department) + @commitments = Commitment.includes(:policy_area, :lead_department, :criteria, :commitment_matches) @commitments = @commitments.search(params[:q]) if params[:q].present? @commitments = @commitments.where(policy_area_id: params[:policy_area_id]) if params[:policy_area_id].present? @@ -26,6 +26,10 @@ def index @commitments = @commitments.joins(:sources).where(sources: { source_type: params[:source_type] }).distinct end + if params[:stale_days].present? + @commitments = @commitments.where("commitments.last_assessed_at IS NULL OR commitments.last_assessed_at < ?", params[:stale_days].to_i.days.ago) + end + @commitments = apply_sorting(@commitments) @total_count = @commitments.count @commitments = @commitments.limit(page_size).offset(page_offset) @@ -41,7 +45,12 @@ def apply_sorting(scope) case params[:sort] when "title" then scope.order(title: sort_direction) when "date_promised" then scope.order(date_promised: sort_direction) - when "last_assessed_at" then scope.order(last_assessed_at: sort_direction) + when "last_assessed_at" + if sort_direction == :asc + scope.order(Arel.sql("commitments.last_assessed_at ASC NULLS FIRST")) + else + scope.order(Arel.sql("commitments.last_assessed_at DESC NULLS LAST")) + end when "status" then scope.order(status: sort_direction) else scope.order(created_at: :desc) end diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index f6ad0b0..8ac54e4 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -2,6 +2,15 @@ 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_policy_areas", + path: "/policy_areas", + description: <<~DESC.strip, + List all policy areas (e.g. Defence, Healthcare, Economy) with their slugs. + Use this first to discover valid policy_area slugs for filtering other tools. + Returns: id, name, slug, description, position. There are ~16 policy areas. + DESC + }, { name: "list_commitments", path: "/commitments", @@ -11,15 +20,16 @@ class McpController < ActionController::API (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. + criteria_count, matches_count, and meta { total_count, page, per_page }. + Use list_policy_areas 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')" }, + policy_area: { type: "string", description: "Policy area slug — use list_policy_areas to see valid values" }, 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')" }, + stale_days: { type: "integer", description: "Only commitments not assessed in this many days" }, 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)" }, @@ -32,8 +42,8 @@ class McpController < ActionController::API 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. + with assessment history), matches (linked bills and entries with relevance scores), + departments, timeline events, status_history, and recent_feed items. DESC properties: { id: { type: "integer", description: "The commitment ID" } }, required: [ "id" ] @@ -68,16 +78,22 @@ class McpController < ActionController::API 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. + 1st/2nd/3rd reading, and Royal Assent. Filter to government bills to exclude + private members' bills. DESC + properties: { + parliament_number: { type: "integer", description: "Filter by parliament (e.g. 45 for current)" }, + government_bills: { type: "string", enum: %w[true], description: "Set to 'true' to only return House/Senate Government Bills" } + } }, { 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). + Get full details for a parliamentary bill. Returns all stage dates, the complete + raw data from the Parliament of Canada API (sponsor, type, session info), and + linked_commitments — which government commitments this bill implements, with + relevance scores and reasoning. DESC properties: { id: { type: "integer", description: "The bill database ID" } }, required: [ "id" ] diff --git a/app/controllers/policy_areas_controller.rb b/app/controllers/policy_areas_controller.rb new file mode 100644 index 0000000..0563d3a --- /dev/null +++ b/app/controllers/policy_areas_controller.rb @@ -0,0 +1,5 @@ +class PolicyAreasController < ApplicationController + def index + render json: PolicyArea.ordered.as_json(only: [ :id, :name, :slug, :description, :position ]) + end +end diff --git a/app/views/bills/show.json.jbuilder b/app/views/bills/show.json.jbuilder new file mode 100644 index 0000000..925cde4 --- /dev/null +++ b/app/views/bills/show.json.jbuilder @@ -0,0 +1,26 @@ +json.(@bill, + :id, + :bill_id, + :bill_number_formatted, + :parliament_number, + :short_title, + :long_title, + :latest_activity, + :latest_activity_at, + :passed_house_first_reading_at, + :passed_house_second_reading_at, + :passed_house_third_reading_at, + :passed_senate_first_reading_at, + :passed_senate_second_reading_at, + :passed_senate_third_reading_at, + :received_royal_assent_at, + :data +) + +json.linked_commitments @bill.commitment_matches.includes(commitment: :lead_department) do |cm| + json.(cm, :relevance_score, :relevance_reasoning) + json.commitment do + json.(cm.commitment, :id, :title, :status, :commitment_type) + json.lead_department cm.commitment.lead_department&.display_name + end +end diff --git a/app/views/commitments/index.json.jbuilder b/app/views/commitments/index.json.jbuilder index b31ba64..1089bc8 100644 --- a/app/views/commitments/index.json.jbuilder +++ b/app/views/commitments/index.json.jbuilder @@ -1,5 +1,7 @@ json.commitments @commitments do |commitment| - json.(commitment, :id, :title, :description, :commitment_type, :status, :date_promised, :target_date, :region_code, :party_code) + json.(commitment, :id, :title, :description, :commitment_type, :status, :date_promised, :target_date, :last_assessed_at, :region_code, :party_code) + json.criteria_count commitment.criteria.size + json.matches_count commitment.commitment_matches.size if commitment.policy_area json.policy_area do diff --git a/app/views/commitments/show.json.jbuilder b/app/views/commitments/show.json.jbuilder index 5830ab9..8bf3f88 100644 --- a/app/views/commitments/show.json.jbuilder +++ b/app/views/commitments/show.json.jbuilder @@ -102,6 +102,18 @@ json.status_history @commitment.status_changes.includes(:source).order(:changed_ end end +json.matches @commitment.commitment_matches.includes(:matchable) do |cm| + json.(cm, :id, :matchable_type, :matchable_id, :relevance_score, :relevance_reasoning, :matched_at, :assessed) + case cm.matchable + when Bill + json.matchable_title cm.matchable.bill_number_formatted + json.matchable_detail cm.matchable.short_title + when Entry + json.matchable_title cm.matchable.title + json.matchable_detail cm.matchable.url + end +end + json.recent_feed @commitment.feed_items.newest_first.limit(20) do |fi| json.(fi, :id, :event_type, :title, :summary, :occurred_at) feedable = fi.feedable diff --git a/config/routes.rb b/config/routes.rb index 6ff8835..206c6d4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,7 @@ resources :feed_items, only: [ :index ], path: "feed" end resources :promises, only: [ :index, :show ] + resources :policy_areas, only: [ :index ] resources :evidences, only: [ :index, :show ] resources :builders, only: [ :index, :show ] resources :statcan_datasets, only: [ :show ]