Portal Community

Step 1 — Create the Project

Custom plugins are class libraries that reference the Octopus Core package. Keep each plugin in its own project to maintain clean dependency boundaries.

# Create the class library
dotnet new classlib -n BizFirst.Octopus.HRPlugin
cd BizFirst.Octopus.HRPlugin

# Reference Octopus Core
dotnet add package BizFirst.Octopus.Core

# Add other dependencies your plugin needs
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.Extensions.Http

Recommended project layout:

BizFirst.Octopus.HRPlugin/
├── HRPlugin.cs                    ← IOctopusPlugin implementation
├── Config/
│   └── HRPluginConfig.cs          ← Typed options class
├── Services/
│   ├── ILeaveService.cs
│   ├── LeaveService.cs
│   ├── IEmployeeRepository.cs
│   └── EmployeeRepository.cs
├── Tools/
│   ├── LeaveToolDefinitions.cs    ← MCP tool JSON schemas
│   └── LeaveToolHandlers.cs       ← Handler methods
└── Http/
    ├── IHRSystemClient.cs
    └── HRSystemClient.cs

Step 2 — Define the Config

namespace BizFirst.Octopus.HRPlugin.Config;

public class HRPluginConfig
{
    public string BaseUrl            { get; set; } = string.Empty;
    public int    ApiKeyCredentialId { get; set; }
    public int    TimeoutSeconds     { get; set; } = 30;
    public int    MaxRetries         { get; set; } = 3;
}

Step 3 — Define the Services

// ILeaveService.cs
public interface ILeaveService
{
    Task<LeaveBalance>    GetBalanceAsync(string employeeId, CancellationToken ct);
    Task<LeaveRequest>    SubmitRequestAsync(LeaveRequest request, CancellationToken ct);
    Task<List<LeaveRequest>> GetPendingRequestsAsync(string managerId, CancellationToken ct);
}

// LeaveService.cs — depends on the HTTP client and ITenantContext
public class LeaveService : ILeaveService
{
    private readonly IHRSystemClient  _client;
    private readonly ITenantContext   _tenant;

    public LeaveService(IHRSystemClient client, ITenantContext tenant)
    {
        _client = client;
        _tenant = tenant;
    }

    public async Task<LeaveBalance> GetBalanceAsync(string employeeId, CancellationToken ct)
    {
        // Always scope calls by tenant ID for multi-tenant isolation
        return await _client.GetLeaveBalanceAsync(_tenant.TenantId, employeeId, ct);
    }

    public async Task<LeaveRequest> SubmitRequestAsync(LeaveRequest request, CancellationToken ct)
    {
        request.TenantId = _tenant.TenantId;
        return await _client.SubmitLeaveRequestAsync(request, ct);
    }

    public async Task<List<LeaveRequest>> GetPendingRequestsAsync(
        string managerId, CancellationToken ct)
    {
        return await _client.GetPendingApprovalsAsync(_tenant.TenantId, managerId, ct);
    }
}

Step 4 — Define the MCP Tools

// LeaveToolDefinitions.cs — tool JSON schemas as static properties
public static class LeaveToolDefinitions
{
    public static ToolDefinition GetBalance => new()
    {
        Name        = "get_leave_balance",
        Description = "Returns the leave balance for an employee. " +
                      "Call when the user asks how many leave days they have remaining.",
        InputSchema = """
        {
          "type": "object",
          "properties": {
            "employee_id": {
              "type": "string",
              "description": "The employee's ID number (e.g. EMP-1234)"
            }
          },
          "required": ["employee_id"]
        }
        """
    };

    public static ToolDefinition SubmitRequest => new()
    {
        Name        = "submit_leave_request",
        Description = "Submits a leave request on behalf of an employee. " +
                      "Always confirm the dates and leave type with the user before calling.",
        InputSchema = """
        {
          "type": "object",
          "properties": {
            "employee_id":  { "type": "string" },
            "start_date":   { "type": "string", "format": "date" },
            "end_date":     { "type": "string", "format": "date" },
            "leave_type":   {
              "type": "string",
              "enum": ["Annual", "Sick", "Unpaid", "Parental"]
            },
            "reason":       { "type": "string" }
          },
          "required": ["employee_id", "start_date", "end_date", "leave_type"]
        }
        """
    };
}

// LeaveToolHandlers.cs — handler implementations
public static class LeaveToolHandlers
{
    public static async Task<string> HandleGetBalanceAsync(
        JsonElement input,
        ConversationContext ctx,
        ILeaveService leaveService,
        CancellationToken ct)
    {
        var employeeId = input.GetProperty("employee_id").GetString()!;
        try
        {
            var balance = await leaveService.GetBalanceAsync(employeeId, ct);
            return JsonSerializer.Serialize(new
            {
                employee_id  = employeeId,
                annual_days  = balance.AnnualRemaining,
                sick_days    = balance.SickRemaining,
                as_of        = balance.CalculatedAt.ToString("yyyy-MM-dd")
            });
        }
        catch (EmployeeNotFoundException)
        {
            return $"{{\"error\": \"Employee {employeeId} not found\"}}";
        }
    }

