Custom Tools

Implement and register app-specific Ansight tools with ITool, schemas, guard scopes, and security metadata.

Custom tools let a paired agent call app-specific operations that the built-in suites cannot know about. Use them for narrow diagnostic and development workflows such as exposing a feature-flag snapshot, clearing a local test queue, replaying a known fixture, or reading a domain-specific health report.

Custom tools use the same remote-tool pipeline as the packaged suites:

  • implement Ansight.Tools.ITool
  • register the tool with OptionsBuilder.AddTool(...) or AddTools(...)
  • choose a guard with WithReadOnlyToolAccess(), WithReadWriteToolAccess(), WithAllToolAccess(), or WithToolGuard(...)
  • keep the implementation and build-time remote-tool opt-in limited to local development builds

For more than one tool, keep the code organized like a first-party suite:

  • MyAppToolIds: stable tool ids such as myapp.diagnostics_snapshot
  • MyAppToolSchemas: shared ToolSchema objects for arguments and results
  • MyAppToolSecurityProfiles: shared ToolSecurity declarations
  • one small ITool implementation per operation
  • MyAppAnsightOptionsBuilderExtensions: a WithMyAppTools(...) registration method
  • optional runtime wrappers for user confirmation or product-specific access checks

This keeps metadata consistent and makes it harder to accidentally ship a broad, hidden command surface.

Build-Time Opt-In

The Ansight build target scans for concrete ITool implementations in your app output, including custom tools defined inside your app assembly.

Use a project-local flag to include custom tools only in local development builds:

<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
  <MyAppAnsightRemoteToolsEnabled>true</MyAppAnsightRemoteToolsEnabled>
</PropertyGroup>

<PropertyGroup>
  <MyAppAnsightRemoteToolsEnabled Condition="'$(MyAppAnsightRemoteToolsEnabled)' == ''">false</MyAppAnsightRemoteToolsEnabled>
  <AnsightRemoteToolsPolicy Condition="'$(AnsightRemoteToolsPolicy)' == '' and '$(MyAppAnsightRemoteToolsEnabled)' == 'true'">AllowedWithWarnings</AnsightRemoteToolsPolicy>
  <AnsightRemoteToolsPolicy Condition="'$(AnsightRemoteToolsPolicy)' == ''">Disallowed</AnsightRemoteToolsPolicy>
</PropertyGroup>

<PropertyGroup Condition="'$(MyAppAnsightRemoteToolsEnabled)' == 'true'">
  <DefineConstants>$(DefineConstants);MYAPP_ANSIGHT_TOOLS</DefineConstants>
</PropertyGroup>

Then wrap the app-specific tool files in the same symbol:

#if MYAPP_ANSIGHT_TOOLS
// Custom Ansight tools live here.
#endif

Protected Release, CI, TestFlight, App Store, Play Store, and other distributable builds should omit custom tool implementations or use AnsightRemoteToolsPolicy=Disallowed so the build fails if any concrete ITool remains.

Shared Metadata

Centralize ids, schemas, and security profiles before writing individual tools.

#if MYAPP_ANSIGHT_TOOLS
using Ansight.Tools;

internal static class MyAppToolIds
{
    public const string DiagnosticsSnapshot = "myapp.diagnostics_snapshot";
}

internal static class MyAppToolSchemas
{
    public static ToolSchema DiagnosticsSnapshotArguments { get; } = ToolSchema.Object(
        description: "Arguments for reading a small app diagnostic summary.",
        properties: new Dictionary<string, ToolSchema>
        {
            ["includeQueues"] = ToolSchema.Boolean("Include queue counters in the result.")
        });

    public static ToolSchema DiagnosticsSnapshotResult { get; } = ToolSchema.Object(
        description: "App diagnostic summary payload.",
        properties: new Dictionary<string, ToolSchema>
        {
            ["connectionState"] = ToolSchema.String("Current backend connection state."),
            ["pendingQueueCount"] = ToolSchema.Integer("Pending queue count when includeQueues is true.")
        },
        required: new[] { "connectionState" });
}

