🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@useatlas/plugin-sdk

Package Overview
Dependencies
Maintainers
1
Versions
10
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@useatlas/plugin-sdk - npm Package Compare versions

Comparing version
0.0.7
to
0.0.9
+74
dist/semantic-whitelist.d.ts
/**
* Semantic-layer whitelist load policy for datasource query tools.
*
* Every dialect query tool (ES Query DSL, SOQL, future datasource plugins)
* gates member access on the semantic layer via `ctx.connections.tables(id)`.
* That accessor has three outcomes, and the policy for each is a SECURITY
* contract (#3243 / #3313) — stated here once instead of re-implemented (and
* re-explained in comments) by every plugin:
*
* - **throws** — the semantic-layer scan FAILED: the whitelist load is
* incomplete. The query must be REFUSED (fail closed) rather than the error
* swallowed into an empty set, which would silently widen access to
* structural-only — the "false negative on a security check" anti-pattern.
* {@link gateOnSemanticWhitelist} returns the canonical refusal.
* - **empty** — a legitimately-empty layer: STRUCTURAL-ONLY mode. The dialect
* validator's always-on rails still apply, but any explicitly named member
* the credential can read is queryable. Surfaced to the operator once at
* registration via {@link warnIfStructuralOnly}.
* - **non-empty** — the membership whitelist the dialect validator checks
* names against.
*/
import type { PluginLogger } from "./types";
/**
* The dialect's vocabulary for whitelist policy copy. Declared once per
* plugin and shared by the query-time gate and the registration-time warning
* so the two surfaces can't drift.
*/
export interface SemanticWhitelistSubject {
/** Registered tool name, e.g. `"queryElasticsearch"`. */
readonly toolName: string;
/** Singular noun for one queryable member: `"index"`, `"object"`, `"table"`. */
readonly member: string;
/**
* What structural-only mode exposes, e.g. `"any explicitly-named,
* non-system index"`. Defaults to `any explicitly-named ${member}`.
*/
readonly structuralExposure?: string;
/** Plural query kind for log copy, e.g. `"DSL queries"`, `"SOQL queries"`. */
readonly queryKind: string;
/** Short label for query-time refusal logs, e.g. `"ES DSL"`, `"SOQL"`. */
readonly logLabel: string;
}
/** Result of the query-time whitelist load. */
export type SemanticWhitelistGate = {
readonly ok: true;
/** Member names to validate against. Empty = structural-only mode. */
readonly allowed: Set<string>;
/** True when the layer is legitimately empty (structural-only mode). */
readonly structuralOnly: boolean;
} | {
readonly ok: false;
/** Canonical agent-facing refusal — return it as the tool error. */
readonly error: string;
};
/**
* Load the semantic whitelist at query time, owning the fail-closed policy.
*
* Call this at the top of a dialect tool's `execute`; on `ok: false` return
* `{ success: false, error: gate.error }` without issuing any request. The
* scan-failure path is logged here (`logger.error`) so callers don't repeat
* the policy.
*/
export declare function gateOnSemanticWhitelist(subject: SemanticWhitelistSubject, read: () => Iterable<string>, logger?: PluginLogger, logContext?: Record<string, unknown>): SemanticWhitelistGate;
/**
* One-time operator signal at tool registration (#3313).
*
* - Empty whitelist → warn that the tool runs in STRUCTURAL-ONLY mode, naming
* the consequence so operators know to add entity YAMLs.
* - Whitelist read throws → warn that the scan failed and queries will fail
* closed (the query-time gate logs each refusal; this only flags the state
* at registration).
* - Non-empty → silent.
*/
export declare function warnIfStructuralOnly(subject: SemanticWhitelistSubject, read: () => Iterable<string>, logger: PluginLogger): void;
+13
-2