    public static async Task<string> HandleSubmitRequestAsync(
        JsonElement input,
        ConversationContext ctx,
        ILeaveService leaveService,
        CancellationToken ct)
    {
        var request = new LeaveRequest
        {
            EmployeeId = input.GetProperty("employee_id").GetString()!,
            StartDate  = DateOnly.Parse(input.GetProperty("start_date").GetString()!),
            EndDate    = DateOnly.Parse(input.GetProperty("end_date").GetString()!),
            LeaveType  = Enum.Parse<LeaveType>(input.GetProperty("leave_type").GetString()!),
            Reason     = input.TryGetProperty("reason", out var r) ? r.GetString() : null
        };

        var result = await leaveService.SubmitRequestAsync(request, ct);
        return JsonSerializer.Serialize(new
        {
            request_id  = result.Id,
            status      = result.Status.ToString(),
            submitted_at = result.SubmittedAt.ToString("o")
        });
    }
}

Step 5 — Implement the Plugin Class

namespace BizFirst.Octopus.HRPlugin;

public class HRPlugin : IOctopusPlugin
{
    // ──────────────────────────────────────────────
    // Phase 1: Service Registration
    // ──────────────────────────────────────────────
    public void OnRegister(IServiceCollection services, OctopusConfig config)
    {
        // Validate and register config
        services.AddOptions<HRPluginConfig>()
            .Bind(config.Configuration.GetSection("HRPlugin"))
            .ValidateDataAnnotations()
            .ValidateOnStart();

        // Typed HttpClient — BaseUrl set from config
        services.AddHttpClient<IHRSystemClient, HRSystemClient>(
            (sp, client) =>
            {
                var cfg = sp.GetRequiredService<IOptions<HRPluginConfig>>().Value;
                client.BaseAddress = new Uri(cfg.BaseUrl);
                client.Timeout     = TimeSpan.FromSeconds(cfg.TimeoutSeconds);
            })
            .AddStandardResilienceHandler();  // Polly retry + circuit breaker

        // Scoped services (per request — required for ITenantContext)
        services.AddScoped<ILeaveService, LeaveService>();
    }

    // ──────────────────────────────────────────────
    // Phase 2: Startup (after DI container is built)
    // ──────────────────────────────────────────────
    public async Task OnStartAsync(IServiceProvider sp, CancellationToken ct)
    {
        var registry = sp.GetRequiredService<MCPToolRegistry>();

        // Resolve the factory — tools need a scoped ILeaveService
        // We capture the IServiceProvider so each tool call creates its own scope
        registry.Register(new MCPTool
        {
            Schema  = LeaveToolDefinitions.GetBalance,
            Handler = async (input, ctx, token) =>
            {
                using var scope       = sp.CreateScope();
                var leaveService      = scope.ServiceProvider.GetRequiredService<ILeaveService>();
                return await LeaveToolHandlers.HandleGetBalanceAsync(input, ctx, leaveService, token);
            }
        });

        registry.Register(new MCPTool
        {
            Schema  = LeaveToolDefinitions.SubmitRequest,
            Handler = async (input, ctx, token) =>
            {
                using var scope       = sp.CreateScope();
                var leaveService      = scope.ServiceProvider.GetRequiredService<ILeaveService>();
                return await LeaveToolHandlers.HandleSubmitRequestAsync(input, ctx, leaveService, token);
            }
        });

        await Task.CompletedTask;
    }

    // ──────────────────────────────────────────────
    // Phase 3: Clean Shutdown
    // ──────────────────────────────────────────────
    public Task OnStopAsync(CancellationToken ct)
    {
        // HttpClient is managed by the DI container — no explicit cleanup needed
        // If your plugin holds background tasks, cancel and await them here
        return Task.CompletedTask;
    }
}

Step 6 — Register in Program.cs

// Program.cs
builder.Services.AddOctopus(config =>
{
    config.AddPlugin<SqlServerPlugin>();
    config.AddPlugin<SemanticKernelPlugin>();
    config.AddPlugin<ChatbotUIPlugin>();

    // Register your custom plugin last (after the plugins it depends on)
    config.AddPlugin<HRPlugin>();
});

Plugin Completion Checklist

ItemDone?Notes
Typed options class with ValidateOnStartRequiredFail-fast on missing config
No raw secrets in configRequiredUse ICredentialResolver credential IDs
Scoped services for per-request stateRequiredAny service using ITenantContext must be Scoped
MCP tool schemas are descriptiveRequiredLLM uses descriptions to decide when to call
Tool handlers return JSON stringsRequiredAll results are serialised JSON
Tool handlers have error handlingRequiredReturn {"error": "..."} on expected failures
Scopes created per tool callRequiredNever share Scoped services across calls
OnStopAsync cleans up all resourcesRequiredCancel background tasks, dispose handles
Plugin registered after its dependenciesRequiredSee Plugin Ordering page
Tools assigned to agents in the admin UIOptionalOtherwise agents cannot see the tools