diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentStore.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentStore.cs new file mode 100644 index 00000000..1152310e --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentStore.cs @@ -0,0 +1,12 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.Services; + +namespace CrestApps.Core.AI.Deployments; + +/// +/// Provides persisted storage for AI deployments while preserving the standard +/// named-and-sourced catalog operations used by deployment managers and editors. +/// +public interface IAIDeploymentStore : INamedSourceCatalog +{ +} diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md index b545fd30..97262e73 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -25,3 +25,6 @@ description: Initial standalone release notes for the CrestApps.Core repository. - evaluates every configured AI connection and deployment section when importing appsettings records, including provider-grouped connection sections and deployment entries that reference shared `ConnectionName` values - adds shared `JsonNode` support extensions for common string, boolean, and raw-value extraction so AI configuration parsing and Elasticsearch document readers reuse one implementation instead of duplicating private helpers - replaces removed obsolete connection-level deployment-name helpers with non-obsolete legacy lookup extensions for `AIProviderConnectionEntry`, keeping backward-compatible fallback resolution without depending on deleted APIs +- clarifies deployment-store registration by introducing `IAIDeploymentStore` for persisted deployments, moves Chat Interactions ahead of AI Profiles / AI Chat in the MVC sample onboarding flow, and adds dedicated AI Profile documentation that explains how profiles power reusable chat, agents, orchestration, retrieval, and session processing +- centralizes reusable MCP runtime registration in `AddCoreAIMcpServices()`, moves the shared MCP metadata, capability-resolution, tool-registry, SSE settings-handler, and invoke-function services into `CrestApps.Core.AI.Mcp`, and splits optional StdIO transport registration so hosts can enable it only where needed +- treats aborted and canceled request-stream failures in the Aspire AppHost as observed task exceptions so local development no longer floods the console with benign unobserved-task noise diff --git a/src/CrestApps.Core.Docs/docs/core/ai-core.md b/src/CrestApps.Core.Docs/docs/core/ai-core.md index bb57c1a4..a7a2b371 100644 --- a/src/CrestApps.Core.Docs/docs/core/ai-core.md +++ b/src/CrestApps.Core.Docs/docs/core/ai-core.md @@ -25,6 +25,14 @@ AI applications need to work with multiple LLM providers (OpenAI, Azure, Ollama, ## Core Concepts +### AI Profile + +An **AI Profile** is the reusable runtime definition that ties deployments, prompts, orchestration, tools, retrieval, memory, and session behavior together. It is the main contract used by higher-level features such as AI Chat and agents. + +Use Chat Interactions when you want fast ad hoc testing. Use an AI Profile when you want a named, reusable experience that multiple sessions, users, or orchestrators can share. + +See [AI Profiles](./ai-profiles.md) for the full conceptual model and guidance. + ### Deployment A **deployment** maps a logical name to a specific model on a specific provider connection. For example, deployment `"gpt-4o"` might map to the `gpt-4o` model on your OpenAI connection. The orchestrator resolves deployments at runtime using a fallback chain: @@ -129,7 +137,7 @@ public interface IAICompletionClient ### `AIOptions` -Central options class for registering profile sources, deployment providers, connection sources, and template sources. +Central options class for registering profile sources, deployment providers, connection sources, and template sources. By default, connections are loaded from `CrestApps:AI:Connections` and deployments are loaded from `CrestApps:AI:Deployments`. ```csharp services.Configure(options => diff --git a/src/CrestApps.Core.Docs/docs/core/ai-profiles.md b/src/CrestApps.Core.Docs/docs/core/ai-profiles.md new file mode 100644 index 00000000..80347e41 --- /dev/null +++ b/src/CrestApps.Core.Docs/docs/core/ai-profiles.md @@ -0,0 +1,192 @@ +--- +sidebar_label: AI Profiles +sidebar_position: 4 +title: AI Profiles +description: Understand AI Profiles as the reusable runtime contract that powers chat, agents, orchestration, memory, and retrieval across CrestApps.Core. +--- + +# AI Profiles + +> The reusable contract that tells CrestApps.Core **how an AI experience should behave**, not just which model to call. + +An **AI Profile** is the main composition unit for higher-level AI features in `CrestApps.Core`. It groups the instructions, deployments, orchestrator choice, tools, knowledge, and session-processing rules that define a reusable AI experience. + +If a deployment answers **"which model should run?"**, an AI Profile answers **"how should this experience behave from start to finish?"** + +## Why AI Profiles matter + +Profiles are used across many parts of the framework because they let you define AI behavior once and reuse it consistently: + +- **AI Chat** uses a profile as the session contract for reusable conversations +- **Agents** use profiles to describe specialized behavior and routing intent +- **Orchestration** reads the profile to decide how prompts, tools, and downstream steps should run +- **Knowledge-aware chat** uses profile-attached documents and data sources for retrieval +- **Memory and analytics** use profile settings to control long-lived personalization and post-session processing +- **Templates** can prefill or stamp profile behavior so teams do not repeat the same configuration manually + +## AI Profile vs. other AI building blocks + +| Concept | Purpose | Best way to think about it | +| --- | --- | --- | +| **AI Connection** | Stores provider credentials and endpoint details | "How do I talk to a provider?" | +| **AI Deployment** | Maps a logical deployment name to a concrete model on a provider/connection | "Which model should be used?" | +| **Chat Interactions** | Playground-style or ad hoc conversations with directly chosen parameters | "Let me test this setup quickly." | +| **AI Profile** | Reusable runtime behavior for chat, agents, orchestration, knowledge, and processing | "How should this AI experience behave?" | +| **AI Chat** | Session-driven chat experience built around a selected profile | "Run ongoing conversations from this reusable profile." | + +## When to use Chat Interactions vs. AI Profiles + +Start with **Chat Interactions** when you want the fastest validation path for a new provider connection and deployment. + +Move to **AI Profiles** when you want any of the following: + +- a reusable system prompt or welcome experience +- a stable deployment choice for repeated sessions +- orchestration and tool usage +- knowledge retrieval from documents or data sources +- memory, analytics, extraction, or post-session behavior +- agent-style routing or specialized assistant identities + +## What an AI Profile contains + +The exact fields depend on enabled features, but a profile can act as the home for: + +### 1. Identity and purpose + +- technical name +- display title +- profile type +- description, especially for agent profiles + +This gives the runtime and UI a stable identity for the experience. + +### 2. Deployment selection + +A profile can point to: + +- a **chat deployment** for primary conversational responses +- a **utility deployment** for supporting tasks such as planning, extraction, or summarization + +That lets the same profile use different models for different responsibilities. + +### 3. Prompt and conversation behavior + +Profiles can define: + +- system instructions +- welcome message +- initial assistant prompt +- prompt subject +- prompt templates +- completion settings such as temperature, top-p, penalties, token limits, and past-message depth + +This is where you shape tone, constraints, and conversation style. + +### 4. Orchestration and tool usage + +Profiles can select: + +- an orchestrator +- local tools +- agent references +- remote A2A connections +- remote MCP connections + +This is why profiles are broader than plain chat presets. They can define how the AI experience coordinates work, not just how it talks. + +### 5. Knowledge and retrieval + +Profiles can be linked to: + +- uploaded profile documents +- session document behavior +- index-backed data sources +- retrieval tuning such as strictness, top-N, scope, and filters + +This makes the profile the reusable knowledge boundary for RAG-oriented experiences. + +### 6. Session and outcome processing + +Profiles can enable: + +- extracted data definitions +- session metrics +- AI resolution detection +- conversion goals +- post-session processing tasks + +That turns a profile into more than a prompt container. It becomes the contract for what should happen during and after a session. + +### 7. Memory and personalization + +Profiles can opt into user memory so experiences can carry durable context forward between sessions instead of starting from zero every time. + +## Profile types + +`AIProfile.Type` lets one model support different runtime roles. + +Common examples: + +- **Chat** for reusable conversational assistants +- **Agent** for specialized routed behavior that an orchestrator can call when appropriate +- **TemplatePrompt** when the profile is oriented around prompt generation or reusable prompt-driven tasks + +The important idea is that the profile type changes how the framework interprets and uses the same underlying profile record. + +## Typical lifecycle + +1. Create a provider connection. +2. Create one or more deployments. +3. Use **Chat Interactions** to verify the model behaves correctly. +4. Create an AI Profile once you want a reusable behavior contract. +5. Attach tools, documents, data sources, memory, or post-session rules as needed. +6. Use the profile from AI Chat, agents, orchestrators, or other runtime features. + +## Practical examples + +### Example 1: Reusable support assistant + +Use an AI Profile when you want: + +- a fixed support tone +- a shared knowledge base +- extracted contact or issue fields +- post-session resolution analysis + +This profile can then power every support chat session consistently. + +### Example 2: Specialized agent + +Use an AI Profile when you want: + +- a description that explains what the agent is good at +- a specific deployment and tool set +- orchestration-based routing into that agent + +The profile becomes the unit the orchestrator can reason about and invoke. + +### Example 3: Knowledge-aware internal assistant + +Use an AI Profile when you want: + +- indexed data sources +- attached profile documents +- stricter retrieval settings +- user memory for returning employees + +That profile can then serve as a reusable internal assistant instead of rebuilding the configuration per session. + +## Design guidance + +- Use **deployments** to separate model selection from behavior. +- Use **profiles** to capture reusable behavior and lifecycle rules. +- Use **Chat Interactions** for fast testing and experimentation. +- Use **AI Chat** when you want repeatable session-based experiences built on top of a profile. + +## Related docs + +- [AI Core](./ai-core.md) +- [Chat Interactions](./chat.md) +- [AI Templates](./ai-templates.md) +- [AI Agents](./agents.md) +- [MVC Example](./mvc-example.md) diff --git a/src/CrestApps.Core.Docs/docs/core/chat.md b/src/CrestApps.Core.Docs/docs/core/chat.md index 64ea22e4..688ecec8 100644 --- a/src/CrestApps.Core.Docs/docs/core/chat.md +++ b/src/CrestApps.Core.Docs/docs/core/chat.md @@ -9,7 +9,7 @@ description: Chat session management, interaction handlers, and response routing > Manages chat sessions, routes responses through pluggable handlers, and tracks interaction history. -If you want the easiest playground-style UI for a new host, start here after you have one provider connection, one deployment, and one AI profile configured. +If you want the easiest playground-style UI for a new host, start here after you have one provider connection and one deployment configured. Unlike AI Chat, Chat Interactions do not require an AI Profile to get started. ## Quick Start @@ -22,6 +22,8 @@ builder.Services.AddCrestAppsCore(crestApps => crestApps By default, connections are discovered from `CrestApps:AI:Connections` and deployments are discovered from `CrestApps:AI:Deployments`. Connection-based deployments can reference a shared `ConnectionName`, while contained-connection deployments can embed provider-specific settings directly in the deployment entry. +When you are ready to turn an ad hoc interaction into a reusable runtime contract, move that setup into an [AI Profile](./ai-profiles.md). + ## Problem & Solution A chat experience involves more than sending messages to an LLM: diff --git a/src/CrestApps.Core.Docs/docs/core/index.md b/src/CrestApps.Core.Docs/docs/core/index.md index 180c5401..a49b6f97 100644 --- a/src/CrestApps.Core.Docs/docs/core/index.md +++ b/src/CrestApps.Core.Docs/docs/core/index.md @@ -48,7 +48,7 @@ By default: - connections are loaded from `CrestApps:AI:Connections` - deployments are loaded from `CrestApps:AI:Deployments` -The quickest way to validate the setup is to create an AI profile and use **Chat Interactions** as your first playground-style UI. +The quickest way to validate the setup is to use **Chat Interactions** first, then create an [AI Profile](./ai-profiles.md) when you want reusable chat, agent, or orchestration behavior. ## Package map diff --git a/src/CrestApps.Core.Docs/docs/mcp/client.md b/src/CrestApps.Core.Docs/docs/mcp/client.md index d3cff9ae..3f19e5ab 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/client.md +++ b/src/CrestApps.Core.Docs/docs/mcp/client.md @@ -18,7 +18,9 @@ builder.Services .AddCoreAIMcpClient(); ``` -This registers transport providers, OAuth2 support, the core `McpService` that manages connections to remote MCP servers, and the shared AI-profile completion-context handler that flows selected MCP connection IDs into the completion request. +This registers the shared MCP runtime services, the default SSE and StdIO transport providers, the core `McpService` that manages connections to remote MCP servers, the MCP tool-registry provider, and the shared AI-profile completion-context handler that flows selected MCP connection IDs into the completion request. + +If your host needs the shared MCP runtime registrations without automatically enabling the StdIO transport, call `AddCoreAIMcpServices()` and then opt into transports explicitly, or call `AddCoreAIMcpClient(includeStdIoTransport: false)`. ## Problem & Solution @@ -40,9 +42,14 @@ The MCP client framework: |---------|---------------|----------|---------| | `McpService` | — | Scoped | Creates MCP clients for configured connections | | `IOAuth2TokenService` | `DefaultOAuth2TokenService` | Scoped | OAuth2 token acquisition and caching | +| `IMcpMetadataPromptGenerator` | `DefaultMcpMetadataPromptGenerator` | Singleton | Builds prompt text from remote MCP metadata | +| `IMcpCapabilityEmbeddingCacheProvider` | `InMemoryMcpCapabilityEmbeddingCacheProvider` | Singleton | Caches capability embeddings for hybrid MCP resolution | +| `IMcpServerMetadataCacheProvider` | `DefaultMcpServerMetadataProvider` | Scoped | Loads and caches remote MCP server metadata | +| `IMcpCapabilityResolver` | `DefaultMcpCapabilityResolver` | Scoped | Resolves likely MCP capabilities for a prompt | | `IMcpClientTransportProvider` | `SseClientTransportProvider` | Scoped | Server-Sent Events transport | | `IMcpClientTransportProvider` | `StdioClientTransportProvider` | Scoped | Standard I/O transport | | `IAICompletionContextBuilderHandler` | `McpAICompletionContextBuilderHandler` | Scoped | Copies selected MCP connection IDs from AI profile metadata into the completion context | +| `IToolRegistryProvider` | `McpToolRegistryProvider` | Scoped | Publishes remote MCP tools into the AI tool registry | Two transport types are automatically registered in `McpClientAIOptions`: diff --git a/src/CrestApps.Core.Docs/docs/mcp/server.md b/src/CrestApps.Core.Docs/docs/mcp/server.md index 678204af..46f39639 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/server.md +++ b/src/CrestApps.Core.Docs/docs/mcp/server.md @@ -22,6 +22,8 @@ builder.Services `AddCoreAIMcpServer()` registers the shared prompt and resource services. FTP and SFTP resource handlers now live in the optional `CrestApps.Core.AI.Ftp` and `CrestApps.Core.AI.Sftp` packages, so hosts opt into those transport dependencies explicitly. +When the same host also acts as an MCP client, use `AddCoreAIMcpServices()` or `AddCoreAIMcpClient(...)` once for the shared runtime pieces, then layer `AddCoreAIMcpServer()` on top for the prompt and resource services. + ## Problem & Solution External AI clients — IDE assistants, chat agents, orchestration frameworks — need a standardized way to discover and call your application's tools, read your prompts, and access your resources. The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) provides that standard. diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/CrestApps.Core.AI.Mcp.csproj b/src/Primitives/CrestApps.Core.AI.Mcp/CrestApps.Core.AI.Mcp.csproj index ec93d284..7589f490 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/CrestApps.Core.AI.Mcp.csproj +++ b/src/Primitives/CrestApps.Core.AI.Mcp/CrestApps.Core.AI.Mcp.csproj @@ -21,6 +21,8 @@ Model Context Protocol (MCP) implementation for CrestApps AI services. + + diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpInvokeFunction.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpInvokeFunction.cs new file mode 100644 index 00000000..25f4887c --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpInvokeFunction.cs @@ -0,0 +1,289 @@ +using System.Text.Json; +using CrestApps.Core.AI.Mcp.Models; +using CrestApps.Core.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; + +namespace CrestApps.Core.AI.Mcp.Functions; + +internal sealed class McpInvokeFunction : AIFunction +{ + public const string FunctionName = "mcp_invoke"; + + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize( + """ + { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "description": "The MCP server connection identifier." + }, + "type": { + "type": "string", + "enum": ["tool", "prompt", "resource"], + "description": "The type of MCP capability to invoke." + }, + "id": { + "type": "string", + "description": "For tools and prompts, this is the capability name. For resources, this MUST be the fully-resolved resource URI." + }, + "inputs": { + "type": "object", + "description": "The input arguments for the invocation. For tools, these MUST match the tool's Parameters schema exactly." + } + }, + "required": ["clientId", "type", "id"], + "additionalProperties": false + } + """); + + public override string Name => FunctionName; + public override string Description => "Invoke an MCP server capability (tool, prompt, or resource) by specifying the server, capability type, and identifier."; + public override JsonElement JsonSchema => _jsonSchema; + + public override IReadOnlyDictionary AdditionalProperties { get; } = new Dictionary + { + ["Strict"] = false, + }; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(arguments.Services); + + var logger = arguments.Services.GetRequiredService>(); + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("AI tool '{ToolName}' invoked.", Name); + } + + var clientId = GetRequiredStringArgument(arguments, "clientId"); + var type = GetRequiredStringArgument(arguments, "type"); + var id = GetRequiredStringArgument(arguments, "id"); + var inputs = GetOptionalObjectArgument(arguments, "inputs"); + + var store = arguments.Services.GetRequiredService>(); + var connection = await store.FindByIdAsync(clientId); + + if (connection is null) + { + logger.LogWarning("AI tool '{ToolName}' failed: MCP connection '{ClientId}' not found.", Name, clientId); + return JsonSerializer.Serialize(new { error = $"MCP connection '{clientId}' not found." }); + } + + var mcpService = arguments.Services.GetRequiredService(); + var client = await mcpService.GetOrCreateClientAsync(connection); + + if (client is null) + { + logger.LogWarning("AI tool '{ToolName}' failed: could not connect to MCP server '{ClientId}'.", Name, clientId); + return JsonSerializer.Serialize(new { error = $"Failed to connect to MCP server '{clientId}'." }); + } + + try + { + var content = type.ToLowerInvariant() switch + { + "tool" => await InvokeToolAsync(client, id, inputs, cancellationToken), + "prompt" => await InvokePromptAsync(client, id, inputs, cancellationToken), + "resource" => await InvokeResourceAsync(client, id, cancellationToken), + _ => JsonSerializer.Serialize(new { error = $"Unknown capability type '{type}'. Use 'tool', 'prompt', or 'resource'." }), + }; + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("AI tool '{ToolName}' completed.", Name); + } + + return content; + } + catch (Exception ex) + { + logger.LogError(ex, "Error invoking MCP capability '{Type}/{Id}' on server '{ClientId}'.", type, id, clientId); + return JsonSerializer.Serialize(new { error = "Error invoking MCP capability." }); + } + } + + private static async Task InvokeToolAsync( + McpClient client, + string toolName, + Dictionary inputs, + CancellationToken cancellationToken) + { + var args = new Dictionary(); + + if (inputs is not null) + { + foreach (var kvp in inputs) + { + args[kvp.Key] = kvp.Value is JsonElement jsonElement ? ConvertJsonElement(jsonElement) : kvp.Value; + } + } + + var result = await client.CallToolAsync(toolName, args, cancellationToken: cancellationToken); + return JsonSerializer.Serialize(result); + } + + private static async Task InvokePromptAsync( + McpClient client, + string promptName, + Dictionary inputs, + CancellationToken cancellationToken) + { + IReadOnlyDictionary args = null; + + if (inputs is not null && inputs.Count > 0) + { + args = inputs; + } + + var result = await client.GetPromptAsync(promptName, args, cancellationToken: cancellationToken); + return JsonSerializer.Serialize(result); + } + + private static async Task InvokeResourceAsync(McpClient client, string resourceUri, CancellationToken cancellationToken) + { + if (!resourceUri.Contains("://", StringComparison.Ordinal)) + { + var resolvedUri = await TryResolveResourceUriByNameAsync(client, resourceUri, cancellationToken); + + if (resolvedUri is not null) + { + resourceUri = resolvedUri; + } + } + + var result = await client.ReadResourceAsync(resourceUri, cancellationToken: cancellationToken); + return JsonSerializer.Serialize(result); + } + + private static async Task TryResolveResourceUriByNameAsync(McpClient client, string name, CancellationToken cancellationToken) + { + try + { + var resources = await client.ListResourcesAsync(cancellationToken: cancellationToken); + var match = resources.FirstOrDefault(resource => string.Equals(resource.Name, name, StringComparison.OrdinalIgnoreCase)); + return match?.Uri; + } + catch + { + return null; + } + } + + private static string GetRequiredStringArgument(AIFunctionArguments arguments, string name) + { + if (arguments.TryGetValue(name, out var value) && value is not null) + { + var stringValue = value switch + { + string text => text, + JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.String => jsonElement.GetString(), + _ => value.ToString(), + }; + + if (!string.IsNullOrEmpty(stringValue)) + { + return stringValue; + } + } + + throw new ArgumentException($"Required argument '{name}' is missing or empty."); + } + + private static Dictionary GetOptionalObjectArgument(AIFunctionArguments arguments, string name) + { + if (!arguments.TryGetValue(name, out var value) || value is null) + { + return null; + } + + if (value is Dictionary dictionary) + { + return dictionary; + } + + if (value is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.Object) + { + var result = new Dictionary(); + + foreach (var property in jsonElement.EnumerateObject()) + { + result[property.Name] = property.Value; + } + + return result; + } + + if (jsonElement.ValueKind == JsonValueKind.String) + { + try + { + using var parsed = JsonDocument.Parse(jsonElement.GetString()); + + if (parsed.RootElement.ValueKind == JsonValueKind.Object) + { + var result = new Dictionary(); + + foreach (var property in parsed.RootElement.EnumerateObject()) + { + result[property.Name] = property.Value; + } + + return result; + } + } + catch (JsonException) + { + } + } + } + + if (value is string text && text.TrimStart().StartsWith('{')) + { + try + { + using var parsed = JsonDocument.Parse(text); + + if (parsed.RootElement.ValueKind == JsonValueKind.Object) + { + var result = new Dictionary(); + + foreach (var property in parsed.RootElement.EnumerateObject()) + { + result[property.Name] = property.Value; + } + + return result; + } + } + catch (JsonException) + { + } + } + + return null; + } + + private static object ConvertJsonElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number when element.TryGetInt64(out var integerValue) => integerValue, + JsonValueKind.Number => element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElement).ToList(), + JsonValueKind.Object => element.EnumerateObject().ToDictionary(property => property.Name, property => ConvertJsonElement(property.Value)), + _ => element.GetRawText(), + }; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpToolProxyFunction.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpToolProxyFunction.cs new file mode 100644 index 00000000..61c7c849 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpToolProxyFunction.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using CrestApps.Core.AI.Mcp.Models; +using CrestApps.Core.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Mcp.Functions; + +internal sealed class McpToolProxyFunction : AIFunction +{ + private readonly string _name; + private readonly string _description; + private readonly JsonElement _jsonSchema; + private readonly string _connectionId; + + public McpToolProxyFunction(string name, string description, JsonElement jsonSchema, string connectionId) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(connectionId); + + _name = name; + _description = description ?? name; + _jsonSchema = jsonSchema; + _connectionId = connectionId; + } + + public override string Name => _name; + public override string Description => _description; + public override JsonElement JsonSchema => _jsonSchema; + public override IReadOnlyDictionary AdditionalProperties { get; } = new Dictionary + { + ["Strict"] = false, + }; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(arguments.Services); + + var logger = arguments.Services.GetRequiredService>(); + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("AI tool '{ToolName}' invoked.", Name); + } + + var store = arguments.Services.GetRequiredService>(); + var connection = await store.FindByIdAsync(_connectionId); + + if (connection is null) + { + logger.LogWarning("AI tool '{ToolName}' failed: MCP connection '{ConnectionId}' not found.", Name, _connectionId); + return JsonSerializer.Serialize(new { error = $"MCP connection '{_connectionId}' not found." }); + } + + var mcpService = arguments.Services.GetRequiredService(); + var client = await mcpService.GetOrCreateClientAsync(connection); + + if (client is null) + { + logger.LogWarning("AI tool '{ToolName}' failed: could not connect to MCP server '{ConnectionId}'.", Name, _connectionId); + return JsonSerializer.Serialize(new { error = $"Failed to connect to MCP server '{_connectionId}'." }); + } + + try + { + var args = new Dictionary(); + + foreach (var kvp in arguments) + { + if (kvp.Value is JsonElement jsonElement) + { + args[kvp.Key] = ConvertJsonElement(jsonElement); + } + else if (kvp.Value is not null) + { + args[kvp.Key] = kvp.Value; + } + } + + var result = await client.CallToolAsync(_name, args, cancellationToken: cancellationToken); + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("AI tool '{ToolName}' completed.", Name); + } + + return JsonSerializer.Serialize(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Error invoking MCP tool '{ToolName}' on server '{ConnectionId}'.", _name, _connectionId); + return JsonSerializer.Serialize(new { error = $"Error invoking MCP tool '{_name}'." }); + } + } + + private static object ConvertJsonElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number when element.TryGetInt64(out var integerValue) => integerValue, + JsonValueKind.Number => element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElement).ToList(), + JsonValueKind.Object => element.EnumerateObject().ToDictionary(property => property.Name, property => ConvertJsonElement(property.Value)), + _ => element.GetRawText(), + }; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/SseMcpConnectionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/SseMcpConnectionSettingsHandler.cs new file mode 100644 index 00000000..7aa8ecce --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/SseMcpConnectionSettingsHandler.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Nodes; +using CrestApps.Core.AI.Mcp.Models; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using Microsoft.AspNetCore.DataProtection; + +namespace CrestApps.Core.AI.Mcp.Handlers; + +internal sealed class SseMcpConnectionSettingsHandler : CatalogEntryHandlerBase +{ + private readonly IDataProtectionProvider _dataProtectionProvider; + + public SseMcpConnectionSettingsHandler(IDataProtectionProvider dataProtectionProvider) + { + _dataProtectionProvider = dataProtectionProvider; + } + + public override Task InitializingAsync(InitializingContext context) + => ProtectSensitiveFieldsAsync(context.Model, context.Data); + + public override Task UpdatingAsync(UpdatingContext context) + => ProtectSensitiveFieldsAsync(context.Model, context.Data); + + private Task ProtectSensitiveFieldsAsync(McpConnection connection, JsonNode data) + { + if (!string.Equals(connection.Source, McpConstants.TransportTypes.Sse, StringComparison.Ordinal)) + { + return Task.CompletedTask; + } + + var metadataNode = data[nameof(McpConnection.Properties)]?[nameof(SseMcpConnectionMetadata)]?.AsObject(); + + if (metadataNode is null || metadataNode.Count == 0) + { + return Task.CompletedTask; + } + + var protector = _dataProtectionProvider.CreateProtector(McpConstants.DataProtectionPurpose); + var metadata = connection.As(); + + ProtectField(protector, metadataNode, nameof(SseMcpConnectionMetadata.ApiKey), value => metadata.ApiKey = value); + ProtectField(protector, metadataNode, nameof(SseMcpConnectionMetadata.BasicPassword), value => metadata.BasicPassword = value); + ProtectField(protector, metadataNode, nameof(SseMcpConnectionMetadata.OAuth2ClientSecret), value => metadata.OAuth2ClientSecret = value); + ProtectField(protector, metadataNode, nameof(SseMcpConnectionMetadata.OAuth2PrivateKey), value => metadata.OAuth2PrivateKey = value); + ProtectField(protector, metadataNode, nameof(SseMcpConnectionMetadata.OAuth2ClientCertificate), value => metadata.OAuth2ClientCertificate = value); + ProtectField(protector, metadataNode, nameof(SseMcpConnectionMetadata.OAuth2ClientCertificatePassword), value => metadata.OAuth2ClientCertificatePassword = value); + + connection.Put(metadata); + + return Task.CompletedTask; + } + + private static void ProtectField(IDataProtector protector, JsonObject node, string fieldName, Action setter) + { + var value = node[fieldName]?.GetValue(); + + if (!string.IsNullOrWhiteSpace(value)) + { + setter(protector.Protect(value)); + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs index 7e22047a..529d821b 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs @@ -1,8 +1,11 @@ using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Mcp.Functions; using CrestApps.Core.AI.Mcp.Handlers; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Mcp.Services; +using CrestApps.Core.AI.Tooling; using CrestApps.Core.Builders; +using CrestApps.Core.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Localization; @@ -11,50 +14,94 @@ namespace CrestApps.Core.AI.Mcp; public static class ServiceCollectionExtensions { - /// - /// Adds MCP client services including transport providers, OAuth2, and the core - /// that manages connections to remote MCP servers. - /// - /// The service collection. - /// The service collection for chaining. - public static IServiceCollection AddCoreAIMcpClient(this IServiceCollection services) + public static IServiceCollection AddCoreAIMcpServices(this IServiceCollection services) { services.AddMemoryCache(); + services.AddDistributedMemoryCache(); services.AddHttpClient(); + services.AddDataProtection(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddOptions(); + services.AddOptions(); + + services.AddCoreAISseMcpClientTransport(); + + return services; + } + + public static IServiceCollection AddCoreAISseMcpClientTransport(this IServiceCollection services) + { services.TryAddEnumerable(ServiceDescriptor.Scoped()); - services.TryAddEnumerable(ServiceDescriptor.Scoped()); - services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped, SseMcpConnectionSettingsHandler>()); services.Configure(options => { options.AddTransportType(McpConstants.TransportTypes.Sse, entry => { entry.DisplayName = new LocalizedString("Server-Sent Events", "Server-Sent Events"); - entry.Description = new LocalizedString("Server-Sent Events Description", "Uses a remote MCP server over HTTP."); + entry.Description = new LocalizedString( + "Server-Sent Events Description", + "Uses Server-Sent Events over HTTP to receive streaming responses from a remote model server. Great for real-time output from hosted models."); }); + }); + + return services; + } + + public static IServiceCollection AddCoreAIStdIoMcpClientTransport(this IServiceCollection services) + { + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.Configure(options => + { options.AddTransportType(McpConstants.TransportTypes.StdIo, entry => { entry.DisplayName = new LocalizedString("Standard Input/Output", "Standard Input/Output"); - entry.Description = new LocalizedString("Standard Input/Output Description", "Uses a local MCP process over standard input/output."); + entry.Description = new LocalizedString( + "Standard Input/Output Description", + "Uses standard input/output streams to communicate with a locally running model process. Ideal for local subprocess integration."); }); - }); return services; } - public static CrestAppsAISuiteBuilder AddMcpClient(this CrestAppsAISuiteBuilder builder) + /// + /// Adds MCP client services including transport providers, OAuth2, and the core + /// that manages connections to remote MCP servers. + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddCoreAIMcpClient(this IServiceCollection services, bool includeStdIoTransport = true) { - builder.Services.AddCoreAIMcpClient(); + services.AddCoreAIMcpServices(); + + if (includeStdIoTransport) + { + services.AddCoreAIStdIoMcpClientTransport(); + } + + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.AddCoreAITool(McpInvokeFunction.FunctionName); + + return services; + } + + public static CrestAppsAISuiteBuilder AddMcpServices(this CrestAppsAISuiteBuilder builder) + { + builder.Services.AddCoreAIMcpServices(); return builder; } - [Obsolete("Use AddAISuite(ai => ai.AddMcpClient()).")] - public static CrestAppsCoreBuilder AddMcpClient(this CrestAppsCoreBuilder builder) + public static CrestAppsAISuiteBuilder AddMcpClient(this CrestAppsAISuiteBuilder builder) { builder.Services.AddCoreAIMcpClient(); return builder; diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpCapabilityResolver.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpCapabilityResolver.cs new file mode 100644 index 00000000..d47725ad --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpCapabilityResolver.cs @@ -0,0 +1,495 @@ +using CrestApps.Core.AI.Clients; +using CrestApps.Core.AI.Deployments; +using CrestApps.Core.AI.Mcp.Models; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Speech; +using CrestApps.Core.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Mcp.Services; + +internal sealed class DefaultMcpCapabilityResolver : IMcpCapabilityResolver +{ + private readonly ISourceCatalog _store; + private readonly IMcpServerMetadataCacheProvider _metadataProvider; + private readonly IMcpCapabilityEmbeddingCacheProvider _embeddingCache; + private readonly IAIClientFactory _aiClientFactory; + private readonly IAIDeploymentManager _deploymentManager; + private readonly ITextTokenizer _tokenizer; + private readonly McpCapabilityResolverOptions _resolverOptions; + private readonly ILogger _logger; + + public DefaultMcpCapabilityResolver( + ISourceCatalog store, + IMcpServerMetadataCacheProvider metadataProvider, + IMcpCapabilityEmbeddingCacheProvider embeddingCache, + IAIClientFactory aiClientFactory, + IAIDeploymentManager deploymentManager, + ITextTokenizer tokenizer, + IOptions resolverOptions, + ILogger logger) + { + _store = store; + _metadataProvider = metadataProvider; + _embeddingCache = embeddingCache; + _aiClientFactory = aiClientFactory; + _deploymentManager = deploymentManager; + _tokenizer = tokenizer; + _resolverOptions = resolverOptions.Value; + _logger = logger; + } + + public async Task ResolveAsync( + string prompt, + string providerName, + string connectionName, + string[] mcpConnectionIds, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(prompt) || mcpConnectionIds is null || mcpConnectionIds.Length == 0) + { + return McpCapabilityResolutionResult.Empty; + } + + try + { + var connections = await _store.GetAsync(mcpConnectionIds); + + if (connections.Count == 0) + { + return McpCapabilityResolutionResult.Empty; + } + + var capabilitiesList = new List(); + + foreach (var connection in connections) + { + try + { + var capabilities = await _metadataProvider.GetCapabilitiesAsync(connection); + + if (capabilities is not null) + { + capabilitiesList.Add(capabilities); + } + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to get capabilities from MCP connection '{ConnectionId}' during pre-intent resolution.", + connection.ItemId); + } + } + + if (capabilitiesList.Count == 0) + { + return McpCapabilityResolutionResult.Empty; + } + + var entries = BuildCapabilityEntries(capabilitiesList); + + if (entries.Count == 0) + { + return McpCapabilityResolutionResult.Empty; + } + + if (entries.Count <= _resolverOptions.IncludeAllThreshold) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Capability count ({Count}) is within include-all threshold ({Threshold}). Returning all capabilities.", + entries.Count, + _resolverOptions.IncludeAllThreshold); + } + + return BuildResult(entries, 1.0f); + } + + var embeddingCandidates = await TryEmbeddingMatchAsync( + prompt, + providerName, + connectionName, + capabilitiesList, + entries, + cancellationToken); + + var keywordCandidates = KeywordMatch(prompt, entries); + var mergedCandidates = MergeCandidates(embeddingCandidates, keywordCandidates); + + if (mergedCandidates.Count > 0) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Hybrid resolution found {Count} candidate(s) (embedding: {EmbeddingCount}, keyword: {KeywordCount}).", + mergedCandidates.Count, + embeddingCandidates?.Count ?? 0, + keywordCandidates?.Count ?? 0); + } + + return new McpCapabilityResolutionResult(mergedCandidates); + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("No capabilities matched user prompt via embedding or keyword strategies."); + } + + return McpCapabilityResolutionResult.Empty; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "MCP capability resolution failed. Continuing without pre-resolved capabilities."); + return McpCapabilityResolutionResult.Empty; + } + } + + private async Task> TryEmbeddingMatchAsync( + string prompt, + string providerName, + string connectionName, + List capabilitiesList, + List entries, + CancellationToken cancellationToken) + { + var embeddingGenerator = await CreateEmbeddingGeneratorAsync(providerName, connectionName); + + if (embeddingGenerator is null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("No embedding generator available. Falling back to keyword matching."); + } + + return null; + } + + var capabilityEmbeddings = await _embeddingCache.GetOrCreateEmbeddingsAsync( + capabilitiesList, + embeddingGenerator, + cancellationToken); + + if (capabilityEmbeddings.Count == 0) + { + return null; + } + + var promptEmbeddings = await embeddingGenerator.GenerateAsync([prompt], cancellationToken: cancellationToken); + + if (promptEmbeddings is null || promptEmbeddings.Count == 0 || promptEmbeddings[0].Vector.Length == 0) + { + _logger.LogWarning("Failed to generate embedding for user prompt during capability resolution."); + return null; + } + + var promptVector = NormalizeL2(promptEmbeddings[0].Vector.ToArray()); + var candidates = new List(); + + foreach (var embedding in capabilityEmbeddings) + { + var similarity = DotProduct(promptVector, embedding.Embedding); + + if (similarity >= _resolverOptions.SimilarityThreshold) + { + candidates.Add(new McpCapabilityCandidate + { + ConnectionId = embedding.ConnectionId, + ConnectionDisplayText = embedding.ConnectionDisplayText, + CapabilityName = embedding.CapabilityName, + CapabilityDescription = embedding.CapabilityDescription, + CapabilityType = embedding.CapabilityType, + SimilarityScore = similarity, + }); + } + } + + if (candidates.Count == 0) + { + return null; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Embedding-based matching found {Count} candidate(s) above threshold {Threshold}.", + candidates.Count, + _resolverOptions.SimilarityThreshold); + } + + return candidates; + } + + private List KeywordMatch(string prompt, List entries) + { + var promptTokens = _tokenizer.Tokenize(prompt); + + if (promptTokens.Count == 0) + { + return null; + } + + var candidates = new List(); + + foreach (var entry in entries) + { + var capabilityTokens = _tokenizer.Tokenize(entry.Text); + + if (capabilityTokens.Count == 0) + { + continue; + } + + var matchCount = 0; + + foreach (var token in promptTokens) + { + if (capabilityTokens.Contains(token)) + { + matchCount++; + } + } + + if (matchCount == 0) + { + continue; + } + + var forwardScore = (float)matchCount / promptTokens.Count; + var reverseScore = (float)matchCount / capabilityTokens.Count; + var score = Math.Max(forwardScore, reverseScore); + + if (score >= _resolverOptions.KeywordMatchThreshold) + { + candidates.Add(new McpCapabilityCandidate + { + ConnectionId = entry.ConnectionId, + ConnectionDisplayText = entry.ConnectionDisplayText, + CapabilityName = entry.Name, + CapabilityDescription = entry.Description, + CapabilityType = entry.Type, + SimilarityScore = score, + }); + } + } + + if (candidates.Count == 0) + { + return null; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Keyword-based matching found {Count} candidate(s) above threshold {Threshold}.", + candidates.Count, + _resolverOptions.KeywordMatchThreshold); + } + + return candidates; + } + + private List MergeCandidates( + List embeddingCandidates, + List keywordCandidates) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + AddToMap(map, embeddingCandidates); + AddToMap(map, keywordCandidates); + + if (map.Count == 0) + { + return []; + } + + var result = map.Values.ToList(); + result.Sort((a, b) => b.SimilarityScore.CompareTo(a.SimilarityScore)); + + if (result.Count > _resolverOptions.TopK) + { + result.RemoveRange(_resolverOptions.TopK, result.Count - _resolverOptions.TopK); + } + + return result; + + static void AddToMap(Dictionary map, List candidates) + { + if (candidates is null) + { + return; + } + + foreach (var candidate in candidates) + { + var key = $"{candidate.ConnectionId}\0{candidate.CapabilityName}"; + + if (!map.TryGetValue(key, out var existing) || candidate.SimilarityScore > existing.SimilarityScore) + { + map[key] = candidate; + } + } + } + } + + private async Task>> CreateEmbeddingGeneratorAsync(string providerName, string connectionName) + { + if (string.IsNullOrEmpty(providerName)) + { + return null; + } + + var deployment = await _deploymentManager.ResolveOrDefaultAsync( + AIDeploymentType.Embedding, + clientName: providerName, + connectionName: connectionName); + + if (deployment is null || string.IsNullOrEmpty(deployment.ConnectionName)) + { + return null; + } + + return await _aiClientFactory.CreateEmbeddingGeneratorAsync( + deployment.ClientName, + deployment.ConnectionName, + deployment.ModelName); + } + + private static List BuildCapabilityEntries(List capabilitiesList) + { + var entries = new List(); + + foreach (var server in capabilitiesList) + { + AddEntries(entries, server, server.Tools, McpCapabilityType.Tool); + AddEntries(entries, server, server.Prompts, McpCapabilityType.Prompt); + AddEntries(entries, server, server.Resources, McpCapabilityType.Resource); + AddEntries(entries, server, server.ResourceTemplates, McpCapabilityType.ResourceTemplate); + } + + return entries; + + static void AddEntries( + List entries, + McpServerCapabilities server, + IReadOnlyList items, + McpCapabilityType type) + { + if (items is null) + { + return; + } + + foreach (var item in items) + { + if (string.IsNullOrWhiteSpace(item.Name)) + { + continue; + } + + var uriText = item.UriTemplate ?? item.Uri; + var parts = new List(3) { item.Name }; + + if (!string.IsNullOrWhiteSpace(uriText)) + { + parts.Add(uriText); + } + + if (!string.IsNullOrWhiteSpace(item.Description)) + { + parts.Add(item.Description); + } + + entries.Add(new CapabilityEntry + { + ConnectionId = server.ConnectionId, + ConnectionDisplayText = server.ConnectionDisplayText, + Name = item.Name, + Description = item.Description ?? string.Empty, + Type = type, + Text = string.Join(": ", parts), + }); + } + } + } + + private static McpCapabilityResolutionResult BuildResult(List entries, float score) + { + var candidates = new List(entries.Count); + + foreach (var entry in entries) + { + candidates.Add(new McpCapabilityCandidate + { + ConnectionId = entry.ConnectionId, + ConnectionDisplayText = entry.ConnectionDisplayText, + CapabilityName = entry.Name, + CapabilityDescription = entry.Description, + CapabilityType = entry.Type, + SimilarityScore = score, + }); + } + + return new McpCapabilityResolutionResult(candidates); + } + + internal static float[] NormalizeL2(float[] vector) + { + var sumOfSquares = 0f; + + for (var i = 0; i < vector.Length; i++) + { + sumOfSquares += vector[i] * vector[i]; + } + + var magnitude = MathF.Sqrt(sumOfSquares); + + if (magnitude == 0f) + { + return vector; + } + + var normalized = new float[vector.Length]; + + for (var i = 0; i < vector.Length; i++) + { + normalized[i] = vector[i] / magnitude; + } + + return normalized; + } + + internal static float DotProduct(float[] vectorA, float[] vectorB) + { + if (vectorA.Length != vectorB.Length || vectorA.Length == 0) + { + return 0f; + } + + var result = 0f; + + for (var i = 0; i < vectorA.Length; i++) + { + result += vectorA[i] * vectorB[i]; + } + + return result; + } + + private struct CapabilityEntry + { + public string ConnectionId; + public string ConnectionDisplayText; + public string Name; + public string Description; + public McpCapabilityType Type; + public string Text; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpMetadataPromptGenerator.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpMetadataPromptGenerator.cs new file mode 100644 index 00000000..8565da51 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpMetadataPromptGenerator.cs @@ -0,0 +1,217 @@ +using System.Text; +using System.Text.Json; +using CrestApps.Core.AI.Mcp.Models; + +namespace CrestApps.Core.AI.Mcp.Services; + +public sealed class DefaultMcpMetadataPromptGenerator : IMcpMetadataPromptGenerator +{ + public string Generate(IReadOnlyList capabilities) + { + if (capabilities is null || capabilities.Count == 0) + { + return null; + } + + var hasAnyCapability = capabilities.Any(server => + server.Tools.Count > 0 || + server.Prompts.Count > 0 || + server.Resources.Count > 0 || + server.ResourceTemplates.Count > 0); + + if (!hasAnyCapability) + { + return null; + } + + var builder = new StringBuilder(); + + builder.AppendLine("You have access to external MCP (Model Context Protocol) servers via the 'mcp_invoke' tool."); + builder.AppendLine("Use the 'mcp_invoke' tool to call any of the capabilities listed below."); + builder.AppendLine(); + builder.AppendLine("IMPORTANT invocation rules:"); + builder.AppendLine("- Always specify the correct 'clientId', 'type', and 'id' parameters."); + builder.AppendLine("- For tools: set type='tool', id=, and inputs=."); + builder.AppendLine(" The 'inputs' object must include all required properties as defined in the tool's Parameters schema. It must be a valid JSON object, with no wrappers (such as code fences) or additional formatting—only pure JSON."); + builder.AppendLine(" Example: if a tool has Parameters with required property 'featureIds' (array of strings), call mcp_invoke with inputs={\"featureIds\":[\"value1\",\"value2\"]}."); + builder.AppendLine("- For prompts: set type='prompt' and id=."); + builder.AppendLine("- For resources: set type='resource' and id=. Do NOT use the resource name as id."); + builder.AppendLine("- For resource templates: set type='resource' and id=."); + builder.AppendLine(); + builder.AppendLine("Available MCP Capabilities:"); + + foreach (var server in capabilities) + { + if (server.Tools.Count == 0 && server.Prompts.Count == 0 && server.Resources.Count == 0 && server.ResourceTemplates.Count == 0) + { + continue; + } + + builder.AppendLine(); + builder.Append("## Server: "); + builder.AppendLine(server.ConnectionDisplayText ?? server.ConnectionId); + builder.Append(" clientId: "); + builder.AppendLine(server.ConnectionId); + + if (server.Tools.Count > 0) + { + builder.AppendLine(" Tools (pass required arguments via 'inputs'):"); + + foreach (var tool in server.Tools.OrderBy(t => t.Name)) + { + builder.Append(" - "); + builder.Append(tool.Name); + + if (!string.IsNullOrEmpty(tool.Description)) + { + builder.Append(": "); + builder.Append(tool.Description); + } + + builder.AppendLine(); + + if (tool.InputSchema.HasValue) + { + AppendParameterSummary(builder, tool.InputSchema.Value); + } + } + } + + if (server.Prompts.Count > 0) + { + builder.AppendLine(" Prompts:"); + + foreach (var prompt in server.Prompts.OrderBy(p => p.Name)) + { + builder.Append(" - "); + builder.Append(prompt.Name); + + if (!string.IsNullOrEmpty(prompt.Description)) + { + builder.Append(": "); + builder.Append(prompt.Description); + } + + builder.AppendLine(); + } + } + + if (server.Resources.Count > 0) + { + builder.AppendLine(" Resources (use the URI as 'id' when invoking):"); + + foreach (var resource in server.Resources.OrderBy(r => r.Name)) + { + builder.Append(" - "); + builder.Append(resource.Uri ?? resource.Name); + + if (!string.IsNullOrEmpty(resource.Description)) + { + builder.Append(": "); + builder.Append(resource.Description); + } + + builder.AppendLine(); + } + } + + if (server.ResourceTemplates.Count > 0) + { + builder.AppendLine(" Resource Templates (replace {parameter} placeholders with actual values and use the resolved URI as 'id'):"); + + foreach (var template in server.ResourceTemplates.OrderBy(r => r.Name)) + { + builder.Append(" - "); + builder.Append(template.UriTemplate ?? template.Name); + + if (!string.IsNullOrEmpty(template.Description)) + { + builder.Append(": "); + builder.Append(template.Description); + } + + builder.AppendLine(); + } + } + } + + return builder.ToString(); + } + + private static void AppendParameterSummary(StringBuilder builder, JsonElement schema) + { + if (schema.ValueKind != JsonValueKind.Object) + { + return; + } + + if (!schema.TryGetProperty("properties", out var properties) || properties.ValueKind != JsonValueKind.Object) + { + return; + } + + var requiredSet = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (schema.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.Array) + { + foreach (var item in required.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + requiredSet.Add(item.GetString()); + } + } + } + + foreach (var property in properties.EnumerateObject()) + { + var name = property.Name; + var isRequired = requiredSet.Contains(name); + var typeName = GetTypeName(property.Value); + var description = property.Value.TryGetProperty("description", out var desc) && desc.ValueKind == JsonValueKind.String + ? desc.GetString() + : null; + + builder.Append(" "); + builder.Append(name); + builder.Append(" ("); + builder.Append(typeName); + + if (isRequired) + { + builder.Append(", required"); + } + + builder.Append(')'); + + if (!string.IsNullOrEmpty(description)) + { + builder.Append(": "); + builder.Append(description); + } + + builder.AppendLine(); + } + } + + private static string GetTypeName(JsonElement propertySchema) + { + if (!propertySchema.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) + { + return "object"; + } + + var type = typeElement.GetString(); + + if (type == "array" && propertySchema.TryGetProperty("items", out var items)) + { + var itemType = items.TryGetProperty("type", out var itemTypeElement) && itemTypeElement.ValueKind == JsonValueKind.String + ? itemTypeElement.GetString() + : "object"; + + return $"{itemType}[]"; + } + + return type; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerMetadataProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerMetadataProvider.cs new file mode 100644 index 00000000..abc5f75f --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerMetadataProvider.cs @@ -0,0 +1,217 @@ +using System.Text.Json; +using CrestApps.Core.AI.Mcp.Models; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Mcp.Services; + +internal sealed class DefaultMcpServerMetadataProvider : IMcpServerMetadataCacheProvider +{ + private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); + private const string CacheKeyPrefix = "McpServerCapabilities_"; + + private readonly McpService _mcpService; + private readonly IDistributedCache _cache; + private readonly IMcpCapabilityEmbeddingCacheProvider _embeddingCache; + private readonly TimeProvider _timeProvider; + private readonly McpMetadataCacheOptions _cacheOptions; + private readonly ILogger _logger; + + public DefaultMcpServerMetadataProvider( + McpService mcpService, + IDistributedCache cache, + IMcpCapabilityEmbeddingCacheProvider embeddingCache, + IOptions cacheOptions, + TimeProvider timeProvider, + ILogger logger) + { + _mcpService = mcpService; + _cache = cache; + _embeddingCache = embeddingCache; + _timeProvider = timeProvider; + _cacheOptions = cacheOptions.Value; + _logger = logger; + } + + public async Task GetCapabilitiesAsync(McpConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + + var cacheKey = CacheKeyPrefix + connection.ItemId; + var cached = await TryGetCachedCapabilitiesAsync(cacheKey); + + if (cached is not null) + { + return cached; + } + + var capabilities = await FetchCapabilitiesAsync(connection); + + if (capabilities is not null) + { + await CacheCapabilitiesAsync(cacheKey, capabilities); + } + + return capabilities; + } + + public async Task InvalidateAsync(string connectionId) + { + ArgumentException.ThrowIfNullOrEmpty(connectionId); + + await _cache.RemoveAsync(CacheKeyPrefix + connectionId); + _embeddingCache.Invalidate(connectionId); + } + + private async Task TryGetCachedCapabilitiesAsync(string cacheKey) + { + try + { + var cachedBytes = await _cache.GetAsync(cacheKey); + + if (cachedBytes is null || cachedBytes.Length == 0) + { + return null; + } + + return JsonSerializer.Deserialize(cachedBytes, _serializerOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read MCP server metadata cache entry '{CacheKey}'.", cacheKey); + return null; + } + } + + private async Task CacheCapabilitiesAsync(string cacheKey, McpServerCapabilities capabilities) + { + try + { + var cacheEntryOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _cacheOptions.GetCacheDuration(), + }; + + var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(capabilities, _serializerOptions); + await _cache.SetAsync(cacheKey, jsonBytes, cacheEntryOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cache MCP server metadata for '{CacheKey}'.", cacheKey); + } + } + + private async Task FetchCapabilitiesAsync(McpConnection connection) + { + var capabilities = new McpServerCapabilities + { + ConnectionId = connection.ItemId, + ConnectionDisplayText = connection.DisplayText, + FetchedUtc = _timeProvider.GetUtcNow().UtcDateTime, + }; + + try + { + var client = await _mcpService.GetOrCreateClientAsync(connection); + + if (client is null) + { + capabilities.IsHealthy = false; + return capabilities; + } + + var tools = new List(); + var prompts = new List(); + var resources = new List(); + var resourceTemplates = new List(); + + try + { + foreach (var tool in await client.ListToolsAsync()) + { + tools.Add(new McpServerCapability + { + Name = tool.Name, + Description = tool.Description, + InputSchema = tool.JsonSchema is JsonElement schema ? schema : null, + }); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to list tools for MCP connection '{ConnectionId}'.", connection.ItemId); + } + + try + { + foreach (var prompt in await client.ListPromptsAsync()) + { + prompts.Add(new McpServerCapability + { + Name = prompt.Name, + Description = prompt.Description, + }); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to list prompts for MCP connection '{ConnectionId}'.", connection.ItemId); + } + + try + { + foreach (var resource in await client.ListResourcesAsync()) + { + resources.Add(new McpServerCapability + { + Name = resource.Name, + Description = resource.Description, + MimeType = resource.MimeType, + Uri = resource.Uri, + }); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to list resources for MCP connection '{ConnectionId}'.", connection.ItemId); + } + + try + { + foreach (var template in await client.ListResourceTemplatesAsync()) + { + resourceTemplates.Add(new McpServerCapability + { + Name = template.Name, + Description = template.Description, + MimeType = template.MimeType, + UriTemplate = template.UriTemplate, + }); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to list resource templates for MCP connection '{ConnectionId}'.", connection.ItemId); + } + + capabilities.Tools = tools; + capabilities.Prompts = prompts; + capabilities.Resources = resources; + capabilities.ResourceTemplates = resourceTemplates; + capabilities.IsHealthy = true; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to fetch capabilities for MCP connection '{ConnectionId}' ('{ConnectionName}').", + connection.ItemId, + connection.DisplayText); + + capabilities.IsHealthy = false; + } + + return capabilities; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/InMemoryMcpCapabilityEmbeddingCacheProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/InMemoryMcpCapabilityEmbeddingCacheProvider.cs new file mode 100644 index 00000000..2f1dbcd1 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/InMemoryMcpCapabilityEmbeddingCacheProvider.cs @@ -0,0 +1,127 @@ +using System.Collections.Concurrent; +using CrestApps.Core.AI.Mcp.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Mcp.Services; + +internal sealed class InMemoryMcpCapabilityEmbeddingCacheProvider : IMcpCapabilityEmbeddingCacheProvider +{ + private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + private readonly ILogger _logger; + + public InMemoryMcpCapabilityEmbeddingCacheProvider(ILogger logger) + { + _logger = logger; + } + + public async Task> GetOrCreateEmbeddingsAsync( + IReadOnlyList capabilities, + IEmbeddingGenerator> embeddingGenerator, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(capabilities); + ArgumentNullException.ThrowIfNull(embeddingGenerator); + + var allEntries = new List(); + + foreach (var server in capabilities) + { + if (string.IsNullOrEmpty(server.ConnectionId)) + { + continue; + } + + if (_cache.TryGetValue(server.ConnectionId, out var cached)) + { + allEntries.AddRange(cached); + continue; + } + + var pendingTexts = new List(); + var pendingMeta = new List<(string Name, string Description, McpCapabilityType Type)>(); + + AddCapabilities(server.Tools, McpCapabilityType.Tool, pendingTexts, pendingMeta); + AddCapabilities(server.Prompts, McpCapabilityType.Prompt, pendingTexts, pendingMeta); + AddCapabilities(server.Resources, McpCapabilityType.Resource, pendingTexts, pendingMeta); + AddCapabilities(server.ResourceTemplates, McpCapabilityType.ResourceTemplate, pendingTexts, pendingMeta); + + if (pendingTexts.Count == 0) + { + _cache[server.ConnectionId] = []; + continue; + } + + try + { + var embeddings = await embeddingGenerator.GenerateAsync(pendingTexts, cancellationToken: cancellationToken); + + if (embeddings is null || embeddings.Count != pendingTexts.Count) + { + _logger.LogWarning( + "Embedding generation returned unexpected count for MCP connection '{ConnectionId}'. Expected {Expected}, got {Actual}.", + server.ConnectionId, + pendingTexts.Count, + embeddings?.Count ?? 0); + continue; + } + + var entries = new McpCapabilityEmbeddingEntry[pendingTexts.Count]; + + for (var i = 0; i < pendingTexts.Count; i++) + { + entries[i] = new McpCapabilityEmbeddingEntry + { + ConnectionId = server.ConnectionId, + ConnectionDisplayText = server.ConnectionDisplayText, + CapabilityName = pendingMeta[i].Name, + CapabilityDescription = pendingMeta[i].Description, + CapabilityType = pendingMeta[i].Type, + Embedding = DefaultMcpCapabilityResolver.NormalizeL2(embeddings[i].Vector.ToArray()), + }; + } + + _cache[server.ConnectionId] = entries; + allEntries.AddRange(entries); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to generate embeddings for MCP connection '{ConnectionId}'. Capabilities from this connection will be excluded from semantic matching.", + server.ConnectionId); + } + } + + return allEntries; + } + + public void Invalidate(string connectionId) + { + ArgumentException.ThrowIfNullOrEmpty(connectionId); + _cache.TryRemove(connectionId, out _); + } + + private static void AddCapabilities( + IReadOnlyList items, + McpCapabilityType type, + List texts, + List<(string Name, string Description, McpCapabilityType Type)> meta) + { + if (items is null) + { + return; + } + + foreach (var item in items) + { + if (string.IsNullOrWhiteSpace(item.Name)) + { + continue; + } + + texts.Add(string.IsNullOrWhiteSpace(item.Description) ? item.Name : $"{item.Name}: {item.Description}"); + meta.Add((item.Name, item.Description ?? string.Empty, type)); + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/McpToolRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/McpToolRegistryProvider.cs new file mode 100644 index 00000000..ebb36250 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/McpToolRegistryProvider.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using CrestApps.Core.AI.Mcp.Functions; +using CrestApps.Core.AI.Mcp.Models; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Mcp.Services; + +internal sealed class McpToolRegistryProvider : IToolRegistryProvider +{ + private static readonly JsonElement _emptySchema = JsonSerializer.Deserialize( + """{"type": "object", "properties": {}, "additionalProperties": false}"""); + + private readonly IMcpServerMetadataCacheProvider _metadataProvider; + private readonly ISourceCatalog _store; + private readonly ILogger _logger; + + public McpToolRegistryProvider( + IMcpServerMetadataCacheProvider metadataProvider, + ISourceCatalog store, + ILogger logger) + { + _metadataProvider = metadataProvider; + _store = store; + _logger = logger; + } + + public async Task> GetToolsAsync( + AICompletionContext context, + CancellationToken cancellationToken = default) + { + var mcpConnectionIds = context?.McpConnectionIds; + + if (mcpConnectionIds is null || mcpConnectionIds.Length == 0) + { + return []; + } + + var connections = await _store.GetAsync(mcpConnectionIds); + + if (connections.Count == 0) + { + return []; + } + + var entries = new List(); + + foreach (var connection in connections) + { + try + { + var capabilities = await _metadataProvider.GetCapabilitiesAsync(connection); + + if (capabilities?.Tools is null || capabilities.Tools.Count == 0) + { + continue; + } + + var connectionId = connection.ItemId; + + foreach (var tool in capabilities.Tools) + { + if (string.IsNullOrWhiteSpace(tool.Name)) + { + continue; + } + + var toolName = tool.Name; + var toolDescription = tool.Description ?? toolName; + var toolSchema = tool.InputSchema ?? _emptySchema; + + entries.Add(new ToolRegistryEntry + { + Id = $"mcp:{connectionId}:{toolName}", + Name = toolName, + Description = toolDescription, + Source = ToolRegistryEntrySource.McpServer, + SourceId = connectionId, + CreateAsync = _ => ValueTask.FromResult( + new McpToolProxyFunction(toolName, toolDescription, toolSchema, connectionId)), + }); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to load MCP tool metadata from connection '{ConnectionId}'. Skipping.", + connection.ItemId); + } + } + + return entries; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs new file mode 100644 index 00000000..4ad7cf4d --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Nodes; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Handlers; + +internal sealed class AIProfileHandler : CatalogEntryHandlerBase +{ + internal readonly IStringLocalizer S; + + public AIProfileHandler( + IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + public override Task InitializingAsync(InitializingContext context) + => PopulateAsync(context.Model, context.Data); + + public override Task UpdatingAsync(UpdatingContext context) + => PopulateAsync(context.Model, context.Data); + + private static Task PopulateAsync(AIProfile profile, JsonNode data) + { + var metadata = profile.As(); + + var settings = profile.GetSettings(); + + if (!settings.LockSystemMessage) + { + var systemMessage = data[nameof(AIProfileMetadata.SystemMessage)]?.GetValue()?.Trim(); + + if (!string.IsNullOrEmpty(systemMessage)) + { + metadata.SystemMessage = systemMessage; + + profile.Put(metadata); + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index ee553e2f..0058b0ad 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ using CrestApps.Core.Templates.Parsing; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DataIngestion; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -93,12 +94,6 @@ public static AIToolBuilder AddCoreAITool(this IServiceCollection } - [Obsolete("Use AddCoreAITool().")] - public static AIToolBuilder AddAITool(this IServiceCollection services, string name) - where TTool : AITool - { - return services.AddCoreAITool(name); - } /// /// Registers the core DI services for an AI tool (singleton and keyed singleton) /// without adding it to the tool definition options. Use this for tools that @@ -124,6 +119,7 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi // Ensure IHttpContextAccessor is available for services that need HTTP context. services.TryAddSingleton(); + services.TryAddSingleton(new ConfigurationBuilder().Build()); services .AddCoreAITemplating() @@ -150,9 +146,9 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi services.TryAddScoped(); services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped, AIProfileHandler>()); return services; - } public static CrestAppsCoreBuilder AddAISuite(this CrestAppsCoreBuilder builder, Action configure = null) @@ -170,13 +166,6 @@ public static CrestAppsCoreBuilder AddAISuite(this CrestAppsCoreBuilder builder, return builder; } - [Obsolete("Use AddAISuite(...).")] - public static CrestAppsCoreBuilder AddAI(this CrestAppsCoreBuilder builder) - { - builder.Services.AddCoreAIServices(); - return builder; - } - public static IServiceCollection AddCoreAIProfile(this IServiceCollection services, string implementationName, string providerName, Action configure = null) where TClient : class, IAICompletionClient @@ -188,7 +177,6 @@ public static IServiceCollection AddCoreAIProfile(this IServiceCollecti }) .AddCoreAICompletionClient(implementationName); - } public static IServiceCollection AddCoreAIDeploymentProvider(this IServiceCollection services, string providerName, Action configure = null) @@ -219,7 +207,6 @@ public static IServiceCollection AddCoreAICompletionClient(this IServic public static IServiceCollection AddCoreAIConnectionSource(this IServiceCollection services, string providerName, Action configure = null) { - services.Configure(o => { o.AddConnectionSource(providerName, configure); @@ -230,7 +217,6 @@ public static IServiceCollection AddCoreAIConnectionSource(this IServiceCollecti public static IServiceCollection AddCoreAITemplateSource(this IServiceCollection services, string sourceName, Action configure = null) { - services.Configure(o => { o.AddTemplateSource(sourceName, configure); @@ -356,9 +342,11 @@ public static IServiceCollection AddCoreAIOrchestration(this IServiceCollection }); // Register the Framework-level deployment manager. - - // OrchardCore overrides this with its ISiteService-backed implementation. services.TryAddScoped(); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Scoped()); @@ -393,7 +381,6 @@ public static IServiceCollection AddCoreAIOrchestration(this IServiceCollection // Register content generation system tools. services.AddCoreAITool(GenerateImageTool.TheName) .WithTitle("Generate Image") - .WithDescription("Generates an image from a text description using an AI image generation model.") .WithPurpose(AIToolPurposes.ContentGeneration); @@ -411,13 +398,6 @@ public static IServiceCollection AddCoreAIOrchestration(this IServiceCollection return services; } - [Obsolete("Use AddAISuite(...), which already includes orchestration.")] - public static CrestAppsCoreBuilder AddOrchestration(this CrestAppsCoreBuilder builder) - { - builder.Services.AddCoreAIOrchestration(); - return builder; - } - /// /// Registers an orchestrator implementation with the given name. /// diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs b/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs index d7550728..6c15a204 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs @@ -1,6 +1,5 @@ using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Models; -using CrestApps.Core.Infrastructure; using Microsoft.Extensions.AI; namespace CrestApps.Core.AI.Services; diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentManagerBase.cs b/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentManagerBase.cs index 92065018..93521623 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentManagerBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentManagerBase.cs @@ -8,7 +8,7 @@ namespace CrestApps.Core.AI.Services; public abstract class AIDeploymentManagerBase : NamedSourceCatalogManager, IAIDeploymentManager { public AIDeploymentManagerBase( - INamedSourceCatalog deploymentStore, + IAIDeploymentStore deploymentStore, IEnumerable> handlers, ILogger logger) : base(deploymentStore, handlers, logger) diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs index dd1f8b67..190ed149 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs @@ -1,11 +1,11 @@ using System.Text.Json; using System.Text.Json.Nodes; +using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; using CrestApps.Core.Models; using CrestApps.Core.Services; using CrestApps.Core.Support; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -14,24 +14,22 @@ namespace CrestApps.Core.AI.Services; /// Decorates a persisted AI deployment store with configuration-backed deployments from appsettings.json. /// Read operations return the merged result while write operations continue to target the persisted store only. /// -public sealed class ConfigurationAIDeploymentCatalog : INamedSourceCatalog +public sealed class ConfigurationAIDeploymentCatalog : IAIDeploymentStore { - public const string PersistedCatalogKey = "PersistedCatalog"; - - private readonly INamedSourceCatalog _inner; + private readonly INamedSourceCatalog _deploymentCatalog; private readonly IConfiguration _configuration; private readonly AIOptions _aiOptions; private readonly AIDeploymentCatalogOptions _catalogOptions; private readonly ILogger _logger; public ConfigurationAIDeploymentCatalog( - [FromKeyedServices(PersistedCatalogKey)] INamedSourceCatalog inner, + INamedSourceCatalog deploymentCatalog, IConfiguration configuration, IOptions aiOptions, IOptions catalogOptions, ILogger logger) { - _inner = inner; + _deploymentCatalog = deploymentCatalog; _configuration = configuration; _aiOptions = aiOptions.Value; _catalogOptions = catalogOptions.Value; @@ -40,20 +38,20 @@ public ConfigurationAIDeploymentCatalog( public async ValueTask FindByIdAsync(string id) { - var result = await _inner.FindByIdAsync(id); + var result = await _deploymentCatalog.FindByIdAsync(id); if (result != null) { return result; } - return (await GetConfigDeploymentsAsync(await _inner.GetAllAsync())) + return (await GetConfigDeploymentsAsync(await _deploymentCatalog.GetAllAsync())) .FirstOrDefault(deployment => string.Equals(deployment.ItemId, id, StringComparison.OrdinalIgnoreCase)) ?.Clone(); } public async ValueTask> GetAllAsync() { - var dbRecords = await _inner.GetAllAsync(); + var dbRecords = await _deploymentCatalog.GetAllAsync(); var configRecords = await GetConfigDeploymentsAsync(dbRecords); if (configRecords.Count == 0) { @@ -65,7 +63,7 @@ public async ValueTask> GetAllAsync() public async ValueTask> GetAsync(IEnumerable ids) { - var dbRecords = await _inner.GetAsync(ids); + var dbRecords = await _deploymentCatalog.GetAsync(ids); var requestedIds = ids.ToHashSet(StringComparer.OrdinalIgnoreCase); var foundIds = dbRecords.Select(static deployment => deployment.ItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); var missingIds = requestedIds.Except(foundIds).ToList(); @@ -86,10 +84,10 @@ public async ValueTask> GetAsync(IEnumerable> PageAsync(int page, int pageSize, TQuery context) where TQuery : QueryContext { - var configRecords = await GetConfigDeploymentsAsync(await _inner.GetAllAsync()); + var configRecords = await GetConfigDeploymentsAsync(await _deploymentCatalog.GetAllAsync()); if (configRecords.Count == 0) { - return await _inner.PageAsync(page, pageSize, context); + return await _deploymentCatalog.PageAsync(page, pageSize, context); } var allRecords = await GetAllAsync(); @@ -104,20 +102,20 @@ public async ValueTask> PageAsync(int page, int public async ValueTask FindByNameAsync(string name) { - var result = await _inner.FindByNameAsync(name); + var result = await _deploymentCatalog.FindByNameAsync(name); if (result != null) { return result; } - return (await GetConfigDeploymentsAsync(await _inner.GetAllAsync())) + return (await GetConfigDeploymentsAsync(await _deploymentCatalog.GetAllAsync())) .FirstOrDefault(deployment => string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase)) ?.Clone(); } public async ValueTask> GetAsync(string source) { - var dbRecords = await _inner.GetAsync(source); + var dbRecords = await _deploymentCatalog.GetAsync(source); var configMatches = (await GetConfigDeploymentsAsync(dbRecords)).Where(deployment => string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase)).ToList(); if (configMatches.Count == 0) { @@ -129,22 +127,22 @@ public async ValueTask> GetAsync(string source public async ValueTask GetAsync(string name, string source) { - var result = await _inner.GetAsync(name, source); + var result = await _deploymentCatalog.GetAsync(name, source); if (result != null) { return result; } - return (await GetConfigDeploymentsAsync(await _inner.GetAllAsync())) + return (await GetConfigDeploymentsAsync(await _deploymentCatalog.GetAllAsync())) .FirstOrDefault(deployment => string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase) && string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase)) ?.Clone(); } - public ValueTask DeleteAsync(AIDeployment entry) => _inner.DeleteAsync(entry); - public ValueTask CreateAsync(AIDeployment entry) => _inner.CreateAsync(entry); - public ValueTask UpdateAsync(AIDeployment entry) => _inner.UpdateAsync(entry); + public ValueTask DeleteAsync(AIDeployment entry) => _deploymentCatalog.DeleteAsync(entry); + public ValueTask CreateAsync(AIDeployment entry) => _deploymentCatalog.CreateAsync(entry); + public ValueTask UpdateAsync(AIDeployment entry) => _deploymentCatalog.UpdateAsync(entry); private async Task> GetConfigDeploymentsAsync(IReadOnlyCollection storedDeployments) { diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs index cd28c434..2dfa7af0 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs @@ -1,3 +1,4 @@ +using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; using CrestApps.Core.Services; using Microsoft.Extensions.Logging; @@ -5,12 +6,12 @@ namespace CrestApps.Core.AI.Services; -public class DefaultAIDeploymentManager : AIDeploymentManagerBase +public sealed class DefaultAIDeploymentManager : AIDeploymentManagerBase { private readonly IOptionsMonitor _deploymentSettings; public DefaultAIDeploymentManager( - INamedSourceCatalog deploymentStore, + IAIDeploymentStore deploymentStore, IEnumerable> handlers, IOptionsMonitor deploymentSettings, ILogger logger) diff --git a/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs b/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs index f2e0b0be..ee53cd06 100644 --- a/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs +++ b/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs @@ -13,8 +13,15 @@ TaskScheduler.UnobservedTaskException += (_, eventArgs) => { + if (IsBenignAppHostException(eventArgs.Exception)) + { + eventArgs.SetObserved(); + return; + } + Console.Error.WriteLine("[AppHost] Unobserved task exception."); Console.Error.WriteLine(eventArgs.Exception); + eventArgs.SetObserved(); }; AppDomain.CurrentDomain.ProcessExit += (_, _) => @@ -80,3 +87,14 @@ Console.Error.WriteLine(ex); throw; } + +static bool IsBenignAppHostException(Exception exception) +{ + return exception switch + { + AggregateException aggregateException => aggregateException.InnerExceptions.All(IsBenignAppHostException), + OperationCanceledException => true, + IOException ioException when ioException.Message.Contains("request was aborted", StringComparison.OrdinalIgnoreCase) => true, + _ => false, + }; +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs index 89af34b3..075be934 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs @@ -1,3 +1,4 @@ +using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Services; using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels; @@ -12,7 +13,7 @@ namespace CrestApps.Core.Mvc.Web.Areas.AI.Controllers; [Authorize(Policy = "Admin")] public sealed class AIDeploymentController : Controller { - private readonly INamedSourceCatalog _deploymentCatalog; + private readonly IAIDeploymentStore _deploymentCatalog; private readonly INamedSourceCatalog _connectionCatalog; private static readonly List _providers = @@ -38,7 +39,7 @@ public sealed class AIDeploymentController : Controller }; public AIDeploymentController( - INamedSourceCatalog deploymentCatalog, + IAIDeploymentStore deploymentCatalog, INamedSourceCatalog connectionCatalog) { _deploymentCatalog = deploymentCatalog; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/YesSqlAIDeploymentStore.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/YesSqlAIDeploymentStore.cs new file mode 100644 index 00000000..6f1fdeb4 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/YesSqlAIDeploymentStore.cs @@ -0,0 +1,14 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.Data.YesSql.Indexes.AI; +using CrestApps.Core.Data.YesSql.Services; +using ISession = YesSql.ISession; + +namespace CrestApps.Core.Mvc.Web.Areas.AI.Services; + +public sealed class YesSqlAIDeploymentStore : NamedSourceDocumentCatalog +{ + public YesSqlAIDeploymentStore(ISession session) + : base(session) + { + } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index c8e89fa8..e10e1dfc 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -38,9 +38,12 @@ using CrestApps.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using YesSql; using YesSql.Provider.Sqlite; using YesSql.Sql; +using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; namespace CrestApps.Core.Mvc.Web.Services; @@ -115,8 +118,17 @@ public static IServiceCollection AddCoreYesSqlDataStore(this IServiceCollection services.AddKeyedScoped, NamedSourceDocumentCatalog>(ConfigurationAIProviderConnectionCatalog.PersistedCatalogKey) .AddYesSqlNamedSourceDocumentCatalog(); - services.AddKeyedScoped, NamedSourceDocumentCatalog>(ConfigurationAIDeploymentCatalog.PersistedCatalogKey) - .AddYesSqlNamedSourceDocumentCatalog(); + services.AddScoped, YesSqlAIDeploymentStore>(); + services.AddScoped(sp => + new ConfigurationAIDeploymentCatalog( + sp.GetRequiredService>(), + sp.GetService() ?? new ConfigurationBuilder().Build(), + sp.GetService>() ?? Options.Create(new AIOptions()), + sp.GetService>() ?? Options.Create(new AIDeploymentCatalogOptions()), + sp.GetService>() ?? NullLogger.Instance)); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); return services; } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Home/Index.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Home/Index.cshtml index f996aef3..68980124 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Views/Home/Index.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Home/Index.cshtml @@ -19,7 +19,7 @@
  1. Add an AI Connection — configure a connection to your AI provider (OpenAI, Azure OpenAI, Ollama, etc.).
  2. Add an AI Deployment — map a model deployment (e.g., gpt-4o-mini) to your connection.
  3. -
  4. Start chatting — use Chat Interactions as a playground, or create an AI Profile for reusable multi-session conversations via AI Chat.
  5. +
  6. Start testing — open Chat Interactions first to validate your connection and deployment immediately, then create an AI Profile when you want reusable chat, agent, or orchestration behavior.
@@ -57,6 +57,20 @@
+
+
+
+
+ +
Chat Interactions
+
+

The fastest way to test a new setup with a real conversation before creating reusable profiles.

+ + Manage Interactions + +
+
+
@@ -64,7 +78,7 @@
AI Profiles
-

Create and manage AI profiles with system prompts, parameters, and provider configurations.

+

Create reusable AI behavior for chat, agents, tools, data sources, memory, and session processing.

Manage Profiles @@ -78,27 +92,13 @@
AI Chat
-

Manage reusable interaction sessions and orchestration-driven conversation flows.

+

Manage reusable interaction sessions and orchestration-driven conversation flows powered by AI Profiles.

Start Chat
-
-
-
-
- -
Chat Interactions
-
-

Start real-time conversations with AI assistants as playground.

- - Manage Interactions - -
-
-
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml index f9494388..f76a84c1 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_Layout.cshtml @@ -75,6 +75,11 @@ AI Deployments + -