@@ -18,2 +18,5 @@ // src/helpers.ts

}
if (plugin.onUninstall !== undefined && typeof plugin.onUninstall !== "function") {
throw new Error('Plugin "onUninstall" must be a function when provided');
}
if (plugin.types.includes("datasource")) {

@@ -24,5 +27,13 @@ const ds = plugin;

}
if (typeof ds.connection.create !== "function") {
throw new Error('Datasource plugin connection must have a "create()" factory function');
const hasCreate = ds.connection.create !== undefined;
const hasCreateFromConfig = ds.connection.createFromConfig !== undefined;
if (!hasCreate && !hasCreateFromConfig) {
throw new Error('Datasource plugin connection must have a "create()" or "createFromConfig()" factory function');
}
if (hasCreate && typeof ds.connection.create !== "function") {
throw new Error('Datasource plugin connection "create" must be a function when provided');
}
if (hasCreateFromConfig && typeof ds.connection.createFromConfig !== "function") {
throw new Error('Datasource plugin connection "createFromConfig" must be a function when provided');
}
if (ds.entities !== undefined && !Array.isArray(ds.entities) && typeof ds.entities !== "function") {

@@ -29,0 +40,0 @@ throw new Error('Datasource plugin "entities" must be an array or a function');

+3
-1

@@ -8,7 +8,9 @@ /**

*/
export type { PluginQueryResult, PluginDBConnection, QueryValidationResult, PluginDBType, ParserDialect, PluginType, PluginStatus, PluginHealthResult, PluginLogger, AtlasPluginContext, PluginHookEntry, QueryHookContext, QueryHookMutation, AfterQueryHookContext, ExploreHookContext, ExploreHookMutation, AfterExploreHookContext, ToolCallSessionContext, ToolCallHookContext, AfterToolCallHookContext, ToolCallArgsMutation, ToolCallResultMutation, RequestHookContext, ResponseHookContext, PluginHooks, PluginFieldDefinition, PluginTableDefinition, AtlasPluginBase, PluginEntity, EntityProvider, AtlasDatasourcePlugin, AtlasContextPlugin, AtlasInteractionPlugin, PluginAction, AtlasActionPlugin, PluginExecResult, PluginExploreBackend, AtlasSandboxPlugin, ActionApprovalMode, ConfigSchemaField, AtlasPlugin, $InferServerPlugin, } from "./types";
export type { PluginQueryResult, PluginDBConnection, QueryValidationResult, PluginDBType, ParserDialect, PluginType, PluginStatus, PluginHealthResult, PluginLogger, AtlasPluginContext, PluginHookEntry, QueryHookContext, QueryHookMutation, AfterQueryHookContext, ExploreHookContext, ExploreHookMutation, AfterExploreHookContext, ToolCallSessionContext, ToolCallHookContext, AfterToolCallHookContext, ToolCallArgsMutation, ToolCallResultMutation, RequestHookContext, ResponseHookContext, PluginHooks, PluginFieldDefinition, PluginTableDefinition, AtlasPluginBase, PluginEntity, EntityProvider, AtlasDatasourcePlugin, AtlasContextPlugin, AtlasInteractionPlugin, PluginAction, AtlasActionPlugin, PluginExecResult, PluginExploreBackend, AtlasSandboxPlugin, ActionApprovalMode, ConfigSchemaField, AtlasPlugin, $InferServerPlugin, AtlasMcpTool, McpToolContext, McpToolAuditEntry, PluginZodSchema, } from "./types";
export { SANDBOX_DEFAULT_PRIORITY } from "./types";
export { definePlugin, createPlugin, isDatasourcePlugin, isContextPlugin, isInteractionPlugin, isActionPlugin, isSandboxPlugin, } from "./helpers";
export type { CreatePluginOptions } from "./helpers";
export { gateOnSemanticWhitelist, warnIfStructuralOnly } from "./semantic-whitelist";
export type { SemanticWhitelistSubject, SemanticWhitelistGate } from "./semantic-whitelist";
export type { ToolSet, Tool } from "./ai";
export type { Context, MiddlewareHandler } from "./hono";

@@ -21,2 +21,5 @@ // src/types.ts

}
if (plugin.onUninstall !== undefined && typeof plugin.onUninstall !== "function") {
throw new Error('Plugin "onUninstall" must be a function when provided');
}
if (plugin.types.includes("datasource")) {

@@ -27,5 +30,13 @@ const ds = plugin;

}
if (typeof ds.connection.create !== "function") {
throw new Error('Datasource plugin connection must have a "create()" factory function');
const hasCreate = ds.connection.create !== undefined;
const hasCreateFromConfig = ds.connection.createFromConfig !== undefined;
if (!hasCreate && !hasCreateFromConfig) {
throw new Error('Datasource plugin connection must have a "create()" or "createFromConfig()" factory function');
}
if (hasCreate && typeof ds.connection.create !== "function") {
throw new Error('Datasource plugin connection "create" must be a function when provided');
}
if (hasCreateFromConfig && typeof ds.connection.createFromConfig !== "function") {
throw new Error('Datasource plugin connection "createFromConfig" must be a function when provided');
}
if (ds.entities !== undefined && !Array.isArray(ds.entities) && typeof ds.entities !== "function") {

@@ -131,3 +142,28 @@ throw new Error('Datasource plugin "entities" must be an array or a function');

}
// src/semantic-whitelist.ts
function gateOnSemanticWhitelist(subject, read, logger, logContext) {
let allowed;
try {
allowed = new Set(read());
} catch (err) {
logger?.error({ ...logContext, error: err instanceof Error ? err.message : String(err) }, `${subject.logLabel} refused — semantic layer unavailable (scan failed)`);
return {
ok: false,
error: `The semantic layer is temporarily unavailable (its scan failed), so ${subject.member} access cannot be verified. Refusing the query to avoid unsafe access — retry once it recovers.`
};
}
return { ok: true, allowed, structuralOnly: allowed.size === 0 };
}
function warnIfStructuralOnly(subject, read, logger) {
try {
if (new Set(read()).size === 0) {
const exposure = subject.structuralExposure ?? `any explicitly-named ${subject.member}`;
logger.warn(`${subject.toolName} registered with an empty semantic-layer whitelist — running in STRUCTURAL-ONLY mode: ${exposure} the credential can read is queryable. Add entity YAMLs to enforce a per-${subject.member} allow-list.`);
}
} catch (err) {
logger.warn(`${subject.toolName}: semantic-layer scan failed at registration — ${subject.queryKind} will fail closed until it recovers (${err instanceof Error ? err.message : String(err)}).`);
}
}
export {
warnIfStructuralOnly,
isSandboxPlugin,

@@ -138,2 +174,3 @@ isInteractionPlugin,

isActionPlugin,
gateOnSemanticWhitelist,
definePlugin,

@@ -140,0 +177,0 @@ createPlugin,

@@ -102,3 +102,4 @@ // src/testing.ts

}),
list: overrides.connections?.list ?? (() => [])
list: overrides.connections?.list ?? (() => []),
tables: overrides.connections?.tables ?? (() => [])
},

