mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 06:41:08 +00:00
* feat(bills): assistant and MCP tools for bills Last of three chunks carved out of #3083, stacked on the UI bundle. Exposes bills to the builtin assistant and to MCP clients. Everything here is gated behind preview features, so the tools are absent from tools/list until a user opts in. Seven tools: - get_bills, get_bill_details and get_paycheck_plan for reads - get_bill_audit, a deterministic review that surfaces likely duplicates, price changes, trials about to convert, upcoming renewals and long-overdue bills - create_bill, update_bill and record_bill_payment for writes Shared argument parsing, permission checks and error shapes live in BillsSupport, so every tool answers with the same {error, hint} contract the existing tools use, and a bad argument never aborts the turn. The write tools mutate financial records on a model's say-so, so they refuse rather than guess: a payment cannot exceed what its cycle still owes, a repeated settle will not quietly close next month, an unrecognized frequency is an error instead of a silent monthly default, and non-finite or negative amounts are rejected before they reach the database. The read tools say what they filtered. An empty result names the statuses that do hold matches, the paycheck plan discloses the unconfirmed series it excluded from spending headroom, and history and price-change windows report their real totals rather than letting a caller sum a truncated list. A not-found no longer returns the scoped relation's SQL, which handed any MCP client the access-control schema for the cost of a guessed id. The in-page AI helpers are not here. Smart fill and smart configuration are buttons on the bills pages, so they ship with the UI bundle along with the provider-side suggester they call. Suite 7,854 runs, 0 failures. Rubocop clean, eager loading verified. * Address the ready-review round * Reject an out-of-range audit lookback out loud * Speak the cycle remainder guard through the allocator locale
672 lines
22 KiB
Ruby
672 lines
22 KiB
Ruby
require "test_helper"
|
|
|
|
class McpControllerTest < ActionDispatch::IntegrationTest
|
|
MCP_PROTOCOL_VERSION = "2025-06-18"
|
|
|
|
setup do
|
|
@user = users(:family_admin)
|
|
@token = "test-mcp-token-#{SecureRandom.hex(8)}"
|
|
end
|
|
|
|
# -- Authentication --
|
|
|
|
test "returns 401 without authorization header" do
|
|
post "/mcp", params: jsonrpc_request("initialize").to_json,
|
|
headers: { "Content-Type" => "application/json" }
|
|
|
|
assert_response :unauthorized
|
|
assert_equal "unauthorized", JSON.parse(response.body)["error"]
|
|
assert response.headers["WWW-Authenticate"].present?, "Must include WWW-Authenticate header"
|
|
assert_includes response.headers["WWW-Authenticate"], "oauth-protected-resource"
|
|
end
|
|
|
|
test "returns 401 with wrong token" do
|
|
post "/mcp", params: jsonrpc_request("initialize").to_json,
|
|
headers: mcp_headers("wrong-token")
|
|
|
|
assert_response :unauthorized
|
|
assert response.headers["WWW-Authenticate"].present?
|
|
end
|
|
|
|
test "authenticates via Doorkeeper bearer token" do
|
|
app = Doorkeeper::Application.create!(
|
|
name: "Test MCP Client #{SecureRandom.hex(4)}",
|
|
redirect_uri: "https://claude.ai/callback",
|
|
confidential: false
|
|
)
|
|
token = Doorkeeper::AccessToken.create!( # pipelock:ignore
|
|
application: app,
|
|
resource_owner_id: @user.id,
|
|
scopes: "read_write",
|
|
expires_in: 1.year
|
|
)
|
|
|
|
post "/mcp", params: jsonrpc_request("initialize").to_json,
|
|
headers: mcp_headers(token.token)
|
|
|
|
assert_response :ok
|
|
result = JSON.parse(response.body)["result"]
|
|
assert_mcp_initialize_response(result)
|
|
end
|
|
|
|
test "authenticates a token issued to a dynamically registered MCP client" do
|
|
post "/register",
|
|
params: {
|
|
client_name: "Claude",
|
|
redirect_uris: [ "https://claude.ai/callback" ],
|
|
grant_types: [ "authorization_code" ],
|
|
response_types: [ "code" ],
|
|
token_endpoint_auth_method: "none"
|
|
}.to_json,
|
|
headers: { "Content-Type" => "application/json" }
|
|
|
|
assert_response :created
|
|
app = Doorkeeper::Application.find_by!(uid: JSON.parse(response.body)["client_id"])
|
|
assert_equal "read_write", app.scopes.to_s
|
|
|
|
sign_in(@user)
|
|
verifier = SecureRandom.urlsafe_base64(64)
|
|
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
|
|
post "/oauth/authorize", params: {
|
|
client_id: app.uid,
|
|
redirect_uri: app.redirect_uri,
|
|
response_type: "code",
|
|
code_challenge: challenge,
|
|
code_challenge_method: "S256"
|
|
}
|
|
|
|
assert_response :redirect
|
|
code = Rack::Utils.parse_query(URI.parse(response.location).query)["code"]
|
|
assert code.present?, "Authorization response should contain a code"
|
|
|
|
post "/oauth/token", params: {
|
|
grant_type: "authorization_code",
|
|
client_id: app.uid,
|
|
redirect_uri: app.redirect_uri,
|
|
code: code,
|
|
code_verifier: verifier
|
|
}
|
|
|
|
assert_response :success
|
|
token_response = JSON.parse(response.body)
|
|
assert_equal "read_write", token_response["scope"]
|
|
|
|
post "/mcp", params: jsonrpc_request("initialize").to_json,
|
|
headers: mcp_headers(token_response["access_token"])
|
|
|
|
assert_response :ok
|
|
assert_mcp_initialize_response(JSON.parse(response.body)["result"])
|
|
end
|
|
|
|
test "rejects token with read-only scope" do
|
|
app = Doorkeeper::Application.create!(
|
|
name: "Test MCP Client #{SecureRandom.hex(4)}",
|
|
redirect_uri: "https://claude.ai/callback",
|
|
confidential: false
|
|
)
|
|
token = Doorkeeper::AccessToken.create!( # pipelock:ignore
|
|
application: app,
|
|
resource_owner_id: @user.id,
|
|
scopes: "read",
|
|
expires_in: 1.year
|
|
)
|
|
|
|
post "/mcp", params: jsonrpc_request("initialize").to_json,
|
|
headers: mcp_headers(token.token)
|
|
|
|
assert_response :unauthorized
|
|
end
|
|
|
|
test "rejects expired Doorkeeper token" do
|
|
app = Doorkeeper::Application.create!(
|
|
name: "Test MCP Client #{SecureRandom.hex(4)}",
|
|
redirect_uri: "https://claude.ai/callback",
|
|
confidential: false
|
|
)
|
|
token = Doorkeeper::AccessToken.create!( # pipelock:ignore
|
|
application: app,
|
|
resource_owner_id: @user.id,
|
|
scopes: "read_write",
|
|
expires_in: -1.second # already expired at creation time
|
|
)
|
|
|
|
post "/mcp", params: jsonrpc_request("initialize").to_json,
|
|
headers: mcp_headers(token.token)
|
|
|
|
assert_response :unauthorized
|
|
end
|
|
|
|
test "rejects token for deactivated user" do
|
|
inactive_user = users(:family_member)
|
|
inactive_user.update!(active: false)
|
|
app = Doorkeeper::Application.create!(
|
|
name: "Test MCP Client #{SecureRandom.hex(4)}",
|
|
redirect_uri: "https://claude.ai/callback",
|
|
confidential: false
|
|
)
|
|
token = Doorkeeper::AccessToken.create!( # pipelock:ignore
|
|
application: app,
|
|
resource_owner_id: inactive_user.id,
|
|
scopes: "read_write",
|
|
expires_in: 1.year
|
|
)
|
|
|
|
post "/mcp", params: jsonrpc_request("initialize").to_json,
|
|
headers: mcp_headers(token.token)
|
|
|
|
assert_response :unauthorized
|
|
ensure
|
|
inactive_user&.update!(active: true)
|
|
end
|
|
|
|
test "env-var token still works as fallback" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("initialize").to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
end
|
|
end
|
|
|
|
test "returns 401 and warns when env-var token matches but MCP_USER_EMAIL finds no user" do
|
|
with_env_overrides("MCP_API_TOKEN" => @token, "MCP_USER_EMAIL" => "nonexistent@example.com") do # pipelock:ignore
|
|
Rails.logger.expects(:warn).with(regexp_matches(/MCP_USER_EMAIL/)).once
|
|
|
|
post "/mcp", params: jsonrpc_request("initialize").to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :unauthorized
|
|
end
|
|
end
|
|
|
|
# -- JSON-RPC protocol --
|
|
|
|
test "returns parse error for invalid JSON" do
|
|
with_mcp_env do
|
|
# Send with text/plain to bypass Rails JSON middleware parsing
|
|
post "/mcp", params: "not valid json",
|
|
headers: mcp_headers(@token).merge("Content-Type" => "text/plain")
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
assert_equal(-32700, body["error"]["code"])
|
|
assert_includes body["error"]["message"], "Parse error"
|
|
end
|
|
end
|
|
|
|
test "returns invalid request for missing jsonrpc version" do
|
|
with_mcp_env do
|
|
post "/mcp", params: { method: "initialize" }.to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
assert_equal(-32600, body["error"]["code"])
|
|
end
|
|
end
|
|
|
|
test "returns method not found for unknown method with request id preserved" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("unknown/method", {}, id: 77).to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
assert_equal(-32601, body["error"]["code"])
|
|
assert_includes body["error"]["message"], "unknown/method"
|
|
assert_equal 77, body["id"], "Error response must echo the request id"
|
|
end
|
|
end
|
|
|
|
# -- Notifications (requests without id) --
|
|
|
|
test "notifications receive no response body" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_notification("notifications/initialized").to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :no_content
|
|
assert response.body.blank?, "Notification must not produce a response body"
|
|
end
|
|
end
|
|
|
|
test "tools/call sent as notification does not execute" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_notification("tools/call", { name: "get_balance_sheet", arguments: {} }).to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :no_content
|
|
assert response.body.blank?, "Notification-style tools/call must not execute or respond"
|
|
end
|
|
end
|
|
|
|
test "unknown notification method still returns no content" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_notification("notifications/unknown").to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :no_content
|
|
assert response.body.blank?
|
|
end
|
|
end
|
|
|
|
# -- initialize --
|
|
|
|
test "initialize returns server info and capabilities" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("initialize", { protocolVersion: MCP_PROTOCOL_VERSION }).to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
result = body["result"]
|
|
|
|
assert_equal "2.0", body["jsonrpc"]
|
|
assert_equal 1, body["id"]
|
|
assert_mcp_initialize_response(result)
|
|
assert_equal "sure", result["serverInfo"]["name"]
|
|
assert result["capabilities"].key?("tools")
|
|
end
|
|
end
|
|
|
|
test "initialize echoes a supported legacy protocol version" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("initialize", { protocolVersion: "2025-03-26" }).to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
result = JSON.parse(response.body)["result"]
|
|
assert_equal "2025-03-26", result["protocolVersion"]
|
|
assert_equal "2025-03-26", response.headers["Mcp-Protocol-Version"]
|
|
assert_match(/\A[0-9a-f-]{36}\z/, result["sessionId"])
|
|
end
|
|
end
|
|
|
|
test "initialize negotiates unsupported protocol versions to the default version" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("initialize", { protocolVersion: "2099-01-01" }, id: 24).to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
assert_equal 24, body["id"]
|
|
assert_equal MCP_PROTOCOL_VERSION, body.dig("result", "protocolVersion")
|
|
assert_equal MCP_PROTOCOL_VERSION, response.headers["Mcp-Protocol-Version"]
|
|
end
|
|
end
|
|
|
|
# -- tools/list --
|
|
|
|
test "tools/list returns all assistant function tools" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("tools/list").to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
tools = body["result"]["tools"]
|
|
|
|
assert_kind_of Array, tools
|
|
assert_equal Assistant.function_classes(@user).size, tools.size
|
|
|
|
tool_names = tools.map { |t| t["name"] }
|
|
assert_includes tool_names, "get_transactions"
|
|
assert_includes tool_names, "get_accounts"
|
|
assert_includes tool_names, "get_holdings"
|
|
assert_includes tool_names, "get_balance_sheet"
|
|
assert_includes tool_names, "get_income_statement"
|
|
assert_includes tool_names, "update_transaction"
|
|
assert_includes tool_names, "update_budget"
|
|
|
|
# Each tool has required fields
|
|
tools.each do |tool|
|
|
assert tool["name"].present?, "Tool missing name"
|
|
assert tool["description"].present?, "Tool #{tool['name']} missing description"
|
|
assert tool["inputSchema"].present?, "Tool #{tool['name']} missing inputSchema"
|
|
assert_equal "object", tool["inputSchema"]["type"]
|
|
end
|
|
end
|
|
end
|
|
|
|
test "tools/list accepts and echoes a valid MCP session id" do
|
|
with_mcp_cache do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("initialize").to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
session_id = response.headers["Mcp-Session-Id"]
|
|
assert session_id.present?, "initialize should return an MCP session id"
|
|
|
|
post "/mcp", params: jsonrpc_request("tools/list").to_json,
|
|
headers: mcp_headers(@token).merge(
|
|
"Mcp-Protocol-Version" => MCP_PROTOCOL_VERSION,
|
|
"Mcp-Session-Id" => session_id
|
|
)
|
|
|
|
assert_response :ok
|
|
assert_equal MCP_PROTOCOL_VERSION, response.headers["Mcp-Protocol-Version"]
|
|
assert_equal session_id, response.headers["Mcp-Session-Id"]
|
|
assert_kind_of Array, JSON.parse(response.body).dig("result", "tools")
|
|
end
|
|
end
|
|
end
|
|
|
|
test "tools/list rejects an invalid MCP session id" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("tools/list", {}, id: 25).to_json,
|
|
headers: mcp_headers(@token).merge(
|
|
"Mcp-Protocol-Version" => MCP_PROTOCOL_VERSION,
|
|
"Mcp-Session-Id" => SecureRandom.uuid
|
|
)
|
|
|
|
assert_response :not_found
|
|
body = JSON.parse(response.body)
|
|
assert_equal 25, body["id"]
|
|
assert_equal(-32600, body["error"]["code"])
|
|
assert_includes body["error"]["message"], "Invalid MCP session id"
|
|
end
|
|
end
|
|
|
|
test "tools/list rejects unsupported MCP protocol version headers" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("tools/list", {}, id: 26).to_json,
|
|
headers: mcp_headers(@token).merge("Mcp-Protocol-Version" => "2099-01-01")
|
|
|
|
assert_response :bad_request
|
|
body = JSON.parse(response.body)
|
|
assert_equal 26, body["id"]
|
|
assert_equal(-32600, body["error"]["code"])
|
|
assert_includes body["error"]["message"], "2099-01-01"
|
|
end
|
|
end
|
|
|
|
test "tools/list omits preview tools for a user without preview features" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
|
|
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("tools/list").to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
tool_names = JSON.parse(response.body)["result"]["tools"].map { |t| t["name"] }
|
|
|
|
assert_includes tool_names, "get_transactions"
|
|
Assistant::PREVIEW_FUNCTION_CLASSES.each do |fn_class|
|
|
assert_not_includes tool_names, fn_class.name
|
|
end
|
|
end
|
|
end
|
|
|
|
test "tools/list includes preview tools for an opted-in user" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
|
|
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("tools/list").to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
tool_names = JSON.parse(response.body)["result"]["tools"].map { |t| t["name"] }
|
|
|
|
Assistant::PREVIEW_FUNCTION_CLASSES.each do |fn_class|
|
|
assert_includes tool_names, fn_class.name
|
|
end
|
|
end
|
|
end
|
|
|
|
# -- tools/call --
|
|
|
|
test "tools/call rejects a preview tool for a user without preview features" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
|
|
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("tools/call", { name: "list_account_statements", arguments: {} }, id: 42).to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
assert_equal(-32602, body["error"]["code"])
|
|
assert_includes body["error"]["message"], "list_account_statements"
|
|
end
|
|
end
|
|
|
|
test "tools/call returns error for unknown tool with request id preserved" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("tools/call", { name: "nonexistent_tool", arguments: {} }, id: 99).to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
assert_equal(-32602, body["error"]["code"])
|
|
assert_includes body["error"]["message"], "nonexistent_tool"
|
|
assert_equal 99, body["id"], "Error response must echo the request id"
|
|
end
|
|
end
|
|
|
|
test "tools/call executes get_balance_sheet" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("tools/call", {
|
|
name: "get_balance_sheet",
|
|
arguments: {}
|
|
}).to_json, headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
result = body["result"]
|
|
|
|
assert_kind_of Array, result["content"]
|
|
assert_equal "text", result["content"][0]["type"]
|
|
|
|
# The text field should be valid JSON
|
|
inner = JSON.parse(result["content"][0]["text"])
|
|
assert inner.key?("net_worth") || inner.key?("error"),
|
|
"Expected balance sheet data or error, got: #{inner.keys}"
|
|
end
|
|
end
|
|
|
|
# A vault payload is richer than the other tools' output — nested account hashes,
|
|
# BigDecimal balances, dates, a `.compact`ed hash — so these exercise a real
|
|
# record all the way out through tools/call's JSON envelope, rather than
|
|
# trusting that the unit-tested return value serializes cleanly.
|
|
test "tools/call round-trips a real vault payload through list_account_statements" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
|
|
statement = AccountStatement.create_from_upload!(
|
|
family: @user.family,
|
|
account: accounts(:depository),
|
|
file: uploaded_file(filename: "statement.csv", content_type: "text/csv", content: "date,amount\n2024-01-01,1\n")
|
|
)
|
|
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("tools/call", {
|
|
name: "list_account_statements",
|
|
arguments: {}
|
|
}).to_json, headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
result = JSON.parse(response.body)["result"]
|
|
assert_not result["isError"], "vault payload should not surface as a tool error"
|
|
|
|
inner = JSON.parse(result["content"][0]["text"])
|
|
assert inner["success"]
|
|
|
|
payload = inner["statements"].find { |s| s["id"] == statement.id }
|
|
assert_not_nil payload, "expected the created statement in the response"
|
|
assert_equal statement.content_sha256, payload["content_sha256"]
|
|
assert_equal accounts(:depository).id, payload.dig("account", "id")
|
|
end
|
|
end
|
|
|
|
test "tools/call round-trips an upload through upload_account_statement" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
|
|
content = "date,amount\n2024-02-01,7\n"
|
|
|
|
with_mcp_env do
|
|
assert_difference "AccountStatement.count", 1 do
|
|
post "/mcp", params: jsonrpc_request("tools/call", {
|
|
name: "upload_account_statement",
|
|
arguments: { filename: "uploaded.csv", content_base64: Base64.strict_encode64(content) }
|
|
}).to_json, headers: mcp_headers(@token)
|
|
end
|
|
|
|
assert_response :ok
|
|
result = JSON.parse(response.body)["result"]
|
|
assert_not result["isError"]
|
|
|
|
inner = JSON.parse(result["content"][0]["text"])
|
|
assert inner["success"]
|
|
assert_not inner["duplicate"]
|
|
assert_equal Digest::SHA256.hexdigest(content), inner.dig("statement", "content_sha256")
|
|
end
|
|
end
|
|
|
|
test "tools/call executes update_transaction" do
|
|
with_mcp_env do
|
|
transaction = transactions(:one)
|
|
category = categories(:subcategory)
|
|
|
|
post "/mcp", params: jsonrpc_request("tools/call", {
|
|
name: "update_transaction",
|
|
arguments: {
|
|
id: transaction.id,
|
|
category_id: category.id,
|
|
notes: "Updated through MCP"
|
|
}
|
|
}).to_json, headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
result = body["result"]
|
|
inner = JSON.parse(result["content"][0]["text"])
|
|
|
|
assert_equal true, inner["success"]
|
|
assert_equal category.id, transaction.reload.category_id
|
|
assert_equal "Updated through MCP", transaction.entry.notes
|
|
end
|
|
end
|
|
|
|
test "tools/call executes update_budget" do
|
|
with_mcp_env do
|
|
budget = budgets(:one)
|
|
|
|
post "/mcp", params: jsonrpc_request("tools/call", {
|
|
name: "update_budget",
|
|
arguments: {
|
|
budgeted_spending: 6200,
|
|
expected_income: 8800
|
|
}
|
|
}).to_json, headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
result = body["result"]
|
|
inner = JSON.parse(result["content"][0]["text"])
|
|
|
|
assert_equal true, inner["success"]
|
|
budget.reload
|
|
assert_equal 6200, budget.budgeted_spending
|
|
assert_equal 8800, budget.expected_income
|
|
end
|
|
end
|
|
|
|
test "tools/call wraps function errors as isError response" do
|
|
with_mcp_env do
|
|
# Force a function error by stubbing
|
|
Assistant::Function::GetBalanceSheet.any_instance.stubs(:call).raises(StandardError, "test error")
|
|
|
|
post "/mcp", params: jsonrpc_request("tools/call", {
|
|
name: "get_balance_sheet",
|
|
arguments: {}
|
|
}).to_json, headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
result = body["result"]
|
|
|
|
assert result["isError"], "Expected isError to be true"
|
|
inner = JSON.parse(result["content"][0]["text"])
|
|
|
|
# The raised text is written for a log, not for an external client: a
|
|
# RecordNotFound carries the access-control SQL and a range error carries
|
|
# the column definition. The client learns which tool failed; the detail
|
|
# stays server-side.
|
|
assert_equal "The tool failed to run", inner["error"]
|
|
assert_equal "get_balance_sheet", inner["tool"]
|
|
assert_no_match(/test error/, response.body)
|
|
end
|
|
end
|
|
|
|
# -- Session isolation --
|
|
|
|
test "does not persist sessions or inherit impersonation state" do
|
|
with_mcp_env do
|
|
assert_no_difference "Session.count" do
|
|
post "/mcp", params: jsonrpc_request("initialize").to_json,
|
|
headers: mcp_headers(@token)
|
|
end
|
|
|
|
assert_response :ok
|
|
end
|
|
end
|
|
|
|
# -- JSON-RPC id preservation --
|
|
|
|
test "preserves request id in successful response" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("initialize", {}, id: 42).to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
assert_equal 42, body["id"]
|
|
end
|
|
end
|
|
|
|
test "preserves string request id" do
|
|
with_mcp_env do
|
|
post "/mcp", params: jsonrpc_request("initialize", {}, id: "req-abc-123").to_json,
|
|
headers: mcp_headers(@token)
|
|
|
|
assert_response :ok
|
|
body = JSON.parse(response.body)
|
|
assert_equal "req-abc-123", body["id"]
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def with_mcp_env(&block)
|
|
with_env_overrides("MCP_API_TOKEN" => @token, "MCP_USER_EMAIL" => @user.email, &block) # pipelock:ignore
|
|
end
|
|
|
|
def with_mcp_cache
|
|
original_cache = Rails.cache
|
|
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
|
yield
|
|
ensure
|
|
Rails.cache = original_cache
|
|
end
|
|
|
|
def mcp_headers(token)
|
|
{
|
|
"Content-Type" => "application/json",
|
|
"Authorization" => "Bearer #{token}"
|
|
}
|
|
end
|
|
|
|
def jsonrpc_request(method, params = {}, id: 1)
|
|
{ jsonrpc: "2.0", id: id, method: method, params: params }
|
|
end
|
|
|
|
def jsonrpc_notification(method, params = {})
|
|
{ jsonrpc: "2.0", method: method, params: params }
|
|
end
|
|
|
|
def assert_mcp_initialize_response(result)
|
|
assert_equal MCP_PROTOCOL_VERSION, result["protocolVersion"]
|
|
assert_match(/\A[0-9a-f-]{36}\z/, result["sessionId"])
|
|
assert_equal MCP_PROTOCOL_VERSION, response.headers["Mcp-Protocol-Version"]
|
|
assert_equal result["sessionId"], response.headers["Mcp-Session-Id"]
|
|
end
|
|
end
|