discourse/plugins/discourse-ai/app/models/ai_secret.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

93 lines
2.8 KiB
Ruby
Vendored

# frozen_string_literal: true
class AiSecret < ActiveRecord::Base
belongs_to :created_by, class_name: "User", optional: true
validates :name, presence: true, uniqueness: true, length: { maximum: 100 }
validates :secret, presence: true, length: { maximum: 10_000 }
has_many :llm_models, dependent: :nullify
has_many :embedding_definitions, dependent: :nullify
has_many :ai_tool_secret_bindings, dependent: :destroy
has_many :ai_mcp_servers, dependent: :nullify
has_many :oauth_client_secret_mcp_servers,
class_name: "AiMcpServer",
foreign_key: :oauth_client_secret_ai_secret_id,
dependent: :nullify
def in_use?
llm_models.exists? || embedding_definitions.exists? || used_by_provider_params? ||
ai_tool_secret_bindings.exists? || ai_mcp_servers.exists? ||
oauth_client_secret_mcp_servers.exists?
end
def used_by
usage = []
llm_models.each { |llm| usage << { type: "llm", name: llm.display_name, id: llm.id } }
embedding_definitions.each do |ed|
usage << { type: "embedding", name: ed.display_name, id: ed.id }
end
provider_param_llms.each do |llm|
usage << { type: "llm_provider_param", name: llm.display_name, id: llm.id }
end
ai_tool_secret_bindings
.includes(:ai_tool)
.each do |binding|
next if binding.ai_tool.blank?
usage << {
type: "tool",
name: "#{binding.ai_tool.name} (#{binding[:alias]})",
id: binding.ai_tool.id,
}
end
ai_mcp_servers.each do |server|
usage << { type: "mcp_server", name: server.name, id: server.id }
end
oauth_client_secret_mcp_servers.each do |server|
usage << { type: "mcp_server_oauth_client_secret", name: server.name, id: server.id }
end
usage
end
private
def used_by_provider_params?
provider_param_llms.exists?
end
def provider_param_llms
secret_keys =
LlmModel.provider_params.flat_map do |provider, params|
params.filter_map { |key, type| [provider, key] if type == :secret }
end
return LlmModel.none if secret_keys.empty?
sql_conditions = []
bind_values = []
secret_keys.each do |provider, key|
sql_conditions << "(provider = ? AND provider_params ->> ? = ?)"
bind_values << provider.to_s << key.to_s << id.to_s
end
LlmModel.where(sql_conditions.join(" OR "), *bind_values).where("llm_models.id > 0")
end
end
# == Schema Information
#
# Table name: ai_secrets
#
# id :bigint not null, primary key
# name :string(100) not null
# secret :string(10000) not null
# created_at :datetime not null
# updated_at :datetime not null
# created_by_id :integer
#
# Indexes
#
# index_ai_secrets_on_name (name) UNIQUE
#