@@ -105,0 +106,0 @@ tools: {

@@ -78,3 +78,27 @@ /**

get(id: string): PluginDBConnection;
/** Registered connection IDs — NOT semantic-layer object names. */
list(): string[];
/**
* Semantic-layer entity/table (for ES: index) names registered for a
* connection — the per-object membership whitelist a plugin query tool must
* enforce. In self-host / static-datasource mode this mirrors the
* filesystem whitelist `executeSQL` validates against, so a plugin's bespoke
* query tool (SOQL / Query DSL) honors the same boundary as the SQL path.
* (Org-scoped SaaS validates `executeSQL` against the DB-backed whitelist;
* the static-config tools this serves are a self-host surface.)
*
* `id` must be a registered connection id (typically the plugin's own `id`);
* an unrecognized id returns `[]`. Returns `[]` when the connection has no
* semantic layer configured — a tool fed an empty set falls back to
* structural-only validation (the intended behavior for an unconfigured
* layer). See #3307.
*
* THROWS when the whitelist is empty because a semantic-layer directory scan
* FAILED (#3243), rather than returning `[]`. This lets a bespoke query tool
* FAIL CLOSED (refuse the query) on a scan failure instead of silently
* dropping to structural-only — which would widen access to any
* explicitly-named, non-system object the credential can read. A tool should
* call this inside a try/catch and surface a clean refusal on throw (#3313).
*/
tables(id: string): readonly string[];
};

