From fa19ee613e44549de2a8cee071ebddab0050f5a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:45:43 +0000 Subject: [PATCH 01/14] Initial plan From 44709fdae1fadd57eb926da901658a62e04e9a91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:51:42 +0000 Subject: [PATCH 02/14] Add NetInteractor.Mcp project with MCP server tools and unit tests Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- Directory.Build.props | 1 + Directory.Packages.props | 1 + NetInteractor.sln | 32 +++ src/NetInteractor.Mcp/ExecuteScriptResult.cs | 25 ++ src/NetInteractor.Mcp/GetRequestResult.cs | 38 +++ .../NetInteractor.Mcp.csproj | 23 ++ src/NetInteractor.Mcp/NetInteractorTools.cs | 218 ++++++++++++++++ src/NetInteractor.Mcp/PostRequestResult.cs | 38 +++ .../NetInteractor.Mcp.Test.csproj | 18 ++ .../NetInteractorToolsTests.cs | 240 ++++++++++++++++++ 10 files changed, 634 insertions(+) create mode 100644 src/NetInteractor.Mcp/ExecuteScriptResult.cs create mode 100644 src/NetInteractor.Mcp/GetRequestResult.cs create mode 100644 src/NetInteractor.Mcp/NetInteractor.Mcp.csproj create mode 100644 src/NetInteractor.Mcp/NetInteractorTools.cs create mode 100644 src/NetInteractor.Mcp/PostRequestResult.cs create mode 100644 test/NetInteractor.Mcp.Test/NetInteractor.Mcp.Test.csproj create mode 100644 test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 0f151ed..e794c6e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,7 @@ netstandard2.0;net8.0;net9.0;net10.0 + diff --git a/Directory.Packages.props b/Directory.Packages.props index 7238e77..da9a6c7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,7 @@ + diff --git a/NetInteractor.sln b/NetInteractor.sln index 8a05bf0..46b0362 100644 --- a/NetInteractor.sln +++ b/NetInteractor.sln @@ -13,6 +13,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.PuppeteerShar EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.Playwright", "src\NetInteractor.Playwright\NetInteractor.Playwright.csproj", "{1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.Mcp", "src\NetInteractor.Mcp\NetInteractor.Mcp.csproj", "{55944B84-EAC8-4723-90BB-C29B9FD433E4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetInteractor.Mcp.Test", "test\NetInteractor.Mcp.Test\NetInteractor.Mcp.Test.csproj", "{DD2E1705-6543-4068-8293-7531AEEDDF4F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,6 +77,30 @@ Global {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|x64.Build.0 = Release|Any CPU {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|x86.ActiveCfg = Release|Any CPU {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62}.Release|x86.Build.0 = Release|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Debug|x64.ActiveCfg = Debug|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Debug|x64.Build.0 = Debug|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Debug|x86.ActiveCfg = Debug|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Debug|x86.Build.0 = Debug|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Release|Any CPU.Build.0 = Release|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Release|x64.ActiveCfg = Release|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Release|x64.Build.0 = Release|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Release|x86.ActiveCfg = Release|Any CPU + {55944B84-EAC8-4723-90BB-C29B9FD433E4}.Release|x86.Build.0 = Release|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Debug|x64.ActiveCfg = Debug|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Debug|x64.Build.0 = Debug|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Debug|x86.ActiveCfg = Debug|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Debug|x86.Build.0 = Debug|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Release|Any CPU.Build.0 = Release|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Release|x64.ActiveCfg = Release|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Release|x64.Build.0 = Release|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Release|x86.ActiveCfg = Release|Any CPU + {DD2E1705-6543-4068-8293-7531AEEDDF4F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -80,5 +110,7 @@ Global {A9186BD7-EFFC-4B4D-B828-C4F4AA924C50} = {F5108642-5386-4F95-ACF8-8DA0AD621820} {96C0F6DB-7F80-4E41-B9E8-75C5F750B4BF} = {F5108642-5386-4F95-ACF8-8DA0AD621820} {1451EBF6-EF0F-4F7E-8F3E-7529AF5F9E62} = {F5108642-5386-4F95-ACF8-8DA0AD621820} + {55944B84-EAC8-4723-90BB-C29B9FD433E4} = {F5108642-5386-4F95-ACF8-8DA0AD621820} + {DD2E1705-6543-4068-8293-7531AEEDDF4F} = {0C88DD14-F956-CE84-757C-A364CCF449FC} EndGlobalSection EndGlobal diff --git a/src/NetInteractor.Mcp/ExecuteScriptResult.cs b/src/NetInteractor.Mcp/ExecuteScriptResult.cs new file mode 100644 index 0000000..37a7a06 --- /dev/null +++ b/src/NetInteractor.Mcp/ExecuteScriptResult.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace NetInteractor.Mcp +{ + /// + /// Result of executing a NetInteractor script. + /// + public class ExecuteScriptResult + { + /// + /// Indicates whether the script execution was successful. + /// + public bool Success { get; set; } + + /// + /// Message describing the result or error. + /// + public string? Message { get; set; } + + /// + /// Extracted output values from the script execution. + /// + public Dictionary? Outputs { get; set; } + } +} diff --git a/src/NetInteractor.Mcp/GetRequestResult.cs b/src/NetInteractor.Mcp/GetRequestResult.cs new file mode 100644 index 0000000..c658c9e --- /dev/null +++ b/src/NetInteractor.Mcp/GetRequestResult.cs @@ -0,0 +1,38 @@ +namespace NetInteractor.Mcp +{ + /// + /// Result of an HTTP GET request. + /// + public class GetRequestResult + { + /// + /// Indicates whether the request was successful (2xx status code). + /// + public bool Success { get; set; } + + /// + /// HTTP status code of the response. + /// + public int StatusCode { get; set; } + + /// + /// Final URL after any redirects. + /// + public string? Url { get; set; } + + /// + /// Value extracted using XPath (if xpath parameter was provided). + /// + public string? ExtractedValue { get; set; } + + /// + /// Raw HTML content (if no xpath was specified). + /// + public string? Html { get; set; } + + /// + /// Error message if the request failed. + /// + public string? Message { get; set; } + } +} diff --git a/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj b/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj new file mode 100644 index 0000000..0bd4d01 --- /dev/null +++ b/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj @@ -0,0 +1,23 @@ + + + net8.0;net9.0;net10.0 + enable + Kerry Jiang + MCP (Model Context Protocol) Server Tools for NetInteractor - enables AI agents like GitHub Copilot and Claude to execute web automation scripts. + Apache-2.0 + Web;Automation;MCP;ModelContextProtocol;AI;Copilot;Claude;WebAutomation;LLM;Tools + https://github.com/kerryjiang/NetInteractor.git + + + v$(PackageVersion).md + + + + + + + + + + + diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs new file mode 100644 index 0000000..2b88d92 --- /dev/null +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using NetInteractor.Config; +using NetInteractor.WebAccessors; + +namespace NetInteractor.Mcp +{ + /// + /// MCP Server Tools for NetInteractor web automation. + /// These tools enable AI agents to execute web interactions and automation scripts. + /// + [McpServerToolType] + public static class NetInteractorTools + { + /// + /// Executes a NetInteractor XML script for web automation. + /// + /// The XML script defining the web interaction workflow. Example: + /// <InteractConfig defaultTarget='Main'> + /// <target name='Main'> + /// <get url='https://example.com'> + /// <output name='title' xpath='//title' attr='text()' /> + /// </get> + /// </target> + /// </InteractConfig> + /// Optional comma-separated key=value pairs for script inputs (e.g., "BaseUrl=https://example.com,Username=user"). + /// Optional target name to execute. If not specified, the default target will be used. + /// The execution result containing outputs and status. + [McpServerTool(Name = "netinteractor_execute_script")] + [Description("Executes a NetInteractor XML script for web automation. Use this to run complex multi-step web workflows including GET requests, form submissions, data extraction, and conditional logic.")] + public static async Task ExecuteScriptAsync( + string script, + string? inputs = null, + string? target = null) + { + try + { + var webAccessor = new HttpClientWebAccessor(); + var executor = new InterationExecutor(webAccessor); + + var inputValues = ParseInputs(inputs); + var result = await executor.ExecuteAsync(script, inputValues, target); + + return new ExecuteScriptResult + { + Success = result.Ok, + Message = result.Message, + Outputs = result.Outputs != null ? ConvertOutputs(result.Outputs) : null + }; + } + catch (Exception ex) + { + return new ExecuteScriptResult + { + Success = false, + Message = $"Script execution failed: {ex.Message}" + }; + } + } + + /// + /// Performs an HTTP GET request and extracts data using XPath. + /// + /// The URL to fetch. + /// Optional XPath expression to extract content from the HTML. + /// Optional attribute to extract from the XPath result (e.g., 'href', 'src'). Use 'text()' for text content. + /// The result containing the extracted content or raw HTML. + [McpServerTool(Name = "netinteractor_get")] + [Description("Performs an HTTP GET request and optionally extracts data using XPath. Use this for simple web scraping and data extraction from web pages.")] + public static async Task GetAsync( + string url, + string? xpath = null, + string? attribute = null) + { + try + { + var webAccessor = new HttpClientWebAccessor(); + var response = await webAccessor.GetAsync(url); + + var result = new GetRequestResult + { + Success = response.StatusCode >= 200 && response.StatusCode < 300, + StatusCode = response.StatusCode, + Url = response.Url + }; + + if (!string.IsNullOrEmpty(xpath)) + { + var pageInfo = new PageInfo(response.Url ?? url, response.Html ?? string.Empty); + var extractedValue = ExtractValue(pageInfo, xpath, attribute); + result.ExtractedValue = extractedValue; + } + else + { + result.Html = response.Html; + } + + return result; + } + catch (Exception ex) + { + return new GetRequestResult + { + Success = false, + StatusCode = 0, + Message = $"GET request failed: {ex.Message}" + }; + } + } + + /// + /// Performs an HTTP POST request with form data. + /// + /// The URL to post to. + /// Comma-separated key=value pairs for form data (e.g., "name=John,email=john@example.com"). + /// Optional XPath expression to extract content from the response HTML. + /// Optional attribute to extract from the XPath result. + /// The result containing the extracted content or raw HTML. + [McpServerTool(Name = "netinteractor_post")] + [Description("Performs an HTTP POST request with form data and optionally extracts data from the response using XPath. Use this for submitting forms and processing responses.")] + public static async Task PostAsync( + string url, + string formData, + string? xpath = null, + string? attribute = null) + { + try + { + var webAccessor = new HttpClientWebAccessor(); + var formValues = ParseInputs(formData); + var response = await webAccessor.PostAsync(url, formValues); + + var result = new PostRequestResult + { + Success = response.StatusCode >= 200 && response.StatusCode < 300, + StatusCode = response.StatusCode, + Url = response.Url + }; + + if (!string.IsNullOrEmpty(xpath)) + { + var pageInfo = new PageInfo(response.Url ?? url, response.Html ?? string.Empty); + var extractedValue = ExtractValue(pageInfo, xpath, attribute); + result.ExtractedValue = extractedValue; + } + else + { + result.Html = response.Html; + } + + return result; + } + catch (Exception ex) + { + return new PostRequestResult + { + Success = false, + StatusCode = 0, + Message = $"POST request failed: {ex.Message}" + }; + } + } + + private static NameValueCollection ParseInputs(string? inputs) + { + var result = new NameValueCollection(); + + if (string.IsNullOrEmpty(inputs)) + return result; + + var pairs = inputs.Split(','); + foreach (var pair in pairs) + { + var keyValue = pair.Split(['='], 2); + if (keyValue.Length == 2) + { + result[keyValue[0].Trim()] = keyValue[1].Trim(); + } + } + + return result; + } + + private static string? ExtractValue(PageInfo pageInfo, string xpath, string? attribute) + { + var node = pageInfo.Document.DocumentNode.SelectSingleNode(xpath); + + if (node == null) + return null; + + if (string.IsNullOrEmpty(attribute) || attribute.Equals("text()", StringComparison.OrdinalIgnoreCase)) + { + return node.InnerText?.Trim(); + } + + return node.GetAttributeValue(attribute, null); + } + + private static System.Collections.Generic.Dictionary? ConvertOutputs(NameValueCollection outputs) + { + if (outputs == null || outputs.Count == 0) + return null; + + var dict = new System.Collections.Generic.Dictionary(); + foreach (string? key in outputs.AllKeys) + { + if (key != null) + { + dict[key] = outputs[key] ?? string.Empty; + } + } + return dict; + } + } +} diff --git a/src/NetInteractor.Mcp/PostRequestResult.cs b/src/NetInteractor.Mcp/PostRequestResult.cs new file mode 100644 index 0000000..3a54af4 --- /dev/null +++ b/src/NetInteractor.Mcp/PostRequestResult.cs @@ -0,0 +1,38 @@ +namespace NetInteractor.Mcp +{ + /// + /// Result of an HTTP POST request. + /// + public class PostRequestResult + { + /// + /// Indicates whether the request was successful (2xx status code). + /// + public bool Success { get; set; } + + /// + /// HTTP status code of the response. + /// + public int StatusCode { get; set; } + + /// + /// Final URL after any redirects. + /// + public string? Url { get; set; } + + /// + /// Value extracted using XPath (if xpath parameter was provided). + /// + public string? ExtractedValue { get; set; } + + /// + /// Raw HTML content (if no xpath was specified). + /// + public string? Html { get; set; } + + /// + /// Error message if the request failed. + /// + public string? Message { get; set; } + } +} diff --git a/test/NetInteractor.Mcp.Test/NetInteractor.Mcp.Test.csproj b/test/NetInteractor.Mcp.Test/NetInteractor.Mcp.Test.csproj new file mode 100644 index 0000000..9e8c847 --- /dev/null +++ b/test/NetInteractor.Mcp.Test/NetInteractor.Mcp.Test.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + false + + + + + + + + + + + + + diff --git a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs new file mode 100644 index 0000000..32627e4 --- /dev/null +++ b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs @@ -0,0 +1,240 @@ +using System.Threading.Tasks; +using NetInteractor.Mcp; +using NetInteractor.Test.TestWebApp; +using Xunit; + +namespace NetInteractor.Mcp.Test +{ + public class NetInteractorToolsTests : IClassFixture + { + private readonly TestWebApplicationFactory _factory; + private readonly string _baseUrl; + + public NetInteractorToolsTests(TestWebApplicationFixture fixture) + { + _factory = fixture.Factory; + _baseUrl = fixture.Factory.ServerUrl; + } + + [Fact] + public async Task GetAsync_SimpleRequest_ReturnsHtml() + { + // Act + var result = await NetInteractorTools.GetAsync($"{_baseUrl}/"); + + // Assert + Assert.True(result.Success); + Assert.Equal(200, result.StatusCode); + Assert.NotNull(result.Html); + Assert.Contains("Welcome to Test Shop", result.Html); + } + + [Fact] + public async Task GetAsync_WithXPath_ExtractsTitle() + { + // Act + var result = await NetInteractorTools.GetAsync($"{_baseUrl}/", "//h1", "text()"); + + // Assert + Assert.True(result.Success); + Assert.Equal(200, result.StatusCode); + Assert.Equal("Welcome to Test Shop", result.ExtractedValue); + Assert.Null(result.Html); + } + + [Fact] + public async Task GetAsync_WithXPath_ExtractsAttribute() + { + // Act + var result = await NetInteractorTools.GetAsync($"{_baseUrl}/data", "//img", "src"); + + // Assert + Assert.True(result.Success); + Assert.Equal(200, result.StatusCode); + Assert.Equal("/images/test.png", result.ExtractedValue); + } + + [Fact] + public async Task GetAsync_InvalidUrl_ReturnsFailed() + { + // Act + var result = await NetInteractorTools.GetAsync("http://invalid-nonexistent-domain-123456789.com/"); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + } + + [Fact] + public async Task PostAsync_SimplePost_ReturnsSuccess() + { + // Arrange + var formData = "billing_name=Test User,email=test@example.com,billing_address=123 Main St,billing_city=Seattle,billing_state=WA,billing_zip=98101,billing_country=USA,credit_card_number=4111111111111111,credit_card_month=12,credit_card_year=2027"; + + // Act + var result = await NetInteractorTools.PostAsync($"{_baseUrl}/checkout/submit", formData); + + // Assert + Assert.True(result.Success); + Assert.Equal(200, result.StatusCode); + Assert.NotNull(result.Html); + Assert.Contains("Test User", result.Html); + } + + [Fact] + public async Task PostAsync_WithXPath_ExtractsValue() + { + // Arrange + var formData = "billing_name=Jane Doe,email=jane@example.com,billing_address=456 Oak St,billing_city=Portland,billing_state=OR,billing_zip=97201,billing_country=USA,credit_card_number=4111111111111111,credit_card_month=12,credit_card_year=2027"; + + // Act + var result = await NetInteractorTools.PostAsync($"{_baseUrl}/checkout/submit", formData, "//span[@class='customer-name']", "text()"); + + // Assert + Assert.True(result.Success); + Assert.Equal(200, result.StatusCode); + Assert.Equal("Jane Doe", result.ExtractedValue); + } + + [Fact] + public async Task ExecuteScriptAsync_SimpleGetScript_ExtractsTitle() + { + // Arrange + var script = @" + + + + + + "; + + // Act + var result = await NetInteractorTools.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); + + // Assert + Assert.True(result.Success, result.Message); + Assert.NotNull(result.Outputs); + Assert.Equal("Welcome to Test Shop", result.Outputs["title"]); + } + + [Fact] + public async Task ExecuteScriptAsync_WithSpecificTarget_ExecutesTarget() + { + // Arrange + var script = @" + + + + + + + + + + + "; + + // Act + var result = await NetInteractorTools.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}", "Products"); + + // Assert + Assert.True(result.Success, result.Message); + Assert.NotNull(result.Outputs); + Assert.Equal("Products", result.Outputs["productTitle"]); + } + + [Fact] + public async Task ExecuteScriptAsync_InvalidScript_ReturnsFailed() + { + // Arrange + var invalidScript = @""; + + // Act + var result = await NetInteractorTools.ExecuteScriptAsync(invalidScript); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Message); + } + + [Fact] + public async Task ExecuteScriptAsync_NoTargetSpecified_ReturnsError() + { + // Arrange - Script without defaultTarget and no target parameter + var script = @" + + + + "; + + // Act + var result = await NetInteractorTools.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); + + // Assert + Assert.False(result.Success); + Assert.Contains("target", result.Message, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteScriptAsync_MultipleOutputs_ExtractsAllValues() + { + // Arrange + var script = @" + + + + + + + "; + + // Act + var result = await NetInteractorTools.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); + + // Assert + Assert.True(result.Success, result.Message); + Assert.NotNull(result.Outputs); + Assert.Equal("Data Extraction Test", result.Outputs["title"]); + Assert.Equal("/images/test.png", result.Outputs["imageSrc"]); + } + + [Fact] + public async Task ExecuteScriptAsync_WithNullInputs_ExecutesSuccessfully() + { + // Arrange + var script = @" + + + + + + "; + + // Act + var result = await NetInteractorTools.ExecuteScriptAsync(script, null); + + // Assert + Assert.True(result.Success, result.Message); + Assert.NotNull(result.Outputs); + Assert.Equal("Welcome to Test Shop", result.Outputs["title"]); + } + } + + /// + /// Shared test fixture that provides a single TestWebApplicationFactory instance for all tests. + /// + public class TestWebApplicationFixture : System.IDisposable + { + public TestWebApplicationFactory Factory { get; } + + public TestWebApplicationFixture() + { + Factory = new TestWebApplicationFactory(ServerMode.Kestrel); + } + + public void Dispose() + { + Factory?.Dispose(); + } + } +} From 963e8035c1a7afcc8b542944d63de2677f5cd65b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:55:05 +0000 Subject: [PATCH 03/14] Fix nullable warning in NetInteractorTools.cs Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- src/NetInteractor.Mcp/NetInteractorTools.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs index 2b88d92..f8c11e0 100644 --- a/src/NetInteractor.Mcp/NetInteractorTools.cs +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -196,7 +196,7 @@ private static NameValueCollection ParseInputs(string? inputs) return node.InnerText?.Trim(); } - return node.GetAttributeValue(attribute, null); + return node.GetAttributeValue(attribute, string.Empty) is { Length: > 0 } value ? value : null; } private static System.Collections.Generic.Dictionary? ConvertOutputs(NameValueCollection outputs) From be23b4fe2bc36a7a139ff2a07be4b304c1962782 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:11:56 +0000 Subject: [PATCH 04/14] Refactor NetInteractor.Mcp to class-based MCP tool, remove Get/Post tools Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- src/NetInteractor.Mcp/GetRequestResult.cs | 38 ---- src/NetInteractor.Mcp/NetInteractorTools.cs | 205 +++++++++--------- src/NetInteractor.Mcp/PostRequestResult.cs | 38 ---- .../NetInteractorToolsTests.cs | 132 ++++------- 4 files changed, 141 insertions(+), 272 deletions(-) delete mode 100644 src/NetInteractor.Mcp/GetRequestResult.cs delete mode 100644 src/NetInteractor.Mcp/PostRequestResult.cs diff --git a/src/NetInteractor.Mcp/GetRequestResult.cs b/src/NetInteractor.Mcp/GetRequestResult.cs deleted file mode 100644 index c658c9e..0000000 --- a/src/NetInteractor.Mcp/GetRequestResult.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace NetInteractor.Mcp -{ - /// - /// Result of an HTTP GET request. - /// - public class GetRequestResult - { - /// - /// Indicates whether the request was successful (2xx status code). - /// - public bool Success { get; set; } - - /// - /// HTTP status code of the response. - /// - public int StatusCode { get; set; } - - /// - /// Final URL after any redirects. - /// - public string? Url { get; set; } - - /// - /// Value extracted using XPath (if xpath parameter was provided). - /// - public string? ExtractedValue { get; set; } - - /// - /// Raw HTML content (if no xpath was specified). - /// - public string? Html { get; set; } - - /// - /// Error message if the request failed. - /// - public string? Message { get; set; } - } -} diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs index f8c11e0..3c7d46d 100644 --- a/src/NetInteractor.Mcp/NetInteractorTools.cs +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -1,20 +1,38 @@ using System; +using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; using System.Threading.Tasks; using ModelContextProtocol.Server; -using NetInteractor.Config; using NetInteractor.WebAccessors; namespace NetInteractor.Mcp { /// - /// MCP Server Tools for NetInteractor web automation. - /// These tools enable AI agents to execute web interactions and automation scripts. + /// MCP Server Tool for NetInteractor web automation. + /// This tool enables AI agents to execute web interactions and automation scripts using InteractExecutor. /// - [McpServerToolType] - public static class NetInteractorTools + public class NetInteractorTool { + private readonly IWebAccessor _webAccessor; + + /// + /// Initializes a new instance of the NetInteractorTool class with the default HttpClientWebAccessor. + /// + public NetInteractorTool() + : this(new HttpClientWebAccessor()) + { + } + + /// + /// Initializes a new instance of the NetInteractorTool class with a custom web accessor. + /// + /// The web accessor to use for HTTP requests. + public NetInteractorTool(IWebAccessor webAccessor) + { + _webAccessor = webAccessor ?? throw new ArgumentNullException(nameof(webAccessor)); + } + /// /// Executes a NetInteractor XML script for web automation. /// @@ -31,15 +49,14 @@ public static class NetInteractorTools /// The execution result containing outputs and status. [McpServerTool(Name = "netinteractor_execute_script")] [Description("Executes a NetInteractor XML script for web automation. Use this to run complex multi-step web workflows including GET requests, form submissions, data extraction, and conditional logic.")] - public static async Task ExecuteScriptAsync( + public async Task ExecuteScriptAsync( string script, string? inputs = null, string? target = null) { try { - var webAccessor = new HttpClientWebAccessor(); - var executor = new InterationExecutor(webAccessor); + var executor = new InterationExecutor(_webAccessor); var inputValues = ParseInputs(inputs); var result = await executor.ExecuteAsync(script, inputValues, target); @@ -62,106 +79,67 @@ public static async Task ExecuteScriptAsync( } /// - /// Performs an HTTP GET request and extracts data using XPath. + /// Gets the input metadata for the ExecuteScript tool. /// - /// The URL to fetch. - /// Optional XPath expression to extract content from the HTML. - /// Optional attribute to extract from the XPath result (e.g., 'href', 'src'). Use 'text()' for text content. - /// The result containing the extracted content or raw HTML. - [McpServerTool(Name = "netinteractor_get")] - [Description("Performs an HTTP GET request and optionally extracts data using XPath. Use this for simple web scraping and data extraction from web pages.")] - public static async Task GetAsync( - string url, - string? xpath = null, - string? attribute = null) + /// A dictionary describing the input parameters. + public static Dictionary GetInputMetadata() { - try + return new Dictionary { - var webAccessor = new HttpClientWebAccessor(); - var response = await webAccessor.GetAsync(url); - - var result = new GetRequestResult + ["script"] = new ToolParameterMetadata { - Success = response.StatusCode >= 200 && response.StatusCode < 300, - StatusCode = response.StatusCode, - Url = response.Url - }; - - if (!string.IsNullOrEmpty(xpath)) + Name = "script", + Description = "The XML script defining the web interaction workflow", + Type = "string", + Required = true + }, + ["inputs"] = new ToolParameterMetadata { - var pageInfo = new PageInfo(response.Url ?? url, response.Html ?? string.Empty); - var extractedValue = ExtractValue(pageInfo, xpath, attribute); - result.ExtractedValue = extractedValue; - } - else + Name = "inputs", + Description = "Optional comma-separated key=value pairs for script inputs (e.g., 'BaseUrl=https://example.com,Username=user')", + Type = "string", + Required = false + }, + ["target"] = new ToolParameterMetadata { - result.Html = response.Html; + Name = "target", + Description = "Optional target name to execute. If not specified, the default target will be used", + Type = "string", + Required = false } - - return result; - } - catch (Exception ex) - { - return new GetRequestResult - { - Success = false, - StatusCode = 0, - Message = $"GET request failed: {ex.Message}" - }; - } + }; } /// - /// Performs an HTTP POST request with form data. + /// Gets the output metadata for the ExecuteScript tool. /// - /// The URL to post to. - /// Comma-separated key=value pairs for form data (e.g., "name=John,email=john@example.com"). - /// Optional XPath expression to extract content from the response HTML. - /// Optional attribute to extract from the XPath result. - /// The result containing the extracted content or raw HTML. - [McpServerTool(Name = "netinteractor_post")] - [Description("Performs an HTTP POST request with form data and optionally extracts data from the response using XPath. Use this for submitting forms and processing responses.")] - public static async Task PostAsync( - string url, - string formData, - string? xpath = null, - string? attribute = null) + /// A dictionary describing the output properties. + public static Dictionary GetOutputMetadata() { - try + return new Dictionary { - var webAccessor = new HttpClientWebAccessor(); - var formValues = ParseInputs(formData); - var response = await webAccessor.PostAsync(url, formValues); - - var result = new PostRequestResult + ["Success"] = new ToolParameterMetadata { - Success = response.StatusCode >= 200 && response.StatusCode < 300, - StatusCode = response.StatusCode, - Url = response.Url - }; - - if (!string.IsNullOrEmpty(xpath)) + Name = "Success", + Description = "Indicates whether the script execution was successful", + Type = "boolean", + Required = true + }, + ["Message"] = new ToolParameterMetadata { - var pageInfo = new PageInfo(response.Url ?? url, response.Html ?? string.Empty); - var extractedValue = ExtractValue(pageInfo, xpath, attribute); - result.ExtractedValue = extractedValue; - } - else + Name = "Message", + Description = "Message describing the result or error", + Type = "string", + Required = false + }, + ["Outputs"] = new ToolParameterMetadata { - result.Html = response.Html; + Name = "Outputs", + Description = "Extracted output values from the script execution as key-value pairs", + Type = "object", + Required = false } - - return result; - } - catch (Exception ex) - { - return new PostRequestResult - { - Success = false, - StatusCode = 0, - Message = $"POST request failed: {ex.Message}" - }; - } + }; } private static NameValueCollection ParseInputs(string? inputs) @@ -184,27 +162,12 @@ private static NameValueCollection ParseInputs(string? inputs) return result; } - private static string? ExtractValue(PageInfo pageInfo, string xpath, string? attribute) - { - var node = pageInfo.Document.DocumentNode.SelectSingleNode(xpath); - - if (node == null) - return null; - - if (string.IsNullOrEmpty(attribute) || attribute.Equals("text()", StringComparison.OrdinalIgnoreCase)) - { - return node.InnerText?.Trim(); - } - - return node.GetAttributeValue(attribute, string.Empty) is { Length: > 0 } value ? value : null; - } - - private static System.Collections.Generic.Dictionary? ConvertOutputs(NameValueCollection outputs) + private static Dictionary? ConvertOutputs(NameValueCollection outputs) { if (outputs == null || outputs.Count == 0) return null; - var dict = new System.Collections.Generic.Dictionary(); + var dict = new Dictionary(); foreach (string? key in outputs.AllKeys) { if (key != null) @@ -215,4 +178,30 @@ private static NameValueCollection ParseInputs(string? inputs) return dict; } } + + /// + /// Metadata describing a tool parameter (input or output). + /// + public class ToolParameterMetadata + { + /// + /// The name of the parameter. + /// + public string Name { get; set; } = string.Empty; + + /// + /// A description of the parameter. + /// + public string Description { get; set; } = string.Empty; + + /// + /// The type of the parameter (e.g., "string", "boolean", "object"). + /// + public string Type { get; set; } = string.Empty; + + /// + /// Indicates whether the parameter is required. + /// + public bool Required { get; set; } + } } diff --git a/src/NetInteractor.Mcp/PostRequestResult.cs b/src/NetInteractor.Mcp/PostRequestResult.cs deleted file mode 100644 index 3a54af4..0000000 --- a/src/NetInteractor.Mcp/PostRequestResult.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace NetInteractor.Mcp -{ - /// - /// Result of an HTTP POST request. - /// - public class PostRequestResult - { - /// - /// Indicates whether the request was successful (2xx status code). - /// - public bool Success { get; set; } - - /// - /// HTTP status code of the response. - /// - public int StatusCode { get; set; } - - /// - /// Final URL after any redirects. - /// - public string? Url { get; set; } - - /// - /// Value extracted using XPath (if xpath parameter was provided). - /// - public string? ExtractedValue { get; set; } - - /// - /// Raw HTML content (if no xpath was specified). - /// - public string? Html { get; set; } - - /// - /// Error message if the request failed. - /// - public string? Message { get; set; } - } -} diff --git a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs index 32627e4..f08be5d 100644 --- a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs +++ b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs @@ -5,95 +5,17 @@ namespace NetInteractor.Mcp.Test { - public class NetInteractorToolsTests : IClassFixture + public class NetInteractorToolTests : IClassFixture { private readonly TestWebApplicationFactory _factory; private readonly string _baseUrl; + private readonly NetInteractorTool _tool; - public NetInteractorToolsTests(TestWebApplicationFixture fixture) + public NetInteractorToolTests(TestWebApplicationFixture fixture) { _factory = fixture.Factory; _baseUrl = fixture.Factory.ServerUrl; - } - - [Fact] - public async Task GetAsync_SimpleRequest_ReturnsHtml() - { - // Act - var result = await NetInteractorTools.GetAsync($"{_baseUrl}/"); - - // Assert - Assert.True(result.Success); - Assert.Equal(200, result.StatusCode); - Assert.NotNull(result.Html); - Assert.Contains("Welcome to Test Shop", result.Html); - } - - [Fact] - public async Task GetAsync_WithXPath_ExtractsTitle() - { - // Act - var result = await NetInteractorTools.GetAsync($"{_baseUrl}/", "//h1", "text()"); - - // Assert - Assert.True(result.Success); - Assert.Equal(200, result.StatusCode); - Assert.Equal("Welcome to Test Shop", result.ExtractedValue); - Assert.Null(result.Html); - } - - [Fact] - public async Task GetAsync_WithXPath_ExtractsAttribute() - { - // Act - var result = await NetInteractorTools.GetAsync($"{_baseUrl}/data", "//img", "src"); - - // Assert - Assert.True(result.Success); - Assert.Equal(200, result.StatusCode); - Assert.Equal("/images/test.png", result.ExtractedValue); - } - - [Fact] - public async Task GetAsync_InvalidUrl_ReturnsFailed() - { - // Act - var result = await NetInteractorTools.GetAsync("http://invalid-nonexistent-domain-123456789.com/"); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.Message); - } - - [Fact] - public async Task PostAsync_SimplePost_ReturnsSuccess() - { - // Arrange - var formData = "billing_name=Test User,email=test@example.com,billing_address=123 Main St,billing_city=Seattle,billing_state=WA,billing_zip=98101,billing_country=USA,credit_card_number=4111111111111111,credit_card_month=12,credit_card_year=2027"; - - // Act - var result = await NetInteractorTools.PostAsync($"{_baseUrl}/checkout/submit", formData); - - // Assert - Assert.True(result.Success); - Assert.Equal(200, result.StatusCode); - Assert.NotNull(result.Html); - Assert.Contains("Test User", result.Html); - } - - [Fact] - public async Task PostAsync_WithXPath_ExtractsValue() - { - // Arrange - var formData = "billing_name=Jane Doe,email=jane@example.com,billing_address=456 Oak St,billing_city=Portland,billing_state=OR,billing_zip=97201,billing_country=USA,credit_card_number=4111111111111111,credit_card_month=12,credit_card_year=2027"; - - // Act - var result = await NetInteractorTools.PostAsync($"{_baseUrl}/checkout/submit", formData, "//span[@class='customer-name']", "text()"); - - // Assert - Assert.True(result.Success); - Assert.Equal(200, result.StatusCode); - Assert.Equal("Jane Doe", result.ExtractedValue); + _tool = new NetInteractorTool(); } [Fact] @@ -109,7 +31,7 @@ public async Task ExecuteScriptAsync_SimpleGetScript_ExtractsTitle() "; // Act - var result = await NetInteractorTools.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); + var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); // Assert Assert.True(result.Success, result.Message); @@ -135,7 +57,7 @@ public async Task ExecuteScriptAsync_WithSpecificTarget_ExecutesTarget() "; // Act - var result = await NetInteractorTools.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}", "Products"); + var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}", "Products"); // Assert Assert.True(result.Success, result.Message); @@ -150,7 +72,7 @@ public async Task ExecuteScriptAsync_InvalidScript_ReturnsFailed() var invalidScript = @""; // Act - var result = await NetInteractorTools.ExecuteScriptAsync(invalidScript); + var result = await _tool.ExecuteScriptAsync(invalidScript); // Assert Assert.False(result.Success); @@ -168,7 +90,7 @@ public async Task ExecuteScriptAsync_NoTargetSpecified_ReturnsError() "; // Act - var result = await NetInteractorTools.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); + var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); // Assert Assert.False(result.Success); @@ -189,7 +111,7 @@ public async Task ExecuteScriptAsync_MultipleOutputs_ExtractsAllValues() "; // Act - var result = await NetInteractorTools.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); + var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); // Assert Assert.True(result.Success, result.Message); @@ -211,13 +133,47 @@ public async Task ExecuteScriptAsync_WithNullInputs_ExecutesSuccessfully() "; // Act - var result = await NetInteractorTools.ExecuteScriptAsync(script, null); + var result = await _tool.ExecuteScriptAsync(script, null); // Assert Assert.True(result.Success, result.Message); Assert.NotNull(result.Outputs); Assert.Equal("Welcome to Test Shop", result.Outputs["title"]); } + + [Fact] + public void GetInputMetadata_ReturnsExpectedParameters() + { + // Act + var metadata = NetInteractorTool.GetInputMetadata(); + + // Assert + Assert.NotNull(metadata); + Assert.True(metadata.ContainsKey("script")); + Assert.True(metadata.ContainsKey("inputs")); + Assert.True(metadata.ContainsKey("target")); + + Assert.True(metadata["script"].Required); + Assert.False(metadata["inputs"].Required); + Assert.False(metadata["target"].Required); + } + + [Fact] + public void GetOutputMetadata_ReturnsExpectedParameters() + { + // Act + var metadata = NetInteractorTool.GetOutputMetadata(); + + // Assert + Assert.NotNull(metadata); + Assert.True(metadata.ContainsKey("Success")); + Assert.True(metadata.ContainsKey("Message")); + Assert.True(metadata.ContainsKey("Outputs")); + + Assert.Equal("boolean", metadata["Success"].Type); + Assert.Equal("string", metadata["Message"].Type); + Assert.Equal("object", metadata["Outputs"].Type); + } } /// From 4cd079f018fd8c9e109aedf3ee79f7ae1f377615 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:32:27 +0000 Subject: [PATCH 05/14] Address PR feedback: inherit from McpServerTool, use PlaywrightWebAccessor, use InteractionResult directly, add comprehensive script documentation Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- src/NetInteractor.Mcp/ExecuteScriptResult.cs | 25 -- .../NetInteractor.Mcp.csproj | 1 + src/NetInteractor.Mcp/NetInteractorTools.cs | 295 +++++++++++++++--- .../NetInteractorToolsTests.cs | 22 +- 4 files changed, 267 insertions(+), 76 deletions(-) delete mode 100644 src/NetInteractor.Mcp/ExecuteScriptResult.cs diff --git a/src/NetInteractor.Mcp/ExecuteScriptResult.cs b/src/NetInteractor.Mcp/ExecuteScriptResult.cs deleted file mode 100644 index 37a7a06..0000000 --- a/src/NetInteractor.Mcp/ExecuteScriptResult.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; - -namespace NetInteractor.Mcp -{ - /// - /// Result of executing a NetInteractor script. - /// - public class ExecuteScriptResult - { - /// - /// Indicates whether the script execution was successful. - /// - public bool Success { get; set; } - - /// - /// Message describing the result or error. - /// - public string? Message { get; set; } - - /// - /// Extracted output values from the script execution. - /// - public Dictionary? Outputs { get; set; } - } -} diff --git a/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj b/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj index 0bd4d01..c3346c0 100644 --- a/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj +++ b/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj @@ -16,6 +16,7 @@ + diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs index 3c7d46d..a09847a 100644 --- a/src/NetInteractor.Mcp/NetInteractorTools.cs +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; -using System.ComponentModel; +using System.Text.Json; +using System.Threading; using System.Threading.Tasks; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using NetInteractor.WebAccessors; @@ -11,16 +13,117 @@ namespace NetInteractor.Mcp /// /// MCP Server Tool for NetInteractor web automation. /// This tool enables AI agents to execute web interactions and automation scripts using InteractExecutor. + /// + /// NetInteractor scripts are XML-based configurations that define web automation workflows. + /// + /// ## Script Structure + /// + /// A script consists of: + /// - **InteractConfig**: The root element with an optional 'defaultTarget' attribute + /// - **target**: Named workflow targets containing action sequences + /// + /// ## Supported Actions + /// + /// ### 1. get - HTTP GET Request + /// Fetches a web page and optionally extracts data. + /// ```xml + /// <get url="https://example.com"> + /// <output name="title" xpath="//h1" attr="text()" /> + /// <output name="link" xpath="//a[@class='main']" attr="href" /> + /// </get> + /// ``` + /// Attributes: + /// - url: The URL to fetch (supports $(InputName) variable substitution) + /// - expectedHttpStatusCodes: Comma-separated list of expected HTTP status codes + /// + /// ### 2. post - HTTP POST Form Submission + /// Submits a form on the current page. + /// ```xml + /// <post formIndex="0"> + /// <formValue name="username" value="$(Username)" /> + /// <formValue name="password" value="$(Password)" /> + /// <output name="result" xpath="//div[@class='message']" attr="text()" /> + /// </post> + /// ``` + /// Attributes (use one to identify the form): + /// - formIndex: Zero-based index of the form on the page + /// - formName: The name attribute of the form + /// - action: The action URL of the form + /// - clientID: The id attribute of the form + /// + /// ### 3. if - Conditional Execution + /// Executes a child action only if a condition is met. + /// ```xml + /// <if property="$(ShouldLogin)" value="true"> + /// <call target="Login" /> + /// </if> + /// ``` + /// Attributes: + /// - property: The property/input to check + /// - value: The expected value + /// + /// ### 4. call - Call Another Target + /// Executes another named target. + /// ```xml + /// <call target="ExtractData" /> + /// ``` + /// Attributes: + /// - target: Name of the target to call + /// + /// ## Output Extraction + /// + /// The output element extracts data from HTML responses: + /// ```xml + /// <output name="outputName" xpath="//selector" attr="text()" /> + /// <output name="price" xpath="//span[@class='price']" regex="\\$([\\d.]+)" /> + /// ``` + /// Attributes: + /// - name: Name of the output variable + /// - xpath: XPath expression to select element + /// - attr: Attribute to extract ('text()' for inner text, or attribute name like 'href') + /// - regex: Optional regex to extract from the selected content + /// - isMultipleValue: If true, extracts all matching values + /// - expectedValue: Validates the extracted value matches expected + /// + /// ## Variable Substitution + /// + /// Use $(VariableName) syntax to reference input values: + /// ```xml + /// <get url="$(BaseUrl)/products" /> + /// ``` + /// + /// ## Example Complete Script + /// + /// ```xml + /// <InteractConfig defaultTarget="Main"> + /// <target name="Main"> + /// <get url="$(BaseUrl)/"> + /// <output name="title" xpath="//h1" attr="text()" /> + /// </get> + /// <if property="$(ShouldLogin)" value="true"> + /// <call target="Login" /> + /// </if> + /// </target> + /// <target name="Login"> + /// <get url="$(BaseUrl)/login" /> + /// <post formIndex="0"> + /// <formValue name="username" value="$(Username)" /> + /// <formValue name="password" value="$(Password)" /> + /// </post> + /// </target> + /// </InteractConfig> + /// ``` /// - public class NetInteractorTool + public class NetInteractorTool : McpServerTool { private readonly IWebAccessor _webAccessor; + private readonly Tool _protocolTool; /// - /// Initializes a new instance of the NetInteractorTool class with the default HttpClientWebAccessor. + /// Initializes a new instance of the NetInteractorTool class with the default PlaywrightWebAccessor. /// public NetInteractorTool() - : this(new HttpClientWebAccessor()) + : this(new PlaywrightWebAccessor()) { } @@ -31,25 +134,115 @@ public NetInteractorTool() public NetInteractorTool(IWebAccessor webAccessor) { _webAccessor = webAccessor ?? throw new ArgumentNullException(nameof(webAccessor)); + + // Define the input schema as JSON + var inputSchemaJson = """ + { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "XML script defining the web automation workflow. Structure:\n\n \n \n \n\n\nActions:\n- \n- \n- \n- \n\nVariables: Use $(InputName) syntax for input substitution." + }, + "inputs": { + "type": "string", + "description": "Comma-separated key=value pairs for script variable substitution. Example: 'BaseUrl=https://example.com,Username=admin,Password=secret'. These values replace $(Key) placeholders in the script." + }, + "target": { + "type": "string", + "description": "Name of the target to execute. If omitted, uses the defaultTarget specified in InteractConfig." + } + }, + "required": ["script"] + } + """; + + // Define the protocol tool representation + _protocolTool = new Tool + { + Name = "netinteractor_execute_script", + Description = "Executes a NetInteractor XML script for web automation. Supports GET requests, form POST submissions, conditional logic (if), and calling other targets. Use XPath expressions to extract data from HTML. Variables use $(Name) syntax.", + InputSchema = JsonDocument.Parse(inputSchemaJson).RootElement + }; + } + + /// + /// Gets the protocol tool representation. + /// + public override Tool ProtocolTool => _protocolTool; + + /// + /// Gets the tool metadata. + /// + public override IReadOnlyList Metadata => Array.Empty(); + + /// + /// Invokes the tool with the given parameters. + /// + public override async ValueTask InvokeAsync( + RequestContext request, + CancellationToken cancellationToken = default) + { + var arguments = request.Params?.Arguments; + + string? script = null; + string? inputs = null; + string? target = null; + + if (arguments != null) + { + if (arguments.TryGetValue("script", out var scriptValue) && scriptValue.ValueKind != JsonValueKind.Undefined && scriptValue.ValueKind != JsonValueKind.Null) + script = scriptValue.GetString(); + if (arguments.TryGetValue("inputs", out var inputsValue) && inputsValue.ValueKind != JsonValueKind.Undefined && inputsValue.ValueKind != JsonValueKind.Null) + inputs = inputsValue.GetString(); + if (arguments.TryGetValue("target", out var targetValue) && targetValue.ValueKind != JsonValueKind.Undefined && targetValue.ValueKind != JsonValueKind.Null) + target = targetValue.GetString(); + } + + if (string.IsNullOrEmpty(script)) + { + return new CallToolResult + { + Content = new List + { + new TextContentBlock { Text = "Error: 'script' parameter is required." } + }, + IsError = true + }; + } + + var result = await ExecuteScriptAsync(script, inputs, target); + + var outputText = result.Ok + ? $"Success: {result.Message ?? "Script executed successfully."}\nOutputs: {FormatOutputs(result.Outputs)}" + : $"Failed: {result.Message}"; + + return new CallToolResult + { + Content = new List + { + new TextContentBlock { Text = outputText } + }, + IsError = !result.Ok + }; } /// /// Executes a NetInteractor XML script for web automation. + /// + /// The script defines a workflow with actions like: + /// - get: Fetch a URL and extract data using XPath + /// - post: Submit forms with field values + /// - if: Conditional execution based on input values + /// - call: Call another named target + /// + /// See the class documentation for full script syntax and examples. /// - /// The XML script defining the web interaction workflow. Example: - /// <InteractConfig defaultTarget='Main'> - /// <target name='Main'> - /// <get url='https://example.com'> - /// <output name='title' xpath='//title' attr='text()' /> - /// </get> - /// </target> - /// </InteractConfig> + /// The XML script defining the web interaction workflow. /// Optional comma-separated key=value pairs for script inputs (e.g., "BaseUrl=https://example.com,Username=user"). /// Optional target name to execute. If not specified, the default target will be used. - /// The execution result containing outputs and status. - [McpServerTool(Name = "netinteractor_execute_script")] - [Description("Executes a NetInteractor XML script for web automation. Use this to run complex multi-step web workflows including GET requests, form submissions, data extraction, and conditional logic.")] - public async Task ExecuteScriptAsync( + /// The InteractionResult containing Ok status, Message, and extracted Outputs. + public async Task ExecuteScriptAsync( string script, string? inputs = null, string? target = null) @@ -57,23 +250,16 @@ public async Task ExecuteScriptAsync( try { var executor = new InterationExecutor(_webAccessor); - var inputValues = ParseInputs(inputs); - var result = await executor.ExecuteAsync(script, inputValues, target); - - return new ExecuteScriptResult - { - Success = result.Ok, - Message = result.Message, - Outputs = result.Outputs != null ? ConvertOutputs(result.Outputs) : null - }; + return await executor.ExecuteAsync(script, inputValues, target); } catch (Exception ex) { - return new ExecuteScriptResult + return new InteractionResult { - Success = false, - Message = $"Script execution failed: {ex.Message}" + Ok = false, + Message = $"Script execution failed: {ex.Message}", + Exception = ex }; } } @@ -89,21 +275,34 @@ public static Dictionary GetInputMetadata() ["script"] = new ToolParameterMetadata { Name = "script", - Description = "The XML script defining the web interaction workflow", + Description = @"XML script defining the web automation workflow. Structure: + + + + + + +Actions: +- +- +- +- + +Variables: Use $(InputName) syntax for input substitution.", Type = "string", Required = true }, ["inputs"] = new ToolParameterMetadata { Name = "inputs", - Description = "Optional comma-separated key=value pairs for script inputs (e.g., 'BaseUrl=https://example.com,Username=user')", + Description = "Comma-separated key=value pairs for script variable substitution. Example: 'BaseUrl=https://example.com,Username=admin,Password=secret'. These values replace $(Key) placeholders in the script.", Type = "string", Required = false }, ["target"] = new ToolParameterMetadata { Name = "target", - Description = "Optional target name to execute. If not specified, the default target will be used", + Description = "Name of the target to execute. If omitted, uses the defaultTarget specified in InteractConfig. Targets are named workflow steps that can call each other.", Type = "string", Required = false } @@ -113,30 +312,44 @@ public static Dictionary GetInputMetadata() /// /// Gets the output metadata for the ExecuteScript tool. /// - /// A dictionary describing the output properties. + /// A dictionary describing the output properties of InteractionResult. public static Dictionary GetOutputMetadata() { return new Dictionary { - ["Success"] = new ToolParameterMetadata + ["Ok"] = new ToolParameterMetadata { - Name = "Success", - Description = "Indicates whether the script execution was successful", + Name = "Ok", + Description = "True if the script execution completed successfully, false if any action failed", Type = "boolean", Required = true }, ["Message"] = new ToolParameterMetadata { Name = "Message", - Description = "Message describing the result or error", + Description = "Descriptive message about the result, especially useful for errors", Type = "string", Required = false }, ["Outputs"] = new ToolParameterMetadata { Name = "Outputs", - Description = "Extracted output values from the script execution as key-value pairs", - Type = "object", + Description = "NameValueCollection containing all extracted output values defined by elements in the script", + Type = "NameValueCollection", + Required = false + }, + ["Target"] = new ToolParameterMetadata + { + Name = "Target", + Description = "The name of the next target to execute (used for workflow chaining)", + Type = "string", + Required = false + }, + ["Exception"] = new ToolParameterMetadata + { + Name = "Exception", + Description = "Exception details if an error occurred during execution", + Type = "Exception", Required = false } }; @@ -162,10 +375,10 @@ private static NameValueCollection ParseInputs(string? inputs) return result; } - private static Dictionary? ConvertOutputs(NameValueCollection outputs) + private static string FormatOutputs(NameValueCollection? outputs) { if (outputs == null || outputs.Count == 0) - return null; + return "{}"; var dict = new Dictionary(); foreach (string? key in outputs.AllKeys) @@ -175,7 +388,7 @@ private static NameValueCollection ParseInputs(string? inputs) dict[key] = outputs[key] ?? string.Empty; } } - return dict; + return JsonSerializer.Serialize(dict); } } diff --git a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs index f08be5d..9551e58 100644 --- a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs +++ b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using NetInteractor.Mcp; using NetInteractor.Test.TestWebApp; +using NetInteractor.WebAccessors; using Xunit; namespace NetInteractor.Mcp.Test @@ -15,7 +16,8 @@ public NetInteractorToolTests(TestWebApplicationFixture fixture) { _factory = fixture.Factory; _baseUrl = fixture.Factory.ServerUrl; - _tool = new NetInteractorTool(); + // Use HttpClientWebAccessor for tests since Playwright requires browser installation + _tool = new NetInteractorTool(new HttpClientWebAccessor()); } [Fact] @@ -34,7 +36,7 @@ public async Task ExecuteScriptAsync_SimpleGetScript_ExtractsTitle() var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); // Assert - Assert.True(result.Success, result.Message); + Assert.True(result.Ok, result.Message); Assert.NotNull(result.Outputs); Assert.Equal("Welcome to Test Shop", result.Outputs["title"]); } @@ -60,7 +62,7 @@ public async Task ExecuteScriptAsync_WithSpecificTarget_ExecutesTarget() var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}", "Products"); // Assert - Assert.True(result.Success, result.Message); + Assert.True(result.Ok, result.Message); Assert.NotNull(result.Outputs); Assert.Equal("Products", result.Outputs["productTitle"]); } @@ -75,7 +77,7 @@ public async Task ExecuteScriptAsync_InvalidScript_ReturnsFailed() var result = await _tool.ExecuteScriptAsync(invalidScript); // Assert - Assert.False(result.Success); + Assert.False(result.Ok); Assert.NotNull(result.Message); } @@ -93,7 +95,7 @@ public async Task ExecuteScriptAsync_NoTargetSpecified_ReturnsError() var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); // Assert - Assert.False(result.Success); + Assert.False(result.Ok); Assert.Contains("target", result.Message, System.StringComparison.OrdinalIgnoreCase); } @@ -114,7 +116,7 @@ public async Task ExecuteScriptAsync_MultipleOutputs_ExtractsAllValues() var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); // Assert - Assert.True(result.Success, result.Message); + Assert.True(result.Ok, result.Message); Assert.NotNull(result.Outputs); Assert.Equal("Data Extraction Test", result.Outputs["title"]); Assert.Equal("/images/test.png", result.Outputs["imageSrc"]); @@ -136,7 +138,7 @@ public async Task ExecuteScriptAsync_WithNullInputs_ExecutesSuccessfully() var result = await _tool.ExecuteScriptAsync(script, null); // Assert - Assert.True(result.Success, result.Message); + Assert.True(result.Ok, result.Message); Assert.NotNull(result.Outputs); Assert.Equal("Welcome to Test Shop", result.Outputs["title"]); } @@ -166,13 +168,13 @@ public void GetOutputMetadata_ReturnsExpectedParameters() // Assert Assert.NotNull(metadata); - Assert.True(metadata.ContainsKey("Success")); + Assert.True(metadata.ContainsKey("Ok")); Assert.True(metadata.ContainsKey("Message")); Assert.True(metadata.ContainsKey("Outputs")); - Assert.Equal("boolean", metadata["Success"].Type); + Assert.Equal("boolean", metadata["Ok"].Type); Assert.Equal("string", metadata["Message"].Type); - Assert.Equal("object", metadata["Outputs"].Type); + Assert.Equal("NameValueCollection", metadata["Outputs"].Type); } } From 88155fae594461776cb0d52516dae18a3cb6da10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:54:55 +0000 Subject: [PATCH 06/14] Address PR feedback: make methods private, return outputs to AI agent, populate Metadata with output schema Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- .../NetInteractor.Mcp.csproj | 3 + src/NetInteractor.Mcp/NetInteractorTools.cs | 109 +++++------------ .../NetInteractorToolsTests.cs | 111 +++++++----------- 3 files changed, 76 insertions(+), 147 deletions(-) diff --git a/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj b/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj index c3346c0..86619e7 100644 --- a/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj +++ b/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj @@ -21,4 +21,7 @@ + + + diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs index a09847a..d435cf3 100644 --- a/src/NetInteractor.Mcp/NetInteractorTools.cs +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -118,6 +118,7 @@ public class NetInteractorTool : McpServerTool { private readonly IWebAccessor _webAccessor; private readonly Tool _protocolTool; + private readonly IReadOnlyList _outputMetadata; /// /// Initializes a new instance of the NetInteractorTool class with the default PlaywrightWebAccessor. @@ -164,6 +165,9 @@ public NetInteractorTool(IWebAccessor webAccessor) Description = "Executes a NetInteractor XML script for web automation. Supports GET requests, form POST submissions, conditional logic (if), and calling other targets. Use XPath expressions to extract data from HTML. Variables use $(Name) syntax.", InputSchema = JsonDocument.Parse(inputSchemaJson).RootElement }; + + // Build output metadata + _outputMetadata = new List(GetOutputMetadata().Values); } /// @@ -172,9 +176,9 @@ public NetInteractorTool(IWebAccessor webAccessor) public override Tool ProtocolTool => _protocolTool; /// - /// Gets the tool metadata. + /// Gets the tool metadata including output schema information. /// - public override IReadOnlyList Metadata => Array.Empty(); + public override IReadOnlyList Metadata => _outputMetadata; /// /// Invokes the tool with the given parameters. @@ -211,38 +215,38 @@ public override async ValueTask InvokeAsync( }; } - var result = await ExecuteScriptAsync(script, inputs, target); - - var outputText = result.Ok - ? $"Success: {result.Message ?? "Script executed successfully."}\nOutputs: {FormatOutputs(result.Outputs)}" - : $"Failed: {result.Message}"; + var result = await ExecuteScriptInternalAsync(script, inputs, target); - return new CallToolResult + if (result.Ok) { - Content = new List + // Return the outputs directly as JSON for the AI agent + var outputsJson = FormatOutputs(result.Outputs); + return new CallToolResult { - new TextContentBlock { Text = outputText } - }, - IsError = !result.Ok - }; + Content = new List + { + new TextContentBlock { Text = outputsJson } + }, + IsError = false + }; + } + else + { + return new CallToolResult + { + Content = new List + { + new TextContentBlock { Text = $"Error: {result.Message}" } + }, + IsError = true + }; + } } /// - /// Executes a NetInteractor XML script for web automation. - /// - /// The script defines a workflow with actions like: - /// - get: Fetch a URL and extract data using XPath - /// - post: Submit forms with field values - /// - if: Conditional execution based on input values - /// - call: Call another named target - /// - /// See the class documentation for full script syntax and examples. + /// Internal method for testing - executes a script and returns the result. /// - /// The XML script defining the web interaction workflow. - /// Optional comma-separated key=value pairs for script inputs (e.g., "BaseUrl=https://example.com,Username=user"). - /// Optional target name to execute. If not specified, the default target will be used. - /// The InteractionResult containing Ok status, Message, and extracted Outputs. - public async Task ExecuteScriptAsync( + internal async Task ExecuteScriptInternalAsync( string script, string? inputs = null, string? target = null) @@ -264,56 +268,7 @@ public async Task ExecuteScriptAsync( } } - /// - /// Gets the input metadata for the ExecuteScript tool. - /// - /// A dictionary describing the input parameters. - public static Dictionary GetInputMetadata() - { - return new Dictionary - { - ["script"] = new ToolParameterMetadata - { - Name = "script", - Description = @"XML script defining the web automation workflow. Structure: - - - - - - -Actions: -- -- -- -- - -Variables: Use $(InputName) syntax for input substitution.", - Type = "string", - Required = true - }, - ["inputs"] = new ToolParameterMetadata - { - Name = "inputs", - Description = "Comma-separated key=value pairs for script variable substitution. Example: 'BaseUrl=https://example.com,Username=admin,Password=secret'. These values replace $(Key) placeholders in the script.", - Type = "string", - Required = false - }, - ["target"] = new ToolParameterMetadata - { - Name = "target", - Description = "Name of the target to execute. If omitted, uses the defaultTarget specified in InteractConfig. Targets are named workflow steps that can call each other.", - Type = "string", - Required = false - } - }; - } - - /// - /// Gets the output metadata for the ExecuteScript tool. - /// - /// A dictionary describing the output properties of InteractionResult. - public static Dictionary GetOutputMetadata() + private static Dictionary GetOutputMetadata() { return new Dictionary { diff --git a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs index 9551e58..accaa4c 100644 --- a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs +++ b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs @@ -21,19 +21,19 @@ public NetInteractorToolTests(TestWebApplicationFixture fixture) } [Fact] - public async Task ExecuteScriptAsync_SimpleGetScript_ExtractsTitle() + public async Task ExecuteScriptInternalAsync_SimpleGetScript_ExtractsTitle() { // Arrange - var script = @" + var script = $@" - + "; // Act - var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); + var result = await _tool.ExecuteScriptInternalAsync(script); // Assert Assert.True(result.Ok, result.Message); @@ -42,7 +42,7 @@ public async Task ExecuteScriptAsync_SimpleGetScript_ExtractsTitle() } [Fact] - public async Task ExecuteScriptAsync_WithSpecificTarget_ExecutesTarget() + public async Task ExecuteScriptInternalAsync_WithInputs_ExtractsTitle() { // Arrange var script = @" @@ -51,130 +51,101 @@ public async Task ExecuteScriptAsync_WithSpecificTarget_ExecutesTarget() - - - - - "; // Act - var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}", "Products"); + var result = await _tool.ExecuteScriptInternalAsync(script, $"BaseUrl={_baseUrl}"); // Assert Assert.True(result.Ok, result.Message); Assert.NotNull(result.Outputs); - Assert.Equal("Products", result.Outputs["productTitle"]); + Assert.Equal("Welcome to Test Shop", result.Outputs["title"]); } [Fact] - public async Task ExecuteScriptAsync_InvalidScript_ReturnsFailed() + public async Task ExecuteScriptInternalAsync_WithSpecificTarget_ExecutesTarget() { // Arrange - var invalidScript = @""; - - // Act - var result = await _tool.ExecuteScriptAsync(invalidScript); - - // Assert - Assert.False(result.Ok); - Assert.NotNull(result.Message); - } - - [Fact] - public async Task ExecuteScriptAsync_NoTargetSpecified_ReturnsError() - { - // Arrange - Script without defaultTarget and no target parameter - var script = @" + var script = $@" - + + + + + + + + "; // Act - var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); + var result = await _tool.ExecuteScriptInternalAsync(script, null, "Products"); // Assert - Assert.False(result.Ok); - Assert.Contains("target", result.Message, System.StringComparison.OrdinalIgnoreCase); + Assert.True(result.Ok, result.Message); + Assert.NotNull(result.Outputs); + Assert.Equal("Products", result.Outputs["productTitle"]); } [Fact] - public async Task ExecuteScriptAsync_MultipleOutputs_ExtractsAllValues() + public async Task ExecuteScriptInternalAsync_InvalidScript_ReturnsError() { // Arrange - var script = @" - - - - - - - "; + var invalidScript = @""; // Act - var result = await _tool.ExecuteScriptAsync(script, $"BaseUrl={_baseUrl}"); + var result = await _tool.ExecuteScriptInternalAsync(invalidScript); // Assert - Assert.True(result.Ok, result.Message); - Assert.NotNull(result.Outputs); - Assert.Equal("Data Extraction Test", result.Outputs["title"]); - Assert.Equal("/images/test.png", result.Outputs["imageSrc"]); + Assert.False(result.Ok); + Assert.NotNull(result.Message); } [Fact] - public async Task ExecuteScriptAsync_WithNullInputs_ExecutesSuccessfully() + public async Task ExecuteScriptInternalAsync_MultipleOutputs_ExtractsAllValues() { // Arrange - var script = @" + var script = $@" - + + "; // Act - var result = await _tool.ExecuteScriptAsync(script, null); + var result = await _tool.ExecuteScriptInternalAsync(script); // Assert Assert.True(result.Ok, result.Message); Assert.NotNull(result.Outputs); - Assert.Equal("Welcome to Test Shop", result.Outputs["title"]); + Assert.Equal("Data Extraction Test", result.Outputs["title"]); + Assert.Equal("/images/test.png", result.Outputs["imageSrc"]); } [Fact] - public void GetInputMetadata_ReturnsExpectedParameters() + public void ProtocolTool_ReturnsValidTool() { // Act - var metadata = NetInteractorTool.GetInputMetadata(); + var protocolTool = _tool.ProtocolTool; // Assert - Assert.NotNull(metadata); - Assert.True(metadata.ContainsKey("script")); - Assert.True(metadata.ContainsKey("inputs")); - Assert.True(metadata.ContainsKey("target")); - - Assert.True(metadata["script"].Required); - Assert.False(metadata["inputs"].Required); - Assert.False(metadata["target"].Required); + Assert.NotNull(protocolTool); + Assert.Equal("netinteractor_execute_script", protocolTool.Name); + Assert.NotNull(protocolTool.Description); } [Fact] - public void GetOutputMetadata_ReturnsExpectedParameters() + public void Metadata_ReturnsOutputMetadata() { // Act - var metadata = NetInteractorTool.GetOutputMetadata(); + var metadata = _tool.Metadata; // Assert Assert.NotNull(metadata); - Assert.True(metadata.ContainsKey("Ok")); - Assert.True(metadata.ContainsKey("Message")); - Assert.True(metadata.ContainsKey("Outputs")); - - Assert.Equal("boolean", metadata["Ok"].Type); - Assert.Equal("string", metadata["Message"].Type); - Assert.Equal("NameValueCollection", metadata["Outputs"].Type); + Assert.True(metadata.Count > 0); } } From cc007f33e83748beddf4e26c93f6be306e20ddd6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:25:33 +0000 Subject: [PATCH 07/14] Address PR feedback: inputs as array, StructuredContent for outputs, extract GetInputMetadata Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- src/NetInteractor.Mcp/NetInteractorTools.cs | 97 +++++++++++-------- .../NetInteractorToolsTests.cs | 2 +- 2 files changed, 58 insertions(+), 41 deletions(-) diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs index d435cf3..4efa5ac 100644 --- a/src/NetInteractor.Mcp/NetInteractorTools.cs +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using ModelContextProtocol.Protocol; @@ -136,29 +137,8 @@ public NetInteractorTool(IWebAccessor webAccessor) { _webAccessor = webAccessor ?? throw new ArgumentNullException(nameof(webAccessor)); - // Define the input schema as JSON - var inputSchemaJson = """ - { - "type": "object", - "properties": { - "script": { - "type": "string", - "description": "XML script defining the web automation workflow. Structure:\n\n \n \n \n\n\nActions:\n- \n- \n- \n- \n\nVariables: Use $(InputName) syntax for input substitution." - }, - "inputs": { - "type": "string", - "description": "Comma-separated key=value pairs for script variable substitution. Example: 'BaseUrl=https://example.com,Username=admin,Password=secret'. These values replace $(Key) placeholders in the script." - }, - "target": { - "type": "string", - "description": "Name of the target to execute. If omitted, uses the defaultTarget specified in InteractConfig." - } - }, - "required": ["script"] - } - """; - - // Define the protocol tool representation + // Define the protocol tool representation using GetInputMetadata + var inputSchemaJson = GetInputMetadata(); _protocolTool = new Tool { Name = "netinteractor_execute_script", @@ -190,15 +170,26 @@ public override async ValueTask InvokeAsync( var arguments = request.Params?.Arguments; string? script = null; - string? inputs = null; + string[]? inputs = null; string? target = null; if (arguments != null) { if (arguments.TryGetValue("script", out var scriptValue) && scriptValue.ValueKind != JsonValueKind.Undefined && scriptValue.ValueKind != JsonValueKind.Null) script = scriptValue.GetString(); - if (arguments.TryGetValue("inputs", out var inputsValue) && inputsValue.ValueKind != JsonValueKind.Undefined && inputsValue.ValueKind != JsonValueKind.Null) - inputs = inputsValue.GetString(); + if (arguments.TryGetValue("inputs", out var inputsValue) && inputsValue.ValueKind == JsonValueKind.Array) + { + var inputList = new List(); + foreach (var item in inputsValue.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var val = item.GetString(); + if (val != null) inputList.Add(val); + } + } + inputs = inputList.ToArray(); + } if (arguments.TryGetValue("target", out var targetValue) && targetValue.ValueKind != JsonValueKind.Undefined && targetValue.ValueKind != JsonValueKind.Null) target = targetValue.GetString(); } @@ -219,14 +210,11 @@ public override async ValueTask InvokeAsync( if (result.Ok) { - // Return the outputs directly as JSON for the AI agent - var outputsJson = FormatOutputs(result.Outputs); + // Return the outputs as structured content for the AI agent + var outputsObject = ConvertOutputsToJsonNode(result.Outputs); return new CallToolResult { - Content = new List - { - new TextContentBlock { Text = outputsJson } - }, + StructuredContent = outputsObject, IsError = false }; } @@ -248,7 +236,7 @@ public override async ValueTask InvokeAsync( /// internal async Task ExecuteScriptInternalAsync( string script, - string? inputs = null, + string[]? inputs = null, string? target = null) { try @@ -268,6 +256,36 @@ internal async Task ExecuteScriptInternalAsync( } } + /// + /// Gets the input metadata schema as JSON string. + /// + private static string GetInputMetadata() + { + return """ + { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "XML script defining the web automation workflow.\n\nStructure:\n\n \n \n \n\n\nSupported Actions:\n\n1. GET Request - Fetches a web page and extracts data:\n \n \n \n \n\n2. POST Form Submission - Submits form data:\n \n \n \n \n \n\n3. Conditional Execution:\n \n \n \n\n4. Call Another Target:\n \n\nOutput Extraction Attributes:\n- name: Output variable name\n- xpath: XPath selector\n- attr: Attribute to extract ('text()' for inner text, or attribute name like 'href')\n- regex: Optional regex pattern\n- isMultipleValue: Extract all matching values\n- expectedValue: Validate extracted value\n\nVariables: Use $(InputName) syntax for substitution." + }, + "inputs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of key=value pairs for script variable substitution. Example: ['BaseUrl=https://example.com', 'Username=admin', 'Password=secret']. These values replace $(Key) placeholders in the script." + }, + "target": { + "type": "string", + "description": "Name of the target to execute. If omitted, uses the defaultTarget specified in InteractConfig." + } + }, + "required": ["script"] + } + """; + } + private static Dictionary GetOutputMetadata() { return new Dictionary @@ -310,15 +328,14 @@ private static Dictionary GetOutputMetadata() }; } - private static NameValueCollection ParseInputs(string? inputs) + private static NameValueCollection ParseInputs(string[]? inputs) { var result = new NameValueCollection(); - if (string.IsNullOrEmpty(inputs)) + if (inputs == null || inputs.Length == 0) return result; - var pairs = inputs.Split(','); - foreach (var pair in pairs) + foreach (var pair in inputs) { var keyValue = pair.Split(['='], 2); if (keyValue.Length == 2) @@ -330,10 +347,10 @@ private static NameValueCollection ParseInputs(string? inputs) return result; } - private static string FormatOutputs(NameValueCollection? outputs) + private static JsonNode? ConvertOutputsToJsonNode(NameValueCollection? outputs) { if (outputs == null || outputs.Count == 0) - return "{}"; + return JsonNode.Parse("{}"); var dict = new Dictionary(); foreach (string? key in outputs.AllKeys) @@ -343,7 +360,7 @@ private static string FormatOutputs(NameValueCollection? outputs) dict[key] = outputs[key] ?? string.Empty; } } - return JsonSerializer.Serialize(dict); + return JsonNode.Parse(JsonSerializer.Serialize(dict)); } } diff --git a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs index accaa4c..1ff0282 100644 --- a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs +++ b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs @@ -54,7 +54,7 @@ public async Task ExecuteScriptInternalAsync_WithInputs_ExtractsTitle() "; // Act - var result = await _tool.ExecuteScriptInternalAsync(script, $"BaseUrl={_baseUrl}"); + var result = await _tool.ExecuteScriptInternalAsync(script, new[] { $"BaseUrl={_baseUrl}" }); // Assert Assert.True(result.Ok, result.Message); From 211ad98a964c292d7dac25a9deaa0781964357f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:28:13 +0000 Subject: [PATCH 08/14] Optimize ConvertOutputsToJsonNode to use JsonObject directly Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- src/NetInteractor.Mcp/NetInteractorTools.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs index 4efa5ac..3a7c1f6 100644 --- a/src/NetInteractor.Mcp/NetInteractorTools.cs +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -350,17 +350,17 @@ private static NameValueCollection ParseInputs(string[]? inputs) private static JsonNode? ConvertOutputsToJsonNode(NameValueCollection? outputs) { if (outputs == null || outputs.Count == 0) - return JsonNode.Parse("{}"); + return new JsonObject(); - var dict = new Dictionary(); + var jsonObject = new JsonObject(); foreach (string? key in outputs.AllKeys) { if (key != null) { - dict[key] = outputs[key] ?? string.Empty; + jsonObject[key] = outputs[key] ?? string.Empty; } } - return JsonNode.Parse(JsonSerializer.Serialize(dict)); + return jsonObject; } } From 1029f29f0b5423d4385c9ff19648c55013a4dca6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:18:30 +0000 Subject: [PATCH 09/14] Address PR feedback: inputs as JsonElement object, return JsonElement from metadata methods, set OutputSchema, return empty Metadata, add detailed target documentation Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- src/NetInteractor.Mcp/NetInteractorTools.cs | 157 +++++++----------- .../NetInteractorToolsTests.cs | 12 +- 2 files changed, 64 insertions(+), 105 deletions(-) diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs index 3a7c1f6..0295ae7 100644 --- a/src/NetInteractor.Mcp/NetInteractorTools.cs +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -119,7 +119,6 @@ public class NetInteractorTool : McpServerTool { private readonly IWebAccessor _webAccessor; private readonly Tool _protocolTool; - private readonly IReadOnlyList _outputMetadata; /// /// Initializes a new instance of the NetInteractorTool class with the default PlaywrightWebAccessor. @@ -137,17 +136,14 @@ public NetInteractorTool(IWebAccessor webAccessor) { _webAccessor = webAccessor ?? throw new ArgumentNullException(nameof(webAccessor)); - // Define the protocol tool representation using GetInputMetadata - var inputSchemaJson = GetInputMetadata(); + // Define the protocol tool representation using GetInputMetadata and GetOutputMetadata _protocolTool = new Tool { Name = "netinteractor_execute_script", Description = "Executes a NetInteractor XML script for web automation. Supports GET requests, form POST submissions, conditional logic (if), and calling other targets. Use XPath expressions to extract data from HTML. Variables use $(Name) syntax.", - InputSchema = JsonDocument.Parse(inputSchemaJson).RootElement + InputSchema = GetInputMetadata(), + OutputSchema = GetOutputMetadata() }; - - // Build output metadata - _outputMetadata = new List(GetOutputMetadata().Values); } /// @@ -156,9 +152,9 @@ public NetInteractorTool(IWebAccessor webAccessor) public override Tool ProtocolTool => _protocolTool; /// - /// Gets the tool metadata including output schema information. + /// Gets the tool metadata - returns empty list as output metadata is returned via OutputSchema. /// - public override IReadOnlyList Metadata => _outputMetadata; + public override IReadOnlyList Metadata => Array.Empty(); /// /// Invokes the tool with the given parameters. @@ -170,25 +166,16 @@ public override async ValueTask InvokeAsync( var arguments = request.Params?.Arguments; string? script = null; - string[]? inputs = null; + NameValueCollection? inputs = null; string? target = null; if (arguments != null) { if (arguments.TryGetValue("script", out var scriptValue) && scriptValue.ValueKind != JsonValueKind.Undefined && scriptValue.ValueKind != JsonValueKind.Null) script = scriptValue.GetString(); - if (arguments.TryGetValue("inputs", out var inputsValue) && inputsValue.ValueKind == JsonValueKind.Array) + if (arguments.TryGetValue("inputs", out var inputsValue) && inputsValue.ValueKind == JsonValueKind.Object) { - var inputList = new List(); - foreach (var item in inputsValue.EnumerateArray()) - { - if (item.ValueKind == JsonValueKind.String) - { - var val = item.GetString(); - if (val != null) inputList.Add(val); - } - } - inputs = inputList.ToArray(); + inputs = ParseInputsFromJsonElement(inputsValue); } if (arguments.TryGetValue("target", out var targetValue) && targetValue.ValueKind != JsonValueKind.Undefined && targetValue.ValueKind != JsonValueKind.Null) target = targetValue.GetString(); @@ -236,13 +223,13 @@ public override async ValueTask InvokeAsync( /// internal async Task ExecuteScriptInternalAsync( string script, - string[]? inputs = null, + NameValueCollection? inputs = null, string? target = null) { try { var executor = new InterationExecutor(_webAccessor); - var inputValues = ParseInputs(inputs); + var inputValues = inputs ?? new NameValueCollection(); return await executor.ExecuteAsync(script, inputValues, target); } catch (Exception ex) @@ -257,24 +244,24 @@ internal async Task ExecuteScriptInternalAsync( } /// - /// Gets the input metadata schema as JSON string. + /// Gets the input metadata schema as JsonElement. /// - private static string GetInputMetadata() + private static JsonElement GetInputMetadata() { - return """ + var schemaJson = """ { "type": "object", "properties": { "script": { "type": "string", - "description": "XML script defining the web automation workflow.\n\nStructure:\n\n \n \n \n\n\nSupported Actions:\n\n1. GET Request - Fetches a web page and extracts data:\n \n \n \n \n\n2. POST Form Submission - Submits form data:\n \n \n \n \n \n\n3. Conditional Execution:\n \n \n \n\n4. Call Another Target:\n \n\nOutput Extraction Attributes:\n- name: Output variable name\n- xpath: XPath selector\n- attr: Attribute to extract ('text()' for inner text, or attribute name like 'href')\n- regex: Optional regex pattern\n- isMultipleValue: Extract all matching values\n- expectedValue: Validate extracted value\n\nVariables: Use $(InputName) syntax for substitution." + "description": "XML script defining the web automation workflow.\n\n## Script Structure\n\n\n \n \n \n \n \n \n\n\n## Target Element\n\nTargets are named workflow steps. The 'defaultTarget' attribute specifies which target runs first.\nTargets can call other targets using the action for modular workflows.\n\n## Supported Action Types\n\n### 1. GET Request ()\nFetches a web page via HTTP GET and optionally extracts data.\n\nAttributes:\n- url (required): URL to fetch. Supports $(Variable) substitution.\n- expectedHttpStatusCodes: Comma-separated valid status codes (default: 200).\n\nChild Elements:\n- : Extract data from the response (see Output Extraction).\n\nExample:\n\n \n \n\n\n### 2. POST Form Submission ()\nSubmits an HTML form. Must be preceded by a GET to load the page with the form.\n\nAttributes (use ONE to identify the form):\n- formIndex: Zero-based index of form on page (e.g., formIndex='0' for first form).\n- formName: The 'name' attribute of the form element.\n- action: The 'action' attribute/URL of the form.\n- clientID: The 'id' attribute of the form element.\n\nChild Elements:\n- : Set form field values.\n - name (required): Form field name.\n - value (required): Value to set. Supports $(Variable) substitution.\n- : Extract data from the response after submission.\n\nExample:\n\n \n \n \n\n\n### 3. Conditional Execution ()\nExecutes child action only when a condition is met.\n\nAttributes:\n- property (required): Input variable to check. Use $(VariableName) syntax.\n- value (required): Expected value to match.\n\nChild Elements: Any single action element (get, post, call, if).\n\nExample:\n\n \n\n\n### 4. Call Another Target ()\nExecutes another named target, enabling modular workflows.\n\nAttributes:\n- target (required): Name of the target to execute.\n\nExample:\n\n\n## Output Extraction ()\n\nExtracts data from HTML responses using XPath.\n\nAttributes:\n- name (required): Variable name for extracted value.\n- xpath (required): XPath expression to select element(s).\n- attr (required): What to extract:\n - 'text()': Inner text content.\n - Any attribute name: e.g., 'href', 'src', 'class'.\n- regex: Optional regex to further extract from the selected content.\n- isMultipleValue: Set 'true' to extract all matching elements as comma-separated values.\n- expectedValue: Validation - fails if extracted value doesn't match.\n\nExamples:\n\n\n\n\n\n## Variable Substitution\n\nUse $(VariableName) syntax anywhere in attribute values. Variables are provided via the 'inputs' parameter as key-value pairs.\n\nExample:\n" }, "inputs": { - "type": "array", - "items": { + "type": "object", + "additionalProperties": { "type": "string" }, - "description": "Array of key=value pairs for script variable substitution. Example: ['BaseUrl=https://example.com', 'Username=admin', 'Password=secret']. These values replace $(Key) placeholders in the script." + "description": "Object with key-value string pairs for script variable substitution. Example: {\"BaseUrl\": \"https://example.com\", \"Username\": \"admin\", \"Password\": \"secret\"}. These values replace $(Key) placeholders in the script." }, "target": { "type": "string", @@ -284,63 +271,57 @@ private static string GetInputMetadata() "required": ["script"] } """; + return JsonDocument.Parse(schemaJson).RootElement; } - private static Dictionary GetOutputMetadata() + /// + /// Gets the output metadata schema as JsonElement. + /// + private static JsonElement GetOutputMetadata() { - return new Dictionary + var schemaJson = """ { - ["Ok"] = new ToolParameterMetadata - { - Name = "Ok", - Description = "True if the script execution completed successfully, false if any action failed", - Type = "boolean", - Required = true - }, - ["Message"] = new ToolParameterMetadata - { - Name = "Message", - Description = "Descriptive message about the result, especially useful for errors", - Type = "string", - Required = false - }, - ["Outputs"] = new ToolParameterMetadata - { - Name = "Outputs", - Description = "NameValueCollection containing all extracted output values defined by elements in the script", - Type = "NameValueCollection", - Required = false - }, - ["Target"] = new ToolParameterMetadata - { - Name = "Target", - Description = "The name of the next target to execute (used for workflow chaining)", - Type = "string", - Required = false + "type": "object", + "properties": { + "Ok": { + "type": "boolean", + "description": "True if the script execution completed successfully, false if any action failed" + }, + "Message": { + "type": "string", + "description": "Descriptive message about the result, especially useful for errors" + }, + "Outputs": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Object containing all extracted output values defined by elements in the script. Keys are output names, values are extracted strings." + }, + "Target": { + "type": "string", + "description": "The name of the next target to execute (used for workflow chaining)" + } }, - ["Exception"] = new ToolParameterMetadata - { - Name = "Exception", - Description = "Exception details if an error occurred during execution", - Type = "Exception", - Required = false - } - }; + "required": ["Ok"] + } + """; + return JsonDocument.Parse(schemaJson).RootElement; } - private static NameValueCollection ParseInputs(string[]? inputs) + private static NameValueCollection ParseInputsFromJsonElement(JsonElement inputsElement) { var result = new NameValueCollection(); - if (inputs == null || inputs.Length == 0) - return result; - - foreach (var pair in inputs) + foreach (var property in inputsElement.EnumerateObject()) { - var keyValue = pair.Split(['='], 2); - if (keyValue.Length == 2) + if (property.Value.ValueKind == JsonValueKind.String) + { + result[property.Name] = property.Value.GetString() ?? string.Empty; + } + else { - result[keyValue[0].Trim()] = keyValue[1].Trim(); + result[property.Name] = property.Value.ToString(); } } @@ -363,30 +344,4 @@ private static NameValueCollection ParseInputs(string[]? inputs) return jsonObject; } } - - /// - /// Metadata describing a tool parameter (input or output). - /// - public class ToolParameterMetadata - { - /// - /// The name of the parameter. - /// - public string Name { get; set; } = string.Empty; - - /// - /// A description of the parameter. - /// - public string Description { get; set; } = string.Empty; - - /// - /// The type of the parameter (e.g., "string", "boolean", "object"). - /// - public string Type { get; set; } = string.Empty; - - /// - /// Indicates whether the parameter is required. - /// - public bool Required { get; set; } - } } diff --git a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs index 1ff0282..295a8e2 100644 --- a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs +++ b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Specialized; using System.Threading.Tasks; using NetInteractor.Mcp; using NetInteractor.Test.TestWebApp; @@ -53,8 +54,10 @@ public async Task ExecuteScriptInternalAsync_WithInputs_ExtractsTitle() "; + var inputs = new NameValueCollection { { "BaseUrl", _baseUrl } }; + // Act - var result = await _tool.ExecuteScriptInternalAsync(script, new[] { $"BaseUrl={_baseUrl}" }); + var result = await _tool.ExecuteScriptInternalAsync(script, inputs); // Assert Assert.True(result.Ok, result.Message); @@ -135,17 +138,18 @@ public void ProtocolTool_ReturnsValidTool() Assert.NotNull(protocolTool); Assert.Equal("netinteractor_execute_script", protocolTool.Name); Assert.NotNull(protocolTool.Description); + Assert.NotNull(protocolTool.OutputSchema); } [Fact] - public void Metadata_ReturnsOutputMetadata() + public void Metadata_ReturnsEmptyList() { // Act var metadata = _tool.Metadata; - // Assert + // Assert - Metadata returns empty list, output schema is on ProtocolTool.OutputSchema Assert.NotNull(metadata); - Assert.True(metadata.Count > 0); + Assert.Empty(metadata); } } From e980c19d1b6728725ba4df6298ff81cbd8b56714 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 00:00:30 +0000 Subject: [PATCH 10/14] Refactor GetInputMetadata and GetOutputMetadata to use JsonObject instead of string literals Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- src/NetInteractor.Mcp/NetInteractorTools.cs | 208 ++++++++++++++++---- 1 file changed, 169 insertions(+), 39 deletions(-) diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs index 0295ae7..c10a5cd 100644 --- a/src/NetInteractor.Mcp/NetInteractorTools.cs +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; @@ -248,30 +249,35 @@ internal async Task ExecuteScriptInternalAsync( /// private static JsonElement GetInputMetadata() { - var schemaJson = """ + var schema = new JsonObject { - "type": "object", - "properties": { - "script": { - "type": "string", - "description": "XML script defining the web automation workflow.\n\n## Script Structure\n\n\n \n \n \n \n \n \n\n\n## Target Element\n\nTargets are named workflow steps. The 'defaultTarget' attribute specifies which target runs first.\nTargets can call other targets using the action for modular workflows.\n\n## Supported Action Types\n\n### 1. GET Request ()\nFetches a web page via HTTP GET and optionally extracts data.\n\nAttributes:\n- url (required): URL to fetch. Supports $(Variable) substitution.\n- expectedHttpStatusCodes: Comma-separated valid status codes (default: 200).\n\nChild Elements:\n- : Extract data from the response (see Output Extraction).\n\nExample:\n\n \n \n\n\n### 2. POST Form Submission ()\nSubmits an HTML form. Must be preceded by a GET to load the page with the form.\n\nAttributes (use ONE to identify the form):\n- formIndex: Zero-based index of form on page (e.g., formIndex='0' for first form).\n- formName: The 'name' attribute of the form element.\n- action: The 'action' attribute/URL of the form.\n- clientID: The 'id' attribute of the form element.\n\nChild Elements:\n- : Set form field values.\n - name (required): Form field name.\n - value (required): Value to set. Supports $(Variable) substitution.\n- : Extract data from the response after submission.\n\nExample:\n\n \n \n \n\n\n### 3. Conditional Execution ()\nExecutes child action only when a condition is met.\n\nAttributes:\n- property (required): Input variable to check. Use $(VariableName) syntax.\n- value (required): Expected value to match.\n\nChild Elements: Any single action element (get, post, call, if).\n\nExample:\n\n \n\n\n### 4. Call Another Target ()\nExecutes another named target, enabling modular workflows.\n\nAttributes:\n- target (required): Name of the target to execute.\n\nExample:\n\n\n## Output Extraction ()\n\nExtracts data from HTML responses using XPath.\n\nAttributes:\n- name (required): Variable name for extracted value.\n- xpath (required): XPath expression to select element(s).\n- attr (required): What to extract:\n - 'text()': Inner text content.\n - Any attribute name: e.g., 'href', 'src', 'class'.\n- regex: Optional regex to further extract from the selected content.\n- isMultipleValue: Set 'true' to extract all matching elements as comma-separated values.\n- expectedValue: Validation - fails if extracted value doesn't match.\n\nExamples:\n\n\n\n\n\n## Variable Substitution\n\nUse $(VariableName) syntax anywhere in attribute values. Variables are provided via the 'inputs' parameter as key-value pairs.\n\nExample:\n" + ["type"] = "object", + ["properties"] = new JsonObject + { + ["script"] = new JsonObject + { + ["type"] = "string", + ["description"] = BuildScriptDescription() }, - "inputs": { - "type": "object", - "additionalProperties": { - "type": "string" + ["inputs"] = new JsonObject + { + ["type"] = "object", + ["additionalProperties"] = new JsonObject + { + ["type"] = "string" }, - "description": "Object with key-value string pairs for script variable substitution. Example: {\"BaseUrl\": \"https://example.com\", \"Username\": \"admin\", \"Password\": \"secret\"}. These values replace $(Key) placeholders in the script." + ["description"] = "Object with key-value string pairs for script variable substitution. Example: {\"BaseUrl\": \"https://example.com\", \"Username\": \"admin\", \"Password\": \"secret\"}. These values replace $(Key) placeholders in the script." }, - "target": { - "type": "string", - "description": "Name of the target to execute. If omitted, uses the defaultTarget specified in InteractConfig." + ["target"] = new JsonObject + { + ["type"] = "string", + ["description"] = "Name of the target to execute. If omitted, uses the defaultTarget specified in InteractConfig." } }, - "required": ["script"] - } - """; - return JsonDocument.Parse(schemaJson).RootElement; + ["required"] = new JsonArray { "script" } + }; + + return JsonDocument.Parse(schema.ToJsonString()).RootElement; } /// @@ -279,34 +285,158 @@ private static JsonElement GetInputMetadata() /// private static JsonElement GetOutputMetadata() { - var schemaJson = """ + var schema = new JsonObject { - "type": "object", - "properties": { - "Ok": { - "type": "boolean", - "description": "True if the script execution completed successfully, false if any action failed" + ["type"] = "object", + ["properties"] = new JsonObject + { + ["Ok"] = new JsonObject + { + ["type"] = "boolean", + ["description"] = "True if the script execution completed successfully, false if any action failed" }, - "Message": { - "type": "string", - "description": "Descriptive message about the result, especially useful for errors" + ["Message"] = new JsonObject + { + ["type"] = "string", + ["description"] = "Descriptive message about the result, especially useful for errors" }, - "Outputs": { - "type": "object", - "additionalProperties": { - "type": "string" + ["Outputs"] = new JsonObject + { + ["type"] = "object", + ["additionalProperties"] = new JsonObject + { + ["type"] = "string" }, - "description": "Object containing all extracted output values defined by elements in the script. Keys are output names, values are extracted strings." + ["description"] = "Object containing all extracted output values defined by elements in the script. Keys are output names, values are extracted strings." }, - "Target": { - "type": "string", - "description": "The name of the next target to execute (used for workflow chaining)" + ["Target"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The name of the next target to execute (used for workflow chaining)" } }, - "required": ["Ok"] - } - """; - return JsonDocument.Parse(schemaJson).RootElement; + ["required"] = new JsonArray { "Ok" } + }; + + return JsonDocument.Parse(schema.ToJsonString()).RootElement; + } + + /// + /// Builds the comprehensive script description for AI agents. + /// + private static string BuildScriptDescription() + { + var sb = new StringBuilder(); + + sb.AppendLine("XML script defining the web automation workflow."); + sb.AppendLine(); + sb.AppendLine("## Script Structure"); + sb.AppendLine(); + sb.AppendLine(""); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(""); + sb.AppendLine(); + sb.AppendLine("## Target Element"); + sb.AppendLine(); + sb.AppendLine("Targets are named workflow steps. The 'defaultTarget' attribute specifies which target runs first."); + sb.AppendLine("Targets can call other targets using the action for modular workflows."); + sb.AppendLine(); + sb.AppendLine("## Supported Action Types"); + sb.AppendLine(); + + // GET action + sb.AppendLine("### 1. GET Request ()"); + sb.AppendLine("Fetches a web page via HTTP GET and optionally extracts data."); + sb.AppendLine(); + sb.AppendLine("Attributes:"); + sb.AppendLine("- url (required): URL to fetch. Supports $(Variable) substitution."); + sb.AppendLine("- expectedHttpStatusCodes: Comma-separated valid status codes (default: 200)."); + sb.AppendLine(); + sb.AppendLine("Child Elements:"); + sb.AppendLine("- : Extract data from the response (see Output Extraction)."); + sb.AppendLine(); + sb.AppendLine("Example:"); + sb.AppendLine(""); + sb.AppendLine(" "); + sb.AppendLine(""); + sb.AppendLine(); + + // POST action + sb.AppendLine("### 2. POST Form Submission ()"); + sb.AppendLine("Submits an HTML form. Must be preceded by a GET to load the page with the form."); + sb.AppendLine(); + sb.AppendLine("Attributes (use ONE to identify the form):"); + sb.AppendLine("- formIndex: Zero-based index of form on page (e.g., formIndex='0')."); + sb.AppendLine("- formName: The 'name' attribute of the form element."); + sb.AppendLine("- action: The 'action' attribute/URL of the form."); + sb.AppendLine("- clientID: The 'id' attribute of the form element."); + sb.AppendLine(); + sb.AppendLine("Child Elements:"); + sb.AppendLine("- : Set form field values."); + sb.AppendLine("- : Extract data from the response after submission."); + sb.AppendLine(); + sb.AppendLine("Example:"); + sb.AppendLine(""); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(""); + sb.AppendLine(); + + // IF action + sb.AppendLine("### 3. Conditional Execution ()"); + sb.AppendLine("Executes child action only when a condition is met."); + sb.AppendLine(); + sb.AppendLine("Attributes:"); + sb.AppendLine("- property (required): Input variable to check. Use $(VariableName) syntax."); + sb.AppendLine("- value (required): Expected value to match."); + sb.AppendLine(); + sb.AppendLine("Example:"); + sb.AppendLine(""); + sb.AppendLine(" "); + sb.AppendLine(""); + sb.AppendLine(); + + // CALL action + sb.AppendLine("### 4. Call Another Target ()"); + sb.AppendLine("Executes another named target, enabling modular workflows."); + sb.AppendLine(); + sb.AppendLine("Attributes:"); + sb.AppendLine("- target (required): Name of the target to execute."); + sb.AppendLine(); + sb.AppendLine("Example:"); + sb.AppendLine(""); + sb.AppendLine(); + + // Output extraction + sb.AppendLine("## Output Extraction ()"); + sb.AppendLine(); + sb.AppendLine("Extracts data from HTML responses using XPath."); + sb.AppendLine(); + sb.AppendLine("Attributes:"); + sb.AppendLine("- name (required): Variable name for extracted value."); + sb.AppendLine("- xpath (required): XPath expression to select element(s)."); + sb.AppendLine("- attr (required): What to extract ('text()' for inner text, or attribute name like 'href')."); + sb.AppendLine("- regex: Optional regex to further extract from the selected content."); + sb.AppendLine("- isMultipleValue: Set 'true' to extract all matching elements as comma-separated values."); + sb.AppendLine("- expectedValue: Validation - fails if extracted value doesn't match."); + sb.AppendLine(); + sb.AppendLine("Example:"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(); + + // Variable substitution + sb.AppendLine("## Variable Substitution"); + sb.AppendLine(); + sb.AppendLine("Use $(VariableName) syntax anywhere in attribute values."); + sb.AppendLine("Variables are provided via the 'inputs' parameter as key-value pairs."); + sb.AppendLine(); + sb.AppendLine("Example:"); + sb.AppendLine(""); + + return sb.ToString(); } private static NameValueCollection ParseInputsFromJsonElement(JsonElement inputsElement) From c408d0b6449422250bb261ceff8bc02576d76050 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 00:03:04 +0000 Subject: [PATCH 11/14] Optimize JsonObject to JsonElement conversion using SerializeToElement Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- src/NetInteractor.Mcp/NetInteractorTools.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs index c10a5cd..ee7bdf3 100644 --- a/src/NetInteractor.Mcp/NetInteractorTools.cs +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -277,7 +277,7 @@ private static JsonElement GetInputMetadata() ["required"] = new JsonArray { "script" } }; - return JsonDocument.Parse(schema.ToJsonString()).RootElement; + return JsonSerializer.SerializeToElement(schema); } /// @@ -318,7 +318,7 @@ private static JsonElement GetOutputMetadata() ["required"] = new JsonArray { "Ok" } }; - return JsonDocument.Parse(schema.ToJsonString()).RootElement; + return JsonSerializer.SerializeToElement(schema); } /// @@ -327,7 +327,6 @@ private static JsonElement GetOutputMetadata() private static string BuildScriptDescription() { var sb = new StringBuilder(); - sb.AppendLine("XML script defining the web automation workflow."); sb.AppendLine(); sb.AppendLine("## Script Structure"); From 756f88f4c9ae8de6356f72d302c753c468c47b3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 01:30:51 +0000 Subject: [PATCH 12/14] Move script description to external file ScriptDescription.txt for easier maintenance Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- .../NetInteractor.Mcp.csproj | 3 + src/NetInteractor.Mcp/NetInteractorTools.cs | 126 ++---------------- src/NetInteractor.Mcp/ScriptDescription.txt | 95 +++++++++++++ 3 files changed, 112 insertions(+), 112 deletions(-) create mode 100644 src/NetInteractor.Mcp/ScriptDescription.txt diff --git a/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj b/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj index 86619e7..24596c5 100644 --- a/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj +++ b/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj @@ -18,6 +18,9 @@ + + + diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs index ee7bdf3..3fc45b0 100644 --- a/src/NetInteractor.Mcp/NetInteractorTools.cs +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -1,7 +1,8 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; -using System.Text; +using System.IO; +using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; @@ -257,7 +258,7 @@ private static JsonElement GetInputMetadata() ["script"] = new JsonObject { ["type"] = "string", - ["description"] = BuildScriptDescription() + ["description"] = LoadScriptDescription() }, ["inputs"] = new JsonObject { @@ -322,120 +323,21 @@ private static JsonElement GetOutputMetadata() } /// - /// Builds the comprehensive script description for AI agents. + /// Loads the script description from the embedded resource file. /// - private static string BuildScriptDescription() + private static string LoadScriptDescription() { - var sb = new StringBuilder(); - sb.AppendLine("XML script defining the web automation workflow."); - sb.AppendLine(); - sb.AppendLine("## Script Structure"); - sb.AppendLine(); - sb.AppendLine(""); - sb.AppendLine(" "); - sb.AppendLine(" "); - sb.AppendLine(" "); - sb.AppendLine(""); - sb.AppendLine(); - sb.AppendLine("## Target Element"); - sb.AppendLine(); - sb.AppendLine("Targets are named workflow steps. The 'defaultTarget' attribute specifies which target runs first."); - sb.AppendLine("Targets can call other targets using the action for modular workflows."); - sb.AppendLine(); - sb.AppendLine("## Supported Action Types"); - sb.AppendLine(); + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = "NetInteractor.Mcp.ScriptDescription.txt"; - // GET action - sb.AppendLine("### 1. GET Request ()"); - sb.AppendLine("Fetches a web page via HTTP GET and optionally extracts data."); - sb.AppendLine(); - sb.AppendLine("Attributes:"); - sb.AppendLine("- url (required): URL to fetch. Supports $(Variable) substitution."); - sb.AppendLine("- expectedHttpStatusCodes: Comma-separated valid status codes (default: 200)."); - sb.AppendLine(); - sb.AppendLine("Child Elements:"); - sb.AppendLine("- : Extract data from the response (see Output Extraction)."); - sb.AppendLine(); - sb.AppendLine("Example:"); - sb.AppendLine(""); - sb.AppendLine(" "); - sb.AppendLine(""); - sb.AppendLine(); - - // POST action - sb.AppendLine("### 2. POST Form Submission ()"); - sb.AppendLine("Submits an HTML form. Must be preceded by a GET to load the page with the form."); - sb.AppendLine(); - sb.AppendLine("Attributes (use ONE to identify the form):"); - sb.AppendLine("- formIndex: Zero-based index of form on page (e.g., formIndex='0')."); - sb.AppendLine("- formName: The 'name' attribute of the form element."); - sb.AppendLine("- action: The 'action' attribute/URL of the form."); - sb.AppendLine("- clientID: The 'id' attribute of the form element."); - sb.AppendLine(); - sb.AppendLine("Child Elements:"); - sb.AppendLine("- : Set form field values."); - sb.AppendLine("- : Extract data from the response after submission."); - sb.AppendLine(); - sb.AppendLine("Example:"); - sb.AppendLine(""); - sb.AppendLine(" "); - sb.AppendLine(" "); - sb.AppendLine(""); - sb.AppendLine(); - - // IF action - sb.AppendLine("### 3. Conditional Execution ()"); - sb.AppendLine("Executes child action only when a condition is met."); - sb.AppendLine(); - sb.AppendLine("Attributes:"); - sb.AppendLine("- property (required): Input variable to check. Use $(VariableName) syntax."); - sb.AppendLine("- value (required): Expected value to match."); - sb.AppendLine(); - sb.AppendLine("Example:"); - sb.AppendLine(""); - sb.AppendLine(" "); - sb.AppendLine(""); - sb.AppendLine(); - - // CALL action - sb.AppendLine("### 4. Call Another Target ()"); - sb.AppendLine("Executes another named target, enabling modular workflows."); - sb.AppendLine(); - sb.AppendLine("Attributes:"); - sb.AppendLine("- target (required): Name of the target to execute."); - sb.AppendLine(); - sb.AppendLine("Example:"); - sb.AppendLine(""); - sb.AppendLine(); - - // Output extraction - sb.AppendLine("## Output Extraction ()"); - sb.AppendLine(); - sb.AppendLine("Extracts data from HTML responses using XPath."); - sb.AppendLine(); - sb.AppendLine("Attributes:"); - sb.AppendLine("- name (required): Variable name for extracted value."); - sb.AppendLine("- xpath (required): XPath expression to select element(s)."); - sb.AppendLine("- attr (required): What to extract ('text()' for inner text, or attribute name like 'href')."); - sb.AppendLine("- regex: Optional regex to further extract from the selected content."); - sb.AppendLine("- isMultipleValue: Set 'true' to extract all matching elements as comma-separated values."); - sb.AppendLine("- expectedValue: Validation - fails if extracted value doesn't match."); - sb.AppendLine(); - sb.AppendLine("Example:"); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(); - - // Variable substitution - sb.AppendLine("## Variable Substitution"); - sb.AppendLine(); - sb.AppendLine("Use $(VariableName) syntax anywhere in attribute values."); - sb.AppendLine("Variables are provided via the 'inputs' parameter as key-value pairs."); - sb.AppendLine(); - sb.AppendLine("Example:"); - sb.AppendLine(""); + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + return "XML script defining the web automation workflow. See documentation for details."; + } - return sb.ToString(); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); } private static NameValueCollection ParseInputsFromJsonElement(JsonElement inputsElement) diff --git a/src/NetInteractor.Mcp/ScriptDescription.txt b/src/NetInteractor.Mcp/ScriptDescription.txt new file mode 100644 index 0000000..d0c0c66 --- /dev/null +++ b/src/NetInteractor.Mcp/ScriptDescription.txt @@ -0,0 +1,95 @@ +XML script defining the web automation workflow. + +## Script Structure + + + + + + + +## Target Element + +Targets are named workflow steps. The 'defaultTarget' attribute specifies which target runs first. +Targets can call other targets using the action for modular workflows. + +## Supported Action Types + +### 1. GET Request () +Fetches a web page via HTTP GET and optionally extracts data. + +Attributes: +- url (required): URL to fetch. Supports $(Variable) substitution. +- expectedHttpStatusCodes: Comma-separated valid status codes (default: 200). + +Child Elements: +- : Extract data from the response (see Output Extraction). + +Example: + + + + +### 2. POST Form Submission () +Submits an HTML form. Must be preceded by a GET to load the page with the form. + +Attributes (use ONE to identify the form): +- formIndex: Zero-based index of form on page (e.g., formIndex='0'). +- formName: The 'name' attribute of the form element. +- action: The 'action' attribute/URL of the form. +- clientID: The 'id' attribute of the form element. + +Child Elements: +- : Set form field values. +- : Extract data from the response after submission. + +Example: + + + + + +### 3. Conditional Execution () +Executes child action only when a condition is met. + +Attributes: +- property (required): Input variable to check. Use $(VariableName) syntax. +- value (required): Expected value to match. + +Example: + + + + +### 4. Call Another Target () +Executes another named target, enabling modular workflows. + +Attributes: +- target (required): Name of the target to execute. + +Example: + + +## Output Extraction () + +Extracts data from HTML responses using XPath. + +Attributes: +- name (required): Variable name for extracted value. +- xpath (required): XPath expression to select element(s). +- attr (required): What to extract ('text()' for inner text, or attribute name like 'href'). +- regex: Optional regex to further extract from the selected content. +- isMultipleValue: Set 'true' to extract all matching elements as comma-separated values. +- expectedValue: Validation - fails if extracted value doesn't match. + +Example: + + + +## Variable Substitution + +Use $(VariableName) syntax anywhere in attribute values. +Variables are provided via the 'inputs' parameter as key-value pairs. + +Example: + From a966968c99c57fc6560620493234d2befd9e4af2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 01:33:12 +0000 Subject: [PATCH 13/14] Improve fallback message when embedded resource is missing Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- src/NetInteractor.Mcp/NetInteractorTools.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/NetInteractor.Mcp/NetInteractorTools.cs b/src/NetInteractor.Mcp/NetInteractorTools.cs index 3fc45b0..d40e6ad 100644 --- a/src/NetInteractor.Mcp/NetInteractorTools.cs +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -333,7 +333,8 @@ private static string LoadScriptDescription() using var stream = assembly.GetManifestResourceStream(resourceName); if (stream == null) { - return "XML script defining the web automation workflow. See documentation for details."; + // Fallback message if embedded resource is missing (should not happen in normal builds) + return "XML script defining the web automation workflow. Warning: ScriptDescription.txt embedded resource not found. Please ensure the resource is properly embedded in the build."; } using var reader = new StreamReader(stream); From b3caa10cf16f4fba907d62ea413263a52cb8c365 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 01:49:44 +0000 Subject: [PATCH 14/14] Refactor tests to test against public InvokeAsync method, add Moq package Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --- Directory.Packages.props | 1 + .../NetInteractor.Mcp.Test.csproj | 2 + .../NetInteractorToolsTests.cs | 136 ++++++++++++++---- 3 files changed, 112 insertions(+), 27 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index da9a6c7..0f48180 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/test/NetInteractor.Mcp.Test/NetInteractor.Mcp.Test.csproj b/test/NetInteractor.Mcp.Test/NetInteractor.Mcp.Test.csproj index 9e8c847..0f6f42e 100644 --- a/test/NetInteractor.Mcp.Test/NetInteractor.Mcp.Test.csproj +++ b/test/NetInteractor.Mcp.Test/NetInteractor.Mcp.Test.csproj @@ -10,6 +10,8 @@ + + diff --git a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs index 295a8e2..1e02c40 100644 --- a/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs +++ b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs @@ -1,5 +1,11 @@ -using System.Collections.Specialized; +#nullable enable +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; using System.Threading.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Moq; using NetInteractor.Mcp; using NetInteractor.Test.TestWebApp; using NetInteractor.WebAccessors; @@ -12,6 +18,7 @@ public class NetInteractorToolTests : IClassFixture private readonly TestWebApplicationFactory _factory; private readonly string _baseUrl; private readonly NetInteractorTool _tool; + private readonly Mock _mockServer; public NetInteractorToolTests(TestWebApplicationFixture fixture) { @@ -19,10 +26,52 @@ public NetInteractorToolTests(TestWebApplicationFixture fixture) _baseUrl = fixture.Factory.ServerUrl; // Use HttpClientWebAccessor for tests since Playwright requires browser installation _tool = new NetInteractorTool(new HttpClientWebAccessor()); + _mockServer = new Mock(); + } + + private RequestContext CreateRequestContext(IDictionary? arguments = null) + { + var jsonRpcRequest = new JsonRpcRequest + { + Id = new RequestId("test-id"), + Method = "tools/call", + Params = null + }; + + var context = new RequestContext(_mockServer.Object, jsonRpcRequest) + { + Params = new CallToolRequestParams + { + Name = "netinteractor_execute_script", + Arguments = arguments + } + }; + + return context; + } + + private static IDictionary CreateArguments(string script, IDictionary? inputs = null, string? target = null) + { + var args = new Dictionary + { + ["script"] = JsonSerializer.SerializeToElement(script) + }; + + if (inputs != null) + { + args["inputs"] = JsonSerializer.SerializeToElement(inputs); + } + + if (target != null) + { + args["target"] = JsonSerializer.SerializeToElement(target); + } + + return args; } [Fact] - public async Task ExecuteScriptInternalAsync_SimpleGetScript_ExtractsTitle() + public async Task InvokeAsync_SimpleGetScript_ExtractsTitle() { // Arrange var script = $@" @@ -33,17 +82,20 @@ public async Task ExecuteScriptInternalAsync_SimpleGetScript_ExtractsTitle() "; + var context = CreateRequestContext(CreateArguments(script)); + // Act - var result = await _tool.ExecuteScriptInternalAsync(script); + var result = await _tool.InvokeAsync(context, CancellationToken.None); // Assert - Assert.True(result.Ok, result.Message); - Assert.NotNull(result.Outputs); - Assert.Equal("Welcome to Test Shop", result.Outputs["title"]); + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.StructuredContent); + Assert.Equal("Welcome to Test Shop", result.StructuredContent["title"]?.ToString()); } [Fact] - public async Task ExecuteScriptInternalAsync_WithInputs_ExtractsTitle() + public async Task InvokeAsync_WithInputs_ExtractsTitle() { // Arrange var script = @" @@ -54,19 +106,21 @@ public async Task ExecuteScriptInternalAsync_WithInputs_ExtractsTitle() "; - var inputs = new NameValueCollection { { "BaseUrl", _baseUrl } }; + var inputs = new Dictionary { { "BaseUrl", _baseUrl } }; + var context = CreateRequestContext(CreateArguments(script, inputs)); // Act - var result = await _tool.ExecuteScriptInternalAsync(script, inputs); + var result = await _tool.InvokeAsync(context, CancellationToken.None); // Assert - Assert.True(result.Ok, result.Message); - Assert.NotNull(result.Outputs); - Assert.Equal("Welcome to Test Shop", result.Outputs["title"]); + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.StructuredContent); + Assert.Equal("Welcome to Test Shop", result.StructuredContent["title"]?.ToString()); } [Fact] - public async Task ExecuteScriptInternalAsync_WithSpecificTarget_ExecutesTarget() + public async Task InvokeAsync_WithSpecificTarget_ExecutesTarget() { // Arrange var script = $@" @@ -82,31 +136,56 @@ public async Task ExecuteScriptInternalAsync_WithSpecificTarget_ExecutesTarget() "; + var context = CreateRequestContext(CreateArguments(script, target: "Products")); + // Act - var result = await _tool.ExecuteScriptInternalAsync(script, null, "Products"); + var result = await _tool.InvokeAsync(context, CancellationToken.None); // Assert - Assert.True(result.Ok, result.Message); - Assert.NotNull(result.Outputs); - Assert.Equal("Products", result.Outputs["productTitle"]); + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.StructuredContent); + Assert.Equal("Products", result.StructuredContent["productTitle"]?.ToString()); } [Fact] - public async Task ExecuteScriptInternalAsync_InvalidScript_ReturnsError() + public async Task InvokeAsync_InvalidScript_ReturnsError() { // Arrange var invalidScript = @""; + var context = CreateRequestContext(CreateArguments(invalidScript)); // Act - var result = await _tool.ExecuteScriptInternalAsync(invalidScript); + var result = await _tool.InvokeAsync(context, CancellationToken.None); // Assert - Assert.False(result.Ok); - Assert.NotNull(result.Message); + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); } [Fact] - public async Task ExecuteScriptInternalAsync_MultipleOutputs_ExtractsAllValues() + public async Task InvokeAsync_MissingScript_ReturnsError() + { + // Arrange - No script argument + var context = CreateRequestContext(new Dictionary()); + + // Act + var result = await _tool.InvokeAsync(context, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("script", textContent.Text.ToLower()); + } + + [Fact] + public async Task InvokeAsync_MultipleOutputs_ExtractsAllValues() { // Arrange var script = $@" @@ -118,14 +197,17 @@ public async Task ExecuteScriptInternalAsync_MultipleOutputs_ExtractsAllValues() "; + var context = CreateRequestContext(CreateArguments(script)); + // Act - var result = await _tool.ExecuteScriptInternalAsync(script); + var result = await _tool.InvokeAsync(context, CancellationToken.None); // Assert - Assert.True(result.Ok, result.Message); - Assert.NotNull(result.Outputs); - Assert.Equal("Data Extraction Test", result.Outputs["title"]); - Assert.Equal("/images/test.png", result.Outputs["imageSrc"]); + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.StructuredContent); + Assert.Equal("Data Extraction Test", result.StructuredContent["title"]?.ToString()); + Assert.Equal("/images/test.png", result.StructuredContent["imageSrc"]?.ToString()); } [Fact]