From fce8bf188621de01fb6317d8f1d486ec00f9a017 Mon Sep 17 00:00:00 2001 From: Laurent Ellerbach Date: Mon, 13 Apr 2026 17:08:14 +0200 Subject: [PATCH 1/9] good first commit --- azure-pipelines.yml | 6 +- doc/skills-discovery.md | 577 ++++++++++++++++++ nanoFramework.WebServer.Skills.nuspec | 38 ++ .../DescriptionAttribute.cs | 27 + .../HashtableExtension.cs | 23 + .../Properties/AssemblyInfo.cs | 20 + .../RegistryBase.cs | 194 ++++++ .../SkillActionAttribute.cs | 51 ++ .../SkillActionMetadata.cs | 85 +++ .../SkillAttribute.cs | 49 ++ .../SkillDiscoveryApiKeyAuthController.cs | 15 + .../SkillDiscoveryBasicAuthController.cs | 15 + .../SkillDiscoveryController.cs | 237 +++++++ .../SkillExampleAttribute.cs | 29 + .../SkillJsonHelper.cs | 218 +++++++ .../SkillMetadata.cs | 185 ++++++ .../SkillRegistry.cs | 362 +++++++++++ .../SkillTagAttribute.cs | 29 + .../nanoFramework.WebServer.Skills.nfproj | 102 ++++ .../packages.config | 13 + nanoFramework.WebServer.sln | 26 + tests/SkillsClientTest/SkillsClientTest.cs | 248 ++++++++ tests/SkillsEndToEndTest/Program.cs | 67 ++ .../Properties/AssemblyInfo.cs | 20 + tests/SkillsEndToEndTest/SkillClasses.cs | 144 +++++ .../SkillsEndToEndTest.nfproj | 74 +++ tests/SkillsEndToEndTest/WiFi.cs | 11 + tests/SkillsEndToEndTest/packages.config | 13 + tests/SkillsEndToEndTest/requests.http | 94 +++ tests/SkillsTests/Properties/AssemblyInfo.cs | 21 + tests/SkillsTests/SkillJsonHelperTests.cs | 206 +++++++ tests/SkillsTests/SkillRegistryTests.cs | 554 +++++++++++++++++ tests/SkillsTests/SkillsTests.nfproj | 81 +++ tests/SkillsTests/nano.runsettings | 17 + tests/SkillsTests/packages.config | 13 + 35 files changed, 3863 insertions(+), 1 deletion(-) create mode 100644 doc/skills-discovery.md create mode 100644 nanoFramework.WebServer.Skills.nuspec create mode 100644 nanoFramework.WebServer.Skills/DescriptionAttribute.cs create mode 100644 nanoFramework.WebServer.Skills/HashtableExtension.cs create mode 100644 nanoFramework.WebServer.Skills/Properties/AssemblyInfo.cs create mode 100644 nanoFramework.WebServer.Skills/RegistryBase.cs create mode 100644 nanoFramework.WebServer.Skills/SkillActionAttribute.cs create mode 100644 nanoFramework.WebServer.Skills/SkillActionMetadata.cs create mode 100644 nanoFramework.WebServer.Skills/SkillAttribute.cs create mode 100644 nanoFramework.WebServer.Skills/SkillDiscoveryApiKeyAuthController.cs create mode 100644 nanoFramework.WebServer.Skills/SkillDiscoveryBasicAuthController.cs create mode 100644 nanoFramework.WebServer.Skills/SkillDiscoveryController.cs create mode 100644 nanoFramework.WebServer.Skills/SkillExampleAttribute.cs create mode 100644 nanoFramework.WebServer.Skills/SkillJsonHelper.cs create mode 100644 nanoFramework.WebServer.Skills/SkillMetadata.cs create mode 100644 nanoFramework.WebServer.Skills/SkillRegistry.cs create mode 100644 nanoFramework.WebServer.Skills/SkillTagAttribute.cs create mode 100644 nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj create mode 100644 nanoFramework.WebServer.Skills/packages.config create mode 100644 tests/SkillsClientTest/SkillsClientTest.cs create mode 100644 tests/SkillsEndToEndTest/Program.cs create mode 100644 tests/SkillsEndToEndTest/Properties/AssemblyInfo.cs create mode 100644 tests/SkillsEndToEndTest/SkillClasses.cs create mode 100644 tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj create mode 100644 tests/SkillsEndToEndTest/WiFi.cs create mode 100644 tests/SkillsEndToEndTest/packages.config create mode 100644 tests/SkillsEndToEndTest/requests.http create mode 100644 tests/SkillsTests/Properties/AssemblyInfo.cs create mode 100644 tests/SkillsTests/SkillJsonHelperTests.cs create mode 100644 tests/SkillsTests/SkillRegistryTests.cs create mode 100644 tests/SkillsTests/SkillsTests.nfproj create mode 100644 tests/SkillsTests/nano.runsettings create mode 100644 tests/SkillsTests/packages.config diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 06eee73..9eb5759 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -68,7 +68,11 @@ steps: parameters: nugetPackageName: 'nanoFramework.WebServer.Mcp' - # publish the 3 libs + - template: azure-pipelines-templates/class-lib-package.yml@templates + parameters: + nugetPackageName: 'nanoFramework.WebServer.Skills' + + # publish the 4 libs - template: azure-pipelines-templates/class-lib-publish.yml@templates # create GitHub release build from main branch diff --git a/doc/skills-discovery.md b/doc/skills-discovery.md new file mode 100644 index 0000000..ac99038 --- /dev/null +++ b/doc/skills-discovery.md @@ -0,0 +1,577 @@ +# AI Agent Skills Discovery + +The nanoFramework WebServer provides a lightweight skills discovery service that enables AI agents to discover and invoke capabilities exposed by embedded devices. Skills follow the [A2A (Agent2Agent) protocol](https://a2a-protocol.org) conventions for agent-to-agent discovery, while using an attribute-driven developer experience inspired by Semantic Kernel plugins. + +## Table of Contents + +- [Overview](#overview) +- [Key Features](#key-features) +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Defining Skills](#defining-skills) +- [Skill Actions](#skill-actions) +- [Tags and Examples](#tags-and-examples) +- [Markdown Document Support](#markdown-document-support) +- [Complex Object Support](#complex-object-support) +- [Server Setup](#server-setup) +- [Authentication Options](#authentication-options) +- [HTTP API Reference](#http-api-reference) +- [Using Alongside MCP](#using-alongside-mcp) +- [Best Practices](#best-practices) +- [Complete Example](#complete-example) + +## Overview + +A **skill** is a named, described capability that an embedded device advertises to any AI agent or orchestrator. Skills group related actions (invokable functions) and expose metadata — tags, version, input/output contract — so an agent can discover **what the device can do** before deciding **how** to invoke it. + +The implementation serves an **A2A-compatible Agent Card** at `/.well-known/agent-card.json`, enabling standard agent discovery. + +### Key Features + +- **Automatic skill discovery** through reflection and attributes +- **A2A-compatible Agent Card** served at `/.well-known/agent-card.json` +- **Markdown document support** — skills can return `text/markdown` content +- **Tag-based filtering** for semantic matching by orchestrators +- **Example prompts** to help LLMs understand when to invoke a skill +- **Type-safe parameter handling** with automatic deserialization +- **Flexible authentication** options (none, basic auth, API key) +- **Complex object support** for input parameters and return values +- **Memory efficient** implementation optimized for embedded devices +- **Protocol-agnostic** — works standalone or alongside MCP + +### Limitations + +- **Single parameter limitation**: Actions can have zero or one parameter (use classes for multiple values) +- **Static methods only**: Skill actions must be static methods +- **No streaming**: Responses are returned as complete payloads +- **Discovery only**: The full A2A task lifecycle is not implemented — only discovery and invocation + +## Requirements + +- **NuGet Package**: `nanoFramework.WebServer.Skills` +- **Network Connectivity**: WiFi, Ethernet, or other network connection +- **Memory**: Sufficient RAM for JSON parsing and reflection + +## Installation + +Install the `nanoFramework.WebServer.Skills` NuGet package. This includes the core `nanoFramework.WebServer` as a dependency. + +## Quick Start + +```csharp +using System; +using System.Threading; +using nanoFramework.WebServer; +using nanoFramework.WebServer.Skills; + +[Skill("hello", "Hello Skill", "A simple greeting skill")] +[SkillTag("greeting")] +[SkillExample("Say hello to someone")] +public class HelloSkill +{ + [SkillAction("SayHello", "Returns a greeting message")] + public static string SayHello(string name) + { + return $"Hello, {name}! Greetings from nanoFramework."; + } +} + +public class Program +{ + public static void Main() + { + // Discover and register skills + SkillRegistry.DiscoverSkills(new Type[] { typeof(HelloSkill) }); + + // Start server + using (var server = new WebServer(80, HttpProtocol.Http, + new Type[] { typeof(SkillDiscoveryController) })) + { + server.Start(); + Thread.Sleep(Timeout.Infinite); + } + } +} +``` + +Agents can now: +- `GET /.well-known/agent-card.json` — discover available skills +- `GET /skills` — lightweight skills list +- `POST /skills/invoke` — invoke skill actions + +## Defining Skills + +Use the `[Skill]` attribute on a class to mark it as a discoverable skill. The attribute takes four parameters aligned with the A2A `AgentSkill` schema: + +```csharp +[Skill("climate-control", "Climate Control", "HVAC management for building zones", "1.0")] +public class ClimateSkill +{ + // ... actions +} +``` + +| Parameter | A2A Field | Description | +| --- | --- | --- | +| `id` | `AgentSkill.id` | Unique identifier for the skill (required) | +| `name` | `AgentSkill.name` | Human-readable display name (required) | +| `description` | `AgentSkill.description` | Detailed description of what the skill does (required) | +| `version` | — | Skill version string, defaults to "1.0" | + +## Skill Actions + +Actions are the invokable functions within a skill. Use the `[SkillAction]` attribute on **static methods**: + +```csharp +[Skill("sensors", "Sensors", "Device sensor readings")] +public class SensorSkill +{ + [SkillAction("ReadTemperature", "Reads the current temperature from the sensor")] + public static double ReadTemperature() + { + return TemperatureSensor.Read(); + } + + [SkillAction("SetThreshold", "Sets the alert temperature threshold")] + public static bool SetThreshold(ThresholdInput input) + { + return AlertSystem.SetThreshold(input.Value); + } +} +``` + +### Actions with Output Description + +Provide output descriptions for better AI understanding: + +```csharp +[SkillAction("GetStatus", "Retrieves system status", outputDescription: "JSON object with device metrics")] +public static DeviceStatus GetStatus() +{ + return new DeviceStatus { Uptime = "2d 5h", Memory = "75%" }; +} +``` + +## Tags and Examples + +### Tags + +Tags enable semantic matching by orchestrators. Use the `[SkillTag]` attribute (one tag per attribute, apply multiple times): + +```csharp +[Skill("climate-control", "Climate Control", "HVAC management")] +[SkillTag("temperature")] +[SkillTag("hvac")] +[SkillTag("sensor")] +[SkillTag("indoor")] +public class ClimateSkill { } +``` + +Tags appear in the A2A `AgentSkill.tags` field and can be used to filter skills via `GET .well-known/agent-card.json?tag=sensor`. + +### Examples + +Examples help LLMs understand when to invoke a skill. Use the `[SkillExample]` attribute: + +```csharp +[Skill("climate-control", "Climate Control", "HVAC management")] +[SkillExample("What is the current temperature?")] +[SkillExample("Set the target temperature to 22 degrees")] +[SkillExample("Show the HVAC system status")] +public class ClimateSkill { } +``` + +## Markdown Document Support + +Skills can return markdown documents via the `contentType` parameter on `[SkillAction]`. This is critical for AI agents that need structured documentation from devices. + +```csharp +[SkillAction("GetDocumentation", "Returns setup and calibration guide", + contentType: "text/markdown")] +public static string GetDocumentation() +{ + return "# Climate Control Setup Guide\n\n" + + "## Sensor Calibration\n" + + "1. Place the sensor in a controlled environment...\n" + + "2. Wait 5 minutes for stabilization...\n\n" + + "## Configuration\n" + + "- **Target Range**: 18°C — 28°C\n" + + "- **Polling Interval**: 30 seconds\n"; +} +``` + +When invoked, the response is returned with `Content-Type: text/markdown` directly (not JSON-wrapped): + +```http +HTTP/1.1 200 OK +Content-Type: text/markdown + +# Climate Control Setup Guide + +## Sensor Calibration +1. Place the sensor in a controlled environment... +2. Wait 5 minutes for stabilization... +``` + +The `outputModes` field in the A2A Agent Card automatically includes `text/markdown` for skills that have markdown actions. + +## Complex Object Support + +Use classes for actions that require multiple input parameters: + +```csharp +public class DeviceConfig +{ + public string DeviceName + { + [Description("Device name identifier")] + get; + set; + } + + public int UpdateInterval + { + [Description("Update interval in seconds")] + get; + set; + } +} + +[Skill("config", "Configuration", "Device configuration management")] +[SkillTag("configuration")] +public class ConfigSkill +{ + [SkillAction("Configure", "Updates device configuration")] + public static string Configure(DeviceConfig config) + { + ApplyConfig(config); + return "Configuration applied: " + config.DeviceName; + } +} +``` + +Nested objects are also supported: + +```csharp +public class SensorConfig +{ + public string SensorId { get; set; } + public CalibrationSettings Calibration { get; set; } +} + +public class CalibrationSettings +{ + public float Offset { get; set; } + public float Scale { get; set; } +} +``` + +When invoking, nested objects are passed as JSON strings within the arguments Hashtable. + +## Server Setup + +### Basic Configuration + +```csharp +// Discover skills +SkillRegistry.DiscoverSkills(new Type[] +{ + typeof(ClimateSkill), + typeof(SensorSkill), + typeof(ConfigSkill) +}); + +// Start server +using (var server = new WebServer(80, HttpProtocol.Http, + new Type[] { typeof(SkillDiscoveryController) })) +{ + server.Start(); + Thread.Sleep(Timeout.Infinite); +} +``` + +### Custom Agent Card Information + +Customize the Agent Card identity: + +```csharp +SkillDiscoveryController.AgentName = "SmartThermostat"; +SkillDiscoveryController.AgentDescription = "Embedded HVAC controller with sensor capabilities"; +SkillDiscoveryController.AgentVersion = "2.1.0"; +SkillDiscoveryController.AgentUrl = "http://192.168.1.100"; +``` + +### HTTPS Configuration + +For secure communication, configure HTTPS. See the [HTTPS documentation](./https-certificates.md). + +```csharp +using (var server = new WebServer(443, HttpProtocol.Https, + new Type[] { typeof(SkillDiscoveryController) })) +{ + server.HttpsCert = certificate; + server.SslProtocols = SslProtocols.Tls12; + server.Start(); +} +``` + +## Authentication Options + +### No Authentication (Default) + +```csharp +new Type[] { typeof(SkillDiscoveryController) } +``` + +### Basic Authentication + +```csharp +var server = new WebServer(80, HttpProtocol.Http, + new Type[] { typeof(SkillDiscoveryBasicAuthController) }); +server.Credential = new NetworkCredential("admin", "password"); +``` + +### API Key Authentication + +```csharp +var server = new WebServer(80, HttpProtocol.Http, + new Type[] { typeof(SkillDiscoveryApiKeyAuthController) }); +server.ApiKey = "your-secret-key"; +``` + +## HTTP API Reference + +### Agent Card Discovery + +``` +GET /.well-known/agent-card.json +``` + +Returns an A2A-compatible Agent Card: + +```json +{ + "name": "SmartThermostat", + "description": "Embedded HVAC controller", + "version": "1.0.0", + "skills": [ + { + "id": "climate-control", + "name": "Climate Control", + "description": "HVAC management for building zones", + "version": "1.0", + "tags": ["temperature", "hvac", "sensor"], + "examples": ["What is the current temperature?"], + "inputModes": ["application/json", "text/plain"], + "outputModes": ["application/json", "text/markdown"], + "actions": [ + { + "name": "GetTemperature", + "description": "Reads current temperature" + }, + { + "name": "GetDocumentation", + "description": "Returns setup guide", + "contentType": "text/markdown" + } + ] + } + ] +} +``` + +### Filtering + +Filter by skill ID: +``` +GET /.well-known/agent-card.json?skill=climate-control +``` + +Filter by tag: +``` +GET /.well-known/agent-card.json?tag=sensor +``` + +### Skills List (Lightweight) + +``` +GET /skills +``` + +Returns just the skills array: +```json +{ + "skills": [ ... ] +} +``` + +### Invoke an Action + +``` +POST /skills/invoke +Content-Type: application/json + +{ + "skill": "climate-control", + "action": "GetTemperature", + "arguments": {} +} +``` + +JSON response: +```json +{ + "result": "22.5" +} +``` + +Markdown response (when action has `contentType: "text/markdown"`): +```http +HTTP/1.1 200 OK +Content-Type: text/markdown + +# Climate Control Setup Guide +... +``` + +### Error Responses + +```json +{ "error": { "code": -1, "message": "Error description" } } +{ "error": { "code": -2, "message": "Skill or action not found" } } +{ "error": { "code": -3, "message": "Missing 'skill' or 'action' field" } } +``` + +## Using Alongside MCP + +Skills and MCP are independent, peer packages. Both can be active simultaneously: + +```csharp +// Discover both +McpToolRegistry.DiscoverTools(new Type[] { typeof(MyMcpTools) }); +SkillRegistry.DiscoverSkills(new Type[] { typeof(ClimateSkill) }); + +// Register both controllers +using (var server = new WebServer(80, HttpProtocol.Http, + new Type[] { typeof(McpServerController), typeof(SkillDiscoveryController) })) +{ + server.Start(); + Thread.Sleep(Timeout.Infinite); +} +// MCP clients use POST /mcp +// A2A agents use GET /.well-known/agent-card.json +``` + +## Best Practices + +1. **Keep markdown documents concise** — embedded devices have limited RAM. Aim for under 4KB per markdown response. +2. **Use meaningful tags** — tags are the primary mechanism for agents to match skills. Use domain-specific keywords. +3. **Provide examples** — LLMs use examples to understand when to invoke a skill. Include 2-3 representative prompts. +4. **Use descriptive action names** — action names should clearly indicate what they do (e.g., `GetTemperature`, not `Read`). +5. **Group related actions** — a skill should represent a coherent capability, not a single function. +6. **Use `[Description]` on properties** — property descriptions appear in the JSON schema and help AI agents understand parameter structure. +7. **One parameter per action** — use a class to wrap multiple values into a single parameter. +8. **Static methods only** — skill actions must be static methods to avoid instantiation overhead. + +## Complete Example + +```csharp +using System; +using System.Threading; +using nanoFramework.WebServer; +using nanoFramework.WebServer.Skills; + +// Define a skill with full A2A-compatible metadata +[Skill("climate-control", "Climate Control", + "HVAC management for building zones", "1.0")] +[SkillTag("temperature")] +[SkillTag("hvac")] +[SkillTag("sensor")] +[SkillExample("What is the current temperature?")] +[SkillExample("Set the target temperature to 22 degrees")] +public class ClimateSkill +{ + [SkillAction("GetTemperature", "Reads current room temperature")] + public static double GetTemperature() + { + return TemperatureSensor.Read(); + } + + [SkillAction("SetTargetTemp", "Sets the target temperature")] + public static bool SetTargetTemp(TargetTempInput input) + { + return HvacController.SetTarget(input.Temperature); + } + + [SkillAction("GetStatus", "Returns HVAC system status", + outputDescription: "HVAC status with temperature and mode")] + public static HvacStatus GetStatus() + { + return new HvacStatus + { + CurrentTemp = TemperatureSensor.Read(), + TargetTemp = HvacController.GetTarget(), + Mode = HvacController.GetMode() + }; + } + + [SkillAction("GetDocumentation", + "Returns setup and calibration guide", + contentType: "text/markdown")] + public static string GetDocumentation() + { + return "# Climate Control Setup Guide\n\n" + + "## Sensor Calibration\n" + + "1. Place the sensor in a controlled environment...\n" + + "2. Wait 5 minutes for stabilization...\n\n" + + "## Configuration\n" + + "- **Target Range**: 18°C — 28°C\n" + + "- **Polling Interval**: 30 seconds\n"; + } +} + +public class TargetTempInput +{ + public double Temperature + { + [Description("Target temperature in Celsius")] + get; + set; + } +} + +public class HvacStatus +{ + public double CurrentTemp { get; set; } + public double TargetTemp { get; set; } + public string Mode { get; set; } +} + +public class Program +{ + public static void Main() + { + // Connect to WiFi + // WifiNetworkHelper.ConnectDhcp(ssid, password); + + // Discover skills + SkillRegistry.DiscoverSkills(new Type[] { typeof(ClimateSkill) }); + + // Configure Agent Card + SkillDiscoveryController.AgentName = "SmartThermostat"; + SkillDiscoveryController.AgentDescription = + "Embedded HVAC controller with sensor capabilities"; + SkillDiscoveryController.AgentVersion = "1.0.0"; + + // Start server + using (var server = new WebServer(80, HttpProtocol.Http, + new Type[] { typeof(SkillDiscoveryController) })) + { + server.Start(); + Console.WriteLine("Skills discovery server running on port 80"); + Thread.Sleep(Timeout.Infinite); + } + } +} +``` diff --git a/nanoFramework.WebServer.Skills.nuspec b/nanoFramework.WebServer.Skills.nuspec new file mode 100644 index 0000000..0f92f48 --- /dev/null +++ b/nanoFramework.WebServer.Skills.nuspec @@ -0,0 +1,38 @@ + + + + nanoFramework.WebServer.Skills + nanoFramework.WebServer.Skills + $version$ + Laurent Ellerbach,nanoframework + false + LICENSE.md + + + docs\README.md + false + https://github.com/nanoframework/nanoFramework.WebServer + images\nf-logo.png + + Copyright (c) .NET Foundation and Contributors + AI Agent Skills Discovery Service for nanoFramework WebServer. +Enables embedded devices to advertise capabilities to AI agents using an A2A-compatible Agent Card. +Attribute-driven, lightweight, protocol-agnostic. Supports JSON and markdown output modes. +This comes also with the nanoFramework WebServer. Allowing to create a REST API based project with ease as well. + + http https webserver net netmf nf nanoframework skills ai agent discovery a2a iot + + + + + + + + + + + + + + + diff --git a/nanoFramework.WebServer.Skills/DescriptionAttribute.cs b/nanoFramework.WebServer.Skills/DescriptionAttribute.cs new file mode 100644 index 0000000..281b712 --- /dev/null +++ b/nanoFramework.WebServer.Skills/DescriptionAttribute.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Specifies a description for a class member, such as a property or method, for use in documentation or metadata. + /// + public class DescriptionAttribute : Attribute + { + /// + /// Gets the description text associated with the member. + /// + public string Description { get; } + + /// + /// Initializes a new instance of the class with the specified description. + /// + /// The description text to associate with the member. + public DescriptionAttribute(string description) + { + Description = description; + } + } +} diff --git a/nanoFramework.WebServer.Skills/HashtableExtension.cs b/nanoFramework.WebServer.Skills/HashtableExtension.cs new file mode 100644 index 0000000..db2cb3b --- /dev/null +++ b/nanoFramework.WebServer.Skills/HashtableExtension.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; + +namespace nanoFramework.WebServer.Skills +{ + internal static class HashtableExtension + { + public static bool ContainsKey(this Hashtable hashtable, string key) + { + foreach (object k in hashtable.Keys) + { + if (k is string strKey && strKey.Equals(key)) + { + return true; + } + } + + return false; + } + } +} diff --git a/nanoFramework.WebServer.Skills/Properties/AssemblyInfo.cs b/nanoFramework.WebServer.Skills/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..8cab2c9 --- /dev/null +++ b/nanoFramework.WebServer.Skills/Properties/AssemblyInfo.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("nanoFramework.WebServer.Skills")] +[assembly: AssemblyCompany("nanoFramework Contributors")] +[assembly: AssemblyProduct("nanoFramework.WebServer.Skills")] +[assembly: AssemblyCopyright("Copyright (c) .NET Foundation and Contributors")] + + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] diff --git a/nanoFramework.WebServer.Skills/RegistryBase.cs b/nanoFramework.WebServer.Skills/RegistryBase.cs new file mode 100644 index 0000000..2e21bac --- /dev/null +++ b/nanoFramework.WebServer.Skills/RegistryBase.cs @@ -0,0 +1,194 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.Reflection; +using nanoFramework.Json; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Base class for registries that support conversion and deserialization of objects. + /// + public abstract class RegistryBase + { + /// + /// Converts a value to the specified primitive type with appropriate type conversion and error handling. + /// + /// The value to convert. + /// The target primitive type to convert to. + /// The converted value as the target type. + protected static object ConvertToPrimitiveType(object value, Type targetType) + { + if (value == null) + { + return null; + } + + if (targetType == typeof(string)) + { + return value.ToString(); + } + else if (targetType == typeof(int)) + { + return Convert.ToInt32(value.ToString()); + } + else if (targetType == typeof(double)) + { + return Convert.ToDouble(value.ToString()); + } + else if (targetType == typeof(bool)) + { + if (value.ToString().Length == 1) + { + try + { + return Convert.ToBoolean(Convert.ToByte(value.ToString())); + } + catch (Exception) + { + } + } + + return value.ToString().ToLower() == "true"; + } + else if (targetType == typeof(long)) + { + return Convert.ToInt64(value.ToString()); + } + else if (targetType == typeof(float)) + { + return Convert.ToSingle(value.ToString()); + } + else if (targetType == typeof(byte)) + { + return Convert.ToByte(value.ToString()); + } + else if (targetType == typeof(short)) + { + return Convert.ToInt16(value.ToString()); + } + else if (targetType == typeof(char)) + { + try + { + return Convert.ToChar(Convert.ToUInt16(value.ToString())); + } + catch (Exception) + { + return string.IsNullOrEmpty(value.ToString()) ? '\0' : value.ToString()[0]; + } + } + else if (targetType == typeof(uint)) + { + return Convert.ToUInt32(value.ToString()); + } + else if (targetType == typeof(ulong)) + { + return Convert.ToUInt64(value.ToString()); + } + else if (targetType == typeof(ushort)) + { + return Convert.ToUInt16(value.ToString()); + } + else if (targetType == typeof(sbyte)) + { + return Convert.ToSByte(value.ToString()); + } + + return value; + } + + /// + /// Recursively deserializes a Hashtable into a strongly-typed object by mapping properties and handling nested objects. + /// + /// The Hashtable containing the data to deserialize. + /// The target type to deserialize the data into. + /// A new instance of the target type with properties populated from the Hashtable, or null if hashtable or targetType is null. + protected static object DeserializeFromHashtable(Hashtable hashtable, Type targetType) + { + if (hashtable == null || targetType == null) + { + return null; + } + + if (SkillJsonHelper.IsPrimitiveType(targetType) || targetType == typeof(string)) + { + return hashtable; + } + + object instance = CreateInstance(targetType); + + MethodInfo[] methods = targetType.GetMethods(); + + foreach (MethodInfo method in methods) + { + if (!method.Name.StartsWith("set_") || method.GetParameters().Length != 1) + { + continue; + } + + string propertyName = method.Name.Substring(4); + + if (!hashtable.Contains(propertyName)) + { + continue; + } + + object value = hashtable[propertyName]; + if (value == null) + { + continue; + } + + try + { + Type propertyType = method.GetParameters()[0].ParameterType; + if (SkillJsonHelper.IsPrimitiveType(propertyType) || propertyType == typeof(string)) + { + object convertedValue = ConvertToPrimitiveType(value, propertyType); + method.Invoke(instance, new object[] { convertedValue }); + } + else + { + if (value is string stringValue) + { + var nestedHashtable = (Hashtable)JsonConvert.DeserializeObject(stringValue, typeof(Hashtable)); + object nestedObject = DeserializeFromHashtable(nestedHashtable, propertyType); + method.Invoke(instance, new object[] { nestedObject }); + } + else if (value is Hashtable nestedHashtable) + { + object nestedObject = DeserializeFromHashtable(nestedHashtable, propertyType); + method.Invoke(instance, new object[] { nestedObject }); + } + } + } + catch (Exception) + { + continue; + } + } + + return instance; + } + + /// + /// Creates an instance of a type using its parameterless constructor. + /// + /// The type to create an instance of. + /// A new instance of the type. + /// Thrown when the type does not have a parameterless constructor. + protected static object CreateInstance(Type type) + { + ConstructorInfo constructor = type.GetConstructor(new Type[0]); + if (constructor == null) + { + throw new Exception($"Type {type.Name} does not have a parameterless constructor"); + } + + return constructor.Invoke(new object[0]); + } + } +} diff --git a/nanoFramework.WebServer.Skills/SkillActionAttribute.cs b/nanoFramework.WebServer.Skills/SkillActionAttribute.cs new file mode 100644 index 0000000..bed24c4 --- /dev/null +++ b/nanoFramework.WebServer.Skills/SkillActionAttribute.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Marks a static method as an invokable action within a skill. + /// + [AttributeUsage(AttributeTargets.Method)] + public class SkillActionAttribute : Attribute + { + /// + /// Gets the name of the action. + /// + public string Name { get; } + + /// + /// Gets the description of the action. + /// + public string Description { get; } + + /// + /// Gets the description of the action's output. + /// + public string OutputDescription { get; } + + /// + /// Gets the MIME type of the output. Defaults to "application/json". + /// Set to "text/markdown" for actions that return markdown documents. + /// + public string ContentType { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the action. + /// The description of the action. + /// The description of the action's output. + /// The MIME type of the output. Defaults to "application/json". + public SkillActionAttribute(string name, string description = "", + string outputDescription = "", string contentType = "application/json") + { + Name = name; + Description = description; + OutputDescription = outputDescription; + ContentType = contentType; + } + } +} diff --git a/nanoFramework.WebServer.Skills/SkillActionMetadata.cs b/nanoFramework.WebServer.Skills/SkillActionMetadata.cs new file mode 100644 index 0000000..87b3a0e --- /dev/null +++ b/nanoFramework.WebServer.Skills/SkillActionMetadata.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Reflection; +using System.Text; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Represents metadata information for a registered skill action, including its name, description, + /// input/output schemas, content type, and associated method. + /// + public class SkillActionMetadata + { + /// + /// Gets or sets the name of the action. + /// + public string Name { get; set; } + + /// + /// Gets or sets the description of the action. + /// + public string Description { get; set; } + + /// + /// Gets or sets the JSON schema string describing the input parameters for the action. + /// + public string InputSchema { get; set; } + + /// + /// Gets or sets the JSON schema string describing the output type for the action. + /// + public string OutputSchema { get; set; } + + /// + /// Gets or sets the MIME type of the output (e.g. "application/json", "text/markdown"). + /// + public string ContentType { get; set; } + + /// + /// Gets or sets the representing the method associated with the action. + /// + public MethodInfo Method { get; set; } + + /// + /// Gets or sets the type of the parameter, or null if parameterless. + /// + public Type ParameterType { get; set; } + + /// + /// Appends the JSON representation of this action metadata to the specified StringBuilder. + /// + /// The StringBuilder to append to. + public void AppendJson(StringBuilder sb) + { + sb.Append("{\"name\":\""); + sb.Append(Name); + sb.Append("\",\"description\":\""); + sb.Append(Description); + sb.Append("\""); + + if (!string.IsNullOrEmpty(ContentType) && ContentType != "application/json") + { + sb.Append(",\"contentType\":\""); + sb.Append(ContentType); + sb.Append("\""); + } + + if (!string.IsNullOrEmpty(InputSchema)) + { + sb.Append(",\"inputSchema\":"); + sb.Append(InputSchema); + } + + if (!string.IsNullOrEmpty(OutputSchema)) + { + sb.Append(",\"outputSchema\":"); + sb.Append(OutputSchema); + } + + sb.Append("}"); + } + } +} diff --git a/nanoFramework.WebServer.Skills/SkillAttribute.cs b/nanoFramework.WebServer.Skills/SkillAttribute.cs new file mode 100644 index 0000000..97478e0 --- /dev/null +++ b/nanoFramework.WebServer.Skills/SkillAttribute.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Marks a class as a discoverable AI agent skill (A2A AgentSkill compatible). + /// + [AttributeUsage(AttributeTargets.Class)] + public class SkillAttribute : Attribute + { + /// + /// Gets the unique identifier (A2A: AgentSkill.id). + /// + public string Id { get; } + + /// + /// Gets the human-readable name (A2A: AgentSkill.name). + /// + public string Name { get; } + + /// + /// Gets the detailed description (A2A: AgentSkill.description). + /// + public string Description { get; } + + /// + /// Gets the agent/skill version. + /// + public string Version { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier for this skill. + /// The human-readable name of this skill. + /// A detailed description of this skill. + /// The version of this skill. Defaults to "1.0". + public SkillAttribute(string id, string name, string description, string version = "1.0") + { + Id = id; + Name = name; + Description = description; + Version = version; + } + } +} diff --git a/nanoFramework.WebServer.Skills/SkillDiscoveryApiKeyAuthController.cs b/nanoFramework.WebServer.Skills/SkillDiscoveryApiKeyAuthController.cs new file mode 100644 index 0000000..804788f --- /dev/null +++ b/nanoFramework.WebServer.Skills/SkillDiscoveryApiKeyAuthController.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using nanoFramework.WebServer; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Skill discovery controller with API key-based authentication. + /// + [Authentication("ApiKey")] + public class SkillDiscoveryApiKeyAuthController : SkillDiscoveryController + { + } +} diff --git a/nanoFramework.WebServer.Skills/SkillDiscoveryBasicAuthController.cs b/nanoFramework.WebServer.Skills/SkillDiscoveryBasicAuthController.cs new file mode 100644 index 0000000..7a23d92 --- /dev/null +++ b/nanoFramework.WebServer.Skills/SkillDiscoveryBasicAuthController.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using nanoFramework.WebServer; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Skill discovery controller with basic (user, password) authentication. + /// + [Authentication("Basic")] + public class SkillDiscoveryBasicAuthController : SkillDiscoveryController + { + } +} diff --git a/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs b/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs new file mode 100644 index 0000000..b2f3106 --- /dev/null +++ b/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs @@ -0,0 +1,237 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.Diagnostics; +using System.Text; +using nanoFramework.Json; +using nanoFramework.WebServer; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Controller providing A2A-compatible skill discovery and invocation endpoints. + /// Serves the Agent Card at .well-known/agent-card.json and skill invocation at skills/invoke. + /// + public class SkillDiscoveryController + { + /// + /// Gets or sets the agent name displayed in the Agent Card. + /// + public static string AgentName { get; set; } = "nanoFramework"; + + /// + /// Gets or sets the agent description displayed in the Agent Card. + /// + public static string AgentDescription { get; set; } = string.Empty; + + /// + /// Gets or sets the agent version displayed in the Agent Card. + /// + public static string AgentVersion { get; set; } = "1.0.0"; + + /// + /// Gets or sets the agent URL for the Agent Card. + /// + public static string AgentUrl { get; set; } = string.Empty; + + /// + /// Handles GET requests to .well-known/agent-card.json. + /// Returns an A2A-compatible Agent Card with the registered skills. + /// Supports optional query parameters: ?skill=id to filter by skill, ?tag=value to filter by tag. + /// + /// The web server event arguments. + [Route(".well-known/agent-card.json"), Method("GET")] + public void GetAgentCard(WebServerEventArgs e) + { + e.Context.Response.ContentType = "application/json"; + + try + { + // Check for query parameters + string skillFilter = null; + string tagFilter = null; + string rawUrl = e.Context.Request.RawUrl; + int paramIndex = rawUrl.IndexOf('?'); + if (paramIndex > 0) + { + string queryString = rawUrl.Substring(paramIndex + 1); + string[] pairs = queryString.Split('&'); + foreach (string pair in pairs) + { + int eqIndex = pair.IndexOf('='); + if (eqIndex > 0) + { + string key = pair.Substring(0, eqIndex); + string value = pair.Substring(eqIndex + 1); + if (key == "skill") + { + skillFilter = value; + } + else if (key == "tag") + { + tagFilter = value; + } + } + } + } + + StringBuilder sb = new StringBuilder(); + sb.Append("{\"name\":\""); + sb.Append(AgentName); + sb.Append("\""); + + if (!string.IsNullOrEmpty(AgentDescription)) + { + sb.Append(",\"description\":\""); + sb.Append(AgentDescription); + sb.Append("\""); + } + + sb.Append(",\"version\":\""); + sb.Append(AgentVersion); + sb.Append("\""); + + if (!string.IsNullOrEmpty(AgentUrl)) + { + sb.Append(",\"url\":\""); + sb.Append(AgentUrl); + sb.Append("\""); + } + + // Add skills + sb.Append(",\"skills\":"); + if (skillFilter != null) + { + string skillJson = SkillRegistry.GetSkillJson(skillFilter); + if (skillJson != null) + { + sb.Append("["); + sb.Append(skillJson); + sb.Append("]"); + } + else + { + sb.Append("[]"); + } + } + else if (tagFilter != null) + { + sb.Append(SkillRegistry.GetSkillsByTagJson(tagFilter)); + } + else + { + sb.Append(SkillRegistry.GetSkillsArrayJson()); + } + + sb.Append("}"); + + Debug.WriteLine($"Agent Card response: {sb}"); + WebServer.OutputAsStream(e.Context.Response, sb.ToString()); + } + catch (Exception ex) + { + WebServer.OutputAsStream(e.Context.Response, + "{\"error\":{\"code\":-1,\"message\":\"" + ex.Message + "\"}}"); + } + } + + /// + /// Handles GET requests to the skills endpoint. + /// Returns a lightweight JSON response with just the skills array. + /// + /// The web server event arguments. + [Route("skills"), Method("GET")] + public void ListSkills(WebServerEventArgs e) + { + e.Context.Response.ContentType = "application/json"; + + try + { + StringBuilder sb = new StringBuilder(); + sb.Append("{\"skills\":"); + sb.Append(SkillRegistry.GetSkillsArrayJson()); + sb.Append("}"); + + WebServer.OutputAsStream(e.Context.Response, sb.ToString()); + } + catch (Exception ex) + { + WebServer.OutputAsStream(e.Context.Response, + "{\"error\":{\"code\":-1,\"message\":\"" + ex.Message + "\"}}"); + } + } + + /// + /// Handles POST requests to skills/invoke. + /// Invokes a skill action with the provided arguments. + /// Request body: { "skill": "skill-id", "action": "ActionName", "arguments": { ... } } + /// Response content type matches the action's declared ContentType. + /// + /// The web server event arguments. + [Route("skills/invoke"), Method("POST")] + public void InvokeSkillAction(WebServerEventArgs e) + { + try + { + // Read the POST body + var requestStream = e.Context.Request.InputStream; + byte[] buffer = new byte[requestStream.Length]; + requestStream.Read(buffer, 0, buffer.Length); + string requestBody = Encoding.UTF8.GetString(buffer, 0, buffer.Length); + + Debug.WriteLine($"Skill invoke request: {requestBody}"); + + Hashtable request = (Hashtable)JsonConvert.DeserializeObject(requestBody, typeof(Hashtable)); + + if (!request.ContainsKey("skill") || !request.ContainsKey("action")) + { + e.Context.Response.ContentType = "application/json"; + WebServer.OutputAsStream(e.Context.Response, + "{\"error\":{\"code\":-3,\"message\":\"Missing 'skill' or 'action' field\"}}"); + return; + } + + string skillId = request["skill"].ToString(); + string actionName = request["action"].ToString(); + Hashtable arguments = request.ContainsKey("arguments") && request["arguments"] != null + ? (Hashtable)request["arguments"] + : null; + + // Get the content type for the action before invoking + string contentType = SkillRegistry.GetActionContentType(skillId, actionName); + if (contentType == null) + { + e.Context.Response.ContentType = "application/json"; + WebServer.OutputAsStream(e.Context.Response, + "{\"error\":{\"code\":-2,\"message\":\"Skill or action not found\"}}"); + return; + } + + string result = SkillRegistry.InvokeAction(skillId, actionName, arguments); + + Debug.WriteLine($"Skill invoke result (contentType: {contentType}): {result}"); + + // For text-based content types, return raw content + if (contentType == "text/markdown" || contentType == "text/plain") + { + e.Context.Response.ContentType = contentType; + WebServer.OutputAsStream(e.Context.Response, result); + } + else + { + // For JSON, wrap in result envelope + e.Context.Response.ContentType = "application/json"; + WebServer.OutputAsStream(e.Context.Response, "{\"result\":" + result + "}"); + } + } + catch (Exception ex) + { + e.Context.Response.ContentType = "application/json"; + WebServer.OutputAsStream(e.Context.Response, + "{\"error\":{\"code\":-1,\"message\":\"" + ex.Message + "\"}}"); + } + } + } +} diff --git a/nanoFramework.WebServer.Skills/SkillExampleAttribute.cs b/nanoFramework.WebServer.Skills/SkillExampleAttribute.cs new file mode 100644 index 0000000..d58dce2 --- /dev/null +++ b/nanoFramework.WebServer.Skills/SkillExampleAttribute.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Provides an example prompt or scenario for a skill (A2A: AgentSkill.examples). + /// Apply multiple times to add multiple examples. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public class SkillExampleAttribute : Attribute + { + /// + /// Gets the example text. + /// + public string Example { get; } + + /// + /// Initializes a new instance of the class. + /// + /// An example prompt or scenario this skill handles. + public SkillExampleAttribute(string example) + { + Example = example; + } + } +} diff --git a/nanoFramework.WebServer.Skills/SkillJsonHelper.cs b/nanoFramework.WebServer.Skills/SkillJsonHelper.cs new file mode 100644 index 0000000..5d37377 --- /dev/null +++ b/nanoFramework.WebServer.Skills/SkillJsonHelper.cs @@ -0,0 +1,218 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Reflection; +using System.Text; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Provides utility methods for generating JSON schemas that describe the input and output parameters of skill actions. + /// + public static class SkillJsonHelper + { + /// + /// Generates a JSON object schema describing the input parameters for a skill action. + /// + /// The representing the input parameter type. + /// A JSON string representing the input parameters schema. + public static string GenerateInputJson(Type inputType) + { + StringBuilder sb = new StringBuilder(); + + sb.Append("{\"type\":\"object\",\"properties\":{"); + AppendInputPropertiesJson(sb, inputType, true); + sb.Append("},\"required\":[]}"); + + return sb.ToString(); + } + + /// + /// Checks if the specified is a primitive type. + /// + /// The to check. + /// true if the type is a primitive type; otherwise, false. + public static bool IsPrimitiveType(Type type) + { + return type == typeof(bool) || + type == typeof(byte) || + type == typeof(sbyte) || + type == typeof(char) || + type == typeof(double) || + type == typeof(float) || + type == typeof(int) || + type == typeof(uint) || + type == typeof(long) || + type == typeof(ulong) || + type == typeof(short) || + type == typeof(ushort); + } + + /// + /// Generates a JSON object describing the output schema for a skill action. + /// + /// The of the output object. + /// A description of the output. + /// A JSON string representing the output schema. + public static string GenerateOutputJson(Type outputType, string description) + { + StringBuilder sb = new StringBuilder(); + AppendOutputJson(sb, outputType, description); + return sb.ToString(); + } + + private static void AppendOutputJson(StringBuilder sb, Type type, string description) + { + string mappedType = MapType(type); + + sb.Append("{"); + sb.Append("\"type\":\"").Append(mappedType).Append("\""); + + bool hasDescription = !string.IsNullOrEmpty(description); + if (hasDescription) + { + sb.Append(",\"description\":\"").Append(description).Append("\""); + } + + if (mappedType == "object") + { + sb.Append(",\"properties\":{"); + AppendOutputPropertiesJson(sb, type, true); + sb.Append("}"); + } + + sb.Append("}"); + } + + private static void AppendOutputPropertiesJson(StringBuilder sb, Type type, bool isFirst) + { + MethodInfo[] methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance); + for (int i = 0; i < methods.Length; i++) + { + MethodInfo method = methods[i]; + if (method.Name.StartsWith("get_") && method.GetParameters().Length == 0) + { + string propName = method.Name.Substring(4); + + Type propType = method.ReturnType; + string mappedType = MapType(propType); + + if (!isFirst) + { + sb.Append(","); + } + + isFirst = false; + + sb.Append("\"").Append(propName).Append("\":"); + if (mappedType == "object") + { + AppendOutputJson(sb, propType, GetTypeDescription(method, propName)); + } + else + { + sb.Append("{"); + sb.Append("\"type\":\"").Append(mappedType).Append("\","); + sb.Append("\"description\":\"").Append(GetTypeDescription(method, propName)).Append("\""); + sb.Append("}"); + } + } + } + } + + private static string GetTypeDescription(MethodInfo method, string propName) + { + var atibs = method.GetCustomAttributes(false); + string desc = propName; + for (int j = 0; j < atibs.Length; j++) + { + if (atibs[j] is DescriptionAttribute descAttrib) + { + desc = descAttrib.Description; + break; + } + } + + return desc; + } + + private static void AppendInputPropertiesJson(StringBuilder sb, Type type, bool isFirst) + { + // If it's a primitive type or string, create a single property entry + if (IsPrimitiveType(type) || type == typeof(string)) + { + string mappedType = MapType(type); + if (!isFirst) + { + sb.Append(","); + } + + sb.Append("\"value\":{"); + sb.Append("\"type\":\"").Append(mappedType).Append("\","); + sb.Append("\"description\":\"Input parameter of type ").Append(type.Name).Append("\""); + sb.Append("}"); + return; + } + + // For complex types, analyze methods to find properties + MethodInfo[] methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance); + + for (int i = 0; i < methods.Length; i++) + { + MethodInfo method = methods[i]; + if (method.Name.StartsWith("get_") && method.GetParameters().Length == 0) + { + string propName = method.Name.Substring(4); + Type propType = method.ReturnType; + + if (!isFirst) + { + sb.Append(","); + } + + isFirst = false; + sb.Append($"\"{propName}\":{{"); + string mappedType = MapType(propType); + sb.Append("\"type\":\"").Append(mappedType).Append("\","); + sb.Append("\"description\":\"").Append(GetTypeDescription(method, propName)).Append("\""); + if (mappedType == "object") + { + sb.Append(",\"properties\":{"); + AppendInputPropertiesJson(sb, propType, true); + sb.Append("}"); + } + + sb.Append("}"); + } + } + } + + private static string MapType(Type type) + { + if (type == typeof(string)) + { + return "string"; + } + else if (type == typeof(int) || type == typeof(double) || type == typeof(float) || + type == typeof(long) || type == typeof(short) || type == typeof(byte)) + { + return "number"; + } + else if (type == typeof(bool)) + { + return "boolean"; + } + else if (type.IsArray) + { + return "array"; + } + else if (type.IsClass && type != typeof(string)) + { + return "object"; + } + + return "string"; + } + } +} diff --git a/nanoFramework.WebServer.Skills/SkillMetadata.cs b/nanoFramework.WebServer.Skills/SkillMetadata.cs new file mode 100644 index 0000000..3fafd3f --- /dev/null +++ b/nanoFramework.WebServer.Skills/SkillMetadata.cs @@ -0,0 +1,185 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Text; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Represents metadata information for a registered skill, compatible with the A2A AgentSkill schema. + /// Contains the skill's identity, description, tags, examples, input/output modes, and actions. + /// + public class SkillMetadata + { + /// + /// Gets or sets the unique identifier (A2A: AgentSkill.id). + /// + public string Id { get; set; } + + /// + /// Gets or sets the human-readable name (A2A: AgentSkill.name). + /// + public string Name { get; set; } + + /// + /// Gets or sets the detailed description (A2A: AgentSkill.description). + /// + public string Description { get; set; } + + /// + /// Gets or sets the version of the skill. + /// + public string Version { get; set; } + + /// + /// Gets or sets the tags for this skill (A2A: AgentSkill.tags). + /// + public string[] Tags { get; set; } + + /// + /// Gets or sets the example prompts or scenarios (A2A: AgentSkill.examples). + /// + public string[] Examples { get; set; } + + /// + /// Gets or sets the input MIME types (A2A: AgentSkill.input_modes). + /// + public string[] InputModes { get; set; } + + /// + /// Gets or sets the output MIME types (A2A: AgentSkill.output_modes). + /// + public string[] OutputModes { get; set; } + + /// + /// Gets or sets the list of actions (SkillActionMetadata) for this skill. + /// + public ArrayList Actions { get; set; } + + /// + /// Finds an action by name within this skill. + /// + /// The name of the action to find. + /// The matching SkillActionMetadata, or null if not found. + public SkillActionMetadata FindAction(string actionName) + { + if (Actions == null) + { + return null; + } + + for (int i = 0; i < Actions.Count; i++) + { + SkillActionMetadata action = (SkillActionMetadata)Actions[i]; + if (action.Name == actionName) + { + return action; + } + } + + return null; + } + + /// + /// Appends the JSON representation of this skill metadata to the specified StringBuilder. + /// Output is compatible with the A2A AgentSkill schema. + /// + /// The StringBuilder to append to. + public void AppendJson(StringBuilder sb) + { + sb.Append("{\"id\":\""); + sb.Append(Id); + sb.Append("\",\"name\":\""); + sb.Append(Name); + sb.Append("\",\"description\":\""); + sb.Append(Description); + sb.Append("\",\"version\":\""); + sb.Append(Version); + sb.Append("\""); + + // Tags (A2A required) + AppendStringArray(sb, "tags", Tags); + + // Examples (A2A optional) + if (Examples != null && Examples.Length > 0) + { + AppendStringArray(sb, "examples", Examples); + } + + // Input/Output Modes (A2A) + if (InputModes != null && InputModes.Length > 0) + { + AppendStringArray(sb, "inputModes", InputModes); + } + + if (OutputModes != null && OutputModes.Length > 0) + { + AppendStringArray(sb, "outputModes", OutputModes); + } + + // Actions (extension beyond A2A) + sb.Append(",\"actions\":["); + if (Actions != null) + { + for (int i = 0; i < Actions.Count; i++) + { + if (i > 0) + { + sb.Append(","); + } + + ((SkillActionMetadata)Actions[i]).AppendJson(sb); + } + } + + sb.Append("]}"); + } + + /// + /// Checks whether this skill has any tag matching the specified value. + /// + /// The tag to search for. + /// true if the skill has the specified tag; otherwise, false. + public bool HasTag(string tag) + { + if (Tags == null) + { + return false; + } + + for (int i = 0; i < Tags.Length; i++) + { + if (Tags[i] == tag) + { + return true; + } + } + + return false; + } + + private static void AppendStringArray(StringBuilder sb, string fieldName, string[] values) + { + sb.Append(",\""); + sb.Append(fieldName); + sb.Append("\":["); + if (values != null) + { + for (int i = 0; i < values.Length; i++) + { + if (i > 0) + { + sb.Append(","); + } + + sb.Append("\""); + sb.Append(values[i]); + sb.Append("\""); + } + } + + sb.Append("]"); + } + } +} diff --git a/nanoFramework.WebServer.Skills/SkillRegistry.cs b/nanoFramework.WebServer.Skills/SkillRegistry.cs new file mode 100644 index 0000000..c885b3a --- /dev/null +++ b/nanoFramework.WebServer.Skills/SkillRegistry.cs @@ -0,0 +1,362 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.Diagnostics; +using System.Reflection; +using System.Text; +using nanoFramework.Json; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Registry for AI agent skills, allowing discovery and invocation of skills defined with the SkillAttribute. + /// + public class SkillRegistry : RegistryBase + { + private static readonly Hashtable _skills = new Hashtable(); + private static bool _isInitialized = false; + + /// + /// Discovers skills by scanning the provided types for classes decorated with the SkillAttribute. + /// This method should be called once at startup to populate the skill registry. + /// + /// An array of types to scan for skills. + public static void DiscoverSkills(Type[] skillTypes) + { + if (_isInitialized) + { + return; + } + + foreach (Type skillType in skillTypes) + { + try + { + SkillAttribute skillAttribute = null; + ArrayList tags = new ArrayList(); + ArrayList examples = new ArrayList(); + + // Scan class-level attributes + var classAttributes = skillType.GetCustomAttributes(true); + foreach (var attr in classAttributes) + { + if (attr is SkillAttribute sa) + { + skillAttribute = sa; + } + else if (attr is SkillTagAttribute tagAttr) + { + tags.Add(tagAttr.Tag); + } + else if (attr is SkillExampleAttribute exAttr) + { + examples.Add(exAttr.Example); + } + } + + if (skillAttribute == null) + { + continue; + } + + // Build tags array + string[] tagsArray = new string[tags.Count]; + for (int i = 0; i < tags.Count; i++) + { + tagsArray[i] = (string)tags[i]; + } + + // Build examples array + string[] examplesArray = new string[examples.Count]; + for (int i = 0; i < examples.Count; i++) + { + examplesArray[i] = (string)examples[i]; + } + + SkillMetadata skillMetadata = new SkillMetadata + { + Id = skillAttribute.Id, + Name = skillAttribute.Name, + Description = skillAttribute.Description, + Version = skillAttribute.Version, + Tags = tagsArray, + Examples = examplesArray, + Actions = new ArrayList() + }; + + // Track unique input/output modes + ArrayList inputModes = new ArrayList(); + ArrayList outputModes = new ArrayList(); + + // Scan methods for actions + MethodInfo[] methods = skillType.GetMethods(); + foreach (MethodInfo method in methods) + { + try + { + var methodAttributes = method.GetCustomAttributes(true); + foreach (var attr in methodAttributes) + { + if (attr.GetType() != typeof(SkillActionAttribute)) + { + continue; + } + + SkillActionAttribute actionAttr = (SkillActionAttribute)attr; + var parameters = method.GetParameters(); + + string inputSchema = string.Empty; + // We only support either no parameters or one parameter + if (parameters.Length == 1) + { + inputSchema = SkillJsonHelper.GenerateInputJson(parameters[0].ParameterType); + } + else if (parameters.Length > 1) + { + continue; + } + + string outputSchema = string.Empty; + if (!SkillJsonHelper.IsPrimitiveType(method.ReturnType) + && method.ReturnType != typeof(string) + && method.ReturnType != typeof(void)) + { + outputSchema = SkillJsonHelper.GenerateOutputJson(method.ReturnType, actionAttr.OutputDescription); + } + + SkillActionMetadata actionMetadata = new SkillActionMetadata + { + Name = actionAttr.Name, + Description = actionAttr.Description, + InputSchema = inputSchema, + OutputSchema = outputSchema, + ContentType = actionAttr.ContentType, + Method = method, + ParameterType = parameters.Length > 0 ? parameters[0].ParameterType : null + }; + + skillMetadata.Actions.Add(actionMetadata); + + // Track input modes + if (parameters.Length > 0) + { + AddIfNotPresent(inputModes, "application/json"); + } + else + { + AddIfNotPresent(inputModes, "text/plain"); + } + + // Track output modes + AddIfNotPresent(outputModes, actionAttr.ContentType); + } + } + catch (Exception) + { + continue; + } + } + + // Build input/output mode arrays + skillMetadata.InputModes = ToStringArray(inputModes); + skillMetadata.OutputModes = ToStringArray(outputModes); + + _skills.Add(skillAttribute.Id, skillMetadata); + } + catch (Exception) + { + continue; + } + } + + _isInitialized = true; + } + + /// + /// Gets the JSON array of all registered skills. + /// + /// A JSON array string containing all skills metadata. + public static string GetSkillsArrayJson() + { + StringBuilder sb = new StringBuilder(); + sb.Append("["); + + bool first = true; + foreach (SkillMetadata skill in _skills.Values) + { + if (!first) + { + sb.Append(","); + } + + skill.AppendJson(sb); + first = false; + } + + sb.Append("]"); + return sb.ToString(); + } + + /// + /// Gets the JSON for a single skill by its identifier. + /// + /// The skill identifier. + /// A JSON string for the skill, or null if not found. + public static string GetSkillJson(string skillId) + { + if (!_skills.Contains(skillId)) + { + return null; + } + + StringBuilder sb = new StringBuilder(); + ((SkillMetadata)_skills[skillId]).AppendJson(sb); + return sb.ToString(); + } + + /// + /// Gets the JSON array of skills filtered by tag. + /// + /// The tag to filter by. + /// A JSON array string containing matching skills. + public static string GetSkillsByTagJson(string tag) + { + StringBuilder sb = new StringBuilder(); + sb.Append("["); + + bool first = true; + foreach (SkillMetadata skill in _skills.Values) + { + if (skill.HasTag(tag)) + { + if (!first) + { + sb.Append(","); + } + + skill.AppendJson(sb); + first = false; + } + } + + sb.Append("]"); + return sb.ToString(); + } + + /// + /// Gets the content type for a specific action within a skill. + /// + /// The skill identifier. + /// The action name. + /// The MIME content type, or null if the skill or action is not found. + public static string GetActionContentType(string skillId, string actionName) + { + if (!_skills.Contains(skillId)) + { + return null; + } + + SkillMetadata skill = (SkillMetadata)_skills[skillId]; + SkillActionMetadata action = skill.FindAction(actionName); + return action?.ContentType; + } + + /// + /// Invokes a skill action by skill identifier and action name. + /// + /// The skill identifier. + /// The action name. + /// The parameters to pass to the action as a Hashtable. + /// The serialized result string. For text/markdown or text/plain actions, the raw string is returned. + /// For JSON actions, the JSON-serialized result is returned. + /// Thrown when the skill or action is not found. + public static string InvokeAction(string skillId, string actionName, Hashtable parameters) + { + if (!_skills.Contains(skillId)) + { + throw new Exception("Skill not found"); + } + + SkillMetadata skill = (SkillMetadata)_skills[skillId]; + SkillActionMetadata action = skill.FindAction(actionName); + if (action == null) + { + throw new Exception("Action not found"); + } + + Debug.WriteLine($"Skill: {skillId}, Action: {actionName}, Method: {action.Method.Name}"); + + object[] methodParams = null; + if (action.ParameterType != null) + { + methodParams = new object[1]; + Type paramType = action.ParameterType; + if (SkillJsonHelper.IsPrimitiveType(paramType) || paramType == typeof(string)) + { + object value = parameters["value"]; + if (value != null) + { + methodParams[0] = ConvertToPrimitiveType(value, paramType); + } + } + else + { + methodParams[0] = DeserializeFromHashtable(parameters, paramType); + } + } + + object result = action.Method.Invoke(null, methodParams); + + // For text-based content types, return raw string + if (action.ContentType == "text/markdown" || action.ContentType == "text/plain") + { + return result?.ToString() ?? string.Empty; + } + + // For JSON content types, serialize the result + if (result == null) + { + return "null"; + } + + Type resultType = result.GetType(); + + if (SkillJsonHelper.IsPrimitiveType(resultType) || resultType == typeof(string)) + { + var stringResult = result.GetType() == typeof(bool) ? result.ToString().ToLower() : result.ToString(); + return "\"" + stringResult + "\""; + } + else + { + string jsonResult = JsonConvert.SerializeObject(result); + return JsonConvert.SerializeObject(jsonResult); + } + } + + private static void AddIfNotPresent(ArrayList list, string value) + { + for (int i = 0; i < list.Count; i++) + { + if ((string)list[i] == value) + { + return; + } + } + + list.Add(value); + } + + private static string[] ToStringArray(ArrayList list) + { + string[] result = new string[list.Count]; + for (int i = 0; i < list.Count; i++) + { + result[i] = (string)list[i]; + } + + return result; + } + } +} diff --git a/nanoFramework.WebServer.Skills/SkillTagAttribute.cs b/nanoFramework.WebServer.Skills/SkillTagAttribute.cs new file mode 100644 index 0000000..5cc3ff4 --- /dev/null +++ b/nanoFramework.WebServer.Skills/SkillTagAttribute.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace nanoFramework.WebServer.Skills +{ + /// + /// Adds a discoverable tag to a skill class (A2A: AgentSkill.tags). + /// Apply multiple times to add multiple tags. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public class SkillTagAttribute : Attribute + { + /// + /// Gets the tag value. + /// + public string Tag { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The tag keyword for this skill. + public SkillTagAttribute(string tag) + { + Tag = tag; + } + } +} diff --git a/nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj b/nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj new file mode 100644 index 0000000..2be3eee --- /dev/null +++ b/nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj @@ -0,0 +1,102 @@ + + + + + $(MSBuildExtensionsPath)\nanoFramework\v1.0\ + + + + Debug + AnyCPU + {11A8DD76-328B-46DF-9F39-F559912D0360};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + d3a78f21-b947-4e6a-9c15-8e7d2f3a4b5c + Library + Properties + 512 + nanoFramework.WebServer.Skills + nanoFramework.WebServer.Skills + v1.0 + bin\$(Configuration)\nanoFramework.WebServer.Skills.xml + true + true + + + true + + + ..\key.snk + + + false + + + + + + + + + + + + + + + + + + + + + + ..\packages\nanoFramework.CoreLibrary.1.17.11\lib\mscorlib.dll + + + ..\packages\nanoFramework.Json.2.2.203\lib\nanoFramework.Json.dll + + + ..\packages\nanoFramework.Runtime.Events.1.11.32\lib\nanoFramework.Runtime.Events.dll + + + ..\packages\nanoFramework.System.Collections.1.5.67\lib\nanoFramework.System.Collections.dll + + + ..\packages\nanoFramework.System.Text.1.3.42\lib\nanoFramework.System.Text.dll + + + ..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll + + + ..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + + ..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + + ..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll + + + + + + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105.The missing file is {0}. + + + + + + diff --git a/nanoFramework.WebServer.Skills/packages.config b/nanoFramework.WebServer.Skills/packages.config new file mode 100644 index 0000000..c592f6a --- /dev/null +++ b/nanoFramework.WebServer.Skills/packages.config @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/nanoFramework.WebServer.sln b/nanoFramework.WebServer.sln index 445ed0a..ae34605 100644 --- a/nanoFramework.WebServer.sln +++ b/nanoFramework.WebServer.sln @@ -25,6 +25,12 @@ Project("{11A8DD76-328B-46DF-9F39-F559912D0360}") = "McpServerTests", "tests\Mcp EndProject Project("{11A8DD76-328B-46DF-9F39-F559912D0360}") = "McpEndToEndTest", "tests\McpEndToEndTest\McpEndToEndTest.nfproj", "{1CDBEB80-6DDF-494B-9A26-47E889E523A9}" EndProject +Project("{11A8DD76-328B-46DF-9F39-F559912D0360}") = "nanoFramework.WebServer.Skills", "nanoFramework.WebServer.Skills\nanoFramework.WebServer.Skills.nfproj", "{D3A78F21-B947-4E6A-9C15-8E7D2F3A4B5C}" +EndProject +Project("{11A8DD76-328B-46DF-9F39-F559912D0360}") = "SkillsTests", "tests\SkillsTests\SkillsTests.nfproj", "{A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D}" +EndProject +Project("{11A8DD76-328B-46DF-9F39-F559912D0360}") = "SkillsEndToEndTest", "tests\SkillsEndToEndTest\SkillsEndToEndTest.nfproj", "{B4E7F2A1-C3D5-4E6F-8A9B-0C1D2E3F4A5B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,6 +79,24 @@ Global {1CDBEB80-6DDF-494B-9A26-47E889E523A9}.Release|Any CPU.ActiveCfg = Release|Any CPU {1CDBEB80-6DDF-494B-9A26-47E889E523A9}.Release|Any CPU.Build.0 = Release|Any CPU {1CDBEB80-6DDF-494B-9A26-47E889E523A9}.Release|Any CPU.Deploy.0 = Release|Any CPU + {D3A78F21-B947-4E6A-9C15-8E7D2F3A4B5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3A78F21-B947-4E6A-9C15-8E7D2F3A4B5C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3A78F21-B947-4E6A-9C15-8E7D2F3A4B5C}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {D3A78F21-B947-4E6A-9C15-8E7D2F3A4B5C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3A78F21-B947-4E6A-9C15-8E7D2F3A4B5C}.Release|Any CPU.Build.0 = Release|Any CPU + {D3A78F21-B947-4E6A-9C15-8E7D2F3A4B5C}.Release|Any CPU.Deploy.0 = Release|Any CPU + {A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D}.Release|Any CPU.Deploy.0 = Release|Any CPU + {B4E7F2A1-C3D5-4E6F-8A9B-0C1D2E3F4A5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4E7F2A1-C3D5-4E6F-8A9B-0C1D2E3F4A5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4E7F2A1-C3D5-4E6F-8A9B-0C1D2E3F4A5B}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {B4E7F2A1-C3D5-4E6F-8A9B-0C1D2E3F4A5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4E7F2A1-C3D5-4E6F-8A9B-0C1D2E3F4A5B}.Release|Any CPU.Build.0 = Release|Any CPU + {B4E7F2A1-C3D5-4E6F-8A9B-0C1D2E3F4A5B}.Release|Any CPU.Deploy.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -82,6 +106,8 @@ Global {A0611EAD-FB04-44E7-BAD3-459DD0A7FF46} = {E76226D2-994C-4EE1-B346-050F31B175BD} {4BC7B119-BF3F-4FED-84BD-5909543343D5} = {E76226D2-994C-4EE1-B346-050F31B175BD} {1CDBEB80-6DDF-494B-9A26-47E889E523A9} = {E76226D2-994C-4EE1-B346-050F31B175BD} + {A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D} = {E76226D2-994C-4EE1-B346-050F31B175BD} + {B4E7F2A1-C3D5-4E6F-8A9B-0C1D2E3F4A5B} = {E76226D2-994C-4EE1-B346-050F31B175BD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {262CE437-AD82-4481-8B77-593288986C70} diff --git a/tests/SkillsClientTest/SkillsClientTest.cs b/tests/SkillsClientTest/SkillsClientTest.cs new file mode 100644 index 0000000..9d364e3 --- /dev/null +++ b/tests/SkillsClientTest/SkillsClientTest.cs @@ -0,0 +1,248 @@ +#!/usr/bin/dotnet run + +#:package DotNetEnv@3.1.1 +#:package Microsoft.SemanticKernel@1.74.0 +#:package Microsoft.SemanticKernel.Agents.Core@1.74.0 + +// Skills Discovery E2E Test — Agent Consumer +// Connects to a nanoFramework device running the Skills Discovery Service, +// discovers available skills via the A2A Agent Card, registers them as +// Semantic Kernel functions, and runs an interactive AI agent that can +// invoke device skills through natural language. +// +// Prerequisites: +// 1. A nanoFramework device running the SkillsEndToEndTest firmware +// 2. .env file with Azure OpenAI credentials and device host +// +// Usage: +// dotnet run SkillsClientTest.cs +// +// Try asking: +// "What is the current temperature?" +// "Set the target temperature to 25 degrees" +// "Show me the HVAC status" +// "Get the climate control documentation" +// "What's the brightness level?" +// "Set brightness to 80% in the kitchen" + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.ChatCompletion; + +#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' +#pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' + +// Load environment variables from .env file +DotNetEnv.Env.Load(); + +// ----------------------------------------------------------------- +// 1. Configuration +// ----------------------------------------------------------------- +var deviceHost = DotNetEnv.Env.GetString("DEVICE_HOST", "192.168.1.139:80"); +var deploymentName = DotNetEnv.Env.GetString("AZUREAI_DEPLOYMENT_NAME"); +var endpoint = DotNetEnv.Env.GetString("AZUREAI_DEPLOYMENT_ENDPOINT"); +var apiKey = DotNetEnv.Env.GetString("AZUREAI_DEPLOYMENT_API_KEY"); + +var httpClient = new HttpClient { BaseAddress = new Uri($"http://{deviceHost}") }; + +Console.WriteLine($"Skills Discovery Agent — connecting to {deviceHost}"); +Console.WriteLine("---"); + +// ----------------------------------------------------------------- +// 2. Discover skills from the nanoFramework device (A2A Agent Card) +// ----------------------------------------------------------------- +Console.WriteLine("Fetching Agent Card from /.well-known/agent-card.json ..."); +string agentCardJson; +try +{ + agentCardJson = await httpClient.GetStringAsync("/.well-known/agent-card.json"); +} +catch (Exception ex) +{ + Console.WriteLine($"ERROR: Could not connect to device at {deviceHost}: {ex.Message}"); + Console.WriteLine("Make sure the nanoFramework device is running the SkillsEndToEndTest firmware."); + return; +} + +Console.WriteLine("Agent Card received:"); +Console.WriteLine(agentCardJson); +Console.WriteLine("---"); + +var agentCard = JsonDocument.Parse(agentCardJson); +var agentName = agentCard.RootElement.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : "Unknown"; +var skills = agentCard.RootElement.GetProperty("skills"); + +Console.WriteLine($"Device: {agentName}"); +Console.WriteLine($"Skills discovered: {skills.GetArrayLength()}"); +Console.WriteLine(); + +// ----------------------------------------------------------------- +// 3. Build Semantic Kernel with Azure OpenAI +// ----------------------------------------------------------------- +var kernel = Kernel.CreateBuilder() + .AddAzureOpenAIChatCompletion(deploymentName, endpoint, apiKey) + .Build(); + +// ----------------------------------------------------------------- +// 4. Create KernelFunctions from discovered skill actions +// ----------------------------------------------------------------- +var functions = new List(); + +foreach (var skill in skills.EnumerateArray()) +{ + var skillId = skill.GetProperty("id").GetString()!; + var skillName = skill.GetProperty("name").GetString()!; + + if (!skill.TryGetProperty("actions", out var actions)) + { + continue; + } + + foreach (var action in actions.EnumerateArray()) + { + var actionName = action.GetProperty("name").GetString()!; + var actionDesc = action.TryGetProperty("description", out var descProp) ? descProp.GetString() : ""; + var hasContentType = action.TryGetProperty("contentType", out var ctProp); + var contentType = hasContentType ? ctProp.GetString() : "application/json"; + + // Build a rich description including the input schema so the AI knows what arguments to provide + bool hasInput = action.TryGetProperty("inputSchema", out var inputSchema); + string? schemaStr = hasInput ? inputSchema.GetRawText() : null; + + string fullDescription = $"[{skillName}] {actionDesc}"; + if (hasInput) + { + fullDescription += $"\nInput (JSON): {schemaStr}"; + } + if (contentType == "text/markdown") + { + fullDescription += "\nReturns: Markdown document"; + } + + // Capture for closure + var capturedSkillId = skillId; + var capturedActionName = actionName; + + // Clean function name: replace hyphens with underscores for SK compatibility + var funcName = $"{skillId.Replace("-", "_")}_{actionName}"; + + KernelFunction func; + if (hasInput) + { + // Action with input parameters — single JSON string argument + func = KernelFunctionFactory.CreateFromMethod( + async ([Description("JSON object with the input parameters matching the schema")] string arguments) => + { + Console.WriteLine($" -> Invoking {capturedSkillId}/{capturedActionName} with: {arguments}"); + var payload = $"{{\"skill\":\"{capturedSkillId}\",\"action\":\"{capturedActionName}\",\"arguments\":{arguments}}}"; + var response = await httpClient.PostAsync("/skills/invoke", + new StringContent(payload, Encoding.UTF8, "application/json")); + var result = await response.Content.ReadAsStringAsync(); + Console.WriteLine($" <- Response [{response.StatusCode}]: {result}"); + return result; + }, + functionName: funcName, + description: fullDescription); + } + else + { + // Parameterless action + func = KernelFunctionFactory.CreateFromMethod( + async () => + { + Console.WriteLine($" -> Invoking {capturedSkillId}/{capturedActionName} (no args)"); + var payload = $"{{\"skill\":\"{capturedSkillId}\",\"action\":\"{capturedActionName}\",\"arguments\":{{}}}}"; + var response = await httpClient.PostAsync("/skills/invoke", + new StringContent(payload, Encoding.UTF8, "application/json")); + var result = await response.Content.ReadAsStringAsync(); + Console.WriteLine($" <- Response [{response.StatusCode}]: {result}"); + return result; + }, + functionName: funcName, + description: fullDescription); + } + + functions.Add(func); + } +} + +// Register all skill functions as a Semantic Kernel plugin +kernel.Plugins.AddFromFunctions("nanoFrameworkSkills", functions); + +Console.WriteLine($"Registered {functions.Count} skill actions as Kernel functions:"); +foreach (var f in functions) +{ + Console.WriteLine($" {f.Name}: {f.Description?.Split('\n')[0]}"); +} +Console.WriteLine("---\n"); + +// ----------------------------------------------------------------- +// 5. Create a ChatCompletionAgent with auto function calling +// ----------------------------------------------------------------- +ChatCompletionAgent agent = new() +{ + Name = "IoTSkillsAgent", + Instructions = + "You are an AI assistant connected to a nanoFramework IoT device via the Skills Discovery API. " + + "The device exposes capabilities (skills) that you can invoke using the registered functions. " + + "When a user asks about the device or its capabilities, use the appropriate function to get real data. " + + "Always explain what you're doing before invoking a function, and present results clearly. " + + "For markdown responses, display them formatted. " + + "If an action fails, explain the error to the user.", + Kernel = kernel, + Arguments = new KernelArguments( + new PromptExecutionSettings() + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }) +}; + +// ----------------------------------------------------------------- +// 6. Interactive chat loop +// ----------------------------------------------------------------- +Console.WriteLine("Skills Discovery Agent ready. Type your questions (Ctrl+C to exit)."); +Console.WriteLine("Examples:"); +Console.WriteLine(" - What is the current temperature?"); +Console.WriteLine(" - Set the target temperature to 25 degrees"); +Console.WriteLine(" - Show me the HVAC system documentation"); +Console.WriteLine(" - What's the brightness? Set it to 80% in the kitchen"); +Console.WriteLine(); + +AgentThread? thread = null; +Console.Write("User > "); +string? userInput; + +while ((userInput = Console.ReadLine()) is not null) +{ + if (string.IsNullOrWhiteSpace(userInput)) + { + Console.Write("User > "); + continue; + } + + ChatMessageContent message = new(AuthorRole.User, userInput); + + try + { + await foreach (AgentResponseItem response in agent.InvokeAsync(message, thread)) + { + if (!string.IsNullOrEmpty(response.Message.Content)) + { + Console.WriteLine("Assistant > " + response.Message.Content); + } + + thread = response.Thread; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + + Console.Write("\nUser > "); +} diff --git a/tests/SkillsEndToEndTest/Program.cs b/tests/SkillsEndToEndTest/Program.cs new file mode 100644 index 0000000..1e35528 --- /dev/null +++ b/tests/SkillsEndToEndTest/Program.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Net; +using System.Net.NetworkInformation; +using System.Threading; +using nanoFramework.Networking; +using nanoFramework.WebServer; +using nanoFramework.WebServer.Skills; + +namespace SkillsEndToEndTest +{ + public partial class Program + { + private static WebServer _server; + + public static void Main() + { + Debug.WriteLine("Hello from Skills Discovery Server!"); + + var res = WifiNetworkHelper.ConnectDhcp(Ssid, Password, requiresDateTime: true, token: new CancellationTokenSource(60_000).Token); + if (!res) + { + Debug.WriteLine("Impossible to connect to wifi, most likely invalid credentials"); + return; + } + + Debug.WriteLine($"Connected with wifi credentials. IP Address: {GetCurrentIPAddress()}"); + + // Discover and register all skills + SkillRegistry.DiscoverSkills(new Type[] { typeof(ClimateSkill), typeof(LightingSkill) }); + Debug.WriteLine("Skills discovered and registered."); + + // Configure Agent Card metadata + SkillDiscoveryController.AgentName = "nanoFramework IoT Device"; + SkillDiscoveryController.AgentDescription = "Embedded HVAC and lighting controller with sensor capabilities"; + SkillDiscoveryController.AgentVersion = "1.0.0"; + + // Start the web server with the Skills Discovery controller + _server = new WebServer(80, HttpProtocol.Http, new Type[] { typeof(SkillDiscoveryController) }); + _server.CommandReceived += ServerCommandReceived; + _server.Start(); + + Debug.WriteLine("Skills Discovery Server started. Endpoints:"); + Debug.WriteLine(" GET /.well-known/agent-card.json — A2A Agent Card"); + Debug.WriteLine(" GET /skills — Skills list"); + Debug.WriteLine(" POST /skills/invoke — Invoke skill action"); + + Thread.Sleep(Timeout.Infinite); + } + + private static void ServerCommandReceived(object obj, WebServerEventArgs e) + { + var url = e.Context.Request.RawUrl; + Debug.WriteLine($"{nameof(ServerCommandReceived)} {e.Context.Request.HttpMethod} {url}"); + WebServer.OutputHttpCode(e.Context.Response, HttpStatusCode.NotFound); + } + + public static string GetCurrentIPAddress() + { + NetworkInterface ni = NetworkInterface.GetAllNetworkInterfaces()[0]; + return ni.IPv4Address.ToString(); + } + } +} diff --git a/tests/SkillsEndToEndTest/Properties/AssemblyInfo.cs b/tests/SkillsEndToEndTest/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..14c8b81 --- /dev/null +++ b/tests/SkillsEndToEndTest/Properties/AssemblyInfo.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("SkillsEndToEndTest")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("SkillsEndToEndTest")] +[assembly: AssemblyCopyright("Copyright (c) 2025 nanoFramework contributors")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +[assembly: ComVisible(false)] + +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/tests/SkillsEndToEndTest/SkillClasses.cs b/tests/SkillsEndToEndTest/SkillClasses.cs new file mode 100644 index 0000000..7dcec85 --- /dev/null +++ b/tests/SkillsEndToEndTest/SkillClasses.cs @@ -0,0 +1,144 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using nanoFramework.WebServer.Skills; + +namespace SkillsEndToEndTest +{ + // --- Climate control skill --- + + public class TargetTempInput + { + [Description("Target temperature in Celsius")] + public double Temperature { get; set; } + } + + public class HvacStatus + { + [Description("Current temperature in Celsius")] + public double CurrentTemp { get; set; } + + [Description("Target temperature in Celsius")] + public double TargetTemp { get; set; } + + [Description("Whether the HVAC unit is currently running")] + public bool IsRunning { get; set; } + + [Description("Current operating mode (cooling, heating, idle)")] + public string Mode { get; set; } + } + + [Skill("climate-control", "Climate Control", "HVAC management for building zones including temperature reading, target setting and status reporting", "1.0")] + [SkillTag("temperature")] + [SkillTag("hvac")] + [SkillTag("sensor")] + [SkillExample("What is the current temperature?")] + [SkillExample("Set the target temperature to 22 degrees")] + [SkillExample("Show me the HVAC system status")] + public class ClimateSkill + { + private static double _targetTemp = 22.0; + private static double _currentTemp = 21.5; + + [SkillAction("GetTemperature", "Reads the current room temperature in Celsius")] + public static double GetTemperature() + { + return _currentTemp; + } + + [SkillAction("SetTargetTemp", "Sets the target temperature for the HVAC system")] + public static bool SetTargetTemp(TargetTempInput input) + { + if (input.Temperature < 10 || input.Temperature > 35) + { + return false; + } + + _targetTemp = input.Temperature; + return true; + } + + [SkillAction("GetStatus", "Returns full HVAC system status including temperatures and mode", + outputDescription: "Complete HVAC status object")] + public static HvacStatus GetStatus() + { + return new HvacStatus + { + CurrentTemp = _currentTemp, + TargetTemp = _targetTemp, + IsRunning = true, + Mode = _currentTemp > _targetTemp ? "cooling" : _currentTemp < _targetTemp ? "heating" : "idle" + }; + } + + [SkillAction("GetDocumentation", "Returns setup and calibration guide as markdown", + contentType: "text/markdown")] + public static string GetDocumentation() + { + return "# Climate Control Setup Guide\n\n" + + "## Sensor Calibration\n" + + "1. Place the sensor in a controlled environment\n" + + "2. Wait 5 minutes for stabilization\n" + + "3. Compare readings with a reference thermometer\n\n" + + "## Configuration\n" + + "- **Target Range**: 10C - 35C\n" + + "- **Polling Interval**: 30 seconds\n" + + "- **Default Target**: 22C\n\n" + + "## Troubleshooting\n" + + "| Symptom | Possible Cause | Fix |\n" + + "|---------|---------------|-----|\n" + + "| No readings | Sensor disconnected | Check wiring |\n" + + "| Erratic values | Electrical noise | Add shielding |\n"; + } + } + + // --- Lighting control skill --- + + public class BrightnessInput + { + [Description("Brightness level from 0 to 100")] + public int Level { get; set; } + + [Description("Optional zone name (e.g. 'living-room', 'kitchen')")] + public string Zone { get; set; } + } + + [Skill("lighting", "Lighting Control", "Smart lighting management for building zones including brightness control and toggling", "1.0")] + [SkillTag("light")] + [SkillTag("automation")] + [SkillExample("Turn on the lights")] + [SkillExample("Set brightness to 75%")] + [SkillExample("Toggle the lights off")] + public class LightingSkill + { + private static int _brightness = 50; + private static bool _isOn = true; + + [SkillAction("GetBrightness", "Reads the current brightness level as a value from 0 to 100")] + public static int GetBrightness() + { + return _isOn ? _brightness : 0; + } + + [SkillAction("SetBrightness", "Sets the brightness level and optional zone")] + public static string SetBrightness(BrightnessInput input) + { + if (input.Level < 0 || input.Level > 100) + { + return "Error: Level must be between 0 and 100"; + } + + _brightness = input.Level; + _isOn = input.Level > 0; + string zone = string.IsNullOrEmpty(input.Zone) ? "all zones" : input.Zone; + return $"Brightness set to {input.Level}% in {zone}"; + } + + [SkillAction("Toggle", "Toggles the lights on or off and returns the new state (true = on, false = off)")] + public static bool Toggle() + { + _isOn = !_isOn; + return _isOn; + } + } +} diff --git a/tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj b/tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj new file mode 100644 index 0000000..fc774f2 --- /dev/null +++ b/tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj @@ -0,0 +1,74 @@ + + + + $(MSBuildExtensionsPath)\nanoFramework\v1.0\ + + + + Debug + AnyCPU + {11A8DD76-328B-46DF-9F39-F559912D0360};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + b4e7f2a1-c3d5-4e6f-8a9b-0c1d2e3f4a5b + Exe + Properties + 512 + SkillsEndToEndTest + SkillsEndToEndTest + v1.0 + + + + + + + + + + + ..\..\packages\nanoFramework.CoreLibrary.1.17.11\lib\mscorlib.dll + + + ..\..\packages\nanoFramework.Json.2.2.203\lib\nanoFramework.Json.dll + + + ..\..\packages\nanoFramework.Runtime.Events.1.11.32\lib\nanoFramework.Runtime.Events.dll + + + ..\..\packages\nanoFramework.System.Collections.1.5.67\lib\nanoFramework.System.Collections.dll + + + ..\..\packages\nanoFramework.System.Text.1.3.42\lib\nanoFramework.System.Text.dll + + + ..\..\packages\nanoFramework.System.Device.Wifi.1.5.139\lib\System.Device.Wifi.dll + + + ..\..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll + + + ..\..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + + ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + + ..\..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll + + + + + + + + + + + + + + + + + + + diff --git a/tests/SkillsEndToEndTest/WiFi.cs b/tests/SkillsEndToEndTest/WiFi.cs new file mode 100644 index 0000000..7f474bb --- /dev/null +++ b/tests/SkillsEndToEndTest/WiFi.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SkillsEndToEndTest +{ + public partial class Program + { + private const string Ssid = "yourSSID"; + private const string Password = "YourPassword"; + } +} diff --git a/tests/SkillsEndToEndTest/packages.config b/tests/SkillsEndToEndTest/packages.config new file mode 100644 index 0000000..c0d82b7 --- /dev/null +++ b/tests/SkillsEndToEndTest/packages.config @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/tests/SkillsEndToEndTest/requests.http b/tests/SkillsEndToEndTest/requests.http new file mode 100644 index 0000000..23f8386 --- /dev/null +++ b/tests/SkillsEndToEndTest/requests.http @@ -0,0 +1,94 @@ +# This file is a collection of requests that can be executed with the REST Client extension for Visual Studio Code +# https://marketplace.visualstudio.com/items?itemName=humao.rest-client +# adjust your host here +@host=192.168.1.139:80 + +### Get A2A Agent Card — full discovery endpoint + +GET http://{{host}}/.well-known/agent-card.json HTTP/1.1 +Content-Type: application/json + +### Get Agent Card filtered by a single skill + +GET http://{{host}}/.well-known/agent-card.json?skill=climate-control HTTP/1.1 +Content-Type: application/json + +### Get Agent Card filtered by tag + +GET http://{{host}}/.well-known/agent-card.json?tag=sensor HTTP/1.1 +Content-Type: application/json + +### Get lightweight skills list + +GET http://{{host}}/skills HTTP/1.1 +Content-Type: application/json + +### Invoke GetTemperature (no arguments) + +POST http://{{host}}/skills/invoke HTTP/1.1 +Content-Type: application/json + +{"skill":"climate-control","action":"GetTemperature","arguments":{}} + +### Invoke SetTargetTemp (with arguments) + +POST http://{{host}}/skills/invoke HTTP/1.1 +Content-Type: application/json + +{"skill":"climate-control","action":"SetTargetTemp","arguments":{"Temperature":"25.0"}} + +### Invoke GetStatus (complex return type) + +POST http://{{host}}/skills/invoke HTTP/1.1 +Content-Type: application/json + +{"skill":"climate-control","action":"GetStatus","arguments":{}} + +### Invoke GetDocumentation (returns text/markdown) + +POST http://{{host}}/skills/invoke HTTP/1.1 +Content-Type: application/json + +{"skill":"climate-control","action":"GetDocumentation","arguments":{}} + +### Invoke GetBrightness (lighting skill) + +POST http://{{host}}/skills/invoke HTTP/1.1 +Content-Type: application/json + +{"skill":"lighting","action":"GetBrightness","arguments":{}} + +### Invoke SetBrightness (with zone) + +POST http://{{host}}/skills/invoke HTTP/1.1 +Content-Type: application/json + +{"skill":"lighting","action":"SetBrightness","arguments":{"Level":"75","Zone":"living-room"}} + +### Invoke Toggle (lighting skill) + +POST http://{{host}}/skills/invoke HTTP/1.1 +Content-Type: application/json + +{"skill":"lighting","action":"Toggle","arguments":{}} + +### Error case — non-existent skill + +POST http://{{host}}/skills/invoke HTTP/1.1 +Content-Type: application/json + +{"skill":"nonexistent","action":"GetTemperature","arguments":{}} + +### Error case — non-existent action + +POST http://{{host}}/skills/invoke HTTP/1.1 +Content-Type: application/json + +{"skill":"climate-control","action":"NonExistent","arguments":{}} + +### Error case — missing fields + +POST http://{{host}}/skills/invoke HTTP/1.1 +Content-Type: application/json + +{"arguments":{}} diff --git a/tests/SkillsTests/Properties/AssemblyInfo.cs b/tests/SkillsTests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..d87efa1 --- /dev/null +++ b/tests/SkillsTests/Properties/AssemblyInfo.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyCopyright("Copyright (c) 2021 nanoFramework contributors")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] diff --git a/tests/SkillsTests/SkillJsonHelperTests.cs b/tests/SkillsTests/SkillJsonHelperTests.cs new file mode 100644 index 0000000..891c3f4 --- /dev/null +++ b/tests/SkillsTests/SkillJsonHelperTests.cs @@ -0,0 +1,206 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using nanoFramework.TestFramework; +using nanoFramework.WebServer.Skills; + +namespace SkillsTests +{ + // Test classes for complex type testing + public class SimpleInputClass + { + public string Name { get; set; } + public int Age { get; set; } + } + + public class ComplexInputClass + { + public string Title { get; set; } + public bool IsActive { get; set; } + public double Score { get; set; } + public SimpleInputClass Nested { get; set; } + } + + public class OutputWithDescription + { + public string Status + { + [Description("The current status")] + get; + set; + } + + public int Count + { + [Description("Number of items")] + get; + set; + } + } + + [TestClass] + public class SkillJsonHelperTests + { + [TestMethod] + public void GenerateInputJson_StringType_ReturnsValueSchema() + { + // Arrange + Type inputType = typeof(string); + string expected = "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\",\"description\":\"Input parameter of type String\"}},\"required\":[]}"; + + // Act + string result = SkillJsonHelper.GenerateInputJson(inputType); + + // Assert + Assert.AreEqual(expected, result, "String type schema does not match expected output."); + } + + [TestMethod] + public void GenerateInputJson_IntType_ReturnsNumberSchema() + { + // Arrange + Type inputType = typeof(int); + string expected = "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"number\",\"description\":\"Input parameter of type Int32\"}},\"required\":[]}"; + + // Act + string result = SkillJsonHelper.GenerateInputJson(inputType); + + // Assert + Assert.AreEqual(expected, result, "Int type schema does not match expected output."); + } + + [TestMethod] + public void GenerateInputJson_BoolType_ReturnsBooleanSchema() + { + // Arrange + Type inputType = typeof(bool); + string expected = "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"boolean\",\"description\":\"Input parameter of type Boolean\"}},\"required\":[]}"; + + // Act + string result = SkillJsonHelper.GenerateInputJson(inputType); + + // Assert + Assert.AreEqual(expected, result, "Bool type schema does not match expected output."); + } + + [TestMethod] + public void GenerateInputJson_DoubleType_ReturnsNumberSchema() + { + // Arrange + Type inputType = typeof(double); + string expected = "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"number\",\"description\":\"Input parameter of type Double\"}},\"required\":[]}"; + + // Act + string result = SkillJsonHelper.GenerateInputJson(inputType); + + // Assert + Assert.AreEqual(expected, result, "Double type schema does not match expected output."); + } + + [TestMethod] + public void GenerateInputJson_FloatType_ReturnsNumberSchema() + { + // Arrange + Type inputType = typeof(float); + string expected = "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"number\",\"description\":\"Input parameter of type Single\"}},\"required\":[]}"; + + // Act + string result = SkillJsonHelper.GenerateInputJson(inputType); + + // Assert + Assert.AreEqual(expected, result, "Float type schema does not match expected output."); + } + + [TestMethod] + public void GenerateInputJson_LongType_ReturnsNumberSchema() + { + // Arrange + Type inputType = typeof(long); + string expected = "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"number\",\"description\":\"Input parameter of type Int64\"}},\"required\":[]}"; + + // Act + string result = SkillJsonHelper.GenerateInputJson(inputType); + + // Assert + Assert.AreEqual(expected, result, "Long type schema does not match expected output."); + } + + [TestMethod] + public void GenerateInputJson_SimpleClass_ReturnsObjectSchema() + { + // Arrange + Type inputType = typeof(SimpleInputClass); + + // Act + string result = SkillJsonHelper.GenerateInputJson(inputType); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("\"type\":\"object\""), "Should be object type"); + Assert.IsTrue(result.Contains("\"Name\""), "Should contain Name property"); + Assert.IsTrue(result.Contains("\"Age\""), "Should contain Age property"); + Assert.IsTrue(result.Contains("\"type\":\"string\""), "Name should be string type"); + Assert.IsTrue(result.Contains("\"type\":\"number\""), "Age should be number type"); + } + + [TestMethod] + public void GenerateInputJson_ComplexClass_ReturnsNestedSchema() + { + // Arrange + Type inputType = typeof(ComplexInputClass); + + // Act + string result = SkillJsonHelper.GenerateInputJson(inputType); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("\"Title\""), "Should contain Title property"); + Assert.IsTrue(result.Contains("\"IsActive\""), "Should contain IsActive property"); + Assert.IsTrue(result.Contains("\"Score\""), "Should contain Score property"); + Assert.IsTrue(result.Contains("\"Nested\""), "Should contain Nested property"); + Assert.IsTrue(result.Contains("\"type\":\"boolean\""), "IsActive should be boolean type"); + } + + [TestMethod] + public void GenerateOutputJson_SimpleClass_ContainsDescriptions() + { + // Arrange + Type outputType = typeof(OutputWithDescription); + + // Act + string result = SkillJsonHelper.GenerateOutputJson(outputType, "Test output"); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("\"type\":\"object\""), "Should be object type"); + Assert.IsTrue(result.Contains("\"description\":\"Test output\""), "Should contain top-level description"); + Assert.IsTrue(result.Contains("\"The current status\""), "Should contain Status description"); + Assert.IsTrue(result.Contains("\"Number of items\""), "Should contain Count description"); + } + + [TestMethod] + public void IsPrimitiveType_PrimitiveTypes_ReturnsTrue() + { + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(int)), "int should be primitive"); + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(bool)), "bool should be primitive"); + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(double)), "double should be primitive"); + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(float)), "float should be primitive"); + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(long)), "long should be primitive"); + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(short)), "short should be primitive"); + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(byte)), "byte should be primitive"); + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(char)), "char should be primitive"); + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(uint)), "uint should be primitive"); + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(ulong)), "ulong should be primitive"); + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(ushort)), "ushort should be primitive"); + Assert.IsTrue(SkillJsonHelper.IsPrimitiveType(typeof(sbyte)), "sbyte should be primitive"); + } + + [TestMethod] + public void IsPrimitiveType_NonPrimitiveTypes_ReturnsFalse() + { + Assert.IsFalse(SkillJsonHelper.IsPrimitiveType(typeof(string)), "string should not be primitive"); + Assert.IsFalse(SkillJsonHelper.IsPrimitiveType(typeof(SimpleInputClass)), "class should not be primitive"); + } + } +} diff --git a/tests/SkillsTests/SkillRegistryTests.cs b/tests/SkillsTests/SkillRegistryTests.cs new file mode 100644 index 0000000..deb5554 --- /dev/null +++ b/tests/SkillsTests/SkillRegistryTests.cs @@ -0,0 +1,554 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using nanoFramework.TestFramework; +using nanoFramework.WebServer.Skills; + +namespace SkillsTests +{ + // Test parameter classes + public class TargetTempInput + { + public double Temperature { get; set; } + } + + public class NestedSkillParam + { + public string Label { get; set; } + public TargetTempInput Config { get; set; } + } + + // Test skill class - climate control + [Skill("climate-control", "Climate Control", "HVAC management for building zones", "1.0")] + [SkillTag("temperature")] + [SkillTag("hvac")] + [SkillTag("sensor")] + [SkillExample("What is the current temperature?")] + [SkillExample("Set the target temperature to 22 degrees")] + public static class TestClimateSkill + { + [SkillAction("GetTemperature", "Reads current room temperature")] + public static double GetTemperature() + { + return 22.5; + } + + [SkillAction("SetTargetTemp", "Sets the target temperature")] + public static bool SetTargetTemp(TargetTempInput input) + { + return input.Temperature > 0 && input.Temperature < 40; + } + + [SkillAction("GetDocumentation", "Returns setup and calibration guide", + contentType: "text/markdown")] + public static string GetDocumentation() + { + return "# Climate Control Setup Guide\n\n## Configuration\n- **Target Range**: 18-28C\n"; + } + + // Non-action method - should be ignored + public static string HelperMethod() + { + return "Not an action"; + } + } + + // Test skill class - lighting + [Skill("lighting", "Lighting", "Smart lighting control", "2.0")] + [SkillTag("light")] + [SkillTag("sensor")] + [SkillExample("Turn on the lights")] + public static class TestLightingSkill + { + [SkillAction("GetBrightness", "Reads current brightness level")] + public static int GetBrightness() + { + return 75; + } + + [SkillAction("SetBrightness", "Sets brightness level")] + public static string SetBrightness(int level) + { + return "Brightness set to " + level; + } + } + + // Empty class with no skill attribute + public static class NotASkillClass + { + public static string DoSomething() + { + return "Not a skill"; + } + } + + // Skill class with nested parameters + [Skill("nested-skill", "Nested Skill", "Skill with nested parameters")] + [SkillTag("test")] + public static class TestNestedSkill + { + [SkillAction("ProcessNested", "Processes nested input")] + public static string ProcessNested(NestedSkillParam param) + { + return param.Label + ": " + param.Config.Temperature; + } + } + + [TestClass] + public class SkillRegistryTests + { + [TestMethod] + public void DiscoverSkills_FindsDecoratedClasses() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill), typeof(TestLightingSkill), typeof(NotASkillClass) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsArrayJson(); + + // Assert + Assert.IsNotNull(json, "Skills JSON should not be null"); + Assert.IsTrue(json.Contains("\"climate-control\""), "Should find climate-control skill"); + Assert.IsTrue(json.Contains("\"lighting\""), "Should find lighting skill"); + Assert.IsFalse(json.Contains("NotASkillClass"), "Should not include non-skill class"); + Assert.IsFalse(json.Contains("DoSomething"), "Should not include non-skill methods"); + } + + [TestMethod] + public void DiscoverSkills_CollectsTags() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsArrayJson(); + + // Assert + Assert.IsTrue(json.Contains("\"tags\":["), "Should contain tags array"); + Assert.IsTrue(json.Contains("\"temperature\""), "Should contain temperature tag"); + Assert.IsTrue(json.Contains("\"hvac\""), "Should contain hvac tag"); + Assert.IsTrue(json.Contains("\"sensor\""), "Should contain sensor tag"); + } + + [TestMethod] + public void DiscoverSkills_CollectsExamples() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsArrayJson(); + + // Assert + Assert.IsTrue(json.Contains("\"examples\":["), "Should contain examples array"); + Assert.IsTrue(json.Contains("What is the current temperature?"), "Should contain first example"); + Assert.IsTrue(json.Contains("Set the target temperature to 22 degrees"), "Should contain second example"); + } + + [TestMethod] + public void DiscoverSkills_FindsActions() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsArrayJson(); + + // Assert + Assert.IsTrue(json.Contains("\"actions\":["), "Should contain actions array"); + Assert.IsTrue(json.Contains("\"GetTemperature\""), "Should find GetTemperature action"); + Assert.IsTrue(json.Contains("\"SetTargetTemp\""), "Should find SetTargetTemp action"); + Assert.IsTrue(json.Contains("\"GetDocumentation\""), "Should find GetDocumentation action"); + Assert.IsFalse(json.Contains("\"HelperMethod\""), "Should not include non-action methods"); + } + + [TestMethod] + public void DiscoverSkills_SetsInputOutputModes() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsArrayJson(); + + // Assert + Assert.IsTrue(json.Contains("\"inputModes\":["), "Should contain inputModes array"); + Assert.IsTrue(json.Contains("\"outputModes\":["), "Should contain outputModes array"); + Assert.IsTrue(json.Contains("\"text/plain\""), "Should contain text/plain input mode (parameterless actions)"); + Assert.IsTrue(json.Contains("\"application/json\""), "Should contain application/json mode"); + Assert.IsTrue(json.Contains("\"text/markdown\""), "Should contain text/markdown output mode"); + } + + [TestMethod] + public void DiscoverSkills_MarkdownActionHasContentType() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsArrayJson(); + + // Assert + Assert.IsTrue(json.Contains("\"contentType\":\"text/markdown\""), "Markdown action should have contentType in JSON"); + } + + [TestMethod] + public void DiscoverSkills_VersionIncluded() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill), typeof(TestLightingSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsArrayJson(); + + // Assert + Assert.IsTrue(json.Contains("\"version\":\"1.0\""), "Climate skill should have version 1.0"); + Assert.IsTrue(json.Contains("\"version\":\"2.0\""), "Lighting skill should have version 2.0"); + } + + [TestMethod] + public void GetSkillsArrayJson_ValidJsonStructure() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsArrayJson(); + + // Assert + Assert.IsTrue(json.StartsWith("["), "Skills JSON should start with ["); + Assert.IsTrue(json.EndsWith("]"), "Skills JSON should end with ]"); + Assert.IsTrue(json.Contains("\"id\":\"climate-control\""), "Should contain skill id"); + Assert.IsTrue(json.Contains("\"name\":\"Climate Control\""), "Should contain skill name"); + Assert.IsTrue(json.Contains("\"description\":\"HVAC management for building zones\""), "Should contain skill description"); + } + + [TestMethod] + public void GetSkillJson_BySkillId_ReturnsCorrectSkill() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill), typeof(TestLightingSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillJson("climate-control"); + + // Assert + Assert.IsNotNull(json, "Should find climate-control skill"); + Assert.IsTrue(json.Contains("\"Climate Control\""), "Should contain correct name"); + Assert.IsFalse(json.Contains("\"lighting\""), "Should not contain other skill ids"); + } + + [TestMethod] + public void GetSkillJson_NonExistentId_ReturnsNull() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillJson("nonexistent"); + + // Assert + Assert.IsNull(json, "Should return null for non-existent skill"); + } + + [TestMethod] + public void GetSkillsByTagJson_MatchingTag_ReturnsFilteredSkills() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill), typeof(TestLightingSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsByTagJson("sensor"); + + // Assert + Assert.IsNotNull(json, "Should return non-null JSON"); + Assert.IsTrue(json.Contains("\"climate-control\""), "Should contain climate-control (has sensor tag)"); + Assert.IsTrue(json.Contains("\"lighting\""), "Should contain lighting (has sensor tag)"); + } + + [TestMethod] + public void GetSkillsByTagJson_UniqueTag_ReturnsOneSkill() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill), typeof(TestLightingSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsByTagJson("hvac"); + + // Assert + Assert.IsTrue(json.Contains("\"climate-control\""), "Should contain climate-control (has hvac tag)"); + Assert.IsFalse(json.Contains("\"lighting\""), "Should not contain lighting (no hvac tag)"); + } + + [TestMethod] + public void GetSkillsByTagJson_NonExistentTag_ReturnsEmptyArray() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsByTagJson("nonexistent"); + + // Assert + Assert.AreEqual("[]", json, "Should return empty array for non-matching tag"); + } + + [TestMethod] + public void GetActionContentType_JsonAction_ReturnsApplicationJson() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string contentType = SkillRegistry.GetActionContentType("climate-control", "GetTemperature"); + + // Assert + Assert.AreEqual("application/json", contentType, "Default content type should be application/json"); + } + + [TestMethod] + public void GetActionContentType_MarkdownAction_ReturnsTextMarkdown() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string contentType = SkillRegistry.GetActionContentType("climate-control", "GetDocumentation"); + + // Assert + Assert.AreEqual("text/markdown", contentType, "Markdown action should return text/markdown"); + } + + [TestMethod] + public void GetActionContentType_NonExistentSkill_ReturnsNull() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string contentType = SkillRegistry.GetActionContentType("nonexistent", "GetTemperature"); + + // Assert + Assert.IsNull(contentType, "Should return null for non-existent skill"); + } + + [TestMethod] + public void GetActionContentType_NonExistentAction_ReturnsNull() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string contentType = SkillRegistry.GetActionContentType("climate-control", "NonExistent"); + + // Assert + Assert.IsNull(contentType, "Should return null for non-existent action"); + } + + [TestMethod] + public void InvokeAction_NoParameters_ReturnsResult() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + SkillRegistry.DiscoverSkills(skillTypes); + + // Act + string result = SkillRegistry.InvokeAction("climate-control", "GetTemperature", null); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("22.5"), "Should return temperature value"); + } + + [TestMethod] + public void InvokeAction_ComplexParameter_ReturnsResult() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + SkillRegistry.DiscoverSkills(skillTypes); + + Hashtable arguments = new Hashtable(); + arguments.Add("Temperature", "22.0"); + + // Act + string result = SkillRegistry.InvokeAction("climate-control", "SetTargetTemp", arguments); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("true"), "Should return true for valid temperature"); + } + + [TestMethod] + public void InvokeAction_PrimitiveParameter_ReturnsResult() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestLightingSkill) }; + SkillRegistry.DiscoverSkills(skillTypes); + + Hashtable arguments = new Hashtable(); + arguments.Add("value", "80"); + + // Act + string result = SkillRegistry.InvokeAction("lighting", "SetBrightness", arguments); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("80"), "Should contain brightness level"); + } + + [TestMethod] + public void InvokeAction_MarkdownAction_ReturnsRawMarkdown() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + SkillRegistry.DiscoverSkills(skillTypes); + + // Act + string result = SkillRegistry.InvokeAction("climate-control", "GetDocumentation", null); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("# Climate Control Setup Guide"), "Should contain markdown heading"); + Assert.IsTrue(result.Contains("## Configuration"), "Should contain markdown sub-heading"); + Assert.IsFalse(result.StartsWith("\""), "Markdown result should not be JSON-quoted"); + } + + [TestMethod] + public void InvokeAction_NestedParameter_ReturnsResult() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestNestedSkill) }; + SkillRegistry.DiscoverSkills(skillTypes); + + Hashtable arguments = new Hashtable(); + arguments.Add("Label", "Test"); + arguments.Add("Config", "{\"Temperature\":\"25.5\"}"); + + // Act + string result = SkillRegistry.InvokeAction("nested-skill", "ProcessNested", arguments); + + // Assert + Assert.IsNotNull(result, "Result should not be null"); + Assert.IsTrue(result.Contains("Test"), "Should contain label"); + Assert.IsTrue(result.Contains("25.5"), "Should contain nested temperature"); + } + + [TestMethod] + public void InvokeAction_NonExistentSkill_ThrowsException() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + SkillRegistry.DiscoverSkills(skillTypes); + + // Act & Assert + bool exceptionThrown = false; + try + { + SkillRegistry.InvokeAction("nonexistent", "GetTemperature", null); + } + catch (Exception ex) + { + exceptionThrown = true; + Assert.AreEqual("Skill not found", ex.Message, "Should throw Skill not found"); + } + + Assert.IsTrue(exceptionThrown, "Exception should have been thrown"); + } + + [TestMethod] + public void InvokeAction_NonExistentAction_ThrowsException() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + SkillRegistry.DiscoverSkills(skillTypes); + + // Act & Assert + bool exceptionThrown = false; + try + { + SkillRegistry.InvokeAction("climate-control", "NonExistent", null); + } + catch (Exception ex) + { + exceptionThrown = true; + Assert.AreEqual("Action not found", ex.Message, "Should throw Action not found"); + } + + Assert.IsTrue(exceptionThrown, "Exception should have been thrown"); + } + + [TestMethod] + public void DiscoverSkills_Idempotent_SecondCallIgnored() + { + // Arrange + Type[] skillTypes1 = new Type[] { typeof(TestClimateSkill) }; + Type[] skillTypes2 = new Type[] { typeof(TestLightingSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes1); + string firstCall = SkillRegistry.GetSkillsArrayJson(); + + SkillRegistry.DiscoverSkills(skillTypes2); // Should be ignored + string secondCall = SkillRegistry.GetSkillsArrayJson(); + + // Assert + Assert.AreEqual(firstCall, secondCall, "Second call should not change results"); + } + + [TestMethod] + public void DiscoverSkills_ComplexActionInputSchema_Generated() + { + // Arrange + Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsArrayJson(); + + // Assert + Assert.IsTrue(json.Contains("\"inputSchema\":{"), "SetTargetTemp should have inputSchema"); + Assert.IsTrue(json.Contains("\"Temperature\""), "Schema should contain Temperature property"); + } + + [TestMethod] + public void DiscoverSkills_MultipleSkills_AllRegistered() + { + // Arrange + Type[] skillTypes = new Type[] + { + typeof(TestClimateSkill), + typeof(TestLightingSkill), + typeof(TestNestedSkill), + typeof(NotASkillClass) + }; + + // Act + SkillRegistry.DiscoverSkills(skillTypes); + string json = SkillRegistry.GetSkillsArrayJson(); + + // Assert + Assert.IsTrue(json.Contains("\"climate-control\""), "Should contain climate-control"); + Assert.IsTrue(json.Contains("\"lighting\""), "Should contain lighting"); + Assert.IsTrue(json.Contains("\"nested-skill\""), "Should contain nested-skill"); + } + } +} diff --git a/tests/SkillsTests/SkillsTests.nfproj b/tests/SkillsTests/SkillsTests.nfproj new file mode 100644 index 0000000..c813bc0 --- /dev/null +++ b/tests/SkillsTests/SkillsTests.nfproj @@ -0,0 +1,81 @@ + + + + $(MSBuildExtensionsPath)\nanoFramework\v1.0\ + + + + + + + Debug + AnyCPU + {11A8DD76-328B-46DF-9F39-F559912D0360};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d + Library + Properties + 512 + SkillsTests + NFUnitTest + False + true + UnitTest + v1.0 + + + + $(MSBuildProjectDirectory)\nano.runsettings + + + + + + + + + ..\..\packages\nanoFramework.CoreLibrary.1.17.11\lib\mscorlib.dll + + + ..\..\packages\nanoFramework.Json.2.2.203\lib\nanoFramework.Json.dll + + + ..\..\packages\nanoFramework.Runtime.Events.1.11.32\lib\nanoFramework.Runtime.Events.dll + + + ..\..\packages\nanoFramework.System.Collections.1.5.67\lib\nanoFramework.System.Collections.dll + + + ..\..\packages\nanoFramework.System.Text.1.3.42\lib\nanoFramework.System.Text.dll + + + ..\..\packages\nanoFramework.TestFramework.3.0.77\lib\nanoFramework.TestFramework.dll + + + ..\..\packages\nanoFramework.TestFramework.3.0.77\lib\nanoFramework.UnitTestLauncher.exe + + + ..\..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll + + + ..\..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + + ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + + ..\..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll + + + + + + + + + + + + + + + diff --git a/tests/SkillsTests/nano.runsettings b/tests/SkillsTests/nano.runsettings new file mode 100644 index 0000000..bc30ef6 --- /dev/null +++ b/tests/SkillsTests/nano.runsettings @@ -0,0 +1,17 @@ + + + + + .\TestResults + 120000 + net48 + x64 + + + None + False + COM3 + + + + diff --git a/tests/SkillsTests/packages.config b/tests/SkillsTests/packages.config new file mode 100644 index 0000000..312c36d --- /dev/null +++ b/tests/SkillsTests/packages.config @@ -0,0 +1,13 @@ + + + + + + + + + + + + + From a7a1e4133358aea785f0b45bb4e2109775a8ed3a Mon Sep 17 00:00:00 2001 From: Laurent Ellerbach Date: Tue, 21 Apr 2026 16:21:33 +0200 Subject: [PATCH 2/9] improving, cleaning code and fixing client file to work properly with local env file --- .../HashtableExtension.cs | 23 ------- .../RegistryBase.cs | 2 +- .../SkillActionMetadata.cs | 4 +- .../SkillDiscoveryController.cs | 16 ++--- .../SkillJsonHelper.cs | 57 ++++++++++++++-- .../SkillMetadata.cs | 10 +-- .../SkillRegistry.cs | 16 +++-- .../nanoFramework.WebServer.Skills.nfproj | 11 ++- .../packages.config | 8 +-- .../packages.lock.json | 67 +++++++++++++++++++ .../nanoFramework.WebServer.nfproj | 8 +-- nanoFramework.WebServer/packages.config | 4 +- nanoFramework.WebServer/packages.lock.json | 12 ++-- tests/SkillsClientTest/SkillsClientTest.cs | 24 ++++++- .../SkillsEndToEndTest.nfproj | 14 ++-- tests/SkillsEndToEndTest/packages.config | 10 +-- 16 files changed, 201 insertions(+), 85 deletions(-) delete mode 100644 nanoFramework.WebServer.Skills/HashtableExtension.cs create mode 100644 nanoFramework.WebServer.Skills/packages.lock.json diff --git a/nanoFramework.WebServer.Skills/HashtableExtension.cs b/nanoFramework.WebServer.Skills/HashtableExtension.cs deleted file mode 100644 index db2cb3b..0000000 --- a/nanoFramework.WebServer.Skills/HashtableExtension.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections; - -namespace nanoFramework.WebServer.Skills -{ - internal static class HashtableExtension - { - public static bool ContainsKey(this Hashtable hashtable, string key) - { - foreach (object k in hashtable.Keys) - { - if (k is string strKey && strKey.Equals(key)) - { - return true; - } - } - - return false; - } - } -} diff --git a/nanoFramework.WebServer.Skills/RegistryBase.cs b/nanoFramework.WebServer.Skills/RegistryBase.cs index 2e21bac..d795157 100644 --- a/nanoFramework.WebServer.Skills/RegistryBase.cs +++ b/nanoFramework.WebServer.Skills/RegistryBase.cs @@ -185,7 +185,7 @@ protected static object CreateInstance(Type type) ConstructorInfo constructor = type.GetConstructor(new Type[0]); if (constructor == null) { - throw new Exception($"Type {type.Name} does not have a parameterless constructor"); + throw new Exception(); } return constructor.Invoke(new object[0]); diff --git a/nanoFramework.WebServer.Skills/SkillActionMetadata.cs b/nanoFramework.WebServer.Skills/SkillActionMetadata.cs index 87b3a0e..e678c0e 100644 --- a/nanoFramework.WebServer.Skills/SkillActionMetadata.cs +++ b/nanoFramework.WebServer.Skills/SkillActionMetadata.cs @@ -55,9 +55,9 @@ public class SkillActionMetadata public void AppendJson(StringBuilder sb) { sb.Append("{\"name\":\""); - sb.Append(Name); + sb.Append(SkillJsonHelper.EscapeJsonString(Name)); sb.Append("\",\"description\":\""); - sb.Append(Description); + sb.Append(SkillJsonHelper.EscapeJsonString(Description)); sb.Append("\""); if (!string.IsNullOrEmpty(ContentType) && ContentType != "application/json") diff --git a/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs b/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs index b2f3106..32594d5 100644 --- a/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs +++ b/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs @@ -79,24 +79,24 @@ public void GetAgentCard(WebServerEventArgs e) StringBuilder sb = new StringBuilder(); sb.Append("{\"name\":\""); - sb.Append(AgentName); + sb.Append(SkillJsonHelper.EscapeJsonString(AgentName)); sb.Append("\""); if (!string.IsNullOrEmpty(AgentDescription)) { sb.Append(",\"description\":\""); - sb.Append(AgentDescription); + sb.Append(SkillJsonHelper.EscapeJsonString(AgentDescription)); sb.Append("\""); } sb.Append(",\"version\":\""); - sb.Append(AgentVersion); + sb.Append(SkillJsonHelper.EscapeJsonString(AgentVersion)); sb.Append("\""); if (!string.IsNullOrEmpty(AgentUrl)) { sb.Append(",\"url\":\""); - sb.Append(AgentUrl); + sb.Append(SkillJsonHelper.EscapeJsonString(AgentUrl)); sb.Append("\""); } @@ -159,7 +159,7 @@ public void ListSkills(WebServerEventArgs e) catch (Exception ex) { WebServer.OutputAsStream(e.Context.Response, - "{\"error\":{\"code\":-1,\"message\":\"" + ex.Message + "\"}}"); + "{\"error\":{\"code\":-1,\"message\":\"" + SkillJsonHelper.EscapeJsonString(ex.Message) + "\"}}"); } } @@ -185,7 +185,7 @@ public void InvokeSkillAction(WebServerEventArgs e) Hashtable request = (Hashtable)JsonConvert.DeserializeObject(requestBody, typeof(Hashtable)); - if (!request.ContainsKey("skill") || !request.ContainsKey("action")) + if (!request.Contains("skill") || !request.Contains("action")) { e.Context.Response.ContentType = "application/json"; WebServer.OutputAsStream(e.Context.Response, @@ -195,7 +195,7 @@ public void InvokeSkillAction(WebServerEventArgs e) string skillId = request["skill"].ToString(); string actionName = request["action"].ToString(); - Hashtable arguments = request.ContainsKey("arguments") && request["arguments"] != null + Hashtable arguments = request.Contains("arguments") && request["arguments"] != null ? (Hashtable)request["arguments"] : null; @@ -230,7 +230,7 @@ public void InvokeSkillAction(WebServerEventArgs e) { e.Context.Response.ContentType = "application/json"; WebServer.OutputAsStream(e.Context.Response, - "{\"error\":{\"code\":-1,\"message\":\"" + ex.Message + "\"}}"); + "{\"error\":{\"code\":-1,\"message\":\"" + SkillJsonHelper.EscapeJsonString(ex.Message) + "\"}}"); } } } diff --git a/nanoFramework.WebServer.Skills/SkillJsonHelper.cs b/nanoFramework.WebServer.Skills/SkillJsonHelper.cs index 5d37377..755cc78 100644 --- a/nanoFramework.WebServer.Skills/SkillJsonHelper.cs +++ b/nanoFramework.WebServer.Skills/SkillJsonHelper.cs @@ -72,7 +72,7 @@ private static void AppendOutputJson(StringBuilder sb, Type type, string descrip bool hasDescription = !string.IsNullOrEmpty(description); if (hasDescription) { - sb.Append(",\"description\":\"").Append(description).Append("\""); + sb.Append(",\"description\":\"").Append(EscapeJsonString(description)).Append("\""); } if (mappedType == "object") @@ -114,7 +114,7 @@ private static void AppendOutputPropertiesJson(StringBuilder sb, Type type, bool { sb.Append("{"); sb.Append("\"type\":\"").Append(mappedType).Append("\","); - sb.Append("\"description\":\"").Append(GetTypeDescription(method, propName)).Append("\""); + sb.Append("\"description\":\"").Append(EscapeJsonString(GetTypeDescription(method, propName))).Append("\""); sb.Append("}"); } } @@ -175,7 +175,7 @@ private static void AppendInputPropertiesJson(StringBuilder sb, Type type, bool sb.Append($"\"{propName}\":{{"); string mappedType = MapType(propType); sb.Append("\"type\":\"").Append(mappedType).Append("\","); - sb.Append("\"description\":\"").Append(GetTypeDescription(method, propName)).Append("\""); + sb.Append("\"description\":\"").Append(EscapeJsonString(GetTypeDescription(method, propName))).Append("\""); if (mappedType == "object") { sb.Append(",\"properties\":{"); @@ -195,7 +195,9 @@ private static string MapType(Type type) return "string"; } else if (type == typeof(int) || type == typeof(double) || type == typeof(float) || - type == typeof(long) || type == typeof(short) || type == typeof(byte)) + type == typeof(long) || type == typeof(short) || type == typeof(byte) || + type == typeof(uint) || type == typeof(ulong) || type == typeof(ushort) || + type == typeof(sbyte)) { return "number"; } @@ -203,6 +205,10 @@ private static string MapType(Type type) { return "boolean"; } + else if (type == typeof(char)) + { + return "string"; + } else if (type.IsArray) { return "array"; @@ -214,5 +220,48 @@ private static string MapType(Type type) return "string"; } + + /// + /// Escapes special characters in a string for use in JSON values. + /// Handles backslash, double-quote, newline, carriage return, and tab. + /// + /// The string to escape. + /// The escaped string safe for JSON embedding. + internal static string EscapeJsonString(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + StringBuilder sb = new StringBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + switch (c) + { + case '\\': + sb.Append("\\\\"); + break; + case '"': + sb.Append("\\\""); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + default: + sb.Append(c); + break; + } + } + + return sb.ToString(); + } } } diff --git a/nanoFramework.WebServer.Skills/SkillMetadata.cs b/nanoFramework.WebServer.Skills/SkillMetadata.cs index 3fafd3f..aac1652 100644 --- a/nanoFramework.WebServer.Skills/SkillMetadata.cs +++ b/nanoFramework.WebServer.Skills/SkillMetadata.cs @@ -89,13 +89,13 @@ public SkillActionMetadata FindAction(string actionName) public void AppendJson(StringBuilder sb) { sb.Append("{\"id\":\""); - sb.Append(Id); + sb.Append(SkillJsonHelper.EscapeJsonString(Id)); sb.Append("\",\"name\":\""); - sb.Append(Name); + sb.Append(SkillJsonHelper.EscapeJsonString(Name)); sb.Append("\",\"description\":\""); - sb.Append(Description); + sb.Append(SkillJsonHelper.EscapeJsonString(Description)); sb.Append("\",\"version\":\""); - sb.Append(Version); + sb.Append(SkillJsonHelper.EscapeJsonString(Version)); sb.Append("\""); // Tags (A2A required) @@ -174,7 +174,7 @@ private static void AppendStringArray(StringBuilder sb, string fieldName, string } sb.Append("\""); - sb.Append(values[i]); + sb.Append(SkillJsonHelper.EscapeJsonString(values[i])); sb.Append("\""); } } diff --git a/nanoFramework.WebServer.Skills/SkillRegistry.cs b/nanoFramework.WebServer.Skills/SkillRegistry.cs index c885b3a..691c9d1 100644 --- a/nanoFramework.WebServer.Skills/SkillRegistry.cs +++ b/nanoFramework.WebServer.Skills/SkillRegistry.cs @@ -271,19 +271,21 @@ public static string GetActionContentType(string skillId, string actionName) /// The parameters to pass to the action as a Hashtable. /// The serialized result string. For text/markdown or text/plain actions, the raw string is returned. /// For JSON actions, the JSON-serialized result is returned. - /// Thrown when the skill or action is not found. + /// Thrown when the skill is not found in the registry. + /// Thrown when the action is not found in the skill. + /// Thrown when the action requires parameters but none were provided. public static string InvokeAction(string skillId, string actionName, Hashtable parameters) { if (!_skills.Contains(skillId)) { - throw new Exception("Skill not found"); + throw new Exception(); } SkillMetadata skill = (SkillMetadata)_skills[skillId]; SkillActionMetadata action = skill.FindAction(actionName); if (action == null) { - throw new Exception("Action not found"); + throw new Exception(); } Debug.WriteLine($"Skill: {skillId}, Action: {actionName}, Method: {action.Method.Name}"); @@ -291,6 +293,11 @@ public static string InvokeAction(string skillId, string actionName, Hashtable p object[] methodParams = null; if (action.ParameterType != null) { + if (parameters == null) + { + throw new Exception(); + } + methodParams = new object[1]; Type paramType = action.ParameterType; if (SkillJsonHelper.IsPrimitiveType(paramType) || paramType == typeof(string)) @@ -330,8 +337,7 @@ public static string InvokeAction(string skillId, string actionName, Hashtable p } else { - string jsonResult = JsonConvert.SerializeObject(result); - return JsonConvert.SerializeObject(jsonResult); + return JsonConvert.SerializeObject(result); } } diff --git a/nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj b/nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj index 2be3eee..a9d1151 100644 --- a/nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj +++ b/nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj @@ -32,7 +32,6 @@ - @@ -66,11 +65,11 @@ ..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll - - ..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + ..\packages\nanoFramework.System.Net.1.11.50\lib\System.Net.dll - - ..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + ..\packages\nanoFramework.System.Net.Http.Server.1.5.203\lib\System.Net.Http.dll ..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll @@ -99,4 +98,4 @@ - + \ No newline at end of file diff --git a/nanoFramework.WebServer.Skills/packages.config b/nanoFramework.WebServer.Skills/packages.config index c592f6a..1477bc5 100644 --- a/nanoFramework.WebServer.Skills/packages.config +++ b/nanoFramework.WebServer.Skills/packages.config @@ -1,13 +1,13 @@ - + - - + + - + \ No newline at end of file diff --git a/nanoFramework.WebServer.Skills/packages.lock.json b/nanoFramework.WebServer.Skills/packages.lock.json new file mode 100644 index 0000000..259d1b5 --- /dev/null +++ b/nanoFramework.WebServer.Skills/packages.lock.json @@ -0,0 +1,67 @@ +{ + "version": 1, + "dependencies": { + ".NETnanoFramework,Version=v1.0": { + "nanoFramework.CoreLibrary": { + "type": "Direct", + "requested": "[1.17.11, 1.17.11]", + "resolved": "1.17.11", + "contentHash": "HezzAc0o2XrSGf85xSeD/6xsO6ohF9hX6/iMQ1IZS6Zw6umr4WfAN2Jv0BrPxkaYwzEegJxxZujkHoUIAqtOMw==" + }, + "nanoFramework.Json": { + "type": "Direct", + "requested": "[2.2.203, 2.2.203]", + "resolved": "2.2.203", + "contentHash": "IsbevoAqPill+t5sU6uLWW8/UgpwfmEUWrZvzwxAOWMnj+wY/68oHcp8N/eJqcGYsUHAyLKKR/LCg4BSgDcZbw==" + }, + "nanoFramework.Runtime.Events": { + "type": "Direct", + "requested": "[1.11.32, 1.11.32]", + "resolved": "1.11.32", + "contentHash": "NyLUIwJDlpl5VKSd+ljmdDtO2WHHBvPvruo1ccaL+hd79z+6XMYze1AccOVXKGiZenLBCwDmFHwpgIQyHkM7GA==" + }, + "nanoFramework.System.Collections": { + "type": "Direct", + "requested": "[1.5.67, 1.5.67]", + "resolved": "1.5.67", + "contentHash": "MjSipUB70vrxjqTm1KfKTUqqjd0wbweiNyYFXONi0XClrH6HXsuX2lhDqXM8NWuYnWyYOqx8y20sXbvsH+4brg==" + }, + "nanoFramework.System.IO.Streams": { + "type": "Direct", + "requested": "[1.1.96, 1.1.96]", + "resolved": "1.1.96", + "contentHash": "kJSy4EJwChO4Vq3vGWP9gNRPFDnTsDU5HxzeI7NDO+RjbDsx7B8EhKymoeTPLJCxQq8y/0P1KG2XCxGpggW+fw==" + }, + "nanoFramework.System.Net": { + "type": "Direct", + "requested": "[1.11.50, 1.11.50]", + "resolved": "1.11.50", + "contentHash": "5tCFNg+yXGAKOGP4cxFLdr+xM4r/za25I9zjom6ZiKGT5i5K+N2zCCr8TA8UmnQRl7xvBRUZ2vSxFF47OMqXwg==" + }, + "nanoFramework.System.Net.Http.Server": { + "type": "Direct", + "requested": "[1.5.203, 1.5.203]", + "resolved": "1.5.203", + "contentHash": "cEQQP7VM6CHdlRyxloA7Z9Pv9s8heFpwp+MpPn9Xk0Fn9/I2l4X4GV4CgDDnKCNSdbyXWREa9ccmZ73WZr7ucw==" + }, + "nanoFramework.System.Text": { + "type": "Direct", + "requested": "[1.3.42, 1.3.42]", + "resolved": "1.3.42", + "contentHash": "68HPjhersNpssbmEMUHdMw3073MHfGTfrkbRk9eILKbNPFfPFck7m4y9BlAi6DaguUJaeKxgyIojXF3SQrF8/A==" + }, + "nanoFramework.System.Threading": { + "type": "Direct", + "requested": "[1.1.52, 1.1.52]", + "resolved": "1.1.52", + "contentHash": "kv+US/+7QKV1iT/snxBh032vwZ+3krJ4vujlSsvmS2nNj/nK64R3bq/ST3bCFquxHDD0mog8irtCBCsFazr4kA==" + }, + "Nerdbank.GitVersioning": { + "type": "Direct", + "requested": "[3.9.50, 3.9.50]", + "resolved": "3.9.50", + "contentHash": "HtOgGF6jZ+WYbXnCUCYPT8Y2d6mIJo9ozjK/FINTRsXdm4Zgv9GehUMa7EFoGQkqrMcDJNOIDwCmENnvXg4UbA==" + } + } + } +} \ No newline at end of file diff --git a/nanoFramework.WebServer/nanoFramework.WebServer.nfproj b/nanoFramework.WebServer/nanoFramework.WebServer.nfproj index 726bcfe..5aeab2a 100644 --- a/nanoFramework.WebServer/nanoFramework.WebServer.nfproj +++ b/nanoFramework.WebServer/nanoFramework.WebServer.nfproj @@ -73,11 +73,11 @@ ..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll - - ..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + ..\packages\nanoFramework.System.Net.1.11.50\lib\System.Net.dll - - ..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + ..\packages\nanoFramework.System.Net.Http.Server.1.5.203\lib\System.Net.Http.dll ..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll diff --git a/nanoFramework.WebServer/packages.config b/nanoFramework.WebServer/packages.config index 9694343..3779184 100644 --- a/nanoFramework.WebServer/packages.config +++ b/nanoFramework.WebServer/packages.config @@ -4,8 +4,8 @@ - - + + diff --git a/nanoFramework.WebServer/packages.lock.json b/nanoFramework.WebServer/packages.lock.json index 573ddc4..100ad17 100644 --- a/nanoFramework.WebServer/packages.lock.json +++ b/nanoFramework.WebServer/packages.lock.json @@ -28,15 +28,15 @@ }, "nanoFramework.System.Net": { "type": "Direct", - "requested": "[1.11.47, 1.11.47]", - "resolved": "1.11.47", - "contentHash": "fPjTPfaqDiirh9AysBLlEX5/KbaOliSxyspwpBTcM5OupCNjVXz0aZ9fOWex+BMjZei6GpG74kP8Z4ml912pcw==" + "requested": "[1.11.50, 1.11.50]", + "resolved": "1.11.50", + "contentHash": "5tCFNg+yXGAKOGP4cxFLdr+xM4r/za25I9zjom6ZiKGT5i5K+N2zCCr8TA8UmnQRl7xvBRUZ2vSxFF47OMqXwg==" }, "nanoFramework.System.Net.Http.Server": { "type": "Direct", - "requested": "[1.5.200, 1.5.200]", - "resolved": "1.5.200", - "contentHash": "no7P7xTkZ7IVSPKeI4YMIBG11YdsHH76hjC4XRaZsohPdsUcLOZbsoonLaQ8VW9l89QLrliYSREgN4C2l6I1mw==" + "requested": "[1.5.203, 1.5.203]", + "resolved": "1.5.203", + "contentHash": "cEQQP7VM6CHdlRyxloA7Z9Pv9s8heFpwp+MpPn9Xk0Fn9/I2l4X4GV4CgDDnKCNSdbyXWREa9ccmZ73WZr7ucw==" }, "nanoFramework.System.Text": { "type": "Direct", diff --git a/tests/SkillsClientTest/SkillsClientTest.cs b/tests/SkillsClientTest/SkillsClientTest.cs index 9d364e3..6560bd2 100644 --- a/tests/SkillsClientTest/SkillsClientTest.cs +++ b/tests/SkillsClientTest/SkillsClientTest.cs @@ -3,6 +3,7 @@ #:package DotNetEnv@3.1.1 #:package Microsoft.SemanticKernel@1.74.0 #:package Microsoft.SemanticKernel.Agents.Core@1.74.0 +#:property JsonSerializerIsReflectionEnabledByDefault=true // Skills Discovery E2E Test — Agent Consumer // Connects to a nanoFramework device running the Skills Discovery Service, @@ -37,13 +38,30 @@ #pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' #pragma warning disable IL3050 // Calling members annotated with 'RequiresDynamicCodeAttribute' -// Load environment variables from .env file -DotNetEnv.Env.Load(); +// Load environment variables from .env file next to this script +var scriptDir = Path.GetDirectoryName(AppContext.BaseDirectory) + ?? Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) + ?? Environment.CurrentDirectory; +// Walk up to find the .env next to the .cs source when running via dotnet run +var envPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ".env"); +if (!File.Exists(envPath)) +{ + // Try alongside the source file + envPath = Path.Combine("tests", "SkillsClientTest", ".env"); +} +if (File.Exists(envPath)) +{ + DotNetEnv.Env.Load(envPath); +} +else +{ + DotNetEnv.Env.Load(); // fallback: current directory +} // ----------------------------------------------------------------- // 1. Configuration // ----------------------------------------------------------------- -var deviceHost = DotNetEnv.Env.GetString("DEVICE_HOST", "192.168.1.139:80"); +var deviceHost = DotNetEnv.Env.GetString("DEVICE_HOST", "192.168.1.113:80"); var deploymentName = DotNetEnv.Env.GetString("AZUREAI_DEPLOYMENT_NAME"); var endpoint = DotNetEnv.Env.GetString("AZUREAI_DEPLOYMENT_ENDPOINT"); var apiKey = DotNetEnv.Env.GetString("AZUREAI_DEPLOYMENT_API_KEY"); diff --git a/tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj b/tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj index fc774f2..1c87a52 100644 --- a/tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj +++ b/tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj @@ -39,17 +39,17 @@ ..\..\packages\nanoFramework.System.Text.1.3.42\lib\nanoFramework.System.Text.dll - - ..\..\packages\nanoFramework.System.Device.Wifi.1.5.139\lib\System.Device.Wifi.dll + + ..\..\packages\nanoFramework.System.Device.Wifi.1.5.141\lib\System.Device.Wifi.dll ..\..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll - - ..\..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + ..\..\packages\nanoFramework.System.Net.1.11.50\lib\System.Net.dll - - ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.203\lib\System.Net.Http.dll ..\..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll @@ -71,4 +71,4 @@ - + \ No newline at end of file diff --git a/tests/SkillsEndToEndTest/packages.config b/tests/SkillsEndToEndTest/packages.config index c0d82b7..aedec28 100644 --- a/tests/SkillsEndToEndTest/packages.config +++ b/tests/SkillsEndToEndTest/packages.config @@ -1,13 +1,13 @@ - + - + - - + + - + \ No newline at end of file From 0fe2194074b46d2425ad84f63e7285a2ca052562 Mon Sep 17 00:00:00 2001 From: Laurent Ellerbach Date: Tue, 21 Apr 2026 16:59:28 +0200 Subject: [PATCH 3/9] polishing --- README.md | 108 +++++++++++++++++- doc/skills-discovery.md | 2 +- .../SkillDiscoveryController.cs | 2 +- 3 files changed, 107 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 60e0995..b014fa9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ----- -# .NET nanoFramework WebServer with Model Context Protocol (MCP) +# .NET nanoFramework WebServer with Model Context Protocol (MCP) and Skills Discovery ## Build status @@ -13,10 +13,11 @@ | nanoFramework.WebServer | [![Build Status](https://dev.azure.com/nanoframework/nanoFramework.WebServer/_apis/build/status/nanoFramework.WebServer?repoName=nanoframework%2FnanoFramework.WebServer&branchName=main)](https://dev.azure.com/nanoframework/nanoFramework.WebServer/_build/latest?definitionId=65&repoName=nanoframework%2FnanoFramework.WebServer&branchName=main) | [![NuGet](https://img.shields.io/nuget/v/nanoFramework.WebServer.svg?label=NuGet&style=flat&logo=nuget)](https://www.nuget.org/packages/nanoFramework.WebServer/) | | nanoFramework.WebServer.FileSystem | [![Build Status](https://dev.azure.com/nanoframework/nanoFramework.WebServer/_apis/build/status/nanoFramework.WebServer?repoName=nanoframework%2FnanoFramework.WebServer&branchName=main)](https://dev.azure.com/nanoframework/nanoFramework.WebServer/_build/latest?definitionId=65&repoName=nanoframework%2FnanoFramework.WebServer&branchName=main) | [![NuGet](https://img.shields.io/nuget/v/nanoFramework.WebServer.FileSystem.svg?label=NuGet&style=flat&logo=nuget)](https://www.nuget.org/packages/nanoFramework.WebServer.FileSystem/) | | nanoFramework.WebServer.Mcp | [![Build Status](https://dev.azure.com/nanoframework/nanoFramework.WebServer/_apis/build/status/nanoFramework.WebServer?repoName=nanoframework%2FnanoFramework.WebServer&branchName=main)](https://dev.azure.com/nanoframework/nanoFramework.WebServer/_build/latest?definitionId=65&repoName=nanoframework%2FnanoFramework.WebServer&branchName=main) | [![NuGet](https://img.shields.io/nuget/v/nanoFramework.WebServer.Mcp.svg?label=NuGet&style=flat&logo=nuget)](https://www.nuget.org/packages/nanoFramework.WebServer.Mcp/) | +| nanoFramework.WebServer.Skills | [![Build Status](https://dev.azure.com/nanoframework/nanoFramework.WebServer/_apis/build/status/nanoFramework.WebServer?repoName=nanoframework%2FnanoFramework.WebServer&branchName=main)](https://dev.azure.com/nanoframework/nanoFramework.WebServer/_build/latest?definitionId=65&repoName=nanoframework%2FnanoFramework.WebServer&branchName=main) | [![NuGet](https://img.shields.io/nuget/v/nanoFramework.WebServer.Skills.svg?label=NuGet&style=flat&logo=nuget)](https://www.nuget.org/packages/nanoFramework.WebServer.Skills/) | ## Overview -This library provides a lightweight, multi-threaded HTTP/HTTPS WebServer for .NET nanoFramework with comprehensive **Model Context Protocol (MCP)** support for AI agent integration. +This library provides a lightweight, multi-threaded HTTP/HTTPS WebServer for .NET nanoFramework with comprehensive **Model Context Protocol (MCP)** support and **AI Agent Skills Discovery** (A2A-compatible) for AI agent integration. ### Key Features @@ -27,6 +28,7 @@ This library provides a lightweight, multi-threaded HTTP/HTTPS WebServer for .NE - **Authentication support** (Basic, API Key) - **HTTPS/SSL support** with certificates - **Model Context Protocol (MCP)** for AI agent integration +- **AI Agent Skills Discovery** with A2A-compatible Agent Card - **Automatic tool discovery** and JSON-RPC 2.0 compliance ## Quick Start @@ -201,6 +203,103 @@ POST /mcp } ``` +## AI Agent Skills Discovery + +Expose device capabilities as discoverable skills for AI agents using the [A2A (Agent2Agent) protocol](https://a2a-protocol.org) conventions. + +### Defining Skills + +```csharp +using nanoFramework.WebServer.Skills; + +[Skill("climate", "Climate Control", "HVAC management for building zones")] +[SkillTag("temperature")] +[SkillTag("hvac")] +[SkillExample("What is the current temperature?")] +public class ClimateSkill +{ + [SkillAction("GetTemperature", "Reads current room temperature")] + public static double GetTemperature() + { + return 22.5; + } + + [SkillAction("SetTarget", "Sets the target temperature")] + public static bool SetTarget(TargetInput input) + { + return true; + } +} + +public class TargetInput +{ + public double Temperature + { + [Description("Target temperature in Celsius")] + get; + set; + } +} +``` + +### Setting Up Skills Server + +```csharp +public static void Main() +{ + // Connect to WiFi first + var connected = WifiNetworkHelper.ConnectDhcp(Ssid, Password, requiresDateTime: true); + + // Discover and register skills + SkillRegistry.DiscoverSkills(new Type[] { typeof(ClimateSkill) }); + + // Start WebServer with Skills Discovery support + using (var server = new WebServer(80, HttpProtocol.Http, new Type[] { typeof(SkillDiscoveryController) })) + { + // Optional customization + SkillDiscoveryController.AgentName = "SmartThermostat"; + SkillDiscoveryController.AgentDescription = "Embedded HVAC controller with sensor capabilities"; + + server.Start(); + Thread.Sleep(Timeout.Infinite); + } +} +``` + +### AI Agent Integration + +Once running, AI agents can discover and invoke your skills: + +```http +GET /.well-known/agent-card.json +``` + +```json +{ + "name": "SmartThermostat", + "description": "Embedded HVAC controller with sensor capabilities", + "version": "1.0.0", + "skills": [ + { + "id": "climate", + "name": "Climate Control", + "tags": ["temperature", "hvac"], + "actions": [ + { "name": "GetTemperature", "description": "Reads current room temperature" }, + { "name": "SetTarget", "description": "Sets the target temperature" } + ] + } + ] +} +``` + +```http +POST /skills/invoke +Content-Type: application/json + +{ "skill": "climate", "action": "GetTemperature", "arguments": {} } +``` + ## Documentation | Topic | Description | @@ -210,6 +309,7 @@ POST /mcp | [HTTPS and Certificates](./doc/https-certificates.md) | Set up SSL/TLS encryption with certificates | | [File System Support](./doc/file-system.md) | Serve static files from storage devices | | [Model Context Protocol (MCP)](./doc/model-context-protocol.md) | Complete MCP guide for AI agent integration | +| [AI Agent Skills Discovery](./doc/skills-discovery.md) | A2A-compatible skills discovery and invocation | | [REST API Development](./doc/rest-api.md) | Build RESTful APIs with request/response handling | | [Event-Driven Programming](./doc/event-driven.md) | Handle requests through events and status monitoring | | [Examples and Samples](./doc/examples.md) | Working examples and code samples | @@ -218,13 +318,15 @@ POST /mcp - No compression support in request/response streams - MCP implementation supports server features only (no notifications or SSE) -- No or single parameter limitation for MCP tools (use complex objects for multiple parameters) +- No or single parameter limitation for MCP tools and Skills actions (use complex objects for multiple parameters) - Prompt parameters, when declared, are always mandatory. +- Skills Discovery implements A2A discovery and invocation only (no task lifecycle) ## Installation Install 'nanoFramework.WebServer' for the Web Server without File System support. Install 'nanoFramework.WebServer.FileSystem' for file serving, so with devices supporting File System. Install 'nanoFramework.WebServer.Mcp' for MCP support. It does contains the full 'nanoFramework.WebServer' but does not include native file serving. You can add this feature fairly easilly by reusing the code function serving it. +Install 'nanoFramework.WebServer.Skills' for A2A-compatible AI Agent Skills Discovery. Like MCP, it includes the core 'nanoFramework.WebServer' and can be used alongside MCP. ## Contributing diff --git a/doc/skills-discovery.md b/doc/skills-discovery.md index ac99038..978af87 100644 --- a/doc/skills-discovery.md +++ b/doc/skills-discovery.md @@ -168,7 +168,7 @@ Tags enable semantic matching by orchestrators. Use the `[SkillTag]` attribute ( public class ClimateSkill { } ``` -Tags appear in the A2A `AgentSkill.tags` field and can be used to filter skills via `GET .well-known/agent-card.json?tag=sensor`. +Tags appear in the A2A `AgentSkill.tags` field and can be used to filter skills via `GET /.well-known/agent-card.json?tag=sensor`. ### Examples diff --git a/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs b/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs index 32594d5..0739854 100644 --- a/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs +++ b/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs @@ -133,7 +133,7 @@ public void GetAgentCard(WebServerEventArgs e) catch (Exception ex) { WebServer.OutputAsStream(e.Context.Response, - "{\"error\":{\"code\":-1,\"message\":\"" + ex.Message + "\"}}"); + "{\"error\":{\"code\":-1,\"message\":\"" + SkillJsonHelper.EscapeJsonString(ex.Message) + "\"}}"); } } From c79adcd153a62aa9a47997ecb44ff652aef96ece Mon Sep 17 00:00:00 2001 From: Laurent Ellerbach Date: Fri, 24 Apr 2026 14:08:22 +0200 Subject: [PATCH 4/9] various fixes and tests Co-authored-by: Copilot --- .../nanoFramework.WebServer.FileSystem.nfproj | 8 +- .../packages.config | 4 +- .../packages.lock.json | 12 +- .../nanoFramework.WebServer.Mcp.nfproj | 8 +- nanoFramework.WebServer.Mcp/packages.config | 4 +- .../packages.lock.json | 12 +- nanoFramework.WebServer.Skills.nuspec | 2 +- .../RegistryBase.cs | 22 ++- .../SkillActionMetadata.cs | 2 +- .../SkillDiscoveryController.cs | 8 +- .../SkillRegistry.cs | 10 ++ tests/McpEndToEndTest/McpEndToEndTest.nfproj | 8 +- tests/McpEndToEndTest/packages.config | 4 +- tests/McpEndToEndTest/packages.lock.json | 12 +- tests/McpServerTests/McpServerTests.nfproj | 8 +- tests/McpServerTests/packages.config | 4 +- tests/McpServerTests/packages.lock.json | 12 +- tests/SkillsTests/SkillRegistryTests.cs | 156 ++++-------------- tests/SkillsTests/SkillsTests.nfproj | 8 +- tests/SkillsTests/packages.config | 4 +- .../WebServerE2ETests.nfproj | 8 +- tests/WebServerE2ETests/packages.config | 4 +- tests/WebServerE2ETests/packages.lock.json | 12 +- .../WebServerTests.cs | 73 +------- .../nanoFramework.WebServer.Tests.nfproj | 8 +- .../packages.config | 4 +- .../packages.lock.json | 12 +- 27 files changed, 157 insertions(+), 272 deletions(-) diff --git a/nanoFramework.WebServer.FileSystem/nanoFramework.WebServer.FileSystem.nfproj b/nanoFramework.WebServer.FileSystem/nanoFramework.WebServer.FileSystem.nfproj index 2aa2c89..26bde1d 100644 --- a/nanoFramework.WebServer.FileSystem/nanoFramework.WebServer.FileSystem.nfproj +++ b/nanoFramework.WebServer.FileSystem/nanoFramework.WebServer.FileSystem.nfproj @@ -77,11 +77,11 @@ ..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll - - ..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + ..\packages\nanoFramework.System.Net.1.11.50\lib\System.Net.dll - - ..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + ..\packages\nanoFramework.System.Net.Http.Server.1.5.203\lib\System.Net.Http.dll ..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll diff --git a/nanoFramework.WebServer.FileSystem/packages.config b/nanoFramework.WebServer.FileSystem/packages.config index 864fece..193b256 100644 --- a/nanoFramework.WebServer.FileSystem/packages.config +++ b/nanoFramework.WebServer.FileSystem/packages.config @@ -5,8 +5,8 @@ - - + + diff --git a/nanoFramework.WebServer.FileSystem/packages.lock.json b/nanoFramework.WebServer.FileSystem/packages.lock.json index ab4d8b0..288ac3c 100644 --- a/nanoFramework.WebServer.FileSystem/packages.lock.json +++ b/nanoFramework.WebServer.FileSystem/packages.lock.json @@ -34,15 +34,15 @@ }, "nanoFramework.System.Net": { "type": "Direct", - "requested": "[1.11.47, 1.11.47]", - "resolved": "1.11.47", - "contentHash": "fPjTPfaqDiirh9AysBLlEX5/KbaOliSxyspwpBTcM5OupCNjVXz0aZ9fOWex+BMjZei6GpG74kP8Z4ml912pcw==" + "requested": "[1.11.50, 1.11.50]", + "resolved": "1.11.50", + "contentHash": "5tCFNg+yXGAKOGP4cxFLdr+xM4r/za25I9zjom6ZiKGT5i5K+N2zCCr8TA8UmnQRl7xvBRUZ2vSxFF47OMqXwg==" }, "nanoFramework.System.Net.Http.Server": { "type": "Direct", - "requested": "[1.5.200, 1.5.200]", - "resolved": "1.5.200", - "contentHash": "no7P7xTkZ7IVSPKeI4YMIBG11YdsHH76hjC4XRaZsohPdsUcLOZbsoonLaQ8VW9l89QLrliYSREgN4C2l6I1mw==" + "requested": "[1.5.203, 1.5.203]", + "resolved": "1.5.203", + "contentHash": "cEQQP7VM6CHdlRyxloA7Z9Pv9s8heFpwp+MpPn9Xk0Fn9/I2l4X4GV4CgDDnKCNSdbyXWREa9ccmZ73WZr7ucw==" }, "nanoFramework.System.Text": { "type": "Direct", diff --git a/nanoFramework.WebServer.Mcp/nanoFramework.WebServer.Mcp.nfproj b/nanoFramework.WebServer.Mcp/nanoFramework.WebServer.Mcp.nfproj index d01a379..307b61b 100644 --- a/nanoFramework.WebServer.Mcp/nanoFramework.WebServer.Mcp.nfproj +++ b/nanoFramework.WebServer.Mcp/nanoFramework.WebServer.Mcp.nfproj @@ -68,11 +68,11 @@ ..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll - - ..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + ..\packages\nanoFramework.System.Net.1.11.50\lib\System.Net.dll - - ..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + ..\packages\nanoFramework.System.Net.Http.Server.1.5.203\lib\System.Net.Http.dll ..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll diff --git a/nanoFramework.WebServer.Mcp/packages.config b/nanoFramework.WebServer.Mcp/packages.config index ca874a1..1477bc5 100644 --- a/nanoFramework.WebServer.Mcp/packages.config +++ b/nanoFramework.WebServer.Mcp/packages.config @@ -5,8 +5,8 @@ - - + + diff --git a/nanoFramework.WebServer.Mcp/packages.lock.json b/nanoFramework.WebServer.Mcp/packages.lock.json index 36ee497..259d1b5 100644 --- a/nanoFramework.WebServer.Mcp/packages.lock.json +++ b/nanoFramework.WebServer.Mcp/packages.lock.json @@ -34,15 +34,15 @@ }, "nanoFramework.System.Net": { "type": "Direct", - "requested": "[1.11.47, 1.11.47]", - "resolved": "1.11.47", - "contentHash": "fPjTPfaqDiirh9AysBLlEX5/KbaOliSxyspwpBTcM5OupCNjVXz0aZ9fOWex+BMjZei6GpG74kP8Z4ml912pcw==" + "requested": "[1.11.50, 1.11.50]", + "resolved": "1.11.50", + "contentHash": "5tCFNg+yXGAKOGP4cxFLdr+xM4r/za25I9zjom6ZiKGT5i5K+N2zCCr8TA8UmnQRl7xvBRUZ2vSxFF47OMqXwg==" }, "nanoFramework.System.Net.Http.Server": { "type": "Direct", - "requested": "[1.5.200, 1.5.200]", - "resolved": "1.5.200", - "contentHash": "no7P7xTkZ7IVSPKeI4YMIBG11YdsHH76hjC4XRaZsohPdsUcLOZbsoonLaQ8VW9l89QLrliYSREgN4C2l6I1mw==" + "requested": "[1.5.203, 1.5.203]", + "resolved": "1.5.203", + "contentHash": "cEQQP7VM6CHdlRyxloA7Z9Pv9s8heFpwp+MpPn9Xk0Fn9/I2l4X4GV4CgDDnKCNSdbyXWREa9ccmZ73WZr7ucw==" }, "nanoFramework.System.Text": { "type": "Direct", diff --git a/nanoFramework.WebServer.Skills.nuspec b/nanoFramework.WebServer.Skills.nuspec index 0f92f48..bd03e2f 100644 --- a/nanoFramework.WebServer.Skills.nuspec +++ b/nanoFramework.WebServer.Skills.nuspec @@ -23,7 +23,7 @@ This comes also with the nanoFramework WebServer. Allowing to create a REST API http https webserver net netmf nf nanoframework skills ai agent discovery a2a iot - + diff --git a/nanoFramework.WebServer.Skills/RegistryBase.cs b/nanoFramework.WebServer.Skills/RegistryBase.cs index d795157..d49a949 100644 --- a/nanoFramework.WebServer.Skills/RegistryBase.cs +++ b/nanoFramework.WebServer.Skills/RegistryBase.cs @@ -19,6 +19,7 @@ public abstract class RegistryBase /// The value to convert. /// The target primitive type to convert to. /// The converted value as the target type. + /// Thrown when the value cannot be converted to the target type (e.g. invalid boolean string). protected static object ConvertToPrimitiveType(object value, Type targetType) { if (value == null) @@ -40,18 +41,30 @@ protected static object ConvertToPrimitiveType(object value, Type targetType) } else if (targetType == typeof(bool)) { - if (value.ToString().Length == 1) + string strVal = value.ToString(); + if (strVal.Length == 1) { try { - return Convert.ToBoolean(Convert.ToByte(value.ToString())); + return Convert.ToBoolean(Convert.ToByte(strVal)); } catch (Exception) { } } - return value.ToString().ToLower() == "true"; + string lower = strVal.ToLower(); + if (lower == "true") + { + return true; + } + + if (lower == "false") + { + return false; + } + + throw new InvalidCastException(); } else if (targetType == typeof(long)) { @@ -106,6 +119,7 @@ protected static object ConvertToPrimitiveType(object value, Type targetType) /// The Hashtable containing the data to deserialize. /// The target type to deserialize the data into. /// A new instance of the target type with properties populated from the Hashtable, or null if hashtable or targetType is null. + /// Thrown when a property value cannot be converted to the expected type. protected static object DeserializeFromHashtable(Hashtable hashtable, Type targetType) { if (hashtable == null || targetType == null) @@ -167,7 +181,7 @@ protected static object DeserializeFromHashtable(Hashtable hashtable, Type targe } catch (Exception) { - continue; + throw; } } diff --git a/nanoFramework.WebServer.Skills/SkillActionMetadata.cs b/nanoFramework.WebServer.Skills/SkillActionMetadata.cs index e678c0e..2b24ced 100644 --- a/nanoFramework.WebServer.Skills/SkillActionMetadata.cs +++ b/nanoFramework.WebServer.Skills/SkillActionMetadata.cs @@ -63,7 +63,7 @@ public void AppendJson(StringBuilder sb) if (!string.IsNullOrEmpty(ContentType) && ContentType != "application/json") { sb.Append(",\"contentType\":\""); - sb.Append(ContentType); + sb.Append(SkillJsonHelper.EscapeJsonString(ContentType)); sb.Append("\""); } diff --git a/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs b/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs index 0739854..66359cb 100644 --- a/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs +++ b/nanoFramework.WebServer.Skills/SkillDiscoveryController.cs @@ -5,6 +5,7 @@ using System.Collections; using System.Diagnostics; using System.Text; +using System.Web; using nanoFramework.Json; using nanoFramework.WebServer; @@ -64,7 +65,7 @@ public void GetAgentCard(WebServerEventArgs e) if (eqIndex > 0) { string key = pair.Substring(0, eqIndex); - string value = pair.Substring(eqIndex + 1); + string value = HttpUtility.UrlDecode(pair.Substring(eqIndex + 1)); if (key == "skill") { skillFilter = value; @@ -132,6 +133,7 @@ public void GetAgentCard(WebServerEventArgs e) } catch (Exception ex) { + e.Context.Response.StatusCode = 500; WebServer.OutputAsStream(e.Context.Response, "{\"error\":{\"code\":-1,\"message\":\"" + SkillJsonHelper.EscapeJsonString(ex.Message) + "\"}}"); } @@ -158,6 +160,7 @@ public void ListSkills(WebServerEventArgs e) } catch (Exception ex) { + e.Context.Response.StatusCode = 500; WebServer.OutputAsStream(e.Context.Response, "{\"error\":{\"code\":-1,\"message\":\"" + SkillJsonHelper.EscapeJsonString(ex.Message) + "\"}}"); } @@ -188,6 +191,7 @@ public void InvokeSkillAction(WebServerEventArgs e) if (!request.Contains("skill") || !request.Contains("action")) { e.Context.Response.ContentType = "application/json"; + e.Context.Response.StatusCode = 400; WebServer.OutputAsStream(e.Context.Response, "{\"error\":{\"code\":-3,\"message\":\"Missing 'skill' or 'action' field\"}}"); return; @@ -204,6 +208,7 @@ public void InvokeSkillAction(WebServerEventArgs e) if (contentType == null) { e.Context.Response.ContentType = "application/json"; + e.Context.Response.StatusCode = 404; WebServer.OutputAsStream(e.Context.Response, "{\"error\":{\"code\":-2,\"message\":\"Skill or action not found\"}}"); return; @@ -229,6 +234,7 @@ public void InvokeSkillAction(WebServerEventArgs e) catch (Exception ex) { e.Context.Response.ContentType = "application/json"; + e.Context.Response.StatusCode = 500; WebServer.OutputAsStream(e.Context.Response, "{\"error\":{\"code\":-1,\"message\":\"" + SkillJsonHelper.EscapeJsonString(ex.Message) + "\"}}"); } diff --git a/nanoFramework.WebServer.Skills/SkillRegistry.cs b/nanoFramework.WebServer.Skills/SkillRegistry.cs index 691c9d1..300391e 100644 --- a/nanoFramework.WebServer.Skills/SkillRegistry.cs +++ b/nanoFramework.WebServer.Skills/SkillRegistry.cs @@ -18,6 +18,16 @@ public class SkillRegistry : RegistryBase private static readonly Hashtable _skills = new Hashtable(); private static bool _isInitialized = false; + /// + /// Resets the skill registry, clearing all registered skills. + /// This is intended for testing scenarios where the registry needs to be re-initialized. + /// + public static void Reset() + { + _skills.Clear(); + _isInitialized = false; + } + /// /// Discovers skills by scanning the provided types for classes decorated with the SkillAttribute. /// This method should be called once at startup to populate the skill registry. diff --git a/tests/McpEndToEndTest/McpEndToEndTest.nfproj b/tests/McpEndToEndTest/McpEndToEndTest.nfproj index 20a4043..fa6008d 100644 --- a/tests/McpEndToEndTest/McpEndToEndTest.nfproj +++ b/tests/McpEndToEndTest/McpEndToEndTest.nfproj @@ -46,11 +46,11 @@ ..\..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll - - ..\..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + ..\..\packages\nanoFramework.System.Net.1.11.50\lib\System.Net.dll - - ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.203\lib\System.Net.Http.dll ..\..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll diff --git a/tests/McpEndToEndTest/packages.config b/tests/McpEndToEndTest/packages.config index db925fc..cc03beb 100644 --- a/tests/McpEndToEndTest/packages.config +++ b/tests/McpEndToEndTest/packages.config @@ -6,8 +6,8 @@ - - + + \ No newline at end of file diff --git a/tests/McpEndToEndTest/packages.lock.json b/tests/McpEndToEndTest/packages.lock.json index 82288c2..40758e5 100644 --- a/tests/McpEndToEndTest/packages.lock.json +++ b/tests/McpEndToEndTest/packages.lock.json @@ -40,15 +40,15 @@ }, "nanoFramework.System.Net": { "type": "Direct", - "requested": "[1.11.47, 1.11.47]", - "resolved": "1.11.47", - "contentHash": "fPjTPfaqDiirh9AysBLlEX5/KbaOliSxyspwpBTcM5OupCNjVXz0aZ9fOWex+BMjZei6GpG74kP8Z4ml912pcw==" + "requested": "[1.11.50, 1.11.50]", + "resolved": "1.11.50", + "contentHash": "5tCFNg+yXGAKOGP4cxFLdr+xM4r/za25I9zjom6ZiKGT5i5K+N2zCCr8TA8UmnQRl7xvBRUZ2vSxFF47OMqXwg==" }, "nanoFramework.System.Net.Http.Server": { "type": "Direct", - "requested": "[1.5.200, 1.5.200]", - "resolved": "1.5.200", - "contentHash": "no7P7xTkZ7IVSPKeI4YMIBG11YdsHH76hjC4XRaZsohPdsUcLOZbsoonLaQ8VW9l89QLrliYSREgN4C2l6I1mw==" + "requested": "[1.5.203, 1.5.203]", + "resolved": "1.5.203", + "contentHash": "cEQQP7VM6CHdlRyxloA7Z9Pv9s8heFpwp+MpPn9Xk0Fn9/I2l4X4GV4CgDDnKCNSdbyXWREa9ccmZ73WZr7ucw==" }, "nanoFramework.System.Text": { "type": "Direct", diff --git a/tests/McpServerTests/McpServerTests.nfproj b/tests/McpServerTests/McpServerTests.nfproj index 31920b1..9138fbc 100644 --- a/tests/McpServerTests/McpServerTests.nfproj +++ b/tests/McpServerTests/McpServerTests.nfproj @@ -54,11 +54,11 @@ ..\..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll - - ..\..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + ..\..\packages\nanoFramework.System.Net.1.11.50\lib\System.Net.dll - - ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.203\lib\System.Net.Http.dll ..\..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll diff --git a/tests/McpServerTests/packages.config b/tests/McpServerTests/packages.config index 26b3df0..12c11e0 100644 --- a/tests/McpServerTests/packages.config +++ b/tests/McpServerTests/packages.config @@ -4,8 +4,8 @@ - - + + diff --git a/tests/McpServerTests/packages.lock.json b/tests/McpServerTests/packages.lock.json index 5e1c295..2f2e4af 100644 --- a/tests/McpServerTests/packages.lock.json +++ b/tests/McpServerTests/packages.lock.json @@ -28,15 +28,15 @@ }, "nanoFramework.System.Net": { "type": "Direct", - "requested": "[1.11.47, 1.11.47]", - "resolved": "1.11.47", - "contentHash": "fPjTPfaqDiirh9AysBLlEX5/KbaOliSxyspwpBTcM5OupCNjVXz0aZ9fOWex+BMjZei6GpG74kP8Z4ml912pcw==" + "requested": "[1.11.50, 1.11.50]", + "resolved": "1.11.50", + "contentHash": "5tCFNg+yXGAKOGP4cxFLdr+xM4r/za25I9zjom6ZiKGT5i5K+N2zCCr8TA8UmnQRl7xvBRUZ2vSxFF47OMqXwg==" }, "nanoFramework.System.Net.Http.Server": { "type": "Direct", - "requested": "[1.5.200, 1.5.200]", - "resolved": "1.5.200", - "contentHash": "no7P7xTkZ7IVSPKeI4YMIBG11YdsHH76hjC4XRaZsohPdsUcLOZbsoonLaQ8VW9l89QLrliYSREgN4C2l6I1mw==" + "requested": "[1.5.203, 1.5.203]", + "resolved": "1.5.203", + "contentHash": "cEQQP7VM6CHdlRyxloA7Z9Pv9s8heFpwp+MpPn9Xk0Fn9/I2l4X4GV4CgDDnKCNSdbyXWREa9ccmZ73WZr7ucw==" }, "nanoFramework.System.Text": { "type": "Direct", diff --git a/tests/SkillsTests/SkillRegistryTests.cs b/tests/SkillsTests/SkillRegistryTests.cs index deb5554..009c462 100644 --- a/tests/SkillsTests/SkillRegistryTests.cs +++ b/tests/SkillsTests/SkillRegistryTests.cs @@ -99,14 +99,23 @@ public static string ProcessNested(NestedSkillParam param) [TestClass] public class SkillRegistryTests { + [Setup] + public void Setup() + { + SkillRegistry.Reset(); + SkillRegistry.DiscoverSkills(new Type[] + { + typeof(TestClimateSkill), + typeof(TestLightingSkill), + typeof(TestNestedSkill), + typeof(NotASkillClass) + }); + } + [TestMethod] public void DiscoverSkills_FindsDecoratedClasses() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill), typeof(TestLightingSkill), typeof(NotASkillClass) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsArrayJson(); // Assert @@ -120,11 +129,7 @@ public void DiscoverSkills_FindsDecoratedClasses() [TestMethod] public void DiscoverSkills_CollectsTags() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsArrayJson(); // Assert @@ -137,11 +142,7 @@ public void DiscoverSkills_CollectsTags() [TestMethod] public void DiscoverSkills_CollectsExamples() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsArrayJson(); // Assert @@ -153,11 +154,7 @@ public void DiscoverSkills_CollectsExamples() [TestMethod] public void DiscoverSkills_FindsActions() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsArrayJson(); // Assert @@ -171,11 +168,7 @@ public void DiscoverSkills_FindsActions() [TestMethod] public void DiscoverSkills_SetsInputOutputModes() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsArrayJson(); // Assert @@ -189,11 +182,7 @@ public void DiscoverSkills_SetsInputOutputModes() [TestMethod] public void DiscoverSkills_MarkdownActionHasContentType() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsArrayJson(); // Assert @@ -203,11 +192,7 @@ public void DiscoverSkills_MarkdownActionHasContentType() [TestMethod] public void DiscoverSkills_VersionIncluded() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill), typeof(TestLightingSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsArrayJson(); // Assert @@ -218,11 +203,7 @@ public void DiscoverSkills_VersionIncluded() [TestMethod] public void GetSkillsArrayJson_ValidJsonStructure() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsArrayJson(); // Assert @@ -236,11 +217,7 @@ public void GetSkillsArrayJson_ValidJsonStructure() [TestMethod] public void GetSkillJson_BySkillId_ReturnsCorrectSkill() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill), typeof(TestLightingSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillJson("climate-control"); // Assert @@ -252,11 +229,7 @@ public void GetSkillJson_BySkillId_ReturnsCorrectSkill() [TestMethod] public void GetSkillJson_NonExistentId_ReturnsNull() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillJson("nonexistent"); // Assert @@ -266,11 +239,7 @@ public void GetSkillJson_NonExistentId_ReturnsNull() [TestMethod] public void GetSkillsByTagJson_MatchingTag_ReturnsFilteredSkills() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill), typeof(TestLightingSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsByTagJson("sensor"); // Assert @@ -282,11 +251,7 @@ public void GetSkillsByTagJson_MatchingTag_ReturnsFilteredSkills() [TestMethod] public void GetSkillsByTagJson_UniqueTag_ReturnsOneSkill() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill), typeof(TestLightingSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsByTagJson("hvac"); // Assert @@ -297,11 +262,7 @@ public void GetSkillsByTagJson_UniqueTag_ReturnsOneSkill() [TestMethod] public void GetSkillsByTagJson_NonExistentTag_ReturnsEmptyArray() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsByTagJson("nonexistent"); // Assert @@ -311,11 +272,7 @@ public void GetSkillsByTagJson_NonExistentTag_ReturnsEmptyArray() [TestMethod] public void GetActionContentType_JsonAction_ReturnsApplicationJson() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string contentType = SkillRegistry.GetActionContentType("climate-control", "GetTemperature"); // Assert @@ -325,11 +282,7 @@ public void GetActionContentType_JsonAction_ReturnsApplicationJson() [TestMethod] public void GetActionContentType_MarkdownAction_ReturnsTextMarkdown() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string contentType = SkillRegistry.GetActionContentType("climate-control", "GetDocumentation"); // Assert @@ -339,11 +292,7 @@ public void GetActionContentType_MarkdownAction_ReturnsTextMarkdown() [TestMethod] public void GetActionContentType_NonExistentSkill_ReturnsNull() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string contentType = SkillRegistry.GetActionContentType("nonexistent", "GetTemperature"); // Assert @@ -353,11 +302,7 @@ public void GetActionContentType_NonExistentSkill_ReturnsNull() [TestMethod] public void GetActionContentType_NonExistentAction_ReturnsNull() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string contentType = SkillRegistry.GetActionContentType("climate-control", "NonExistent"); // Assert @@ -367,10 +312,6 @@ public void GetActionContentType_NonExistentAction_ReturnsNull() [TestMethod] public void InvokeAction_NoParameters_ReturnsResult() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - SkillRegistry.DiscoverSkills(skillTypes); - // Act string result = SkillRegistry.InvokeAction("climate-control", "GetTemperature", null); @@ -383,9 +324,6 @@ public void InvokeAction_NoParameters_ReturnsResult() public void InvokeAction_ComplexParameter_ReturnsResult() { // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - SkillRegistry.DiscoverSkills(skillTypes); - Hashtable arguments = new Hashtable(); arguments.Add("Temperature", "22.0"); @@ -401,9 +339,6 @@ public void InvokeAction_ComplexParameter_ReturnsResult() public void InvokeAction_PrimitiveParameter_ReturnsResult() { // Arrange - Type[] skillTypes = new Type[] { typeof(TestLightingSkill) }; - SkillRegistry.DiscoverSkills(skillTypes); - Hashtable arguments = new Hashtable(); arguments.Add("value", "80"); @@ -418,10 +353,6 @@ public void InvokeAction_PrimitiveParameter_ReturnsResult() [TestMethod] public void InvokeAction_MarkdownAction_ReturnsRawMarkdown() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - SkillRegistry.DiscoverSkills(skillTypes); - // Act string result = SkillRegistry.InvokeAction("climate-control", "GetDocumentation", null); @@ -436,12 +367,11 @@ public void InvokeAction_MarkdownAction_ReturnsRawMarkdown() public void InvokeAction_NestedParameter_ReturnsResult() { // Arrange - Type[] skillTypes = new Type[] { typeof(TestNestedSkill) }; - SkillRegistry.DiscoverSkills(skillTypes); - + Hashtable config = new Hashtable(); + config.Add("Temperature", "42"); Hashtable arguments = new Hashtable(); arguments.Add("Label", "Test"); - arguments.Add("Config", "{\"Temperature\":\"25.5\"}"); + arguments.Add("Config", config); // Act string result = SkillRegistry.InvokeAction("nested-skill", "ProcessNested", arguments); @@ -449,26 +379,21 @@ public void InvokeAction_NestedParameter_ReturnsResult() // Assert Assert.IsNotNull(result, "Result should not be null"); Assert.IsTrue(result.Contains("Test"), "Should contain label"); - Assert.IsTrue(result.Contains("25.5"), "Should contain nested temperature"); + Assert.IsTrue(result.Contains("42"), "Should contain nested temperature"); } [TestMethod] public void InvokeAction_NonExistentSkill_ThrowsException() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - SkillRegistry.DiscoverSkills(skillTypes); - // Act & Assert bool exceptionThrown = false; try { SkillRegistry.InvokeAction("nonexistent", "GetTemperature", null); } - catch (Exception ex) + catch (Exception) { exceptionThrown = true; - Assert.AreEqual("Skill not found", ex.Message, "Should throw Skill not found"); } Assert.IsTrue(exceptionThrown, "Exception should have been thrown"); @@ -477,20 +402,15 @@ public void InvokeAction_NonExistentSkill_ThrowsException() [TestMethod] public void InvokeAction_NonExistentAction_ThrowsException() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - SkillRegistry.DiscoverSkills(skillTypes); - // Act & Assert bool exceptionThrown = false; try { SkillRegistry.InvokeAction("climate-control", "NonExistent", null); } - catch (Exception ex) + catch (Exception) { exceptionThrown = true; - Assert.AreEqual("Action not found", ex.Message, "Should throw Action not found"); } Assert.IsTrue(exceptionThrown, "Exception should have been thrown"); @@ -499,29 +419,35 @@ public void InvokeAction_NonExistentAction_ThrowsException() [TestMethod] public void DiscoverSkills_Idempotent_SecondCallIgnored() { - // Arrange - Type[] skillTypes1 = new Type[] { typeof(TestClimateSkill) }; - Type[] skillTypes2 = new Type[] { typeof(TestLightingSkill) }; - - // Act - SkillRegistry.DiscoverSkills(skillTypes1); + // Arrange — reset and register only climate skill + SkillRegistry.Reset(); + SkillRegistry.DiscoverSkills(new Type[] { typeof(TestClimateSkill) }); string firstCall = SkillRegistry.GetSkillsArrayJson(); - SkillRegistry.DiscoverSkills(skillTypes2); // Should be ignored + // Act — second call with different types should be ignored + SkillRegistry.DiscoverSkills(new Type[] { typeof(TestLightingSkill) }); string secondCall = SkillRegistry.GetSkillsArrayJson(); // Assert Assert.AreEqual(firstCall, secondCall, "Second call should not change results"); + Assert.IsTrue(firstCall.Contains("\"climate-control\""), "Should contain first-registered skill"); + Assert.IsFalse(secondCall.Contains("\"lighting\""), "Should not contain second-call skill"); + + // Restore state for subsequent tests since [Setup] only runs once + SkillRegistry.Reset(); + SkillRegistry.DiscoverSkills(new Type[] + { + typeof(TestClimateSkill), + typeof(TestLightingSkill), + typeof(TestNestedSkill), + typeof(NotASkillClass) + }); } [TestMethod] public void DiscoverSkills_ComplexActionInputSchema_Generated() { - // Arrange - Type[] skillTypes = new Type[] { typeof(TestClimateSkill) }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsArrayJson(); // Assert @@ -532,17 +458,7 @@ public void DiscoverSkills_ComplexActionInputSchema_Generated() [TestMethod] public void DiscoverSkills_MultipleSkills_AllRegistered() { - // Arrange - Type[] skillTypes = new Type[] - { - typeof(TestClimateSkill), - typeof(TestLightingSkill), - typeof(TestNestedSkill), - typeof(NotASkillClass) - }; - // Act - SkillRegistry.DiscoverSkills(skillTypes); string json = SkillRegistry.GetSkillsArrayJson(); // Assert diff --git a/tests/SkillsTests/SkillsTests.nfproj b/tests/SkillsTests/SkillsTests.nfproj index c813bc0..189b7f8 100644 --- a/tests/SkillsTests/SkillsTests.nfproj +++ b/tests/SkillsTests/SkillsTests.nfproj @@ -56,11 +56,11 @@ ..\..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll - - ..\..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + ..\..\packages\nanoFramework.System.Net.1.11.50\lib\System.Net.dll - - ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.203\lib\System.Net.Http.dll ..\..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll diff --git a/tests/SkillsTests/packages.config b/tests/SkillsTests/packages.config index 312c36d..c97a51b 100644 --- a/tests/SkillsTests/packages.config +++ b/tests/SkillsTests/packages.config @@ -5,8 +5,8 @@ - - + + diff --git a/tests/WebServerE2ETests/WebServerE2ETests.nfproj b/tests/WebServerE2ETests/WebServerE2ETests.nfproj index d486308..4a177ec 100644 --- a/tests/WebServerE2ETests/WebServerE2ETests.nfproj +++ b/tests/WebServerE2ETests/WebServerE2ETests.nfproj @@ -51,11 +51,11 @@ ..\..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll - - ..\..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + ..\..\packages\nanoFramework.System.Net.1.11.50\lib\System.Net.dll - - ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.203\lib\System.Net.Http.dll ..\..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll diff --git a/tests/WebServerE2ETests/packages.config b/tests/WebServerE2ETests/packages.config index 2714233..cec5f30 100644 --- a/tests/WebServerE2ETests/packages.config +++ b/tests/WebServerE2ETests/packages.config @@ -6,8 +6,8 @@ - - + + \ No newline at end of file diff --git a/tests/WebServerE2ETests/packages.lock.json b/tests/WebServerE2ETests/packages.lock.json index a93cd86..68448bf 100644 --- a/tests/WebServerE2ETests/packages.lock.json +++ b/tests/WebServerE2ETests/packages.lock.json @@ -40,15 +40,15 @@ }, "nanoFramework.System.Net": { "type": "Direct", - "requested": "[1.11.47, 1.11.47]", - "resolved": "1.11.47", - "contentHash": "fPjTPfaqDiirh9AysBLlEX5/KbaOliSxyspwpBTcM5OupCNjVXz0aZ9fOWex+BMjZei6GpG74kP8Z4ml912pcw==" + "requested": "[1.11.50, 1.11.50]", + "resolved": "1.11.50", + "contentHash": "5tCFNg+yXGAKOGP4cxFLdr+xM4r/za25I9zjom6ZiKGT5i5K+N2zCCr8TA8UmnQRl7xvBRUZ2vSxFF47OMqXwg==" }, "nanoFramework.System.Net.Http.Server": { "type": "Direct", - "requested": "[1.5.200, 1.5.200]", - "resolved": "1.5.200", - "contentHash": "no7P7xTkZ7IVSPKeI4YMIBG11YdsHH76hjC4XRaZsohPdsUcLOZbsoonLaQ8VW9l89QLrliYSREgN4C2l6I1mw==" + "requested": "[1.5.203, 1.5.203]", + "resolved": "1.5.203", + "contentHash": "cEQQP7VM6CHdlRyxloA7Z9Pv9s8heFpwp+MpPn9Xk0Fn9/I2l4X4GV4CgDDnKCNSdbyXWREa9ccmZ73WZr7ucw==" }, "nanoFramework.System.Text": { "type": "Direct", diff --git a/tests/nanoFramework.WebServer.Tests/WebServerTests.cs b/tests/nanoFramework.WebServer.Tests/WebServerTests.cs index 0018056..bec3918 100644 --- a/tests/nanoFramework.WebServer.Tests/WebServerTests.cs +++ b/tests/nanoFramework.WebServer.Tests/WebServerTests.cs @@ -4,7 +4,6 @@ // using System; -using System.Net; using nanoFramework.TestFramework; namespace nanoFramework.WebServer.Tests @@ -219,30 +218,6 @@ public void WebServerConstructors() httpsServer.Dispose(); } - [TestMethod] - public void WebServerConstructor_WithIPAddress_HTTP() - { - // Test constructor with IP address for HTTP - var address = IPAddress.Parse("127.0.0.1"); - var server = new WebServer(8080, HttpProtocol.Http, address); - - Assert.AreEqual(8080, server.Port); - Assert.AreEqual(HttpProtocol.Http, server.Protocol); - server.Dispose(); - } - - [TestMethod] - public void WebServerConstructor_WithIPAddress_HTTPS() - { - // Test constructor with IP address for HTTPS - var address = IPAddress.Parse("192.168.1.100"); - var server = new WebServer(8443, HttpProtocol.Https, address); - - Assert.AreEqual(8443, server.Port); - Assert.AreEqual(HttpProtocol.Https, server.Protocol); - server.Dispose(); - } - [TestMethod] public void WebServerConstructor_WithoutIPAddress() { @@ -290,70 +265,34 @@ public void WebServerConstructor_WithEmptyControllers() server.Dispose(); } - [TestMethod] - public void WebServerConstructor_FullConstructor_HTTP() - { - // Test full constructor with IP address and controllers for HTTP - var address = IPAddress.Parse("10.0.0.1"); - var controllers = new Type[] { typeof(TestController) }; - var server = new WebServer(8080, HttpProtocol.Http, address, controllers); - - Assert.AreEqual(8080, server.Port); - Assert.AreEqual(HttpProtocol.Http, server.Protocol); - server.Dispose(); - } - - [TestMethod] - public void WebServerConstructor_FullConstructor_HTTPS() - { - // Test full constructor with IP address and controllers for HTTPS - var address = IPAddress.Parse("172.16.0.1"); - var controllers = new Type[] { typeof(TestController), typeof(AnotherTestController) }; - var server = new WebServer(8443, HttpProtocol.Https, address, controllers); - - Assert.AreEqual(8443, server.Port); - Assert.AreEqual(HttpProtocol.Https, server.Protocol); - server.Dispose(); - } - [TestMethod] public void WebServerConstructor_IsRunningProperty() { - // Test IsRunning property is false initially for all constructor variations + // Test IsRunning property is false initially + // Note: IPAddress-based constructors are not testable on nanoCLR (NotImplementedException) var server1 = new WebServer(8080, HttpProtocol.Http); - var server2 = new WebServer(8081, HttpProtocol.Http, IPAddress.Parse("127.0.0.1")); - var server3 = new WebServer(8082, HttpProtocol.Http, new Type[] { typeof(TestController) }); - var server4 = new WebServer(8083, HttpProtocol.Http, IPAddress.Parse("127.0.0.1"), new Type[] { typeof(TestController) }); + var server2 = new WebServer(8082, HttpProtocol.Http, new Type[] { typeof(TestController) }); Assert.IsFalse(server1.IsRunning); Assert.IsFalse(server2.IsRunning); - Assert.IsFalse(server3.IsRunning); - Assert.IsFalse(server4.IsRunning); server1.Dispose(); server2.Dispose(); - server3.Dispose(); - server4.Dispose(); } [TestMethod] public void WebServerConstructor_DifferentPortNumbers() { // Test various port numbers across different constructor overloads + // Note: IPAddress-based constructors are not testable on nanoCLR (NotImplementedException) var server1 = new WebServer(80, HttpProtocol.Http); - var server2 = new WebServer(443, HttpProtocol.Https, IPAddress.Parse("127.0.0.1")); - var server3 = new WebServer(3000, HttpProtocol.Http, new Type[] { typeof(TestController) }); - var server4 = new WebServer(5000, HttpProtocol.Https, IPAddress.Parse("127.0.0.1"), new Type[] { typeof(TestController) }); + var server2 = new WebServer(3000, HttpProtocol.Http, new Type[] { typeof(TestController) }); Assert.AreEqual(80, server1.Port); - Assert.AreEqual(443, server2.Port); - Assert.AreEqual(3000, server3.Port); - Assert.AreEqual(5000, server4.Port); + Assert.AreEqual(3000, server2.Port); server1.Dispose(); server2.Dispose(); - server3.Dispose(); - server4.Dispose(); } } diff --git a/tests/nanoFramework.WebServer.Tests/nanoFramework.WebServer.Tests.nfproj b/tests/nanoFramework.WebServer.Tests/nanoFramework.WebServer.Tests.nfproj index 9293034..83da489 100644 --- a/tests/nanoFramework.WebServer.Tests/nanoFramework.WebServer.Tests.nfproj +++ b/tests/nanoFramework.WebServer.Tests/nanoFramework.WebServer.Tests.nfproj @@ -58,11 +58,11 @@ ..\..\packages\nanoFramework.System.IO.Streams.1.1.96\lib\System.IO.Streams.dll - - ..\..\packages\nanoFramework.System.Net.1.11.47\lib\System.Net.dll + + ..\..\packages\nanoFramework.System.Net.1.11.50\lib\System.Net.dll - - ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.200\lib\System.Net.Http.dll + + ..\..\packages\nanoFramework.System.Net.Http.Server.1.5.203\lib\System.Net.Http.dll ..\..\packages\nanoFramework.System.Threading.1.1.52\lib\System.Threading.dll diff --git a/tests/nanoFramework.WebServer.Tests/packages.config b/tests/nanoFramework.WebServer.Tests/packages.config index 2d23f5d..3958529 100644 --- a/tests/nanoFramework.WebServer.Tests/packages.config +++ b/tests/nanoFramework.WebServer.Tests/packages.config @@ -5,8 +5,8 @@ - - + + diff --git a/tests/nanoFramework.WebServer.Tests/packages.lock.json b/tests/nanoFramework.WebServer.Tests/packages.lock.json index 98523ff..8060871 100644 --- a/tests/nanoFramework.WebServer.Tests/packages.lock.json +++ b/tests/nanoFramework.WebServer.Tests/packages.lock.json @@ -34,15 +34,15 @@ }, "nanoFramework.System.Net": { "type": "Direct", - "requested": "[1.11.47, 1.11.47]", - "resolved": "1.11.47", - "contentHash": "fPjTPfaqDiirh9AysBLlEX5/KbaOliSxyspwpBTcM5OupCNjVXz0aZ9fOWex+BMjZei6GpG74kP8Z4ml912pcw==" + "requested": "[1.11.50, 1.11.50]", + "resolved": "1.11.50", + "contentHash": "5tCFNg+yXGAKOGP4cxFLdr+xM4r/za25I9zjom6ZiKGT5i5K+N2zCCr8TA8UmnQRl7xvBRUZ2vSxFF47OMqXwg==" }, "nanoFramework.System.Net.Http.Server": { "type": "Direct", - "requested": "[1.5.200, 1.5.200]", - "resolved": "1.5.200", - "contentHash": "no7P7xTkZ7IVSPKeI4YMIBG11YdsHH76hjC4XRaZsohPdsUcLOZbsoonLaQ8VW9l89QLrliYSREgN4C2l6I1mw==" + "requested": "[1.5.203, 1.5.203]", + "resolved": "1.5.203", + "contentHash": "cEQQP7VM6CHdlRyxloA7Z9Pv9s8heFpwp+MpPn9Xk0Fn9/I2l4X4GV4CgDDnKCNSdbyXWREa9ccmZ73WZr7ucw==" }, "nanoFramework.System.Text": { "type": "Direct", From f893d96582981809a87dc37100942cf31ab9f3b3 Mon Sep 17 00:00:00 2001 From: Laurent Ellerbach Date: Fri, 24 Apr 2026 14:26:47 +0200 Subject: [PATCH 5/9] updating package json --- .../nanoFramework.WebServer.Skills.nfproj | 2 +- nanoFramework.WebServer.Skills/packages.config | 2 +- nanoFramework.WebServer.Skills/packages.lock.json | 6 +++--- tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj | 2 +- tests/SkillsEndToEndTest/packages.config | 2 +- tests/SkillsTests/SkillsTests.nfproj | 4 ++-- tests/SkillsTests/packages.config | 6 +++--- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj b/nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj index a9d1151..bff5687 100644 --- a/nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj +++ b/nanoFramework.WebServer.Skills/nanoFramework.WebServer.Skills.nfproj @@ -51,7 +51,7 @@ ..\packages\nanoFramework.CoreLibrary.1.17.11\lib\mscorlib.dll - ..\packages\nanoFramework.Json.2.2.203\lib\nanoFramework.Json.dll + ..\packages\nanoFramework.Json.2.2.210\lib\nanoFramework.Json.dll ..\packages\nanoFramework.Runtime.Events.1.11.32\lib\nanoFramework.Runtime.Events.dll diff --git a/nanoFramework.WebServer.Skills/packages.config b/nanoFramework.WebServer.Skills/packages.config index 1477bc5..939c68b 100644 --- a/nanoFramework.WebServer.Skills/packages.config +++ b/nanoFramework.WebServer.Skills/packages.config @@ -1,7 +1,7 @@  - + diff --git a/nanoFramework.WebServer.Skills/packages.lock.json b/nanoFramework.WebServer.Skills/packages.lock.json index 259d1b5..e155576 100644 --- a/nanoFramework.WebServer.Skills/packages.lock.json +++ b/nanoFramework.WebServer.Skills/packages.lock.json @@ -10,9 +10,9 @@ }, "nanoFramework.Json": { "type": "Direct", - "requested": "[2.2.203, 2.2.203]", - "resolved": "2.2.203", - "contentHash": "IsbevoAqPill+t5sU6uLWW8/UgpwfmEUWrZvzwxAOWMnj+wY/68oHcp8N/eJqcGYsUHAyLKKR/LCg4BSgDcZbw==" + "requested": "[2.2.210, 2.2.210]", + "resolved": "2.2.210", + "contentHash": "pu7+1ia/oZ2t7TN/30UV022w7YBEysdn8+ECIK1gSGLrW0YWO0TChD5dkR55EAuTCCqQTf+6qoZt28D8rvtDDw==" }, "nanoFramework.Runtime.Events": { "type": "Direct", diff --git a/tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj b/tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj index 1c87a52..be1a2f6 100644 --- a/tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj +++ b/tests/SkillsEndToEndTest/SkillsEndToEndTest.nfproj @@ -28,7 +28,7 @@ ..\..\packages\nanoFramework.CoreLibrary.1.17.11\lib\mscorlib.dll - ..\..\packages\nanoFramework.Json.2.2.203\lib\nanoFramework.Json.dll + ..\..\packages\nanoFramework.Json.2.2.210\lib\nanoFramework.Json.dll ..\..\packages\nanoFramework.Runtime.Events.1.11.32\lib\nanoFramework.Runtime.Events.dll diff --git a/tests/SkillsEndToEndTest/packages.config b/tests/SkillsEndToEndTest/packages.config index aedec28..e696119 100644 --- a/tests/SkillsEndToEndTest/packages.config +++ b/tests/SkillsEndToEndTest/packages.config @@ -1,7 +1,7 @@  - + diff --git a/tests/SkillsTests/SkillsTests.nfproj b/tests/SkillsTests/SkillsTests.nfproj index 189b7f8..3964dfe 100644 --- a/tests/SkillsTests/SkillsTests.nfproj +++ b/tests/SkillsTests/SkillsTests.nfproj @@ -36,7 +36,7 @@ ..\..\packages\nanoFramework.CoreLibrary.1.17.11\lib\mscorlib.dll - ..\..\packages\nanoFramework.Json.2.2.203\lib\nanoFramework.Json.dll + ..\..\packages\nanoFramework.Json.2.2.210\lib\nanoFramework.Json.dll ..\..\packages\nanoFramework.Runtime.Events.1.11.32\lib\nanoFramework.Runtime.Events.dll @@ -78,4 +78,4 @@ - + \ No newline at end of file diff --git a/tests/SkillsTests/packages.config b/tests/SkillsTests/packages.config index c97a51b..e054dc4 100644 --- a/tests/SkillsTests/packages.config +++ b/tests/SkillsTests/packages.config @@ -1,7 +1,7 @@ - + - + @@ -10,4 +10,4 @@ - + \ No newline at end of file From ca5d1b0583081b5fd85c64dca83aca2664a6aa4c Mon Sep 17 00:00:00 2001 From: Laurent Ellerbach Date: Fri, 24 Apr 2026 14:35:05 +0200 Subject: [PATCH 6/9] fixing nuget version --- nanoFramework.WebServer.Skills.nuspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanoFramework.WebServer.Skills.nuspec b/nanoFramework.WebServer.Skills.nuspec index bd03e2f..700581a 100644 --- a/nanoFramework.WebServer.Skills.nuspec +++ b/nanoFramework.WebServer.Skills.nuspec @@ -24,7 +24,7 @@ This comes also with the nanoFramework WebServer. Allowing to create a REST API - + From d9e1dfd5ef8b6ec40a5ea3ae4bac4f622be1c9c4 Mon Sep 17 00:00:00 2001 From: Laurent Ellerbach Date: Fri, 24 Apr 2026 14:47:06 +0200 Subject: [PATCH 7/9] missing packages.lock.json --- tests/SkillsTests/packages.lock.json | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/SkillsTests/packages.lock.json diff --git a/tests/SkillsTests/packages.lock.json b/tests/SkillsTests/packages.lock.json new file mode 100644 index 0000000..24997be --- /dev/null +++ b/tests/SkillsTests/packages.lock.json @@ -0,0 +1,67 @@ +{ + "version": 1, + "dependencies": { + ".NETnanoFramework,Version=v1.0": { + "nanoFramework.CoreLibrary": { + "type": "Direct", + "requested": "[1.17.11, 1.17.11]", + "resolved": "1.17.11", + "contentHash": "HezzAc0o2XrSGf85xSeD/6xsO6ohF9hX6/iMQ1IZS6Zw6umr4WfAN2Jv0BrPxkaYwzEegJxxZujkHoUIAqtOMw==" + }, + "nanoFramework.Json": { + "type": "Direct", + "requested": "[2.2.210, 2.2.210]", + "resolved": "2.2.210", + "contentHash": "pu7+1ia/oZ2t7TN/30UV022w7YBEysdn8+ECIK1gSGLrW0YWO0TChD5dkR55EAuTCCqQTf+6qoZt28D8rvtDDw==" + }, + "nanoFramework.Runtime.Events": { + "type": "Direct", + "requested": "[1.11.32, 1.11.32]", + "resolved": "1.11.32", + "contentHash": "NyLUIwJDlpl5VKSd+ljmdDtO2WHHBvPvruo1ccaL+hd79z+6XMYze1AccOVXKGiZenLBCwDmFHwpgIQyHkM7GA==" + }, + "nanoFramework.System.Collections": { + "type": "Direct", + "requested": "[1.5.67, 1.5.67]", + "resolved": "1.5.67", + "contentHash": "MjSipUB70vrxjqTm1KfKTUqqjd0wbweiNyYFXONi0XClrH6HXsuX2lhDqXM8NWuYnWyYOqx8y20sXbvsH+4brg==" + }, + "nanoFramework.System.IO.Streams": { + "type": "Direct", + "requested": "[1.1.96, 1.1.96]", + "resolved": "1.1.96", + "contentHash": "kJSy4EJwChO4Vq3vGWP9gNRPFDnTsDU5HxzeI7NDO+RjbDsx7B8EhKymoeTPLJCxQq8y/0P1KG2XCxGpggW+fw==" + }, + "nanoFramework.System.Net": { + "type": "Direct", + "requested": "[1.11.50, 1.11.50]", + "resolved": "1.11.50", + "contentHash": "5tCFNg+yXGAKOGP4cxFLdr+xM4r/za25I9zjom6ZiKGT5i5K+N2zCCr8TA8UmnQRl7xvBRUZ2vSxFF47OMqXwg==" + }, + "nanoFramework.System.Net.Http.Server": { + "type": "Direct", + "requested": "[1.5.203, 1.5.203]", + "resolved": "1.5.203", + "contentHash": "cEQQP7VM6CHdlRyxloA7Z9Pv9s8heFpwp+MpPn9Xk0Fn9/I2l4X4GV4CgDDnKCNSdbyXWREa9ccmZ73WZr7ucw==" + }, + "nanoFramework.System.Text": { + "type": "Direct", + "requested": "[1.3.42, 1.3.42]", + "resolved": "1.3.42", + "contentHash": "68HPjhersNpssbmEMUHdMw3073MHfGTfrkbRk9eILKbNPFfPFck7m4y9BlAi6DaguUJaeKxgyIojXF3SQrF8/A==" + }, + "nanoFramework.System.Threading": { + "type": "Direct", + "requested": "[1.1.52, 1.1.52]", + "resolved": "1.1.52", + "contentHash": "kv+US/+7QKV1iT/snxBh032vwZ+3krJ4vujlSsvmS2nNj/nK64R3bq/ST3bCFquxHDD0mog8irtCBCsFazr4kA==" + }, + "nanoFramework.TestFramework": { + "type": "Direct", + "requested": "[3.0.77, 3.0.77]", + "resolved": "3.0.77", + "contentHash": "Py5W1oN84KMBmOOHCzdz6pyi3bZTnQu9BoqIx0KGqkhG3V8kGoem/t+BuCM0pMIWAyl2iMP1n2S9624YXmBJZw==" + } + } + } +} \ No newline at end of file From 3b978deaf9db97f95bc638972acc76989bc4c73b Mon Sep 17 00:00:00 2001 From: Laurent Ellerbach Date: Fri, 24 Apr 2026 15:12:52 +0200 Subject: [PATCH 8/9] fix package lock --- tests/SkillsEndToEndTest/packages.lock.json | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/SkillsEndToEndTest/packages.lock.json diff --git a/tests/SkillsEndToEndTest/packages.lock.json b/tests/SkillsEndToEndTest/packages.lock.json new file mode 100644 index 0000000..9e0e3b1 --- /dev/null +++ b/tests/SkillsEndToEndTest/packages.lock.json @@ -0,0 +1,67 @@ +{ + "version": 1, + "dependencies": { + ".NETnanoFramework,Version=v1.0": { + "nanoFramework.CoreLibrary": { + "type": "Direct", + "requested": "[1.17.11, 1.17.11]", + "resolved": "1.17.11", + "contentHash": "HezzAc0o2XrSGf85xSeD/6xsO6ohF9hX6/iMQ1IZS6Zw6umr4WfAN2Jv0BrPxkaYwzEegJxxZujkHoUIAqtOMw==" + }, + "nanoFramework.Json": { + "type": "Direct", + "requested": "[2.2.210, 2.2.210]", + "resolved": "2.2.210", + "contentHash": "pu7+1ia/oZ2t7TN/30UV022w7YBEysdn8+ECIK1gSGLrW0YWO0TChD5dkR55EAuTCCqQTf+6qoZt28D8rvtDDw==" + }, + "nanoFramework.Runtime.Events": { + "type": "Direct", + "requested": "[1.11.32, 1.11.32]", + "resolved": "1.11.32", + "contentHash": "NyLUIwJDlpl5VKSd+ljmdDtO2WHHBvPvruo1ccaL+hd79z+6XMYze1AccOVXKGiZenLBCwDmFHwpgIQyHkM7GA==" + }, + "nanoFramework.System.Collections": { + "type": "Direct", + "requested": "[1.5.67, 1.5.67]", + "resolved": "1.5.67", + "contentHash": "MjSipUB70vrxjqTm1KfKTUqqjd0wbweiNyYFXONi0XClrH6HXsuX2lhDqXM8NWuYnWyYOqx8y20sXbvsH+4brg==" + }, + "nanoFramework.System.Device.Wifi": { + "type": "Direct", + "requested": "[1.5.141, 1.5.141]", + "resolved": "1.5.141", + "contentHash": "D9cDgHmsWc3Ick+P69OBKgzDMg0UT4n0kUx/4vkESpbnUCfW1Ed0uJ4aFZAtf4MQzFGKO8dolzHEmFachHMzwg==" + }, + "nanoFramework.System.IO.Streams": { + "type": "Direct", + "requested": "[1.1.96, 1.1.96]", + "resolved": "1.1.96", + "contentHash": "kJSy4EJwChO4Vq3vGWP9gNRPFDnTsDU5HxzeI7NDO+RjbDsx7B8EhKymoeTPLJCxQq8y/0P1KG2XCxGpggW+fw==" + }, + "nanoFramework.System.Net": { + "type": "Direct", + "requested": "[1.11.50, 1.11.50]", + "resolved": "1.11.50", + "contentHash": "5tCFNg+yXGAKOGP4cxFLdr+xM4r/za25I9zjom6ZiKGT5i5K+N2zCCr8TA8UmnQRl7xvBRUZ2vSxFF47OMqXwg==" + }, + "nanoFramework.System.Net.Http.Server": { + "type": "Direct", + "requested": "[1.5.203, 1.5.203]", + "resolved": "1.5.203", + "contentHash": "cEQQP7VM6CHdlRyxloA7Z9Pv9s8heFpwp+MpPn9Xk0Fn9/I2l4X4GV4CgDDnKCNSdbyXWREa9ccmZ73WZr7ucw==" + }, + "nanoFramework.System.Text": { + "type": "Direct", + "requested": "[1.3.42, 1.3.42]", + "resolved": "1.3.42", + "contentHash": "68HPjhersNpssbmEMUHdMw3073MHfGTfrkbRk9eILKbNPFfPFck7m4y9BlAi6DaguUJaeKxgyIojXF3SQrF8/A==" + }, + "nanoFramework.System.Threading": { + "type": "Direct", + "requested": "[1.1.52, 1.1.52]", + "resolved": "1.1.52", + "contentHash": "kv+US/+7QKV1iT/snxBh032vwZ+3krJ4vujlSsvmS2nNj/nK64R3bq/ST3bCFquxHDD0mog8irtCBCsFazr4kA==" + } + } + } +} From e49bddac2a86d45b99db5510fb3d4924eb889965 Mon Sep 17 00:00:00 2001 From: Laurent Ellerbach Date: Fri, 24 Apr 2026 15:39:30 +0200 Subject: [PATCH 9/9] fix packages --- tests/SkillsEndToEndTest/packages.lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SkillsEndToEndTest/packages.lock.json b/tests/SkillsEndToEndTest/packages.lock.json index 9e0e3b1..b85d5ed 100644 --- a/tests/SkillsEndToEndTest/packages.lock.json +++ b/tests/SkillsEndToEndTest/packages.lock.json @@ -64,4 +64,4 @@ } } } -} +} \ No newline at end of file