@@ -300,2 +324,45 @@ /** Tool registry — plugins can register additional tools. */

/**
* Per-workspace uninstall hook (#3188). Invoked when a workspace
* uninstalls this plugin — on both uninstall paths (the marketplace
* `DELETE /api/v1/admin/marketplace/:id` route and
* `WorkspaceInstaller.uninstall`) — BEFORE Atlas removes the install
* row and credential stores. At call time the plugin can therefore
* still authenticate against the external platform to revoke webhook
* subscriptions, OAuth grants, or any other external state it
* registered for that workspace.
*
* Contrast with {@link teardown}, which runs once at process shutdown:
* `onUninstall` is per-workspace and fires at uninstall time. A plugin
* that registers an external webhook subscription (Slack, GitHub,
* Stripe, …) MUST revoke it here — an un-revoked webhook keeps
* delivering events to a workspace that no longer has the plugin
* installed, and Atlas cannot revoke it for you.
*
* Attribution rule: NEVER revoke an external subscription you cannot
* positively attribute to the uninstalling workspace (a recorded id,
* a metadata workspace tag, or a workspace marker in the callback
* URL). The hook may fire against a credential shared with other
* workspaces or out-of-band tooling — bulk-deleting everything the
* credential can see destroys state that isn't yours.
*
* Best-effort contract: a thrown error is logged by the host (with
* plugin id + workspaceId) and does NOT abort the uninstall — the
* install-row removal proceeds; each invocation also runs against a
* host-side deadline (15s), so don't rely on this hook for
* load-bearing cleanup.
*
* Coverage carve-outs: the hook does NOT fire on datasource
* disconnects (datasource installs are removed via the
* datasource-specific delete paths) or on a workspace purge — only
* the two plugin-uninstall paths above invoke it.
*
* Resolution: the host invokes the hook on the per-workspace plugin
* instance built by its lazy loader (SaaS / marketplace installs), and
* on any globally-registered plugin whose `id` equals the uninstalled
* catalog entry's slug, catalog id, or `<slug>-<type>` (the naming
* convention used by the bundled plugins, e.g. `jira-action` for
* catalog slug `jira`).
*/
onUninstall?(workspaceId: string): Promise<void> | void;
/**
* Agent lifecycle and HTTP hooks using the matcher + handler pattern.

@@ -315,4 +382,152 @@ * Matchers are optional — omit to always fire.

cacheBackend?: PluginCacheBackend;
/**
* Register MCP tools the plugin contributes. Called once at boot after
* `initialize()` resolves. The host namespaces each returned tool's
* `name` as `<plugin-id>.<name>` and registers it on the MCP server so
* it appears in `tools/list` alongside Atlas's own (`executeSQL`,
* `explore`, the typed semantic tools).
*
* Plugin tool descriptions go through the same `withErrorContract` +
* word-count rubric as native tools — drift fails CI. Inputs are
* zod-validated before the handler runs; handler errors get wrapped in
* the standard `AtlasMcpToolError` envelope so a misbehaving plugin
* cannot return arbitrary error shapes. Audit + OTel coverage matches
* native tools with no special-case path.
*/
mcpTools?(): readonly AtlasMcpTool[];
}
/**
* Structural Zod-shaped schema accepted by `AtlasMcpTool.inputSchema`.
*
* Defined structurally so the plugin SDK does not take a hard runtime
* dependency on a specific Zod version (plugins ship their own zod). Any
* object exposing `parse()`, `safeParse()`, and `_def` (the marker the AI
* SDK / MCP SDK use to detect Zod schemas) satisfies the contract.
*/
export interface PluginZodSchema<TOut = unknown> {
parse(input: unknown): TOut;
safeParse(input: unknown): {
success: true;
data: TOut;
} | {
success: false;
error: {
issues: ReadonlyArray<{
path: ReadonlyArray<PropertyKey>;
message: string;
}>;
message: string;
};
};
/**
* Zod's internal definition object — the MCP SDK and the AI SDK both
* introspect this to derive a JSON Schema for the tool's input.
* Required (not optional) so a structural impostor with only
* `parse` / `safeParse` cannot typecheck through `register()` and
* later fail at MCP `tools/list` generation far from the authoring
* site. Type is `unknown` because the shape is opaque across Zod
* versions; the contract is "this field exists and the host's
* schema-introspection layer will probe it."
*/
readonly _def: unknown;
}
/**
* Per-dispatch context passed to a plugin MCP tool's `handler`.
*
* The host populates this from the same RequestContext that backs the
* audit log and OTel attribution. `clientId` is set for hosted MCP (per
* #2067) and undefined for stdio. `audit` is a fire-and-forget structured
* event emitter — it never throws back to the handler.
*/
export interface McpToolContext {
/** Resolved workspace id (`actor.activeOrganizationId` or `actor.id`). */
readonly workspaceId: string;
/** Bound MCP actor user id. */
readonly userId: string;
/** Hosted-MCP OAuth `client_id`. Undefined for stdio dispatches. */
readonly clientId?: string;
/** Per-dispatch request id — surfaces in `audit_log.request_id` / OTel spans. */
readonly requestId: string;
/** Owning plugin id (matches the `<plugin-id>.<name>` namespace). */
readonly pluginId: string;
/**
* #2345 — group-aware routing surfaced additively to plugin tools.
*
* `connectionId` is the conversation's *execution target* (or the
* per-turn override) — pass through to `executeSQL` or any other
* connection-keyed tool the plugin invokes. Absent when the
* dispatch is not chat-routed (legacy single-connection deploy,
* scheduler context, ad-hoc MCP call with no env picker).
*
* `connectionGroupId` is the *content scope* — the connection group
* whose entities, dashboards, and approvals resolve for this turn.
* Decoupled from `connectionId` (a multi-member "prod" group may
* resolve content while `connectionId` targets a single replica),
* so a plugin that overlays group-scoped content should read this
* field rather than reaching for `connectionId`.
*/
readonly connectionId?: string;
readonly connectionGroupId?: string;
/** Pino-compatible child logger scoped to the plugin + tool. */
readonly logger: PluginLogger;
/**
* Fire-and-forget structured event emitter. Plugins log domain-specific
* audit signals here; the host binds the `mcp` actor on the request
* context so any nested `executeSQL` (or any other code path that
* writes its own `audit_log` row) is stamped with the plugin tool's
* `qualifiedName`, `clientId`, and request id consistently. The host
* does NOT itself write a row to `audit_log` for the dispatch — pure
* plugin tools that don't invoke `executeSQL` produce zero rows in
* `audit_log`, just structured pino events via this `audit()` call.
* Failures inside `audit` are swallowed and logged — they never
* propagate.
*/
audit(entry: McpToolAuditEntry): void;
}
/** Structured audit event a plugin handler can emit via `McpToolContext.audit`. */
export interface McpToolAuditEntry {
/** Short event name, e.g. `"runbooks.search"`, `"runbook.fetched"`. */
readonly event: string;
/** Whether the operation the event describes succeeded. */
readonly success: boolean;
/** Optional duration in ms for timing-shaped events. */
readonly durationMs?: number;
/** Optional structured payload. Avoid sensitive data — values land in pino logs. */
readonly metadata?: Readonly<Record<string, unknown>>;
}
/**
* A single MCP tool contributed by a plugin via `AtlasPluginBase.mcpTools()`.
*
* The `name` is the local (un-namespaced) tool identifier. The host
* registers it as `<plugin-id>.<name>` to avoid collisions with Atlas's
* own tools (`executeSQL`, `explore`, `listEntities`, `describeEntity`,
* `searchGlossary`, `runMetric`). Local names must match
* `^[a-zA-Z][a-zA-Z0-9_-]{0,63}$` — no dots, slashes, or whitespace.
*
* `inputSchema` is enforced before `handler` runs: a parse failure short-
* circuits with a `validation_failed` error envelope and the handler is
* never invoked. Handler throws are wrapped in an `internal_error`
* envelope carrying the dispatch's `request_id` so an LLM agent can
* correlate the failure with server logs.
*/
export interface AtlasMcpTool<TInput = unknown, TOutput = unknown> {
/** Local tool name. Host namespaces as `<plugin-id>.<name>`. */
readonly name: string;
/**
* LLM-facing description. Goes through the same rubric as native tools
* (80–150 words, `Use this when …`, `Don't use this …`/`Avoid …`, at
* least one inline JSON example) and gets the same `Error contract:`
* appendage at registration time. Drift fails CI.
*/
readonly description: string;
/** Per-tool error catalog appended to the description via `withErrorContract`. */
readonly errorCodes?: ReadonlyArray<string>;
/** Zod schema for validating the LLM-supplied arguments. */
readonly inputSchema: PluginZodSchema<TInput>;
/** Optional Zod schema describing the structured response shape. */
readonly outputSchema?: PluginZodSchema<TOutput>;
/** Tool handler. Receives the parsed args and a per-dispatch context. */
handler(args: TInput, context: McpToolContext): Promise<TOutput>;
}
/**
* A single entity definition shipped by a datasource plugin.

@@ -334,4 +549,37 @@ * Uses the same YAML format as `semantic/entities/*.yml`.

readonly connection: {
/** Factory: create a DBConnection for the registry. */
create(): Promise<PluginDBConnection> | PluginDBConnection;
/**
* Factory: create a DBConnection for the registry from the plugin's
* config-time config (the object passed to the plugin factory in
* `atlas.config.ts`). Used by the boot-time static wiring path
* (`wireDatasourcePlugins`) to register a single config-defined
* connection.
*
* Optional: a plugin registered as an ADAPTER ONLY — the SaaS
* per-workspace model where every datasource is DB-stored
* (admin-UI-registered, encrypted), not baked into operator config —
* omits `create` and implements only {@link createFromConfig}.
* `wireDatasourcePlugins` skips adapter-only plugins for static wiring;
* the datasource bridge still finds them via the registry's `getAll()`
* to build per-(workspace, install) connections on demand. At least one
* of `create` / `createFromConfig` must be present — enforced by
* `validatePluginShape`.
*/
create?(): Promise<PluginDBConnection> | PluginDBConnection;
/**
* Factory: create a DBConnection from a runtime config resolved from a
* DB-stored datasource install (admin-UI-registered, persisted in
* `workspace_plugins`). Unlike {@link create}, which closes over the
* config-time config, this accepts the per-(workspace, install) config
* decrypted from the database — enabling multi-tenant, DB-driven
* datasources of this plugin's `dbType` rather than a single static
* connection.
*
* The `config` is the raw decrypted config record; the plugin validates
* it with its own schema (typically the same `configSchema`) and builds
* the connection. Throw a clear error when required fields are missing.
*
* Plugins that only support static config-defined connections may omit
* this; DB-stored installs of their `dbType` then remain unsupported.
*/
createFromConfig?(config: Readonly<Record<string, unknown>>): Promise<PluginDBConnection> | PluginDBConnection;
/** Database type identifier (used for SQL dialect selection). */

