Skip to content
Merged

Mcp #385

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d1152be
Add MCP server exposing AAS query/read tools
aorzelskiGH Jun 19, 2026
10ca1b0
Add product/shell tools, /mcp-basic endpoint and query refinements
aorzelskiGH Jun 19, 2026
050bbd9
Add aas_find_product_simple and three-tier MCP endpoints
aorzelskiGH Jun 20, 2026
8fb3c55
Harden aas_find_product_simple for very small models
aorzelskiGH Jun 20, 2026
cbbd8ef
Add projection, batch tools, in-operator and per-call timing
aorzelskiGH Jun 21, 2026
65806b5
Make eq/ne match string OR number for digit-only string ids
aorzelskiGH Jun 21, 2026
fa49260
Prefer main-product over accessory in leaf-name projection
aorzelskiGH Jun 21, 2026
1a68484
Catch query-engine errors and return a readable result
aorzelskiGH Jun 21, 2026
de40b50
Fix BUG A: correlate leaf-name value filters via wildcard path
aorzelskiGH Jun 21, 2026
1e5d1cb
PERF C: real COUNT path for aas_count (no materialization, uncapped)
aorzelskiGH Jun 21, 2026
b6de92f
Optimize SQLite search and resumable imports
aorzelskiGH Jun 22, 2026
f2fe1dc
Limit debug messages
aorzelskiGH Jun 23, 2026
6d98e6d
Merge branch 'main' into mcp-merge-main
aorzelskiGH Jun 29, 2026
e98d4cd
Distribute top-level OR for $UNION/$TEMPTABLE without security
aorzelskiGH Jun 29, 2026
ccd9bb9
Route common $contains to EXISTS via capped FTS probe
aorzelskiGH Jun 30, 2026
b127ae3
Make MCP tool descriptors ChatGPT Apps-SDK compatible
aorzelskiGH Jun 30, 2026
9d03427
Route bare $sme#value to EXISTS and probe eq/prefix selectivity
aorzelskiGH Jun 30, 2026
46571db
Improve MCP query logging and search heuristics
aorzelskiGH Jul 1, 2026
e768d2c
Improve MCP query export and projections
aorzelskiGH Jul 2, 2026
750611b
Improve MCP query exports and projections
aorzelskiGH Jul 2, 2026
7e739f9
Merge branch 'main' into mcp
whamm86 Jul 8, 2026
76f3e63
Fix crash on start because of invalid parameter cast
whamm86 Jul 9, 2026
8c83b36
Merge branch 'main' into mcp
whamm86 Jul 16, 2026
3d112dc
Add MCP concept and model discovery tools
aorzelskiGH Jul 18, 2026
77a0014
Accept negative number literals in AASQL JSON grammar
aorzelskiGH Jul 18, 2026
25dd343
Make search plans deterministic on large corpora
aorzelskiGH Jul 18, 2026
bcf3e10
Add explicit MCP browser log sessions
aorzelskiGH Jul 19, 2026
f976b7a
Merge branch 'main' into mcp
whamm86 Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/AasxServerBlazor/AasxServerBlazor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
</PackageReference>
<PackageReference Include="ScottPlot" Version="4.1.74" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
</ItemGroup>

<ItemGroup>
Expand Down
148 changes: 144 additions & 4 deletions src/AasxServerBlazor/Configuration/ServerConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace AasxServerBlazor.Configuration;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AasSecurity;
using AasxServerStandardBib.ServiceExtensions;
using IO.Swagger.Controllers;
Expand All @@ -34,8 +35,10 @@ namespace AasxServerBlazor.Configuration;
using Microsoft.OpenApi.Models;

using System.Text.Json;
using System.Text.Json.Serialization;
using AdminShellNS;
using System.Text.Json.Serialization;
using AdminShellNS;
using Contracts;
using IO.Swagger.Lib.V3.MCP;
#if GRAPHQL
using HotChocolate.AspNetCore;
#endif
Expand Down Expand Up @@ -77,7 +80,127 @@ public static void ConfigureServer(IServiceCollection services)
IncludeExceptionDetails = true
});
#endif
}

