discourse/plugins/discourse-ai/spec/models/ai_secret_spec.rb
Sam b2d73b346d
FEATURE: Add MCP server integration to AI agents (#38706)
Introduce support for Model Context Protocol (MCP) servers in the
discourse-ai plugin, allowing AI agents to connect to external tool
servers via the MCP standard.

Key additions:
- AiMcpServer model with CRUD admin UI, health tracking, and
  tool caching (hourly refresh via scheduled job)
- MCP client (Streamable HTTP transport) with session management
  and tool invocation
- Full OAuth 2.1 flow support (discovery, dynamic registration,
  authorization code grant, token refresh, and disconnect)
- MCP tool type for AI agents that proxies tool calls to remote
  MCP servers at runtime
- Agent editor updated to show combined tool/token counts from
  both local tools and MCP servers
- Agent import/export includes MCP server associations
- Admin secrets UI updated to surface MCP server usage
- Comprehensive specs for models, controllers, client, tool
  registry, and OAuth flow
2026-03-25 17:32:27 +11:00

191 lines
5.7 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe AiSecret do
fab!(:ai_secret)
describe "validations" do
it "requires a name" do
secret = AiSecret.new(secret: "test")
expect(secret).not_to be_valid
expect(secret.errors[:name]).to be_present
end
it "requires a secret" do
secret = AiSecret.new(name: "test")
expect(secret).not_to be_valid
expect(secret.errors[:secret]).to be_present
end
it "requires unique name" do
AiSecret.create!(name: "unique-name", secret: "test")
duplicate = AiSecret.new(name: "unique-name", secret: "other")
expect(duplicate).not_to be_valid
expect(duplicate.errors[:name]).to be_present
end
it "enforces max name length" do
secret = AiSecret.new(name: "a" * 101, secret: "test")
expect(secret).not_to be_valid
end
end
describe "#in_use?" do
it "returns false when not referenced" do
expect(ai_secret.in_use?).to eq(false)
end
it "returns true when used by an llm_model" do
Fabricate(:llm_model, ai_secret: ai_secret)
expect(ai_secret.in_use?).to eq(true)
end
it "returns true when used by an embedding_definition" do
Fabricate(:embedding_definition, ai_secret: ai_secret)
expect(ai_secret.in_use?).to eq(true)
end
it "returns true when referenced in provider_params as string" do
Fabricate(
:llm_model,
provider: "aws_bedrock",
url: "",
provider_params: {
region: "us-east-1",
access_key_id: ai_secret.id.to_s,
},
)
expect(ai_secret.in_use?).to eq(true)
end
it "returns true when referenced in provider_params as numeric" do
llm = Fabricate(:bedrock_model)
DB.exec(
<<~SQL,
UPDATE llm_models SET provider_params = :params::jsonb WHERE id = :id
SQL
id: llm.id,
params: { region: "us-east-1", access_key_id: ai_secret.id }.to_json,
)
expect(ai_secret.in_use?).to eq(true)
end
it "does not false-match unrelated provider_params values" do
other_secret = Fabricate(:ai_secret)
Fabricate(
:llm_model,
provider: "aws_bedrock",
url: "",
provider_params: {
region: "us-east-1",
access_key_id: other_secret.id.to_s,
},
)
expect(ai_secret.in_use?).to eq(false)
end
it "returns true when used by a tool binding" do
tool = Fabricate(:ai_tool, secret_contracts: [{ alias: "external_api_key" }])
AiToolSecretBinding.create!(
ai_tool: tool,
alias: "external_api_key",
ai_secret_id: ai_secret.id,
)
expect(ai_secret.in_use?).to eq(true)
end
it "returns true when used by an mcp server" do
Fabricate(:ai_mcp_server, ai_secret: ai_secret)
expect(ai_secret.in_use?).to eq(true)
end
it "returns true when used as an OAuth client secret for an mcp server" do
Fabricate(
:ai_mcp_server,
auth_type: "oauth",
oauth_client_registration: "manual",
oauth_client_secret: ai_secret,
oauth_client_id: "manual-client",
)
expect(ai_secret.in_use?).to eq(true)
end
end
describe "ai_secret_id existence validation" do
it "rejects non-existent ai_secret_id on LlmModel" do
llm = Fabricate.build(:llm_model, api_key: nil, ai_secret_id: -999)
expect(llm).not_to be_valid
expect(llm.errors[:ai_secret_id]).to be_present
end
it "rejects non-existent ai_secret_id on EmbeddingDefinition" do
embedding = Fabricate.build(:embedding_definition, api_key: nil, ai_secret_id: -999)
expect(embedding).not_to be_valid
expect(embedding.errors[:ai_secret_id]).to be_present
end
it "accepts valid ai_secret_id on LlmModel" do
llm = Fabricate.build(:llm_model, api_key: nil, ai_secret: ai_secret)
expect(llm).to be_valid
end
end
describe "#used_by" do
it "lists all models using this secret" do
llm = Fabricate(:llm_model, ai_secret: ai_secret)
embedding = Fabricate(:embedding_definition, ai_secret: ai_secret)
usage = ai_secret.used_by
expect(usage.length).to eq(2)
expect(usage.map { |u| u[:type] }).to contain_exactly("llm", "embedding")
end
it "lists tools using this secret" do
tool =
Fabricate(:ai_tool, name: "Weather Tool", secret_contracts: [{ alias: "weather_api_key" }])
AiToolSecretBinding.create!(
ai_tool: tool,
alias: "weather_api_key",
ai_secret_id: ai_secret.id,
)
usage = ai_secret.used_by
tool_usage = usage.find { |u| u[:type] == "tool" }
expect(tool_usage).to be_present
expect(tool_usage[:name]).to include("Weather Tool")
end
it "lists mcp servers using this secret" do
server = Fabricate(:ai_mcp_server, name: "Jira", ai_secret: ai_secret)
usage = ai_secret.used_by
server_usage = usage.find { |u| u[:type] == "mcp_server" }
expect(server_usage).to be_present
expect(server_usage[:id]).to eq(server.id)
expect(server_usage[:name]).to eq("Jira")
end
it "lists mcp servers using this secret as an OAuth client secret" do
server =
Fabricate(
:ai_mcp_server,
name: "OAuth Jira",
auth_type: "oauth",
oauth_client_registration: "manual",
oauth_client_secret: ai_secret,
oauth_client_id: "manual-client",
)
usage = ai_secret.used_by
server_usage = usage.find { |u| u[:type] == "mcp_server_oauth_client_secret" }
expect(server_usage).to be_present
expect(server_usage[:id]).to eq(server.id)
expect(server_usage[:name]).to eq("OAuth Jira")
end
end
end