@@ -529,3 +777,3 @@ dbType: PluginDBType;

* type CH = $InferServerPlugin<typeof clickhousePlugin>;
* // CH["Config"] → { url: string; database?: string }
* // CH["Config"] → { url?: string; database?: string }
* // CH["Types"] → readonly ["datasource"]

@@ -532,0 +780,0 @@ * // CH["Id"] → string

{
"name": "@useatlas/plugin-sdk",
"version": "0.0.7",
"version": "0.0.9",
"description": "Type definitions and helpers for authoring Atlas plugins",
"type": "module",
"scripts": {
"build": "rm -rf dist && bun build src/index.ts src/types.ts src/helpers.ts src/ai.ts src/hono.ts src/testing.ts --outdir dist --target node --packages external && bun x tsc -p tsconfig.build.json",
"prepublishOnly": "bun run build",
"test": "bun test src/__tests__/types.test.ts && bun test src/__tests__/infer.test.ts && bun test src/__tests__/testing.test.ts"
"build": "rm -rf dist && bun build src/index.ts src/types.ts src/helpers.ts src/ai.ts src/hono.ts src/testing.ts --outdir dist --target node --packages external && ./node_modules/.bin/tsc -p tsconfig.build.json",
"prepare": "bun run build",
"test": "bun test src/__tests__/types.test.ts && bun test src/__tests__/infer.test.ts && bun test src/__tests__/testing.test.ts && bun test src/__tests__/semantic-whitelist.test.ts"
},

@@ -60,3 +60,3 @@ "exports": {

},
"homepage": "https://useatlas.dev",
"homepage": "https://www.useatlas.dev",
"bugs": {

@@ -79,6 +79,7 @@ "url": "https://github.com/AtlasDevHQ/atlas/issues"

"devDependencies": {
"ai": "^6.0.141",
"hono": "^4.12.9",
"zod": "^4.3.6"
"ai": "^6.0.193",
"hono": "^4.12.23",
"typescript": "^6.0.3",
"zod": "^4.4.3"
}
}

@@ -45,5 +45,8 @@ # @useatlas/plugin-sdk

A datasource plugin must provide **at least one** connection factory: `create()` for a static config-defined connection, `createFromConfig()` for a DB-stored (admin-registered) per-workspace connection, or both. A plugin with only `createFromConfig()` is an **adapter-only** plugin — registered for use but with no static datasource (the multi-tenant SaaS model, where every connection is added per workspace).
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `connection.create()` | `() => PluginDBConnection` | Yes | Factory that returns a connection with `query()` and `close()` |
| `connection.create()` | `() => PluginDBConnection \| Promise<PluginDBConnection>` | One of create/createFromConfig | Factory that returns a connection with `query()` and `close()` from the plugin's config-time config (static / self-host) |
| `connection.createFromConfig()` | `(config) => PluginDBConnection \| Promise<PluginDBConnection>` | One of create/createFromConfig | Factory that builds a connection from a DB-stored per-(workspace, install) config (adapter-only / SaaS) |
| `connection.dbType` | `PluginDBType` | Yes | Database type identifier (`postgres`, `mysql`, `clickhouse`, `snowflake`, `duckdb`, or custom) |

@@ -74,3 +77,3 @@ | `entities` | `EntityProvider` | No | Semantic layer fragments merged into the table whitelist at boot |

**Reference:** [`slack`](../../plugins/slack/src/index.ts), [`mcp`](../../plugins/mcp/src/index.ts)
**Reference:** [`chat`](../../plugins/chat/src/index.ts), [`mcp`](../../plugins/mcp/src/index.ts)

@@ -119,2 +122,3 @@ ### Action (`AtlasActionPlugin`)

| `teardown()` | `() => Promise<void>` | No | Graceful shutdown (LIFO order) |
| `onUninstall(workspaceId)` | `(string) => Promise<void> \| void` | No | Per-workspace uninstall hook — revoke external webhook subscriptions / OAuth grants the plugin registered for that workspace |
| `hooks` | `PluginHooks` | No | Agent lifecycle and HTTP hooks |

@@ -127,2 +131,3 @@ | `schema` | `Record<string, PluginTableDefinition>` | No | Declarative table definitions for the internal DB |

register → initialize(ctx) → healthCheck() → ... → teardown()
└── onUninstall(workspaceId) (per-workspace, at uninstall time)
```

@@ -137,7 +142,22 @@

- `ctx.config` — Resolved Atlas configuration.
3. **Health check** — Periodic probe. Return `{ healthy: false, message }` to signal degradation. Never throw.
4. **Teardown** — Graceful shutdown in reverse registration order (LIFO).
3. **Health check** — Periodic probe. Return `{ healthy: false, message }` to signal degradation. Never throw. Results surface in the `plugins` component of `GET /api/health` — a failing probe shifts the top-level status to `degraded` (HTTP 200, never 503).
4. **Teardown** — Graceful shutdown in reverse registration order (LIFO). Use `teardown()` to release process-level state — third-party connections, drained queues, timers. **Note:** `teardown()` runs on server shutdown, *not* on a per-workspace uninstall (uninstall is a DB-row removal, not a process event) — per-workspace cleanup belongs in `onUninstall`.
5. **Per-workspace uninstall** — `onUninstall(workspaceId)` fires when a workspace uninstalls the plugin (both the marketplace `DELETE` route and `WorkspaceInstaller.uninstall`), **before** Atlas removes the install row and credential stores — so the plugin can still authenticate against the external platform. Use it to revoke webhook subscriptions, OAuth grants, or any other external state registered for that workspace. **Attribution rule: never revoke a subscription you can't positively attribute to the uninstalling workspace** (recorded id, metadata tag, or workspace marker in the callback URL) — the credential may be shared with other workspaces or out-of-band tooling. Best-effort: a thrown error is logged with the plugin id + workspaceId and the uninstall proceeds (each invocation also runs against a 15s host-side deadline); never rely on it for load-bearing cleanup. It does **not** fire on datasource disconnects or a workspace purge — only the two plugin-uninstall paths above. See `packages/api/src/lib/integrations/jira/lazy-builder.ts` for a reference implementation (revokes only workspace-attributed Jira dynamic webhook subscriptions).
> **v1.1 note:** `AtlasPluginContext` will gain `executeQuery`, `conversations`, and `actions` fields for full host-level decoupling. Currently, interaction plugins that need these inject them via config callbacks.
## Uninstall Contract
`DELETE /api/v1/admin/marketplace/:id` removes a plugin from a workspace. The cleanup contract:
| State | Survives uninstall? | Notes |
|-------|---------------------|-------|
| `workspace_plugins` row | No (deleted) | Canonical "is this plugin installed?" record. |
| `scheduled_tasks` rows tagged with the plugin's `catalog_id` | No (deleted) | Scoped by `(plugin_id, org_id)` so cleanup never crosses workspaces. `scheduled_task_runs` cascade via FK. **Cleanup runs in a separate statement after the install row is removed; partial failure leaves the uninstall committed and is recorded as a `cleanupFailed: true` audit event.** |
| `plugin_<pluginId>_*` tables (declared via `schema`) | **Yes** (retained) | Reinstall picks up where it left off — cached digest history, sync cursors, etc. Hard-reset only via workspace purge. |
| In-process hook registrations | **Not detached** on uninstall — `teardown()` runs only at server shutdown | Hooks become inert for the uninstalled workspace because dispatch checks `workspace_plugins` for the installation. |
| Webhook subscriptions registered with external platforms | **Yes — unless your `onUninstall(workspaceId)` revokes them** | Atlas has no visibility into external state. Implement `onUninstall` to revoke the subscription — it fires before the install row and credentials are removed, so your plugin can still authenticate. Revoke only subscriptions attributable to the uninstalling workspace. An un-revoked webhook keeps delivering events to a workspace that no longer has the plugin installed. Note the hook fires only on plugin uninstalls — datasource disconnects and workspace purges skip it. |
If your plugin creates `scheduled_tasks` rows, set `plugin_id = $catalogId` and `org_id = $orgId` on insert so the uninstall cleanup picks them up. Untagged tasks (`plugin_id IS NULL`) are treated as user-created and survive uninstall. See the [authoring guide](https://docs.useatlas.dev/plugins/authoring-guide#uninstall-contract) for the full lifecycle.
## Config Validation

@@ -296,3 +316,3 @@

| [mcp](../../plugins/mcp/) | Interaction | `@useatlas/mcp` | MCP server lifecycle (stdio + SSE) |
| [slack](../../plugins/slack/) | Interaction | `@useatlas/slack` | Slack bot (slash commands, threads, OAuth) |
| [chat](../../plugins/chat/) | Interaction | `@useatlas/chat` | Chat SDK bridge — Slack, Teams, Discord, etc. via unified adapter |
| [jira](../../plugins/jira/) | Action | `@useatlas/jira` | Create JIRA tickets from analysis |

@@ -299,0 +319,0 @@ | [email](../../plugins/email/) | Action | `@useatlas/email` | Send email reports via Resend |