// MCP-Server (Streamable HTTP) als dünner Adapter über die Query-Pipeline. Immer aktiv.
// Zwei Endpunkte: /mcp (voll, alle Tools) und /mcp-basic (nur aas_find_product, für schwache Modelle).
// Die Aufteilung erfolgt über Request-Filter, die den Request-Pfad prüfen.
services.AddHttpContextAccessor();
services.AddMcpServer()
.WithHttpTransport()
.WithTools<McpQueryTools>()
// Exportdateien (CSV/XLSX) der Export-Tools als MCP-Resource: aas-export://{token}.
.WithResources<McpExportResources>()
.WithRequestFilters(filters =>
{
filters.AddListToolsFilter(next => async (context, ct) =>
{
var result = await next(context, ct);
var allowed = AllowedMcpTools(context.Services);
if (result.Tools is not null)
{
if (allowed is not null)
{
result.Tools = result.Tools.Where(t => allowed.Contains(t.Name)).ToList();
}

// OpenAI Apps SDK / ChatGPT compatibility: declare each (read-only, public) tool as
// no-auth and give it invocation status text. securitySchemes is mirrored into _meta
// (the location ChatGPT reads; the typed SDK cannot emit a custom top-level field).
// title + readOnly/idempotent/etc. annotations come from the [McpServerTool] attributes.
foreach (var tool in result.Tools)
{
var meta = tool.Meta ?? new System.Text.Json.Nodes.JsonObject();
meta["securitySchemes"] = new System.Text.Json.Nodes.JsonArray(
new System.Text.Json.Nodes.JsonObject { ["type"] = "noauth" });
if (McpToolStatus.TryGetValue(tool.Name, out var status))
{
meta["openai/toolInvocation/invoking"] = status.Invoking;
meta["openai/toolInvocation/invoked"] = status.Invoked;
}
tool.Meta = meta;
}
}

return result;
});

filters.AddCallToolFilter(next => async (context, ct) =>
{
var allowed = AllowedMcpTools(context.Services);
if (allowed is not null
&& context.Params is not null
&& !allowed.Contains(context.Params.Name))
{
throw new InvalidOperationException(
$"Tool '{context.Params.Name}' ist auf diesem MCP-Endpunkt nicht verfügbar.");
}

var logSession = ExplicitLogSession(context.Services);
if (!McpSessionLogStore.IsValidSessionId(logSession))
{
return await next(context, ct);
}

using var _ = DiagnosticsLog.BeginBrowserLogSession(logSession!);
return await next(context, ct);
});
});
}

// Per-tool invocation status text (OpenAI Apps SDK _meta), kept <= 64 chars per the spec.
private static readonly IReadOnlyDictionary<string, (string Invoking, string Invoked)> McpToolStatus =
new Dictionary<string, (string, string)>(StringComparer.Ordinal)
{
["aas_query"] = ("Searching Voyager…", "Voyager search complete"),
["aas_query_export_csv"] = ("Exporting Voyager results…", "CSV export ready"),
["aas_query_export_xlsx"] = ("Exporting Voyager results…", "Excel export ready"),
["aas_count"] = ("Counting Voyager results…", "Voyager count complete"),
["aas_get_submodel"] = ("Reading AAS submodel…", "AAS submodel loaded"),
["aas_get_submodels"] = ("Reading AAS submodels…", "AAS submodels loaded"),
["aas_get_shell"] = ("Reading AAS shell…", "AAS shell loaded"),
["aas_get_shells"] = ("Reading AAS shells…", "AAS shells loaded"),
["aas_get_product"] = ("Reading AAS product…", "AAS product loaded"),
["aas_find_product"] = ("Finding AAS product…", "AAS product found"),
["aas_find_product_simple"] = ("Finding product…", "Product found"),
["aas_get_element"] = ("Reading AAS element…", "AAS element loaded"),
["aas_find_concepts"] = ("Searching concept definitions…", "Concept definitions found"),
["aas_describe_model"] = ("Analyzing data structures…", "Data structure overview ready"),
};

