EthisysCore.Plugin.Sdk
1.4.0
dotnet add package EthisysCore.Plugin.Sdk --version 1.4.0
NuGet\Install-Package EthisysCore.Plugin.Sdk -Version 1.4.0
<PackageReference Include="EthisysCore.Plugin.Sdk" Version="1.4.0" />
<PackageVersion Include="EthisysCore.Plugin.Sdk" Version="1.4.0" />
<PackageReference Include="EthisysCore.Plugin.Sdk" />
paket add EthisysCore.Plugin.Sdk --version 1.4.0
#r "nuget: EthisysCore.Plugin.Sdk, 1.4.0"
#:package EthisysCore.Plugin.Sdk@1.4.0
#addin nuget:?package=EthisysCore.Plugin.Sdk&version=1.4.0
#tool nuget:?package=EthisysCore.Plugin.Sdk&version=1.4.0
EthisysCore Plugin SDK
The C# SDK for building plugins on the EthisysCore extension platform.
For scaffolding, packaging, and running plugins, see the EthisysCore CLI.
Identity contract:
PluginManifest.IdandPluginDependency.FeatureIdare UUIDs in .NET code and serialize as canonical UUID strings in packaged manifests.
Key Types
| Type | Purpose |
|---|---|
PluginBase |
Abstract base class — extend this to build plugins. Implements IDisposable and IAsyncDisposable. |
IPlugin |
Low-level interface (IAsyncDisposable) used by the runtime for type discovery (prefer PluginBase) |
ExecutionMode |
Enum: InProcess (ALC, default), OutOfProcess (gRPC child process), or Container (Dapr service invocation) |
ToolPermission |
[Flags] enum declaring required permission per tool (Read=16, Write=8, Delete=4, View=2, Assign=1, Approve=32, PublishGlobal=64) |
IPluginContext |
Runtime context: events, logging, data store, MCP tool catalogue, cache, storage, reference data |
IPluginLogger |
Structured logging (Debug, Information, Warning, Error, Critical) |
IPluginDataStore |
Key-value persistence scoped per plugin per tenant |
IMcpToolCatalogClient |
Preferred for tool discovery/invocation — typed facade that filters reserved connector:* tools from discovery and rejects them at invoke |
IMcpClient |
Raw MCP transport. Owns MCP resources (the facade is tool-catalogue specific) and remains available for raw tool-invocation edge cases |
| Handlers | |
IToolHandler |
Tool handler contract (implement for argument-less tools) |
ToolHandler<TRequest> |
Typed tool handler base — deserializes arguments before middleware pipeline |
IRequestPreparation |
Pre-pipeline request deserialization (implemented by ToolHandler<T>) |
IResourceHandler |
Resource handler contract |
ResourceHandler |
Resource handler base class |
| Pipeline | |
ToolDelegate |
Pre-compiled pipeline delegate: Task<McpToolResult> (ToolInvocationContext, CancellationToken) |
IToolMiddleware |
Middleware contract — next parameter is ToolDelegate (not Func<Task<McpToolResult>>) |
ErrorHandlingMiddleware |
Built-in: catches exceptions → McpToolResult.Fail |
LoggingMiddleware |
Built-in: logs tool name + duration + outcome |
ValidationMiddleware |
Built-in: DataAnnotations validation on request objects |
| Cache & Storage | |
IPluginCache |
Plugin-local near-cache with optional host-backed cache bridge, tag invalidation, stampede protection, per-org namespacing |
IPluginStorage |
Blob-like file storage scoped per plugin per org: upload, download, list, delete |
| Events & Background | |
IPluginEventHandler<TEvent> |
Event subscriber handler — TypeCode must match Events.Subscribes in manifest |
IBackgroundTaskHandler |
Background task execution handler — TaskName must match manifest declaration |
| App Builder | |
IPluginAppBuilder |
Register custom REST endpoints and middleware (container and OOP modes only) |
| DI | |
IServiceCollection |
Standard .NET DI registration — available in ConfigureServices(services) |
IServiceProvider |
Service resolution — available via context.Services per invocation or ServiceProvider at init time |
| Result Pattern | |
Result / Result<T> |
Result types returned by handlers and repositories — Ok, Fail, NotFound, Invalid, Forbidden, Conflict, Unauthorized |
ApplicationError |
Machine-readable error with Code, Message, Details, and Metadata |
| Registries | |
IToolRegistry |
Tool handler registration + assembly scanning |
IResourceRegistry |
Resource handler registration + assembly scanning |
IPluginPipelineBuilder |
Middleware pipeline configuration |
Plugin Architecture (Handler Pattern)
Plugins use a builder lifecycle orchestrated by PluginBase:
InitializeAsync(context)
→ Seeds DI with platform services (IPluginContext, IPluginDataStore, IPluginLogger,
IMcpClient, IMcpToolCatalogClient, IReferenceData)
→ ConfigureServices(services) [register your own services]
→ ConfigurePipeline(pipeline) [add middleware]
→ ConfigureTools(tools) [register/scan tool handlers]
→ ConfigureResources(resources) [register/scan resource handlers]
→ OnInitializedAsync(context, ct) [startup logic]
Minimal Plugin
public sealed class MyPlugin : PluginBase
{
protected override PluginManifest ManifestCore => MyManifest.Instance;
protected override void ConfigurePipeline(IPluginPipelineBuilder pipeline)
{
pipeline.UseErrorHandling();
pipeline.UseLogging();
}
// Tool and resource handlers are auto-discovered via assembly scanning.
// Override ConfigureTools/ConfigureResources only for explicit registrations
// from other assemblies or pre-built handler instances.
}
Tool Handler
Each tool is a separate file with primary constructor DI. The ToolInvocationContext provides
convenience access to Logger, DataStore, McpTools, McpClient, Services, and
PublishEventAsync:
internal sealed class GreetTool(IGreetingService greetings) : ToolHandler<GreetRequest>
{
// Manual IToolHandler / ToolHandler<T> implementations own their ToolName verbatim —
// the SDK does NOT prefix these. Use the fully qualified form with the plugin's GroupCode
// (e.g. "my-plugin-greet") so every tool name ends up in a single namespace. If you want
// the SDK to apply the GroupCode automatically, expose the handler as a CQRS query/command
// and register via `_cqrs.MapTools(tools, Manifest.GroupCode)`.
public override string ToolName => "my-plugin-greet";
protected override async Task<McpToolResult> HandleAsync(
GreetRequest request, ToolInvocationContext context, CancellationToken ct)
{
var result = await greetings.GreetAsync(request.Name, ct);
context.Logger.Information("Greeted {0}", request.Name);
return McpToolResult.Ok(new { message = result });
}
}
For tools with no arguments, implement IToolHandler directly:
internal sealed class StatsTool(IStatsService stats) : IToolHandler
{
// Same contract: manual handlers carry the full {GroupCode}-{name} literal.
public string ToolName => "my-plugin-stats";
public async Task<McpToolResult> HandleAsync(ToolInvocationContext context, CancellationToken ct)
{
var result = await stats.GetStatsAsync(ct);
return McpToolResult.Ok(JsonSerializer.Serialize(new { content = result }));
}
}
Resource Handler
internal sealed class StatusResource(IStatusService status) : ResourceHandler
{
public override string ResourceUri => "my-plugin://status";
public override async Task<McpResourceResult> HandleAsync(
ResourceRequestContext context, CancellationToken ct)
{
var data = await status.GetStatusAsync(ct);
return McpResourceResult.Ok(JsonSerializer.SerializeToUtf8Bytes(data));
}
}
DI Registration
Platform services are auto-registered. Register your own in ConfigureServices.
Three lifetimes are supported:
| Lifetime | Scope | Use Case |
|---|---|---|
AddSingleton |
One instance for the plugin lifetime | Stateless services, configuration, repositories |
AddScoped |
One instance per tool/resource invocation | Per-request state, unit-of-work |
AddTransient |
New instance on every resolve | Lightweight, stateless helpers |
protected override void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IMyRepository, MyRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddTransient<IValidator, RequestValidator>();
services.AddSingleton<IMyService>(sp =>
new MyService(sp.GetRequiredService<IMyRepository>()));
}
Handler constructors receive singleton services (handlers are created at init time).
Scoped/transient services are resolved per invocation via context.Services:
protected override async Task<McpToolResult> HandleAsync(
MyRequest request, ToolInvocationContext context, CancellationToken ct)
{
var uow = context.Services.GetRequiredService<IUnitOfWork>();
// uow is fresh per invocation and disposed when the invocation completes
}
Use ServiceProvider in OnInitializedAsync for startup logic:
protected override async Task OnInitializedAsync(IPluginContext context, CancellationToken ct)
{
var repo = ServiceProvider.GetRequiredService<IMyRepository>();
var count = await repo.CountAsync(ct);
context.Logger.Information("Loaded with {0} items", count);
}
Available Overrides
| Method | Default Behavior |
|---|---|
Manifest (abstract) |
Must implement — returns PluginManifest with tools, resources, permissions |
ConfigureServices(services) |
No-op. Register plugin services using standard IServiceCollection. |
ConfigurePipeline(pipeline) |
Applies UseErrorHandling() + UseValidation() by default. Override to customise. |
ConfigureTools(tools) |
No-op. Register/scan tool handlers. |
ConfigureResources(resources) |
No-op. Register/scan resource handlers. |
ConfigureEventHandlers(registry) |
No-op. Cross-assembly event handler registration. |
ConfigureBackgroundTasks(services, types) |
No-op. Cross-assembly background task registration. |
ConfigureApp(app) |
No-op. Register custom REST endpoints and middleware (container and OOP modes only). |
ConfigureHost(builder) |
No-op. Configure IHostedService, logging, or host-level options. |
OnInitializedAsync(context, ct) |
No-op. Startup logic after DI is built. |
OnEnableAsync(context, ct) |
No-op. Called when extension is enabled for a tenant. |
OnDisableAsync(context, ct) |
No-op. Called when extension is disabled for a tenant. |
OnShutdownAsync(context, ct) |
Delegates to OnDisableAsync. Final cleanup before permanent unload. |
OnNotificationAsync(notification, ct) |
No-op. Custom platform notifications (ref-data invalidation is auto-handled). |
OnCheckHealthAsync(context, ct) |
Returns PluginHealthCheckResult.Healthy() |
CacheMaxEntries |
Returns the SDK default near-cache entry limit. Override to tune the plugin-local memory cache size. |
DisposeAsync() |
Delegates to Dispose(). Override for plugins holding async resources. |
Middleware Pipeline
Middleware wraps tool execution in registration order (first = outermost):
protected override void ConfigurePipeline(IPluginPipelineBuilder pipeline)
{
pipeline.UseErrorHandling(); // Outermost: catches all exceptions
pipeline.UseValidation(); // DataAnnotations on request objects (included in base default)
pipeline.UseLogging(); // Opt-in: logs tool name + duration + outcome
pipeline.Use<MyCustomMiddleware>(); // Custom middleware
}
Default pipeline: the base
PluginBase.ConfigurePipelineaddsUseErrorHandling()+UseValidation().UseLogging()is opt-in — add it explicitly when you want structured per-tool timing.
Custom middleware implements IToolMiddleware. The next parameter is a ToolDelegate — call it as next(context, ct):
public sealed class MyCustomMiddleware : IToolMiddleware
{
public async Task<McpToolResult> InvokeAsync(
ToolInvocationContext context, ToolDelegate next, CancellationToken ct)
{
// Before handler
var result = await next(context, ct);
// After handler
return result;
}
}
Pre-compiled pipeline: middleware chains are compiled once per tool at initialization time (not per invocation), so there is zero per-request allocation for pipeline construction.
Hardened dispatch: PluginBase.ExecuteToolAsync and ReadResourceAsync include two extra catch layers around every invocation:
OperationCanceledExceptionis caught and returned as a clean "was cancelled"McpToolResultrather than propagating.- A general
Exceptioncatch logs the full exception (including stack trace) viaIPluginLogger.Errorfor debugging, then converts the error to primitive strings before returning, preventing exception type objects from pinning the collectibleAssemblyLoadContextacross the ALC boundary. context.Servicesis set tonull!in thefinallyblock to prevent use-after-dispose of the per-invocation scope.
Testing
Reference EthisysCore.Plugin.Sdk.Testing and use PluginTestHost<T> for handler-level testing:
[Fact]
public async Task GreetTool_ShouldReturnSuccess()
{
// Arrange
var host = await PluginTestHost<MyPlugin>.CreateAsync();
// Act
var result = await host.InvokeToolAsync("my-plugin-greet", new { name = "Alice" });
// Assert
Assert.True(result.Success);
Assert.Single(host.Context.TestLogger.Entries);
}
Access test fakes for assertions:
host.Context.TestLogger.Entries // Captured log entries
host.Context.PublishedEvents // Captured domain events
host.Context.TestDataStore // Direct data store access
host.Context.TestStorage // Captured blob storage operations
Test resource handlers and raw-JSON invocations:
// Resource handler testing
var resource = await host.ReadResourceAsync("my-plugin://status");
resource.Success.Should().BeTrue();
// Transport-boundary test — raw JSON bypasses argument serialization
var result = await host.InvokeToolRawJsonAsync("my-plugin-greet", """{"name":"Alice"}""");
result.Success.Should().BeTrue();
Data Store
Persistent key-value storage scoped per plugin per tenant. Backed by SQL Server with rate limiting and quota enforcement.
// Store data and capture the new ETag
var write = await context.DataStore.SetAsync("users/123", new User("Alice"), options: null);
// Read data together with concurrency metadata
var record = await context.DataStore.GetRecordAsync<User>("users/123");
var user = record?.Value;
var etag = record?.Etag;
// Optimistic concurrency
await context.DataStore.SetAsync(
"users/123",
user! with { Name = "Alice Updated" },
new PluginDataWriteOptions { IfMatch = etag! });
// Optional TTL for short-lived data
await context.DataStore.SetAsync(
"cache/users/123",
user,
new PluginDataWriteOptions { TtlExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5) });
// Query values or page through keys
var allUsers = await context.DataStore.QueryAsync<User>("users/");
var keyPage = await context.DataStore.ListKeysPageAsync("users/", take: 100);
// Batch operations and quota introspection
await context.DataStore.SetBatchAsync(items);
await context.DataStore.DeleteByPrefixAsync("temp/");
var info = await context.DataStore.GetStorageInfoAsync();
Filtered Queries
Use QueryAsync with QueryOptions for filtered, sorted, paginated queries against stored JSON values:
var result = await context.DataStore.QueryAsync<Movie>("movies/", new QueryOptions
{
Filters = [new FieldFilter("genre", FilterOperator.Eq, "Sci-Fi")],
Sort = new FieldSort("year", SortDirection.Descending),
Take = 20,
});
foreach (var movie in result.Items)
{
context.Logger.Information("Found: {0} ({1})", movie.Title, movie.Year);
}
// Page through results using the continuation token
if (result.NextPageToken is not null)
{
var nextPage = await context.DataStore.QueryAsync<Movie>("movies/", new QueryOptions
{
Filters = [new FieldFilter("genre", FilterOperator.Eq, "Sci-Fi")],
Sort = new FieldSort("year", SortDirection.Descending),
Take = 20,
PageToken = result.NextPageToken,
});
}
// Count without fetching
var count = await context.DataStore.CountAsync("movies/",
[new FieldFilter("genre", FilterOperator.Eq, "Sci-Fi")]);
Available operators: Eq, NotEq, Gt, Gte, Lt, Lte, In, Contains, StartsWith.
When Sort is null, results are sorted by key ascending. The executor always appends key as a secondary tie-break to ensure deterministic pagination.
Take must be between 1 and 1000 (default: 50). IncludeTotalCount defaults to true.
Key rules: max 512 chars, allowed characters a-zA-Z0-9-_/., no .. sequences, no leading/trailing /.
Default limits: 100 MB storage, 10,000 keys, 500 data ops/min.
Container plugins:
IPluginDataStoreis a platform convenience, not a requirement. Container plugins (ExecutionMode.Container) can use their own databases (PostgreSQL, MongoDB, etc.) bundled in the Docker image. If you manage your own persistence, you don't need to callcontext.DataStoreat all.
MCP Client
Discover and invoke MCP tools from other plugins and core platform modules. Rate-limited and audited.
context.McpTools — preferred surface
IMcpToolCatalogClient is a thin, allocation-aware facade over IMcpClient. Use it for all
cross-plugin tool discovery and invocation. It filters reserved connector:* tools (which
route through a connector-specific SDK surface) from ListAsync and rejects them at the call
boundary on InvokeAsync / InvokeRawJsonAsync — keeping handlers on the supported path and
surfacing a clear client-side error with guidance on the correct escape hatch if raw access is
truly required.
// List tools the caller's organisation can invoke. connector:* entries are filtered.
var tools = await context.McpTools.ListAsync();
// Invoke a core platform tool with an object argument — the SDK serialises it.
var result = await context.McpTools.InvokeAsync("hr:list-employees", new { });
if (result.Success)
{
var data = JsonSerializer.Deserialize<JsonElement>(result.ResultJson!);
}
// Invoke another plugin's tool.
var forecast = await context.McpTools.InvokeAsync("forecasting:predict", new { months = 3 });
// Raw JSON — for transport-boundary cases where you already hold a serialised payload.
var raw = await context.McpTools.InvokeRawJsonAsync("forecasting:predict", """{"months":3}""");
McpTools is also resolvable via constructor injection from any plugin service —
PluginBase registers IMcpToolCatalogClient as a singleton alongside IMcpClient in the
plugin's DI container, so tool handlers and domain services can depend on it directly.
Only the connector:* prefix is reserved. Core-platform and plugin tool names like
hr:list-employees or forecasting:predict continue to flow through the facade
unchanged.
context.McpClient — raw transport and MCP resources
IMcpClient remains the surface for MCP resources (GetResourceAsync,
ListAvailableResourcesAsync) — the facade is tool-catalogue specific and does not wrap
them. Use McpClient directly for any resource read. Tool-invocation calls that need to
bypass the facade's guards (for example, a deliberate transport-boundary test) also use
McpClient. For ordinary cross-plugin tool discovery and invocation, stay on
McpTools.
Default limit: 100 MCP requests/min.
Recommended Project Structure
MyPlugin/
├── Manifest/
│ └── MyPluginManifest.cs # Static PluginManifest Instance
├── Tools/
│ ├── GreetTool.cs # ToolHandler<GreetRequest>
│ └── StatsTool.cs # IToolHandler (no args)
├── Resources/
│ └── StatusResource.cs # ResourceHandler
├── Models/
│ └── GreetRequest.cs # Request records
├── Plugin.cs # Builder-style plugin (~30-40 lines)
├── extension.manifest.json
└── tests/
├── PluginTests.cs
└── GreetToolTests.cs # PluginTestHost-based tests
extension.manifest.json in the project is the source/UI overlay manifest used by the CLI and Vite tooling. The packaged .ccpkg contains feature.manifest.json.
Navigation Contribution
Plugins can declare a hierarchical navigation section that appears in the host sidebar. Set the Navigation property on PluginManifest:
protected override PluginManifest ManifestCore => new()
{
// ... other manifest properties ...
Navigation = new NavigationContribution("my-plugin", "My Plugin", "Dashboard", "primary-nav")
{
Children = [
new NavigationItem("dashboard", "Dashboard") { Path = "dashboard", RequiredPermission = 16 },
new NavigationItem("settings", "Settings") { Path = "settings", RequiredPermission = 8 },
]
}
};
NavigationContribution
| Parameter | Type | Required | Description |
|---|---|---|---|
Id |
string |
Yes | Unique identifier (e.g. "inventory-nav") |
Label |
string |
Yes | Display text in the sidebar |
Icon |
string |
Yes | MUI icon name (e.g. "Inventory2", "Dashboard") |
Location |
string |
Yes | "primary-nav" or "settings-nav" |
Order |
int |
No | Sort order (core: 1-40, plugins default to 50+) |
Children |
IEnumerable<NavigationItem>? |
No | Level 2+ children |
NavigationItem
| Parameter | Type | Required | Description |
|---|---|---|---|
Id |
string |
Yes | Unique ID (used by frontend setNavVisibility()) |
Label |
string |
Yes | Display text (max 50 characters) |
Order |
int |
No | Sort order within siblings (default: 0) |
Path |
string? |
No | Relative route segment (kebab-case). Host prefixes /extensions/{slug}/ |
RequiredPermission |
int? |
No | ToolPermission flag value. Inherits parent if null |
InitialVisibility |
bool |
No | If false, item starts hidden (default: true) |
Permanent |
bool |
No | If true, cannot be hidden at runtime (default: false) |
Children |
IEnumerable<NavigationItem>? |
No | Child items (max depth: 3 from root) |
Token Version
Set TokenVersion on PluginManifest to declare which design-token schema your plugin targets:
protected override PluginManifest ManifestCore => new()
{
// ... other manifest properties ...
TokenVersion = "1.0.0"
};
The host uses this to determine theme-token compatibility. Plugins receive the full BridgeTheme payload (mode, accessibility signals, and design tokens) via the App Bridge.
ToolPermission Values
The ToolPermission flags enum declares permission requirements for tools and navigation items:
| Flag | Value | Description |
|---|---|---|
None |
0 | No permission required |
Assign |
1 | Assign resources or roles |
View |
2 | View summary/listing data |
Delete |
4 | Delete records |
Write |
8 | Create or modify records (default for tools) |
Read |
16 | Read detailed record data |
Approve |
32 | Approve workflows |
PublishGlobal |
64 | Publish globally |
Combine with bitwise OR for composite masks: ToolPermission.View | ToolPermission.Read (18, reader access).
Tool Naming
The SDK owns {GroupCode}- prefixing for manifest declarations and CQRS registrations —
plugin authors write bare local names and the kernel sees fully qualified names. Manual
IToolHandler / ToolHandler<T> implementations are the exception: they own their
ToolName verbatim and must hand-prefix.
| Where | Author writes | Kernel sees | Who prefixes |
|---|---|---|---|
ManifestCore.McpTools[i].Name |
"search" |
"myplugin-search" |
SDK (PluginBase.Manifest normalisation) |
[ToolName("...")] on a CQRS request |
"forecast" |
"myplugin-forecast" |
SDK (MapTools(tools, Manifest.GroupCode)) |
| CQRS handler derived from record name | (no attribute) | "myplugin-get-book-by-id" |
SDK (convention + MapTools) |
IToolHandler.ToolName / ToolHandler<T>.ToolName |
"myplugin-search" |
"myplugin-search" |
Author (manual registration has no prefix hook) |
- Use lowercase with hyphens:
my-tool-name. Tool names must match^[a-z0-9]+(-[a-z0-9]+)*$after prefix application. - Declaring a tool name that already starts with
{GroupCode}-in the manifest or in a CQRS[ToolName]throws at firstManifestaccess / atMapToolscall. Write bare local names; let the SDK namespace them. PluginManifest.GroupCodemust itself be kebab-case — the SDK validates the shape at firstManifestaccess so the resulting fully qualified names stay legal MCP identifiers.- Reserved prefixes (core modules):
hr:,projects:,finance:,operations:,timesheets:. Tools with reserved prefixes are silently filtered from plugin tool lists.
Packaging
my-plugin-1.0.0.ccpkg (ZIP)
+-- feature.manifest.json <- Runtime discovery
+-- MyPlugin.Plugin.dll <- Your plugin assembly
+-- EthisysCore.Plugin.Sdk.dll
+-- ui/ <- Frontend assets (if --template was used)
+-- (other dependencies)
Use cc package <dir> to build, bundle, and sign in one step. See the CLI README for details.
Cache
IPluginCache provides a plugin-local near-cache with tag-based group invalidation, local single-flight stampede protection, and an optional host-backed cache bridge for cross-replica reuse. The host owns the distributed cache implementation (for example HybridCache/Redis); the SDK keeps plugin code decoupled from that infrastructure while still avoiding a network hop on hot reads. Cache keys and tags are automatically namespaced to the plugin ID and organisation ID, making cross-tenant data leaks impossible at the cache layer.
// Cache-aside with stampede protection (single-flight per key)
var employees = await context.Cache.GetOrCreateAsync(
"employees:all",
async ct => await repo.GetAllAsync(ct),
new PluginCacheOptions { Expiration = TimeSpan.FromMinutes(10) },
tags: ["Employees"],
ct);
// Explicit set
await context.Cache.SetAsync(total, "employees:count",
new PluginCacheOptions { Expiration = TimeSpan.FromMinutes(5) },
tags: ["Employees"], ct);
// Tag-based group invalidation (invalidates all entries tagged "Employees" for this org)
await context.Cache.InvalidateByTagsAsync(["Employees"], ct);
PluginCacheOptions properties: Expiration (host-backed cache TTL), LocalCacheExpiration (plugin near-cache TTL, falling back to Expiration, then 5 minutes), NegativeCacheDuration (cache null/empty results).
Override CacheMaxEntries in your plugin to tune the plugin-local near-cache entry limit.
OpenTelemetry metrics emitted: plugin.cache.hit, plugin.cache.miss, plugin.cache.invalidation, plugin.cache.backend.*.
Storage
IPluginStorage provides blob-like file storage scoped per plugin per organisation. Paths are relative and namespaced automatically to plugins/{extensionId}/ — no cross-tenant access is possible.
// Upload a file
var uri = await context.Storage.UploadAsync(
"reports/monthly-2026-04.csv",
csvBytes,
"text/csv",
ct);
// Download
var content = await context.Storage.DownloadAsync("reports/monthly-2026-04.csv", ct);
if (content is null) { /* not found */ }
// List with prefix
var files = await context.Storage.ListAsync("reports/", ct);
foreach (var entry in files)
{
context.Logger.Information("File: {0} ({1} bytes)", entry.Path, entry.ContentLength);
}
// Delete
await context.Storage.DeleteAsync("reports/monthly-2026-04.csv", ct);
Path rules: relative, no .., no leading slash. Maximum blob size: ~75 MB (Base64 transport).
StorageEntry record: Path, ContentLength, ContentType, LastModified.
Events
Plugins can publish events to other plugins and subscribe to events from the platform or other plugins.
Publishing
// Publish a domain event from a tool handler
await context.PublishEventAsync(new TechCreatedEvent(entity.Id, entity.Name), ct);
Declare published events in PluginManifest.Events.Publishes. The TypeCode routing key must match the EventContractRef declaration.
Subscribing
Implement IPluginEventHandler<TEvent> — handlers are auto-discovered from the plugin assembly:
internal sealed class EmployeeCreatedHandler
: IPluginEventHandler<EmployeeCreatedEvent>
{
public static string TypeCode => "hr.employee-created";
public async Task HandleAsync(
EmployeeCreatedEvent evt, IPluginEventContext context, CancellationToken ct)
{
// context.SourceExtensionId, context.EventId, context.OccurredAt
await context.PluginContext.DataStore.SetAsync(
$"employees/{evt.EmployeeId}", evt, ct: ct);
}
}
Declare subscriptions in PluginManifest.Events.Subscribes. For cross-assembly registration, override ConfigureEventHandlers(IEventHandlerRegistry).
Background Tasks
IBackgroundTaskHandler runs scheduled or triggered tasks in a single-replica-at-a-time manner (distributed lock prevents concurrent execution across replicas).
internal sealed class CacheWarmupTask : IBackgroundTaskHandler
{
public static string TaskName => "cache-warmup";
public async Task<BackgroundTaskResult> ExecuteAsync(
IBackgroundTaskContext context, CancellationToken ct)
{
var data = await context.PluginContext.DataStore.QueryAsync<Tech>("tech/", ct: ct);
await context.PluginContext.Cache.SetAsync("tech:all", data, ct: ct);
return BackgroundTaskResult.Success(executionTimeMs: 0);
}
}
TaskName must match the BackgroundTaskDeclaration.Name in PluginManifest. Handlers are auto-discovered. For cross-assembly registration, override ConfigureBackgroundTasks.
Custom REST Endpoints
Container and OutOfProcess plugins can expose standard REST endpoints via IPluginAppBuilder in ConfigureApp. These are served under /plugin/api/ and proxied by the kernel YARP gateway with full auth context.
protected override void ConfigureApp(IPluginAppBuilder app)
{
app.MapGet("/status", async (PluginRouteContext ctx, CancellationToken ct) =>
{
var info = await ctx.PluginContext.DataStore.GetStorageInfoAsync(ct);
return PluginRouteResult.Ok(new { keyCount = info.KeyCount });
});
app.MapPost("/items", async (PluginRouteContext ctx, CancellationToken ct) =>
{
var body = await ctx.Request.ReadFromJsonAsync<CreateItemRequest>(ct);
// ...
return PluginRouteResult.Created(new { id });
});
}
SupportsCustomEndpointsreturnsfalsefor InProcess (ALC) plugins —ConfigureAppis a silent no-op in that mode.
Reference Data
IReferenceData provides access to kernel-managed reference entities (e.g. organisations, users, tenants) via an in-memory L1 cache with automatic background sync.
// Single entity — O(1) L1 read after first sync
var org = await context.ReferenceData.GetAsync<OrganisationRef>("Organisation", orgId, ct);
// Filtered in-memory scan
var activeOrgs = await context.ReferenceData.QueryAsync<OrganisationRef>(
"Organisation", o => o.IsActive, ct);
// Force re-sync (rate-limited: 1 request per 5 minutes per plugin per org)
await context.ReferenceData.RequestSyncAsync("Organisation", ct);
Declare required reference data types in
PluginManifest.ReferenceDataSubscriptions. The SDK will not receive sync events for undeclared types.
Result Pattern
Handlers return Result / Result<T> instead of throwing exceptions:
// Success variants
Result.Ok(value) // 200 OK
Result.NoContent() // 204 No Content
Result.Created(value) // 201 Created
// Failure variants
Result.NotFound()
Result.Forbidden()
Result.Invalid("Name is required")
Result.Conflict("Item already exists")
Result.Unauthorized()
Result.Error("Unexpected storage failure")
Result.CriticalError("Database is unavailable")
For machine-readable errors, use ApplicationError:
return Result.Invalid(new ApplicationError("VALIDATION_FAILED", "Name is required", details: null));
McpToolResult wraps Result — a successful Result<T> serializes the value to JSON; a failed result maps the status to a structured error message.
Companion Packages
| Package | Purpose |
|---|---|
EthisysCore.Plugin.Sdk.Cache |
Plugin-local near-cache primitives and host-backed cache bridge contract used by IPluginCache |
EthisysCore.Plugin.Sdk.Cqrs |
CQRS pattern: IQuery<T>, ICommand<T>, typed handlers, IPluginValidator<T>, [PluginCacheableQuery], [PluginInvalidatesCache] |
EthisysCore.Plugin.Sdk.Data |
EF Core data layer: PluginDbContext, IRepository<T,TKey>, Specification<T>, entity traits, org-scoping, soft-delete |
EthisysCore.Plugin.Sdk.Testing |
Test-only helpers: PluginTestHost, fakes, in-memory DataStore/Storage, log capture |
Further Reading
- CLI Reference — scaffolding, packaging, sandbox
- Sample Plugins — reference implementations (books, movies, movies-container)
Last Updated: 2026-05-01
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- EthisysCore.Plugin.Sdk.Cache (>= 1.4.0)
- EthisysCore.Protocol (>= 0.3.9)
- Microsoft.Extensions.DependencyInjection (>= 10.0.7)
- Microsoft.Extensions.Hosting (>= 10.0.7)
- Microsoft.Extensions.Options (>= 10.0.7)
- Scrutor (>= 7.0.0)
NuGet packages (4)
Showing the top 4 NuGet packages that depend on EthisysCore.Plugin.Sdk:
| Package | Downloads |
|---|---|
|
EthisysCore.Plugin.Sdk.Host
Hosting runtime for EthisysCore plugins running out-of-process. Provides gRPC bridge between the plugin and the EthisysCore host. |
|
|
EthisysCore.Plugin.Sdk.Cqrs
CQRS package for EthisysCore plugins. Provides IQueryHandler, ICommandHandler, Result pattern, pipeline behaviors, and CqrsToolBridge for auto-exposing handlers as MCP tools. |
|
|
EthisysCore.Plugin.Sdk.Data
Data persistence package for EthisysCore plugins. Provides PluginDbContext, IReadOnlyRepository, IRepository, Specification, IUnitOfWork with schema isolation, soft-delete, audit, and org-scoping. |
|
|
EthisysCore.Plugin.Sdk.Testing
Testing helpers for EthisysCore plugins. Provides PluginTestHost, TestPluginContext, in-memory DataStore and Storage fakes, NullMcpClient, and log capture utilities. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.4.0 | 129 | 5/2/2026 |
| 1.3.9 | 123 | 5/1/2026 |
| 1.3.8 | 115 | 5/1/2026 |
| 1.3.7 | 117 | 5/1/2026 |
| 1.3.6 | 122 | 4/29/2026 |
| 1.3.5 | 119 | 4/29/2026 |
| 1.3.4 | 121 | 4/29/2026 |
| 1.3.2 | 120 | 4/29/2026 |
| 1.3.1 | 119 | 4/29/2026 |
| 1.3.0 | 122 | 4/28/2026 |
| 1.2.16 | 120 | 4/28/2026 |
| 1.2.15 | 119 | 4/28/2026 |
| 1.2.13 | 117 | 4/28/2026 |
| 1.2.9 | 123 | 4/27/2026 |
| 1.2.8 | 121 | 4/26/2026 |
| 1.2.7 | 115 | 4/26/2026 |
| 1.2.6 | 126 | 4/26/2026 |
| 1.2.5 | 116 | 4/25/2026 |
| 1.2.4 | 123 | 4/23/2026 |
| 1.2.3 | 121 | 4/23/2026 |