internal static class MyAppToolSecurityProfiles
{
    public static ToolSecurity DiagnosticsSnapshot { get; } = new(
        ToolSecurityLevel.Low,
        "Reads app-owned diagnostic state.",
        ToolSecurityImplications.ReadsAppData,
        ToolSecurityImplications.InspectsRuntimeState);
}
#endif

Implement a Tool

Each tool declares catalog metadata, a guard scope, argument and result schemas, security metadata, and an execution method. ITool.Definition is supplied by the interface from those properties, so most tools do not need to implement it manually.

#if MYAPP_ANSIGHT_TOOLS
using System.Text.Json.Nodes;
using Ansight.Tools;

internal sealed record DiagnosticsSnapshot(
    string ConnectionState,
    int PendingQueueCount);

internal sealed class DiagnosticsSnapshotTool(
    Func<DiagnosticsSnapshot> snapshotProvider) : ITool
{
    public string Category => "myapp";

    public ToolScope Scope => ToolScope.Read;

    public string Id => MyAppToolIds.DiagnosticsSnapshot;

    public string Name => "Diagnostics Snapshot";

    public string Description => "Returns a small app-specific diagnostic summary.";

    public string Keywords => "diagnostics health queues";

    public ToolSchema ArgumentsSchema => MyAppToolSchemas.DiagnosticsSnapshotArguments;

    public ToolSchema ResultSchema => MyAppToolSchemas.DiagnosticsSnapshotResult;

    public ToolSecurity Security => MyAppToolSecurityProfiles.DiagnosticsSnapshot;

    public Task<ToolResult> Execute(IReadOnlyDictionary<string, string> arguments)
    {
        ArgumentNullException.ThrowIfNull(arguments);

        var includeQueues = arguments.TryGetValue("includeQueues", out var includeQueuesText)
                            && bool.TryParse(includeQueuesText, out var parsedIncludeQueues)
                            && parsedIncludeQueues;

        var snapshot = snapshotProvider();
        var payload = new JsonObject
        {
            ["connectionState"] = snapshot.ConnectionState
        };

        if (includeQueues)
        {
            payload["pendingQueueCount"] = snapshot.PendingQueueCount;
        }

        return Task.FromResult(ToolResult.Success(payload));
    }
}
#endif

Arguments arrive as flattened string values. Parse and validate them inside Execute(...), and return ToolResult.Failure(...) with a stable errorCode when the request is invalid or the app cannot perform the operation.

For MAUI tools that touch UI state, marshal onto the main thread inside the tool or a shared helper before reading or mutating controls.

Register the Tools

Expose one extension method that registers your app tools as a group:

#if MYAPP_ANSIGHT_TOOLS
using Ansight;
using Ansight.Tools;

internal static class MyAppAnsightOptionsBuilderExtensions
{
    public static Options.OptionsBuilder WithMyAppTools(
        this Options.OptionsBuilder builder,
        Func<DiagnosticsSnapshot> snapshotProvider)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(snapshotProvider);

        return builder.AddTools(new ITool[]
        {
            new DiagnosticsSnapshotTool(snapshotProvider)
        });
    }
}
#endif

For a core-only setup:

var optionsBuilder = Options.CreateBuilder();

#if MYAPP_ANSIGHT_TOOLS
optionsBuilder = optionsBuilder.WithMyAppTools(diagnostics.CreateSnapshot);
#endif

var options = optionsBuilder
    .WithReadOnlyToolAccess()
    .Build();

For the all-in-one package:

var options = Options.CreateBuilder()
    .WithAnsightSdk(ansight =>
    {
#if MYAPP_ANSIGHT_TOOLS
        ansight.WithMyAppTools(diagnostics.CreateSnapshot);
#endif
        ansight.WithReadOnlyToolAccess();
    })
    .Build();

For MAUI:

builder.UseAnsight<App>(ansight =>
{
#if MYAPP_ANSIGHT_TOOLS
    ansight.WithMyAppTools(diagnostics.CreateSnapshot);
#endif
    ansight.WithReadOnlyToolAccess();
});

Registered tools stay hidden and unusable until the guard allows their ToolScope.

Runtime Confirmation

