Octopus
Building a Custom Plugin
A complete walkthrough for building a production-grade custom Octopus plugin from scratch. This example builds an HR Plugin that registers leave-management services, provides two MCP tools, and connects to an external HR API.
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
| Item | Done? | Notes |
|---|---|---|
Typed options class with ValidateOnStart | Required | Fail-fast on missing config |
| No raw secrets in config | Required | Use ICredentialResolver credential IDs |
| Scoped services for per-request state | Required | Any service using ITenantContext must be Scoped |
| MCP tool schemas are descriptive | Required | LLM uses descriptions to decide when to call |
| Tool handlers return JSON strings | Required | All results are serialised JSON |
| Tool handlers have error handling | Required | Return {"error": "..."} on expected failures |
| Scopes created per tool call | Required | Never share Scoped services across calls |
OnStopAsync cleans up all resources | Required | Cancel background tasks, dispose handles |
| Plugin registered after its dependencies | Required | See Plugin Ordering page |
| Tools assigned to agents in the admin UI | Optional | Otherwise agents cannot see the tools |