Skip to content
Open

Mcp #385

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
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
118 changes: 118 additions & 0 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 @@ -36,6 +37,7 @@ namespace AasxServerBlazor.Configuration;
using System.Text.Json;
using System.Text.Json.Serialization;
using AdminShellNS;
using IO.Swagger.Lib.V3.MCP;
#if GRAPHQL
using HotChocolate.AspNetCore;
#endif
Expand Down Expand Up @@ -77,6 +79,105 @@ 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 => (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.");
}

return 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"),
};

// 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;
}

/// <summary>
Expand Down Expand Up @@ -169,6 +270,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
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
196 changes: 196 additions & 0 deletions src/AasxServerDB.Tests/ProjectionOperatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
namespace AasxServerDB.Tests;

using Contracts.DbRequests;
using FluentAssertions;

/// <summary>
/// Tests der SQL-Batchprojektion (MCP-Fast-Path) gegen die importierten AASX-Testdaten:
/// exakte idShortPath-Treffer im Treffer-Submodel, Cross-Submodel-Auflösung über die AAS
/// und Not-Found-Verhalten. Die Erwartungswerte werden datengetrieben aus denselben
/// Tabellen (SMSets/SMESets/ValueSets) ermittelt.
/// </summary>
[Collection(DatabaseFixture.Collection)]
public sealed class ProjectionOperatorTests
{
private readonly DatabaseFixture _fixture;

public ProjectionOperatorTests(DatabaseFixture fixture)
{
_fixture = fixture;
}

private sealed record PropCandidate(string SubmodelIdentifier, string IdShortPath, string SValue);

private static PropCandidate FirstDottedProperty(AasContext db, int? smId = null)
{
var candidate = (from sme in db.SMESets
join v in db.ValueSets on sme.Id equals v.SMEId
join sm in db.SMSets on sme.SMId equals sm.Id
where sme.SMEType == "Prop"
&& sme.IdShortPath != null && sme.IdShortPath.Contains(".")
&& v.SValue != null && v.SValue != ""
&& sm.Identifier != null
&& (smId == null || sme.SMId == smId)
orderby sme.Id
select new { sm.Identifier, sme.IdShortPath, v.SValue })
.FirstOrDefault();

candidate.Should().NotBeNull("the imported test data should contain a nested string property");
return new PropCandidate(candidate!.Identifier!, candidate.IdShortPath!, candidate.SValue!);
}

[Fact]
public void Project_SameSubmodelFullPath_ReturnsStoredValue()
{
using var db = _fixture.CreateDbContext();
var candidate = FirstDottedProperty(db);

var rows = ProjectionOperator.Project(db, new DbProjectionRequest
{
SubmodelIdentifiers = [candidate.SubmodelIdentifier],
Paths = [new DbProjectionPath { RawPath = candidate.IdShortPath, ElementIdShortPath = candidate.IdShortPath }],
});

rows.Should().HaveCount(1);
var cell = rows[0].Cells[candidate.IdShortPath];
cell.Found.Should().BeTrue();
cell.SmeType.Should().Be("Prop");
cell.SourceSubmodelIdentifier.Should().Be(candidate.SubmodelIdentifier);
cell.Values.Should().Contain(v => v.SValue == candidate.SValue);
}

[Fact]
public void Project_AbsolutePathToHitSubmodel_ReturnsStoredValue()
{
using var db = _fixture.CreateDbContext();
var candidate = FirstDottedProperty(db);
var hitSubmodel = db.SMSets.Single(sm => sm.Identifier == candidate.SubmodelIdentifier);
hitSubmodel.IdShort.Should().NotBeNullOrWhiteSpace();

var rawPath = "/" + hitSubmodel.IdShort + "/" + candidate.IdShortPath;
var rows = ProjectionOperator.Project(db, new DbProjectionRequest
{
SubmodelIdentifiers = [candidate.SubmodelIdentifier],
Paths =
[
new DbProjectionPath
{
RawPath = rawPath,
TargetSubmodelIdShort = hitSubmodel.IdShort,
ElementIdShortPath = candidate.IdShortPath,
},
],
});

rows.Should().HaveCount(1);
var cell = rows[0].Cells[rawPath];
cell.Found.Should().BeTrue();
cell.SmeType.Should().Be("Prop");
cell.SourceSubmodelIdentifier.Should().Be(candidate.SubmodelIdentifier);
cell.Values.Should().Contain(v => v.SValue == candidate.SValue);
}

[Fact]
public void Project_CrossSubmodelPath_ResolvesSiblingOfSameAas()
{
using var db = _fixture.CreateDbContext();

// Ein Treffer-Submodel plus ein Geschwister-Submodel derselben AAS (verknüpft über die
// Shell-Referenzen in SMRefSets — in den Testdaten ist SMSets.AASId nicht gesetzt),
// dessen IdShort innerhalb der AAS eindeutig ist und das ein verschachteltes
// String-Property enthält.
var pair = (from hitRef in db.SMRefSets
join siblingRef in db.SMRefSets on hitRef.AASId equals siblingRef.AASId
where hitRef.AASId != null && hitRef.Id != siblingRef.Id
&& hitRef.Identifier != null && siblingRef.Identifier != null
join sibling in db.SMSets on siblingRef.Identifier equals sibling.Identifier
where sibling.IdShort != null
select new { HitIdentifier = hitRef.Identifier, hitRef.AASId, SiblingId = sibling.Id, SiblingIdShort = sibling.IdShort })
.ToList()
.FirstOrDefault(p =>
{
var refIdentifiers = db.SMRefSets
.Where(r => r.AASId == p.AASId && r.Identifier != null)
.Select(r => r.Identifier!)
.ToList();
return db.SMSets.Count(sm => sm.Identifier != null && refIdentifiers.Contains(sm.Identifier) && sm.IdShort == p.SiblingIdShort) == 1
&& db.SMESets.Any(sme => sme.SMId == p.SiblingId && sme.SMEType == "Prop"
&& sme.IdShortPath != null && sme.IdShortPath.Contains(".")
&& sme.ValueSets.Any(v => v.SValue != null && v.SValue != ""));
});

pair.Should().NotBeNull("the test data should contain an AAS with at least two submodels");
var target = FirstDottedProperty(db, pair!.SiblingId);

var rawPath = "/" + pair.SiblingIdShort + "/" + target.IdShortPath;
var rows = ProjectionOperator.Project(db, new DbProjectionRequest
{
SubmodelIdentifiers = [pair.HitIdentifier!],
Paths =
[
new DbProjectionPath
{
RawPath = rawPath,
TargetSubmodelIdShort = pair.SiblingIdShort,
ElementIdShortPath = target.IdShortPath,
},
],
});

rows.Should().HaveCount(1);
var cell = rows[0].Cells[rawPath];
cell.Found.Should().BeTrue();
cell.SourceSubmodelIdentifier.Should().Be(target.SubmodelIdentifier);
cell.Values.Should().Contain(v => v.SValue == target.SValue);
}

[Fact]
public void Project_UnknownPathOrSubmodel_YieldsNotFoundCells()
{
using var db = _fixture.CreateDbContext();
var candidate = FirstDottedProperty(db);

var rows = ProjectionOperator.Project(db, new DbProjectionRequest
{
SubmodelIdentifiers = [candidate.SubmodelIdentifier, "urn:does:not:exist"],
Paths =
[
new DbProjectionPath { RawPath = "No.Such.Path", ElementIdShortPath = "No.Such.Path" },
new DbProjectionPath
{
RawPath = "/NoSuchSubmodel/Some.Path",
TargetSubmodelIdShort = "NoSuchSubmodel",
ElementIdShortPath = "Some.Path",
},
],
});

rows.Should().HaveCount(2);
rows[0].SubmodelIdentifier.Should().Be(candidate.SubmodelIdentifier);
rows[0].Cells["No.Such.Path"].Found.Should().BeFalse();
rows[0].Cells["/NoSuchSubmodel/Some.Path"].Found.Should().BeFalse();
rows[1].Cells["No.Such.Path"].Found.Should().BeFalse();
}

[Fact]
public void Project_MultipleIdentifiers_PreservesRequestOrder()
{
using var db = _fixture.CreateDbContext();
var identifiers = db.SMSets
.Where(sm => sm.Identifier != null)
.OrderBy(sm => sm.Id)
.Select(sm => sm.Identifier!)
.Take(3)
.ToList();
identifiers.Should().NotBeEmpty();
identifiers.Reverse();

var rows = ProjectionOperator.Project(db, new DbProjectionRequest
{
SubmodelIdentifiers = identifiers,
Paths = [new DbProjectionPath { RawPath = "A.B", ElementIdShortPath = "A.B" }],
});

rows.Select(r => r.SubmodelIdentifier).Should().ContainInOrder(identifiers);
}
}
Loading
Loading