Plugin Design Principles
Every Octopus plugin — first-party or community — must follow these design principles. They ensure security, multi-tenancy, observability, and a consistent agent experience across the entire plugin ecosystem.
The Five Core Principles
1. Credential Safety
All secrets (API keys, tokens, passwords) must flow through ICredentialResolver. Store only a credential ID in configuration — never the secret itself.
2. Tenant Isolation
Every data access and every external API call must be scoped to the current tenant. Use ITenantContext to read the tenant ID from the JWT on every request.
3. MCP Tool Schema Standards
Every MCP tool must expose a valid JSON Schema as its inputSchema. Required fields must be listed in the required array. No schema-free tools.
4. IOctopusPlugin Contract
Implement all three lifecycle methods: OnRegister, OnStartAsync, OnStopAsync. Register DI services in OnRegister, start background work in OnStartAsync.
5. Graceful Degradation
A plugin failure must never crash the Octopus host. Wrap external calls in try/catch, return structured error JSON from tool handlers, and log with structured context.
Principle 1: Credential Safety
The credential pattern is mandatory for every plugin that connects to an external service. Raw secrets in appsettings.json — even in environment variables — are a security violation.
// CORRECT — store only the credential ID
{
"EmailPlugin": {
"CredentialId": 30 // integer — resolved at runtime
}
}
// WRONG — never store raw secrets
{
"EmailPlugin": {
"SmtpPassword": "hunter2" // ❌ violates credential pattern
}
}
// In OnStartAsync — resolve the secret at runtime
public async Task OnStartAsync(IOctopusPluginContext context)
{
var config = context.Config.GetSection<EmailPluginConfig>("EmailPlugin");
var secret = await context.CredentialResolver
.GetPasswordAsync(config.CredentialId);
_smtpClient = new SmtpClient(config.SmtpHost)
{
Credentials = new NetworkCredential(config.SmtpUser, secret)
};
}
| Secret Type | Storage | Resolution |
|---|---|---|
| SMTP / IMAP password | Credential store (ID in config) | GetPasswordAsync(credentialId) |
| OAuth access token | Credential store (ID in config) | GetPasswordAsync(credentialId) |
| API key (SendGrid, HubSpot) | Credential store (ID in config) | GetPasswordAsync(credentialId) |
| Database connection string | Credential store (ID in config) | GetPasswordAsync(credentialId) |
| Host / port / endpoint URL | appsettings.json (safe) | Direct config bind |
Principle 2: Tenant Isolation
Multi-tenancy is not optional. Every external API call, every database query, and every cache entry must be keyed or filtered by tenant ID.
// Inject ITenantContext into your tool handler
public class CrmToolHandler
{
private readonly ITenantContext _tenant;
private readonly ICrmClient _crm;
public CrmToolHandler(ITenantContext tenant, ICrmClient crm)
{
_tenant = tenant;
_crm = crm;
}
public async Task<JsonElement> HandleGetContactAsync(JsonElement input)
{
var tenantId = _tenant.TenantId; // read from JWT "tid" claim
// Scope the query to this tenant's CRM instance
var contact = await _crm.GetContactAsync(
tenantId: tenantId,
email: input.GetProperty("email").GetString());
return JsonSerializer.SerializeToElement(contact);
}
}
ITenantContext.TenantId, any authenticated agent can access another tenant's contacts, emails, or purchase orders. This is a critical security violation.
| Isolation Layer | Mechanism | Example |
|---|---|---|
| EF Core entities | Global query filter on TenantId | HasQueryFilter(e => e.TenantId == _tenant.TenantId) |
| External API calls | Tenant-scoped client or header | X-Tenant-Id header, or per-tenant OAuth token |
| In-memory cache | Key prefix with tenant ID | $"{tenantId}:crm:contact:{email}" |
| File / blob storage | Tenant-scoped path or container | tenants/{tenantId}/attachments/ |
| Background workers | Capture tenant at start, propagate | OctopusHILActor pattern — TenantId in task payload |
Principle 3: MCP Tool Schema Standards
Octopus agents use the JSON Schema defined in inputSchema to decide how to call your tool. A poorly defined schema produces hallucinated or missing arguments at runtime.
// Well-formed MCP tool definition
new MCPTool
{
Name = "crm_get_contact",
Description = "Look up a CRM contact by email address. " +
"Returns contact name, account, phone, and open opportunities.",
InputSchema = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"description": "The contact's email address (required)"
},
"include_opportunities": {
"type": "boolean",
"description": "Include open opportunities in the response",
"default": false
}
},
"required": ["email"]
}
""")
}
| Schema Rule | Reason |
|---|---|
All required params listed in required array | Agent knows which fields it must supply |
Every property has a description | LLM uses descriptions to map user intent to schema |
Use enum for fixed-value fields | Prevents invalid values reaching your handler |
Use format hints (email, date, uri) | Agent validates before calling; better error messages |
No additionalProperties: true without reason | Avoid accepting unknown fields that bypass validation |
| Tool name uses snake_case with plugin prefix | Avoids collisions: crm_, email_, erp_ |
Principle 4: IOctopusPlugin Contract
The plugin lifecycle contract is strict. Violating it causes startup failures, resource leaks, or incorrect DI resolution.
public interface IOctopusPlugin
{
// Called during DI container setup — register services here only.
// Do NOT start connections, open sockets, or call external APIs.
void OnRegister(IServiceCollection services, IOctopusConfig config);
// Called after DI container is built and host is starting.
// Resolve connections, start background workers, register MCP tools.
Task OnStartAsync(IOctopusPluginContext context,
CancellationToken cancellationToken);
// Called during graceful shutdown — release all resources.
// Must complete within the host's shutdown timeout (default 30 s).
Task OnStopAsync(CancellationToken cancellationToken);
}
| Action | OnRegister | OnStartAsync | OnStopAsync |
|---|---|---|---|
| Register services in DI | Yes | No | No |
| Read configuration | Yes (for DI) | Yes (for runtime) | No |
| Open TCP connections | No | Yes | Close them here |
| Register MCP tools | No | Yes | No |
| Start background tasks | No | Yes | Cancel them here |
| Resolve credentials | No | Yes | No |
Principle 5: Graceful Degradation
External services fail. Network partitions happen. Rate limits are hit. A plugin that throws an unhandled exception in a tool handler will kill the agent's turn. Return structured errors instead.
public async Task<JsonElement> HandleGetContactAsync(JsonElement input)
{
try
{
var contact = await _crm.GetContactAsync(
email: input.GetProperty("email").GetString());
return JsonSerializer.SerializeToElement(new
{
success = true,
contact
});
}
catch (CrmRateLimitException ex)
{
_logger.LogWarning(ex, "CRM rate limit hit for tenant {TenantId}",
_tenant.TenantId);
return JsonSerializer.SerializeToElement(new
{
success = false,
error = "rate_limit",
message = "CRM rate limit reached. Please retry in 60 seconds.",
retry_after = 60
});
}
catch (CrmNotFoundException)
{
return JsonSerializer.SerializeToElement(new
{
success = false,
error = "not_found",
message = $"No contact found for email: {input.GetProperty("email")}"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error in crm_get_contact");
return JsonSerializer.SerializeToElement(new
{
success = false,
error = "internal_error",
message = "An unexpected error occurred. Please try again."
});
}
}
message field and relay a meaningful explanation to the user — instead of a raw exception stack trace or a silent failure.
Plugin Compliance Checklist
| # | Requirement | Verification |
|---|---|---|
| 1 | All secrets via ICredentialResolver | Grep for raw passwords / API keys in config |
| 2 | All data reads scoped to ITenantContext.TenantId | Code review: every query/call checks tenant |
| 3 | Every MCP tool has a valid JSON Schema with required | Call GET /mcp/tools and validate schemas |
| 4 | Every tool property has a description | Review InputSchema definitions |
| 5 | OnRegister only registers DI — no I/O | Step through startup; confirm no network calls |
| 6 | OnStopAsync disposes all connections and cancels tasks | Trigger graceful shutdown; confirm clean exit |
| 7 | All tool handlers catch exceptions and return error JSON | Unit test with simulated network failures |
| 8 | Structured logging with tenant and correlation IDs | Review log output under load |
| 9 | Tool names prefixed with plugin namespace (crm_, email_) | Check tool registry for naming collisions |
| 10 | Rate-limit and retry logic for external APIs | Integration test against throttled mock |