mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-19 05:35:40 +08:00
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
191 lines
5.7 KiB
Ruby
Vendored
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
|