// Erlaubter Tool-Satz je nach MCP-Endpunkt-Pfad (null = alle Tools, voller Endpunkt /mcp).
// /mcp-simple zuerst prüfen (Pfad enthält nicht "mcp-basic"), dann /mcp-basic, sonst voll.
private static HashSet<string>? AllowedMcpTools(IServiceProvider? services)
{
var path = services?.GetService<Microsoft.AspNetCore.Http.IHttpContextAccessor>()?.HttpContext?.Request.Path.Value;
if (path is null)
{
return null;
}

if (path.Contains("mcp-simple", StringComparison.OrdinalIgnoreCase))
{
return McpQueryTools.SimpleToolNames;
}

if (path.Contains("mcp-basic", StringComparison.OrdinalIgnoreCase))
{
return McpQueryTools.BasicToolNames;
}

return null;
}

private static string? ExplicitLogSession(IServiceProvider? services)
{
var request = services?
.GetService<Microsoft.AspNetCore.Http.IHttpContextAccessor>()?
.HttpContext?
.Request;

return request?.Query.TryGetValue("logSession", out var value) == true
? value.FirstOrDefault()
: null;
}

/// <summary>
/// Adds framework-specific services required by the application.
Expand Down Expand Up @@ -169,6 +292,23 @@ private static void ConfigureEndpoints(IEndpointRouteBuilder endpoints)
Tool = { Enable = true }
});
#endif
// MCP-Endpoints (Streamable HTTP). PathBase ist "/api/v3.0", real also "/api/v3.0/mcp" bzw. "/api/v3.0/mcp-basic".
// /mcp = voller Toolsatz (starke Modelle), /mcp-basic = nur aas_find_product (schwache Modelle); Filter s. ConfigureMcp.
endpoints.MapMcp("/mcp");
endpoints.MapMcp("/mcp-basic");
endpoints.MapMcp("/mcp-simple");

// Download-Endpunkt für die von aas_query_export_csv/xlsx erzeugten Dateien.
// Das Token stammt aus der Tool-Antwort (downloadUrl); die Dateien verfallen nach ca. 60 Minuten.
endpoints.MapGet("/mcp-exports/{token}", (string token) =>
{
if (!McpExportFileStore.TryGet(token, out var content, out var fileName, out var mimeType))
{
return Microsoft.AspNetCore.Http.Results.NotFound();
}

return Microsoft.AspNetCore.Http.Results.File(content, mimeType, fileName);
});
}

#endregion
Expand Down Expand Up @@ -286,4 +426,4 @@ private static void AddSwaggerGen(IServiceCollection services) =>
});

#endregion
}
}
180 changes: 180 additions & 0 deletions src/AasxServerBlazor/Pages/McpLog.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
@page "/McpLog"
@using Contracts
@using System.Threading
@using System.Threading.Tasks
@implements IDisposable
@inject NavigationManager Nav

<style>
.mcp-log-page {
max-width: 1200px;
margin: 0 auto;
padding: 16px 0 32px;
}

.mcp-log-toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}

.mcp-log-toolbar input {
min-width: 280px;
max-width: 520px;
flex: 1;
}

.mcp-log-view {
height: calc(100vh - 220px);
min-height: 420px;
overflow: auto;
border: 1px solid #c8d0d8;
background: #0b1220;
color: #d9e7ff;
padding: 12px;
font-family: Consolas, "Liberation Mono", monospace;
font-size: 13px;
line-height: 1.4;
white-space: pre-wrap;
}

.mcp-log-muted {
color: #5f6c7a;
margin-bottom: 12px;
}
</style>

<div class="mcp-log-page">
<h3>MCP Log</h3>
<p class="mcp-log-muted">
Shows server-side MCP diagnostics only for the explicit <code>logSession</code> value.
</p>

<div class="mcp-log-toolbar">
<input class="form-control"
placeholder="logSession"
@bind="_sessionInput"
@bind:event="oninput" />
<button class="btn btn-primary" @onclick="ApplySession">Show</button>
<button class="btn btn-secondary" @onclick="ClearView" disabled="@(!HasSession)">Clear</button>
</div>

