diff --git a/src/System Application/App/MCP/src/Configuration/Codeunits/MCPConfig.Codeunit.al b/src/System Application/App/MCP/src/Configuration/Codeunits/MCPConfig.Codeunit.al index d114f9161c..d6ae1dc9eb 100644 --- a/src/System Application/App/MCP/src/Configuration/Codeunits/MCPConfig.Codeunit.al +++ b/src/System Application/App/MCP/src/Configuration/Codeunits/MCPConfig.Codeunit.al @@ -230,4 +230,26 @@ codeunit 8350 "MCP Config" begin MCPConfigImplementation.DeleteEntraApplication(Name); end; + + /// + /// Exports the specified MCP configuration and its tools to a JSON stream. + /// + /// The SystemId (GUID) of the configuration to export. + /// The output stream to write the JSON to. + procedure ExportConfiguration(ConfigId: Guid; var OutStream: OutStream) + begin + MCPConfigImplementation.ExportConfiguration(ConfigId, OutStream); + end; + + /// + /// Imports an MCP configuration and its tools from a JSON stream. + /// + /// The input stream containing the JSON configuration. + /// The name for the imported configuration. + /// The description for the imported configuration. + /// The SystemId (GUID) of the imported configuration. + procedure ImportConfiguration(var InStream: InStream; NewName: Text[100]; NewDescription: Text[250]): Guid + begin + exit(MCPConfigImplementation.ImportConfiguration(InStream, NewName, NewDescription)); + end; } \ No newline at end of file diff --git a/src/System Application/App/MCP/src/Configuration/Codeunits/MCPConfigImplementation.Codeunit.al b/src/System Application/App/MCP/src/Configuration/Codeunits/MCPConfigImplementation.Codeunit.al index 99380e840e..ba878f94f8 100644 --- a/src/System Application/App/MCP/src/Configuration/Codeunits/MCPConfigImplementation.Codeunit.al +++ b/src/System Application/App/MCP/src/Configuration/Codeunits/MCPConfigImplementation.Codeunit.al @@ -47,6 +47,12 @@ codeunit 8351 "MCP Config Implementation" VSCodeAppNameLbl: Label 'VS Code', Locked = true; VSCodeAppDescriptionLbl: Label 'Visual Studio Code'; VSCodeClientIdLbl: Label 'aebc6443-996d-45c2-90f0-388ff96faa56', Locked = true; + ExportFileNameTxt: Label 'MCPConfig_%1_%2.json', Locked = true, Comment = '%1 = config name, %2 = date'; + ExportTitleTxt: Label 'Export Configuration'; + ImportTitleTxt: Label 'Import Configuration'; + JsonFilterTxt: Label 'JSON Files (*.json)|*.json'; + InvalidJsonErr: Label 'The selected file is not a valid configuration file.'; + ConfigNameExistsMsg: Label 'A configuration with the name ''%1'' already exists. Please provide a different name.', Comment = '%1 = configuration name'; #region Configurations internal procedure GetConfigurationIdByName(Name: Text[100]): Guid @@ -251,19 +257,6 @@ codeunit 8351 "MCP Config Implementation" MCPConfiguration.Insert(); end; - internal procedure CreateVSCodeEntraApplication() - var - MCPEntraApplication: Record "MCP Entra Application"; - begin - if MCPEntraApplication.Get(VSCodeAppNameLbl) then - exit; - - MCPEntraApplication.Name := VSCodeAppNameLbl; - MCPEntraApplication.Description := VSCodeAppDescriptionLbl; - Evaluate(MCPEntraApplication."Client ID", VSCodeClientIdLbl); - MCPEntraApplication.Insert(); - end; - internal procedure IsDefaultConfiguration(MCPConfiguration: Record "MCP Configuration"): Boolean begin exit(MCPConfiguration.Name = ''); @@ -639,6 +632,19 @@ codeunit 8351 "MCP Config Implementation" #endregion #region Connection String + internal procedure CreateVSCodeEntraApplication() + var + MCPEntraApplication: Record "MCP Entra Application"; + begin + if MCPEntraApplication.Get(VSCodeAppNameLbl) then + exit; + + MCPEntraApplication.Name := VSCodeAppNameLbl; + MCPEntraApplication.Description := VSCodeAppDescriptionLbl; + Evaluate(MCPEntraApplication."Client ID", VSCodeClientIdLbl); + MCPEntraApplication.Insert(); + end; + internal procedure ShowConnectionString(ConfigurationName: Text[100]) var MCPConnectionString: Page "MCP Connection String"; @@ -724,6 +730,189 @@ codeunit 8351 "MCP Config Implementation" end; #endregion + #region Export/Import + internal procedure ExportConfigurationToFile(ConfigId: Guid; ConfigName: Text[100]) + var + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + InStream: InStream; + FileName: Text; + begin + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + ExportConfiguration(ConfigId, OutStream); + TempBlob.CreateInStream(InStream, TextEncoding::UTF8); + FileName := StrSubstNo(ExportFileNameTxt, ConfigName, Format(Today(), 0, '--')); + DownloadFromStream(InStream, ExportTitleTxt, '', JsonFilterTxt, FileName); + end; + + internal procedure ImportConfigurationFromFile() + var + MCPConfiguration: Record "MCP Configuration"; + TempBlob: Codeunit "Temp Blob"; + MCPCopyConfig: Page "MCP Copy Config"; + InStream: InStream; + OutStream: OutStream; + FileName: Text; + ConfigName: Text[100]; + ConfigDescription: Text[250]; + begin + if not UploadIntoStream(ImportTitleTxt, '', JsonFilterTxt, FileName, InStream) then + exit; + + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + CopyStream(OutStream, InStream); + TempBlob.CreateInStream(InStream, TextEncoding::UTF8); + + if not GetConfigFromJson(InStream, ConfigName, ConfigDescription) then + Error(InvalidJsonErr); + + MCPConfiguration.SetRange(Name, ConfigName); + if not MCPConfiguration.IsEmpty() then begin + MCPCopyConfig.SetConfigName(ConfigName); + MCPCopyConfig.SetConfigDescription(ConfigDescription); + MCPCopyConfig.SetInstructionMessage(StrSubstNo(ConfigNameExistsMsg, ConfigName)); + MCPCopyConfig.LookupMode := true; + if MCPCopyConfig.RunModal() <> Action::LookupOK then + exit; + ConfigName := MCPCopyConfig.GetConfigName(); + ConfigDescription := MCPCopyConfig.GetConfigDescription(); + end; + + TempBlob.CreateInStream(InStream, TextEncoding::UTF8); + ImportConfiguration(InStream, ConfigName, ConfigDescription); + end; + + internal procedure ExportConfiguration(ConfigId: Guid; var OutStream: OutStream) + var + MCPConfiguration: Record "MCP Configuration"; + MCPConfigurationTool: Record "MCP Configuration Tool"; + ConfigJson: JsonObject; + ToolsArray: JsonArray; + ToolJson: JsonObject; + OutputText: Text; + begin + if not MCPConfiguration.GetBySystemId(ConfigId) then + exit; + + ConfigJson.Add('name', MCPConfiguration.Name); + ConfigJson.Add('description', MCPConfiguration.Description); + ConfigJson.Add('enableDynamicToolMode', MCPConfiguration.EnableDynamicToolMode); + ConfigJson.Add('discoverReadOnlyObjects', MCPConfiguration.DiscoverReadOnlyObjects); + ConfigJson.Add('allowProdChanges', MCPConfiguration.AllowProdChanges); + + MCPConfigurationTool.SetRange(ID, ConfigId); + if MCPConfigurationTool.FindSet() then + repeat + Clear(ToolJson); + ToolJson.Add('objectType', Format(MCPConfigurationTool."Object Type")); + ToolJson.Add('objectId', MCPConfigurationTool."Object ID"); + ToolJson.Add('allowRead', MCPConfigurationTool."Allow Read"); + ToolJson.Add('allowCreate', MCPConfigurationTool."Allow Create"); + ToolJson.Add('allowModify', MCPConfigurationTool."Allow Modify"); + ToolJson.Add('allowDelete', MCPConfigurationTool."Allow Delete"); + ToolJson.Add('allowBoundActions', MCPConfigurationTool."Allow Bound Actions"); + ToolsArray.Add(ToolJson); + until MCPConfigurationTool.Next() = 0; + + ConfigJson.Add('tools', ToolsArray); + ConfigJson.WriteTo(OutputText); + OutStream.WriteText(OutputText); + end; + + local procedure GetConfigFromJson(var InStream: InStream; var ConfigName: Text[100]; var ConfigDescription: Text[250]): Boolean + var + ConfigJson: JsonObject; + JsonToken: JsonToken; + InputText: Text; + begin + InStream.ReadText(InputText); + if not ConfigJson.ReadFrom(InputText) then + exit(false); + + if not ConfigJson.Get('name', JsonToken) then + exit(false); + + ConfigName := CopyStr(JsonToken.AsValue().AsText(), 1, MaxStrLen(ConfigName)); + + if ConfigJson.Get('description', JsonToken) then + ConfigDescription := CopyStr(JsonToken.AsValue().AsText(), 1, MaxStrLen(ConfigDescription)); + + exit(true); + end; + + internal procedure ImportConfiguration(var InStream: InStream; NewName: Text[100]; NewDescription: Text[250]): Guid + var + MCPConfiguration: Record "MCP Configuration"; + ConfigJson: JsonObject; + ToolsArray: JsonArray; + ToolToken: JsonToken; + InputText: Text; + begin + InStream.ReadText(InputText); + if not ConfigJson.ReadFrom(InputText) then + exit; + + MCPConfiguration.Name := NewName; + MCPConfiguration.Description := NewDescription; + MCPConfiguration.Active := false; + + if ConfigJson.Contains('enableDynamicToolMode') then + MCPConfiguration.EnableDynamicToolMode := ConfigJson.GetBoolean('enableDynamicToolMode'); + + if ConfigJson.Contains('discoverReadOnlyObjects') then + MCPConfiguration.DiscoverReadOnlyObjects := ConfigJson.GetBoolean('discoverReadOnlyObjects'); + + if ConfigJson.Contains('allowProdChanges') then + MCPConfiguration.AllowProdChanges := ConfigJson.GetBoolean('allowProdChanges'); + + MCPConfiguration.Insert(); + LogConfigurationCreated(MCPConfiguration); + + if ConfigJson.Contains('tools') then begin + ToolsArray := ConfigJson.GetArray('tools'); + foreach ToolToken in ToolsArray do + ImportTool(MCPConfiguration.SystemId, ToolToken.AsObject()); + end; + + exit(MCPConfiguration.SystemId); + end; + + local procedure ImportTool(ConfigId: Guid; ToolJson: JsonObject) + var + MCPConfigurationTool: Record "MCP Configuration Tool"; + ObjectTypeText: Text; + begin + MCPConfigurationTool.Init(); + MCPConfigurationTool.ID := ConfigId; + + if ToolJson.Contains('objectType') then begin + ObjectTypeText := ToolJson.GetText('objectType'); + if ObjectTypeText = 'Page' then + MCPConfigurationTool."Object Type" := MCPConfigurationTool."Object Type"::Page; + end; + + if ToolJson.Contains('objectId') then + MCPConfigurationTool."Object ID" := ToolJson.GetInteger('objectId'); + + if ToolJson.Contains('allowRead') then + MCPConfigurationTool."Allow Read" := ToolJson.GetBoolean('allowRead'); + + if ToolJson.Contains('allowCreate') then + MCPConfigurationTool."Allow Create" := ToolJson.GetBoolean('allowCreate'); + + if ToolJson.Contains('allowModify') then + MCPConfigurationTool."Allow Modify" := ToolJson.GetBoolean('allowModify'); + + if ToolJson.Contains('allowDelete') then + MCPConfigurationTool."Allow Delete" := ToolJson.GetBoolean('allowDelete'); + + if ToolJson.Contains('allowBoundActions') then + MCPConfigurationTool."Allow Bound Actions" := ToolJson.GetBoolean('allowBoundActions'); + + MCPConfigurationTool.Insert(); + end; + #endregion + #if not CLEAN28 internal procedure IsFeatureEnabled(): Boolean var diff --git a/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigCard.Page.al b/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigCard.Page.al index 1ee87dbac9..50c373a0c9 100644 --- a/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigCard.Page.al +++ b/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigCard.Page.al @@ -142,7 +142,7 @@ page 8351 "MCP Config Card" { Caption = 'Connection String'; ToolTip = 'Generate a connection string for this MCP configuration to use in your MCP client.'; - Image = Export; + Image = Link; trigger OnAction() begin diff --git a/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigList.Page.al b/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigList.Page.al index 5c172350c9..33f6ec5ece 100644 --- a/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigList.Page.al +++ b/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigList.Page.al @@ -51,6 +51,7 @@ page 8350 "MCP Config List" ToolTip = 'Creates a copy of the current MCP configuration, including its tools and permissions.'; Image = Copy; AccessByPermission = tabledata "MCP Configuration" = IM; + Scope = Repeater; trigger OnAction() var @@ -71,7 +72,7 @@ page 8350 "MCP Config List" { Caption = 'Connection String'; ToolTip = 'Generate a connection string for this MCP configuration to use in your MCP client.'; - Image = Export; + Image = Link; Scope = Repeater; trigger OnAction() @@ -88,6 +89,35 @@ page 8350 "MCP Config List" Image = Setup; RunObject = page "MCP Entra Application List"; } + action(ExportConfiguration) + { + Caption = 'Export'; + ToolTip = 'Export the selected MCP configuration and its tools to a JSON file.'; + Image = Export; + Scope = Repeater; + + trigger OnAction() + var + MCPConfigImplementation: Codeunit "MCP Config Implementation"; + begin + MCPConfigImplementation.ExportConfigurationToFile(Rec.SystemId, Rec.Name); + end; + } + action(ImportConfiguration) + { + Caption = 'Import'; + ToolTip = 'Import an MCP configuration and its tools from a JSON file.'; + Image = Import; + AccessByPermission = tabledata "MCP Configuration" = IM; + + trigger OnAction() + var + MCPConfigImplementation: Codeunit "MCP Config Implementation"; + begin + MCPConfigImplementation.ImportConfigurationFromFile(); + CurrPage.Update(false); + end; + } } } area(Promoted) @@ -99,6 +129,8 @@ page 8350 "MCP Config List" actionref(Promoted_GenerateConnectionString; GenerateConnectionString) { } actionref(Promoted_MCPEntraApplications; MCPEntraApplications) { } + actionref(Promoted_ExportConfiguration; ExportConfiguration) { } + actionref(Promoted_ImportConfiguration; ImportConfiguration) { } } } } @@ -131,4 +163,5 @@ page 8350 "MCP Config List" FeatureNotEnabledErr: Label 'MCP server feature is not enabled. Please contact your system administrator to enable the feature.'; GoToFeatureManagementLbl: Label 'Go to Feature Management'; #endif + } \ No newline at end of file diff --git a/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigToolList.Page.al b/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigToolList.Page.al index eb8958bd5f..03f61c3736 100644 --- a/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigToolList.Page.al +++ b/src/System Application/App/MCP/src/Configuration/Pages/MCPConfigToolList.Page.al @@ -41,6 +41,8 @@ page 8352 "MCP Config Tool List" exit; repeat + if MCPConfigImplementation.CheckAPIToolExists(Rec.ID, PageMetadata.ID) then + continue; MCPConfig.CreateAPITool(Rec.ID, PageMetadata.ID); until PageMetadata.Next() = 0; @@ -104,6 +106,8 @@ page 8352 "MCP Config Tool List" exit; repeat + if MCPConfigImplementation.CheckAPIToolExists(Rec.ID, PageMetadata.ID) then + continue; MCPConfig.CreateAPITool(Rec.ID, PageMetadata.ID); until PageMetadata.Next() = 0; diff --git a/src/System Application/App/MCP/src/Configuration/Pages/MCPCopyConfig.Page.al b/src/System Application/App/MCP/src/Configuration/Pages/MCPCopyConfig.Page.al index 18e2110f29..66d5bfcc62 100644 --- a/src/System Application/App/MCP/src/Configuration/Pages/MCPCopyConfig.Page.al +++ b/src/System Application/App/MCP/src/Configuration/Pages/MCPCopyConfig.Page.al @@ -19,6 +19,15 @@ page 8355 "MCP Copy Config" group(Control1) { ShowCaption = false; + field(InstructionMessage; InstructionMessage) + { + ApplicationArea = All; + Editable = false; + ShowCaption = false; + MultiLine = true; + Style = Attention; + Visible = InstructionMessage <> ''; + } field(ConfigName; ConfigName) { ApplicationArea = All; @@ -38,6 +47,7 @@ page 8355 "MCP Copy Config" var ConfigName: Text[100]; ConfigDescription: Text[250]; + InstructionMessage: Text; internal procedure GetConfigName(): Text[100] begin @@ -48,4 +58,19 @@ page 8355 "MCP Copy Config" begin exit(ConfigDescription); end; + + internal procedure SetConfigName(NewConfigName: Text[100]) + begin + ConfigName := NewConfigName; + end; + + internal procedure SetConfigDescription(NewConfigDescription: Text[250]) + begin + ConfigDescription := NewConfigDescription; + end; + + internal procedure SetInstructionMessage(NewInstructionMessage: Text) + begin + InstructionMessage := NewInstructionMessage; + end; } \ No newline at end of file diff --git a/src/System Application/Test/MCP/app.json b/src/System Application/Test/MCP/app.json index 36e090514a..66ff28ebb5 100644 --- a/src/System Application/Test/MCP/app.json +++ b/src/System Application/Test/MCP/app.json @@ -40,6 +40,12 @@ "name": "Environment Information Test Library", "publisher": "Microsoft", "version": "28.0.0.0" + }, + { + "id": "e31ad830-3d46-472e-afeb-1d3d35247943", + "name": "BLOB Storage", + "publisher": "Microsoft", + "version": "28.0.0.0" } ], "screenshots": [], diff --git a/src/System Application/Test/MCP/src/MCPConfigTest.Codeunit.al b/src/System Application/Test/MCP/src/MCPConfigTest.Codeunit.al index c7b10b1191..68b8f33a0a 100644 --- a/src/System Application/Test/MCP/src/MCPConfigTest.Codeunit.al +++ b/src/System Application/Test/MCP/src/MCPConfigTest.Codeunit.al @@ -9,6 +9,7 @@ using System.MCP; using System.Reflection; using System.TestLibraries.MCP; using System.TestLibraries.Utilities; +using System.Utilities; codeunit 130130 "MCP Config Test" { @@ -760,6 +761,83 @@ codeunit 130130 "MCP Config Test" Assert.RecordIsEmpty(MCPConfigWarning); end; + [Test] + procedure TestExportConfiguration() + var + MCPConfiguration: Record "MCP Configuration"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + InStream: InStream; + ConfigId: Guid; + JsonText: Text; + ConfigJson: JsonObject; + JsonToken: JsonToken; + begin + // [GIVEN] Configuration with two tools is created + ConfigId := CreateMCPConfig(false, true, true, true); + CreateMCPConfigTool(ConfigId); + CreateMCPConfigTool(ConfigId); + MCPConfiguration.GetBySystemId(ConfigId); + + // [WHEN] Export configuration is called + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + MCPConfig.ExportConfiguration(ConfigId, OutStream); + + // [THEN] JSON contains configuration data + TempBlob.CreateInStream(InStream, TextEncoding::UTF8); + InStream.ReadText(JsonText); + Assert.IsTrue(ConfigJson.ReadFrom(JsonText), 'Invalid JSON exported'); + + ConfigJson.Get('name', JsonToken); + Assert.AreEqual(MCPConfiguration.Name, JsonToken.AsValue().AsText(), 'Name mismatch'); + + ConfigJson.Get('enableDynamicToolMode', JsonToken); + Assert.AreEqual(true, JsonToken.AsValue().AsBoolean(), 'EnableDynamicToolMode mismatch'); + + ConfigJson.Get('tools', JsonToken); + Assert.AreEqual(2, JsonToken.AsArray().Count(), 'Tools count mismatch'); + end; + + [Test] + procedure TestImportConfiguration() + var + MCPConfiguration: Record "MCP Configuration"; + MCPConfigurationTool: Record "MCP Configuration Tool"; + TempBlob: Codeunit "Temp Blob"; + OutStream: OutStream; + InStream: InStream; + SourceConfigId: Guid; + ImportedConfigId: Guid; + NewName: Text[100]; + NewDescription: Text[250]; + begin + // [GIVEN] Configuration with two tools is created and exported + SourceConfigId := CreateMCPConfig(false, true, true, true); + CreateMCPConfigTool(SourceConfigId); + CreateMCPConfigTool(SourceConfigId); + + TempBlob.CreateOutStream(OutStream, TextEncoding::UTF8); + MCPConfig.ExportConfiguration(SourceConfigId, OutStream); + + // [WHEN] Import configuration is called with new name + NewName := CopyStr(Format(CreateGuid()), 1, 100); + NewDescription := 'Imported configuration'; + TempBlob.CreateInStream(InStream, TextEncoding::UTF8); + ImportedConfigId := MCPConfig.ImportConfiguration(InStream, NewName, NewDescription); + + // [THEN] New configuration is created with imported settings + MCPConfiguration.GetBySystemId(ImportedConfigId); + Assert.AreEqual(NewName, MCPConfiguration.Name, 'Name mismatch'); + Assert.AreEqual(NewDescription, MCPConfiguration.Description, 'Description mismatch'); + Assert.IsFalse(MCPConfiguration.Active, 'Imported config should be inactive'); + Assert.IsTrue(MCPConfiguration.EnableDynamicToolMode, 'EnableDynamicToolMode mismatch'); + Assert.IsTrue(MCPConfiguration.DiscoverReadOnlyObjects, 'DiscoverReadOnlyObjects mismatch'); + + // [THEN] Tools are imported + MCPConfigurationTool.SetRange(ID, ImportedConfigId); + Assert.RecordCount(MCPConfigurationTool, 2); + end; + local procedure CreateMCPConfig(Active: Boolean; DynamicToolMode: Boolean; AllowCreateUpdateDeleteTools: Boolean; DiscoverReadOnlyObjects: Boolean): Guid var MCPConfiguration: Record "MCP Configuration";