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..0f48180 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,8 @@ + + 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/NetInteractor.Mcp.csproj b/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj new file mode 100644 index 0000000..24596c5 --- /dev/null +++ b/src/NetInteractor.Mcp/NetInteractor.Mcp.csproj @@ -0,0 +1,30 @@ + + + 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..d40e6ad --- /dev/null +++ b/src/NetInteractor.Mcp/NetInteractorTools.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.IO; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using NetInteractor.WebAccessors; + +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 : McpServerTool + { + private readonly IWebAccessor _webAccessor; + private readonly Tool _protocolTool; + + /// + /// Initializes a new instance of the NetInteractorTool class with the default PlaywrightWebAccessor. + /// + public NetInteractorTool() + : this(new PlaywrightWebAccessor()) + { + } + + /// + /// 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)); + + // 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 = GetInputMetadata(), + OutputSchema = GetOutputMetadata() + }; + } + + /// + /// Gets the protocol tool representation. + /// + public override Tool ProtocolTool => _protocolTool; + + /// + /// Gets the tool metadata - returns empty list as output metadata is returned via OutputSchema. + /// + 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; + 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.Object) + { + inputs = ParseInputsFromJsonElement(inputsValue); + } + 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 ExecuteScriptInternalAsync(script, inputs, target); + + if (result.Ok) + { + // Return the outputs as structured content for the AI agent + var outputsObject = ConvertOutputsToJsonNode(result.Outputs); + return new CallToolResult + { + StructuredContent = outputsObject, + IsError = false + }; + } + else + { + return new CallToolResult + { + Content = new List + { + new TextContentBlock { Text = $"Error: {result.Message}" } + }, + IsError = true + }; + } + } + + /// + /// Internal method for testing - executes a script and returns the result. + /// + internal async Task ExecuteScriptInternalAsync( + string script, + NameValueCollection? inputs = null, + string? target = null) + { + try + { + var executor = new InterationExecutor(_webAccessor); + var inputValues = inputs ?? new NameValueCollection(); + return await executor.ExecuteAsync(script, inputValues, target); + } + catch (Exception ex) + { + return new InteractionResult + { + Ok = false, + Message = $"Script execution failed: {ex.Message}", + Exception = ex + }; + } + } + + /// + /// Gets the input metadata schema as JsonElement. + /// + private static JsonElement GetInputMetadata() + { + var schema = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["script"] = new JsonObject + { + ["type"] = "string", + ["description"] = LoadScriptDescription() + }, + ["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." + }, + ["target"] = new JsonObject + { + ["type"] = "string", + ["description"] = "Name of the target to execute. If omitted, uses the defaultTarget specified in InteractConfig." + } + }, + ["required"] = new JsonArray { "script" } + }; + + return JsonSerializer.SerializeToElement(schema); + } + + /// + /// Gets the output metadata schema as JsonElement. + /// + private static JsonElement GetOutputMetadata() + { + var schema = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["Ok"] = new JsonObject + { + ["type"] = "boolean", + ["description"] = "True if the script execution completed successfully, false if any action failed" + }, + ["Message"] = new JsonObject + { + ["type"] = "string", + ["description"] = "Descriptive message about the result, especially useful for errors" + }, + ["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." + }, + ["Target"] = new JsonObject + { + ["type"] = "string", + ["description"] = "The name of the next target to execute (used for workflow chaining)" + } + }, + ["required"] = new JsonArray { "Ok" } + }; + + return JsonSerializer.SerializeToElement(schema); + } + + /// + /// Loads the script description from the embedded resource file. + /// + private static string LoadScriptDescription() + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = "NetInteractor.Mcp.ScriptDescription.txt"; + + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + // 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); + return reader.ReadToEnd(); + } + + private static NameValueCollection ParseInputsFromJsonElement(JsonElement inputsElement) + { + var result = new NameValueCollection(); + + foreach (var property in inputsElement.EnumerateObject()) + { + if (property.Value.ValueKind == JsonValueKind.String) + { + result[property.Name] = property.Value.GetString() ?? string.Empty; + } + else + { + result[property.Name] = property.Value.ToString(); + } + } + + return result; + } + + private static JsonNode? ConvertOutputsToJsonNode(NameValueCollection? outputs) + { + if (outputs == null || outputs.Count == 0) + return new JsonObject(); + + var jsonObject = new JsonObject(); + foreach (string? key in outputs.AllKeys) + { + if (key != null) + { + jsonObject[key] = outputs[key] ?? string.Empty; + } + } + return jsonObject; + } + } +} 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: + 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..0f6f42e --- /dev/null +++ b/test/NetInteractor.Mcp.Test/NetInteractor.Mcp.Test.csproj @@ -0,0 +1,20 @@ + + + + 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..1e02c40 --- /dev/null +++ b/test/NetInteractor.Mcp.Test/NetInteractorToolsTests.cs @@ -0,0 +1,255 @@ +#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; +using Xunit; + +namespace NetInteractor.Mcp.Test +{ + public class NetInteractorToolTests : IClassFixture + { + private readonly TestWebApplicationFactory _factory; + private readonly string _baseUrl; + private readonly NetInteractorTool _tool; + private readonly Mock _mockServer; + + public NetInteractorToolTests(TestWebApplicationFixture fixture) + { + _factory = fixture.Factory; + _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 InvokeAsync_SimpleGetScript_ExtractsTitle() + { + // Arrange + var script = $@" + + + + + + "; + + var context = CreateRequestContext(CreateArguments(script)); + + // Act + var result = await _tool.InvokeAsync(context, CancellationToken.None); + + // Assert + 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 InvokeAsync_WithInputs_ExtractsTitle() + { + // Arrange + var script = @" + + + + + + "; + + var inputs = new Dictionary { { "BaseUrl", _baseUrl } }; + var context = CreateRequestContext(CreateArguments(script, inputs)); + + // Act + var result = await _tool.InvokeAsync(context, CancellationToken.None); + + // Assert + 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 InvokeAsync_WithSpecificTarget_ExecutesTarget() + { + // Arrange + var script = $@" + + + + + + + + + + + "; + + var context = CreateRequestContext(CreateArguments(script, target: "Products")); + + // Act + var result = await _tool.InvokeAsync(context, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.StructuredContent); + Assert.Equal("Products", result.StructuredContent["productTitle"]?.ToString()); + } + + [Fact] + public async Task InvokeAsync_InvalidScript_ReturnsError() + { + // Arrange + var invalidScript = @""; + var context = CreateRequestContext(CreateArguments(invalidScript)); + + // 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); + } + + [Fact] + 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 = $@" + + + + + + + "; + + var context = CreateRequestContext(CreateArguments(script)); + + // Act + var result = await _tool.InvokeAsync(context, CancellationToken.None); + + // Assert + 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] + public void ProtocolTool_ReturnsValidTool() + { + // Act + var protocolTool = _tool.ProtocolTool; + + // Assert + Assert.NotNull(protocolTool); + Assert.Equal("netinteractor_execute_script", protocolTool.Name); + Assert.NotNull(protocolTool.Description); + Assert.NotNull(protocolTool.OutputSchema); + } + + [Fact] + public void Metadata_ReturnsEmptyList() + { + // Act + var metadata = _tool.Metadata; + + // Assert - Metadata returns empty list, output schema is on ProtocolTool.OutputSchema + Assert.NotNull(metadata); + Assert.Empty(metadata); + } + } + + /// + /// 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(); + } + } +}