@if (!HasSession)
{
<p class="mcp-log-muted">
Open this page with <code>?logSession=your-token</code> or enter the same token used on the MCP endpoint.
</p>
}
else if (!McpSessionLogStore.IsValidSessionId(_session))
{
<p class="text-danger">
Invalid logSession. Use letters, digits, dash, underscore, dot or colon; max 128 characters.
</p>
}
else
{
<div class="mcp-log-muted">
MCP endpoint: <code>/api/v3.0/mcp?logSession=@_session</code>
</div>
<div class="mcp-log-view">
@LogText
</div>
}
</div>

@code {
private readonly List<McpSessionLogEntry> _entries = new();
private PeriodicTimer? _timer;
private CancellationTokenSource? _cts;
private string? _session;
private string? _sessionInput;
private long _lastSequence;

private bool HasSession => !string.IsNullOrWhiteSpace(_session);

private string LogText => _entries.Count == 0
? "Waiting for MCP log lines..."
: string.Join(Environment.NewLine, _entries.Select(e => e.Line));

protected override void OnInitialized()
{
_session = ReadQueryValue("logSession");
_sessionInput = _session;
_cts = new CancellationTokenSource();
_timer = new PeriodicTimer(TimeSpan.FromMilliseconds(750));
_ = PollAsync(_cts.Token);
}

private void ApplySession()
{
_session = string.IsNullOrWhiteSpace(_sessionInput) ? null : _sessionInput.Trim();
_entries.Clear();
_lastSequence = 0;
}

private void ClearView()
{
if (McpSessionLogStore.IsValidSessionId(_session))
{
McpSessionLogStore.Clear(_session!);
}

_entries.Clear();
_lastSequence = 0;
}

private async Task PollAsync(CancellationToken cancellationToken)
{
while (_timer is not null && await _timer.WaitForNextTickAsync(cancellationToken))
{
if (!McpSessionLogStore.IsValidSessionId(_session))
{
continue;
}

var next = McpSessionLogStore.Read(_session!, _lastSequence, 500);
if (next.Count == 0)
{
continue;
}

_entries.AddRange(next);
_lastSequence = _entries[^1].Sequence;

if (_entries.Count > 2000)
{
_entries.RemoveRange(0, _entries.Count - 2000);
}

await InvokeAsync(StateHasChanged);
}
}

private string? ReadQueryValue(string key)
{
var query = new Uri(Nav.Uri).Query.TrimStart('?');
if (query.Length == 0)
{
return null;
}

foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var pair = part.Split('=', 2);
if (pair.Length == 2 && string.Equals(Uri.UnescapeDataString(pair[0]), key, StringComparison.Ordinal))
{
return Uri.UnescapeDataString(pair[1]);
}
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Select Note

This foreach loop immediately
maps its iteration variable to another variable
- consider mapping the sequence explicitly using '.Select(...)'.

return null;
}

public void Dispose()
{
_cts?.Cancel();
_timer?.Dispose();
_cts?.Dispose();
}
}
2 changes: 1 addition & 1 deletion src/AasxServerBlazor/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"AasxServerBlazor": {
"commandName": "Project",
"commandLineArgs": "--with-db --start-index 1000 --secret-string-api 1234 --data-path \"C:\\Development\\newdb1\" --edit --external-blazor http://localhost:5001",
"commandLineArgs": "--no-security --with-db --start-index 1000 --secret-string-api 1234 --data-path \"C:\\Development\\newdb1\" --edit --external-blazor http://localhost:5001",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
Expand Down
7 changes: 7 additions & 0 deletions src/AasxServerDB.Tests/DatabaseFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ public DatabaseFixture()
VisitorAASX.ImportAASXIntoDB(file, createFilesOnly: false);
}

// Production installs this during InitDB. Build it after fixture import so
// query execution exercises the same SQLite substring-search path.
using (var indexDb = new AasContext())
{
SqliteTrigramIndex.Initialize(indexDb);
}

Console.WriteLine("[DatabaseFixture] Import done.");
using var verify = new AasContext();
Console.WriteLine($" SMSets: {verify.SMSets.Count()}");
Expand Down
Loading
Loading