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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="EthisysCore.Plugin.Sdk" Version="1.4.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="EthisysCore.Plugin.Sdk" Version="1.4.0" />
                    
Directory.Packages.props
<PackageReference Include="EthisysCore.Plugin.Sdk" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add EthisysCore.Plugin.Sdk --version 1.4.0
                    
#r "nuget: EthisysCore.Plugin.Sdk, 1.4.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package EthisysCore.Plugin.Sdk@1.4.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=EthisysCore.Plugin.Sdk&version=1.4.0
                    
Install as a Cake Addin
#tool nuget:?package=EthisysCore.Plugin.Sdk&version=1.4.0
                    
Install as a Cake Tool

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.Id and PluginDependency.FeatureId are 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.ConfigurePipeline adds UseErrorHandling() + 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:

  • OperationCanceledException is caught and returned as a clean "was cancelled" McpToolResult rather than propagating.
  • A general Exception catch logs the full exception (including stack trace) via IPluginLogger.Error for debugging, then converts the error to primitive strings before returning, preventing exception type objects from pinning the collectible AssemblyLoadContext across the ALC boundary.
  • context.Services is set to null! in the finally block 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: IPluginDataStore is 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 call context.DataStore at 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.

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.

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 },
        ]
    }
};
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
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 first Manifest access / at MapTools call. Write bare local names; let the SDK namespace them.
  • PluginManifest.GroupCode must itself be kebab-case — the SDK validates the shape at first Manifest access 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 });
    });
}

SupportsCustomEndpoints returns false for InProcess (ALC) plugins — ConfigureApp is 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

Last Updated: 2026-05-01

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
Loading failed