For sensitive tools, add a runtime consent or policy wrapper in addition to build-time and guard controls:

internal sealed class UserConfirmedTool(
    ITool innerTool,
    Func<string, Task<bool>> confirmAsync) : ITool
{
    public string Category => innerTool.Category;
    public ToolScope Scope => innerTool.Scope;
    public string Id => innerTool.Id;
    public string Name => innerTool.Name;
    public string Description => innerTool.Description;
    public string Keywords => innerTool.Keywords;
    public ToolSchema ArgumentsSchema => innerTool.ArgumentsSchema;
    public ToolSchema ResultSchema => innerTool.ResultSchema;
    public ToolSecurity Security => innerTool.Security;

    public async Task<ToolResult> Execute(IReadOnlyDictionary<string, string> arguments)
    {
        if (!await confirmAsync(Name))
        {
            return ToolResult.Failure(
                "Remote tool access was not allowed by the user.",
                errorCode: "remote_tools_not_allowed");
        }

        return await innerTool.Execute(arguments);
    }
}

To wrap every registered tool, build once, replace the tool collection, then choose the guard:

var configured = optionsBuilder.Build();
var confirmedTools = configured.Tools.Select(tool =>
    new UserConfirmedTool(tool, ConfirmRemoteToolAsync));

var options = Options.CreateBuilder(configured)
    .WithTools(confirmedTools)
    .WithReadOnlyToolAccess()
    .Build();

Runtime confirmation is useful for internal dogfood or staff builds, but it is not a reason to ship remote tools in public distribution builds.

Scopes and Guards

Choose the narrowest scope that matches the operation:

ScopeUse forGuard that enables it
ToolScope.ReadInspecting app state without mutation.WithReadOnlyToolAccess()
ToolScope.WriteCreating, updating, replaying, resetting, or otherwise mutating app-owned state.WithReadWriteToolAccess()
ToolScope.DeleteRemoving app-owned data or destructive cleanup.WithAllToolAccess()

A custom ToolGuard can be used when a workflow needs a narrower policy than the built-in presets.

Schemas

Use ToolSchema so Ansight Studio and MCP clients can show useful argument templates:

  • ToolSchema.Object(...)
  • ToolSchema.Array(...)
  • ToolSchema.String(...)
  • ToolSchema.Integer(...)
  • ToolSchema.Number(...)
  • ToolSchema.Boolean(...)

Keep schemas small and explicit. Prefer stable argument names, mark required fields on object schemas, and avoid accepting arbitrary JSON unless the tool genuinely needs it.

Security Metadata

Declare Security for every custom tool. Choose the highest level that reflects the real risk, then list relevant ToolSecurityImplications.

Common implications include:

  • ToolSecurityImplications.ReadsAppData
  • ToolSecurityImplications.WritesAppData
  • ToolSecurityImplications.DeletesAppData
  • ToolSecurityImplications.AccessesFileSystem
  • ToolSecurityImplications.AccessesDatabases
  • ToolSecurityImplications.AccessesPreferences
  • ToolSecurityImplications.AccessesSecureStorage
  • ToolSecurityImplications.HandlesSecrets
  • ToolSecurityImplications.InspectsRuntimeState
  • ToolSecurityImplications.MutatesRuntimeState
  • ToolSecurityImplications.InvokesAppCode
  • ToolSecurityImplications.CapturesScreenshots

Do not expose secrets, account data, production endpoints, destructive app actions, or broad command dispatchers through custom tools. Build the smallest operation that satisfies the local debugging workflow.

Calling From Ansight Studio or MCP

After the app is paired and connected, Ansight Studio can query the live app tool catalog. MCP clients can use:

  • ansight_list_app_tools to see visible custom and packaged app tools
  • ansight_call_app_tool with the custom toolId and JSON arguments to invoke a visible tool

Example MCP call payload:

{
  "toolId": "myapp.diagnostics_snapshot",
  "arguments": {
    "includeQueues": true
  }
}

If the tool does not appear in the catalog, check that the app is connected, the tool is registered, the custom tool code is included only in a local development build, and the active guard allows the tool scope.