@gcszhn/mcp-sentinel-opencode2-plugin 0.0.0-stage → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 mcp-sentinel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # @gcszhn/mcp-sentinel-opencode2-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [OpenCode 2](https://opencode.ai) (**V2**) plugin that acts as a **sentinel**
4
+ between the AI agent and MCP servers — polling long-running tasks on the agent's
5
+ behalf so that token-costly status loops never enter the LLM inference path.
6
+
7
+ This package is the OpenCode 2 harness adapter for `@gcszhn/mcp-sentinel-core`.
8
+ It uses the V2 plugin API (`Plugin.define` / `ctx.tool.transform` /
9
+ `ctx.mcp.transform` / `ctx.session.synthetic`) and is independent from the V1
10
+ `@gcszhn/mcp-sentinel-opencode-plugin`.
11
+
12
+ ## Install
13
+
14
+ Add the plugin to your OpenCode 2 config (`opencode.jsonc`):
15
+
16
+ ```jsonc
17
+ {
18
+ "plugins": ["@gcszhn/mcp-sentinel-opencode2-plugin"],
19
+ }
20
+ ```
21
+
22
+ The plugin discovers the MCP servers already configured under `mcp.servers` in
23
+ your OpenCode config and opens its own connections to them. **No sentinel-specific
24
+ MCP configuration is required.**
25
+
26
+ Why its own connections: the V2 plugin SDK (`@opencode/plugin` 2.3.0) has no API
27
+ for invoking a host MCP tool. `ctx.mcp` only exposes `list` / `transform` /
28
+ `reload` (server config and status), and the host client's `mcp.*` API is
29
+ limited to `list`, `resource.catalog`, `add`, `remove`, `connect`, `disconnect`,
30
+ and `client.token`. `ctx.tool` can register and inspect plugin tools, and hooks
31
+ can observe agent tool calls, but nothing can execute a host MCP tool
32
+ programmatically. The adapter therefore runs in connection-pool mode: it reads
33
+ the server config from the host and lets the shared core own the MCP client.
34
+
35
+ The config capture also acts as the reload hook. V2 replays every registered
36
+ MCP transform whenever the registry is rebuilt (config change, another plugin's
37
+ transform, `ctx.mcp.reload()`), so the same callback refreshes the server list
38
+ and calls the core's `reconcileConnections`: servers that were removed, disabled,
39
+ or whose `command`/`cwd`/`env`/`url`/`headers` changed have their cached client
40
+ closed instead of leaking a child process. Servers that are still configured
41
+ keep their connection, and every V2 location's config is passed to the
42
+ reconciliation so one location never closes a connection another one still
43
+ polls.
44
+
45
+ Unlike V1, V2 config declares servers under `mcp.servers.{name}`:
46
+
47
+ ```jsonc
48
+ {
49
+ "mcp": {
50
+ "servers": {
51
+ "my-server": {
52
+ "type": "local",
53
+ "command": ["bun", "run", "./server.ts"],
54
+ },
55
+ },
56
+ },
57
+ }
58
+ ```
59
+
60
+ ## Tools
61
+
62
+ ### `mcp_sentinel_poll`
63
+
64
+ Submit a long-running MCP tool call and poll it at regular intervals until a
65
+ condition is met. Returns a sentinel ID immediately; the agent is notified in
66
+ its session when the sentinel resolves.
67
+
68
+ | Parameter | Type | Default | Description |
69
+ | ---------- | ------ | ---------- | ------------------------------------------ |
70
+ | `server` | string | _required_ | MCP server name (from `mcp.servers`) |
71
+ | `tool` | string | _required_ | Tool name to call on the server |
72
+ | `args` | object | `{}` | JSON object of arguments for the tool |
73
+ | `interval` | number | `5000` | Poll interval in milliseconds |
74
+ | `timeout` | number | _optional_ | Max poll duration in ms (unset = no limit) |
75
+ | `until` | object | _required_ | JSON condition object |
76
+
77
+ ### `mcp_sentinel_status`
78
+
79
+ Check status, list active tasks, or cancel a running task (`action` =
80
+ `status` | `list` | `cancel`).
81
+
82
+ ### `mcp_sentinel_attach`
83
+
84
+ Block the agent, waiting for a sentinel to complete. Zero token cost during
85
+ the wait.
86
+
87
+ ### `mcp_sentinel_read`
88
+
89
+ Read raw poll outputs with offset/limit pagination.
90
+
91
+ ## Condition model
92
+
93
+ Conditions are pure declarative data:
94
+
95
+ ```jsonc
96
+ { "path": "status", "is": "eq", "value": "completed" }
97
+ { "and": [
98
+ { "path": "status", "is": "eq", "value": "completed" },
99
+ { "path": "tasks[0].exit_code", "is": "eq", "value": 0 }
100
+ ] }
101
+ ```
102
+
103
+ See the repository root `README.md` for the full operator and path syntax.
104
+
105
+ ## Options
106
+
107
+ Options are read from the plugin entry in V2 config:
108
+
109
+ ```jsonc
110
+ {
111
+ "plugins": [
112
+ {
113
+ "package": "@gcszhn/mcp-sentinel-opencode2-plugin",
114
+ "options": { "delivery": "queue", "resume": true },
115
+ },
116
+ ],
117
+ }
118
+ ```
119
+
120
+ | Option | Type | Default | Description |
121
+ | ---------- | --------- | --------- | --------------------------------------------------------------------- |
122
+ | `delivery` | `string` | `"queue"` | How completion notices enter the session: `"queue"` or `"steer"`. |
123
+ | `resume` | `boolean` | `true` | Whether a completion notice resumes a session that is currently idle. |
124
+
125
+ Notifications are delivered with `ctx.session.synthetic`, so they appear as a
126
+ background system notice rather than a user prompt.
127
+
128
+ ## OAuth limitation
129
+
130
+ The sentinel opens its **own** MCP connection using the connection details in
131
+ `mcp.servers`, so it cannot reuse credentials that OpenCode obtained through an
132
+ interactive OAuth flow. If a remote server is reported as `needs_auth` and has
133
+ no static `headers`, `mcp_sentinel_poll` returns an explicit error explaining
134
+ that static headers must be configured instead of failing later with an opaque 401.
135
+
136
+ ## Environment variables
137
+
138
+ | Variable | Default | Description |
139
+ | ----------------------- | --------- | ----------------------------------------- |
140
+ | `SENTINEL_MAX_POLL_LOG` | unlimited | Max poll log entries per task (FIFO trim) |
141
+ | `SENTINEL_TASK_TTL_MS` | unlimited | Auto-cleanup completed tasks after N ms |
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,93 @@
1
+ /**
2
+ * OpenCode 2 (V2) harness — MCP config discovery and preflight checks.
3
+ *
4
+ * In V2 the host exposes MCP configuration through the `ctx.mcp` domain. The
5
+ * plugin captures the current server list from a synchronous `transform`
6
+ * callback (the only place V2 exposes `editor.list()`), then normalizes that
7
+ * V2 shape into the core's {@link McpConfig} for connection-pool mode:
8
+ *
9
+ * ```jsonc
10
+ * {
11
+ * "mcp": {
12
+ * "servers": {
13
+ * "name": {
14
+ * "type": "local",
15
+ * "command": ["bun", "run", "server.ts"],
16
+ * "environment": { "KEY": "value" },
17
+ * "disabled": false
18
+ * }
19
+ * }
20
+ * }
21
+ * }
22
+ * ```
23
+ *
24
+ * Differences from V1:
25
+ * - servers live under `mcp.servers.{name}` (not flat `mcp.{name}`)
26
+ * - `environment` replaces `env`
27
+ * - `disabled` is the inverse of V1's `enabled`
28
+ *
29
+ * @module
30
+ */
31
+ import type { McpConfig } from "@gcszhn/mcp-sentinel-core";
32
+ /**
33
+ * Minimal structural view of a V2 `Mcp.ServerConfig`.
34
+ *
35
+ * Kept intentionally loose (and free of `@opencode/plugin`/`effect` types) so
36
+ * this module is testable in isolation and stays tolerant of schema additions.
37
+ */
38
+ export interface Opencode2McpServerConfig {
39
+ readonly type?: string;
40
+ readonly command?: unknown;
41
+ readonly cwd?: unknown;
42
+ readonly environment?: unknown;
43
+ readonly disabled?: unknown;
44
+ readonly url?: unknown;
45
+ readonly headers?: unknown;
46
+ readonly oauth?: unknown;
47
+ }
48
+ /** One `[name, config]` entry as returned by the V2 MCP editor. */
49
+ export type Opencode2McpEntry = readonly [string, Opencode2McpServerConfig];
50
+ /** MCP connection status as reported by `ctx.mcp.list()`. */
51
+ export interface Opencode2McpStatus {
52
+ readonly status: string;
53
+ readonly error?: string;
54
+ }
55
+ /**
56
+ * Normalize the V2 MCP server list into the core's {@link McpConfig}.
57
+ *
58
+ * Invalid entries (missing/non-string `command`, empty `url`, …) are skipped,
59
+ * matching the core's contract that only usable server configs reach the
60
+ * resolver. Disabled servers are kept but marked `enabled: false`, so the
61
+ * core's {@link makeServerResolver} excludes them consistently.
62
+ *
63
+ * @param entries - `[name, config]` entries captured from `ctx.mcp.transform`.
64
+ * @returns A typed config with a null-prototype `servers` map.
65
+ */
66
+ export declare function normalizeOpencode2McpConfig(entries: Iterable<Opencode2McpEntry> | null | undefined): McpConfig;
67
+ /**
68
+ * Look up a raw V2 entry by server name.
69
+ *
70
+ * Iterates instead of indexing a map so names such as `__proto__` cannot read
71
+ * inherited properties.
72
+ */
73
+ export declare function findMcpEntry(entries: Iterable<Opencode2McpEntry> | null | undefined, name: string): Opencode2McpEntry | undefined;
74
+ /** Whether a remote server declares OAuth authentication (`oauth` other than `false`). */
75
+ export declare function serverUsesOAuth(entry: Opencode2McpEntry | undefined): boolean;
76
+ /** Whether a remote server has non-empty static headers the sentinel can reuse. */
77
+ export declare function serverHasHeaders(entry: Opencode2McpEntry | undefined): boolean;
78
+ /**
79
+ * Preflight a poll target and return a clean `"Error: ..."` string when the
80
+ * server cannot be used, or `undefined` when it can.
81
+ *
82
+ * V2's MCP config is the source of truth for connection details, while the
83
+ * host's connection status is the source of truth for authentication. The
84
+ * sentinel opens its own MCP connection, so it cannot inherit host OAuth
85
+ * credentials — a `needs_auth` server without static headers is reported
86
+ * explicitly instead of failing later with an opaque 401.
87
+ *
88
+ * @param entries - Captured V2 MCP entries.
89
+ * @param server - Requested server name.
90
+ * @param status - Optional `ctx.mcp.list()` status for that server.
91
+ */
92
+ export declare function describeServerProblem(entries: Iterable<Opencode2McpEntry> | null | undefined, server: string, status?: Opencode2McpStatus): string | undefined;
93
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,KAAK,EACV,SAAS,EAIV,MAAM,2BAA2B,CAAC;AAEnC;;;;;GAKG;AACH,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,mEAAmE;AACnE,MAAM,MAAM,iBAAiB,GAAG,SAAS,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;AAE5E,6DAA6D;AAC7D,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,QAAQ,CAAC,iBAAiB,CAAC,GAAG,IAAI,GAAG,SAAS,GACtD,SAAS,CAgBX;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,QAAQ,CAAC,iBAAiB,CAAC,GAAG,IAAI,GAAG,SAAS,EACvD,IAAI,EAAE,MAAM,GACX,iBAAiB,GAAG,SAAS,CAM/B;AAED,0FAA0F;AAC1F,wBAAgB,eAAe,CAAC,KAAK,EAAE,iBAAiB,GAAG,SAAS,GAAG,OAAO,CAI7E;AAED,mFAAmF;AACnF,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,iBAAiB,GAAG,SAAS,GAAG,OAAO,CAI9E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,QAAQ,CAAC,iBAAiB,CAAC,GAAG,IAAI,GAAG,SAAS,EACvD,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,kBAAkB,GAC1B,MAAM,GAAG,SAAS,CAsBpB"}
package/dist/config.js ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * OpenCode 2 (V2) harness — MCP config discovery and preflight checks.
3
+ *
4
+ * In V2 the host exposes MCP configuration through the `ctx.mcp` domain. The
5
+ * plugin captures the current server list from a synchronous `transform`
6
+ * callback (the only place V2 exposes `editor.list()`), then normalizes that
7
+ * V2 shape into the core's {@link McpConfig} for connection-pool mode:
8
+ *
9
+ * ```jsonc
10
+ * {
11
+ * "mcp": {
12
+ * "servers": {
13
+ * "name": {
14
+ * "type": "local",
15
+ * "command": ["bun", "run", "server.ts"],
16
+ * "environment": { "KEY": "value" },
17
+ * "disabled": false
18
+ * }
19
+ * }
20
+ * }
21
+ * }
22
+ * ```
23
+ *
24
+ * Differences from V1:
25
+ * - servers live under `mcp.servers.{name}` (not flat `mcp.{name}`)
26
+ * - `environment` replaces `env`
27
+ * - `disabled` is the inverse of V1's `enabled`
28
+ *
29
+ * @module
30
+ */
31
+ /**
32
+ * Normalize the V2 MCP server list into the core's {@link McpConfig}.
33
+ *
34
+ * Invalid entries (missing/non-string `command`, empty `url`, …) are skipped,
35
+ * matching the core's contract that only usable server configs reach the
36
+ * resolver. Disabled servers are kept but marked `enabled: false`, so the
37
+ * core's {@link makeServerResolver} excludes them consistently.
38
+ *
39
+ * @param entries - `[name, config]` entries captured from `ctx.mcp.transform`.
40
+ * @returns A typed config with a null-prototype `servers` map.
41
+ */
42
+ export function normalizeOpencode2McpConfig(entries) {
43
+ // Null-prototype map so a server literally named `__proto__` cannot mutate
44
+ // the object's prototype.
45
+ const servers = Object.create(null);
46
+ if (!entries)
47
+ return { servers };
48
+ for (const entry of entries) {
49
+ if (!Array.isArray(entry) || entry.length < 2)
50
+ continue;
51
+ const [name, raw] = entry;
52
+ if (typeof name !== "string" || !isRecord(raw))
53
+ continue;
54
+ const normalized = normalizeServerEntry(raw);
55
+ if (normalized)
56
+ servers[name] = normalized;
57
+ }
58
+ return { servers };
59
+ }
60
+ /**
61
+ * Look up a raw V2 entry by server name.
62
+ *
63
+ * Iterates instead of indexing a map so names such as `__proto__` cannot read
64
+ * inherited properties.
65
+ */
66
+ export function findMcpEntry(entries, name) {
67
+ if (!entries)
68
+ return undefined;
69
+ for (const entry of entries) {
70
+ if (Array.isArray(entry) && entry.length >= 2 && entry[0] === name)
71
+ return entry;
72
+ }
73
+ return undefined;
74
+ }
75
+ /** Whether a remote server declares OAuth authentication (`oauth` other than `false`). */
76
+ export function serverUsesOAuth(entry) {
77
+ if (!entry)
78
+ return false;
79
+ const raw = entry[1];
80
+ return raw.type === "remote" && raw.oauth !== undefined && raw.oauth !== false;
81
+ }
82
+ /** Whether a remote server has non-empty static headers the sentinel can reuse. */
83
+ export function serverHasHeaders(entry) {
84
+ if (!entry)
85
+ return false;
86
+ const headers = entry[1].headers;
87
+ return isRecord(headers) && Object.keys(headers).length > 0;
88
+ }
89
+ /**
90
+ * Preflight a poll target and return a clean `"Error: ..."` string when the
91
+ * server cannot be used, or `undefined` when it can.
92
+ *
93
+ * V2's MCP config is the source of truth for connection details, while the
94
+ * host's connection status is the source of truth for authentication. The
95
+ * sentinel opens its own MCP connection, so it cannot inherit host OAuth
96
+ * credentials — a `needs_auth` server without static headers is reported
97
+ * explicitly instead of failing later with an opaque 401.
98
+ *
99
+ * @param entries - Captured V2 MCP entries.
100
+ * @param server - Requested server name.
101
+ * @param status - Optional `ctx.mcp.list()` status for that server.
102
+ */
103
+ export function describeServerProblem(entries, server, status) {
104
+ // Empty names are reported by the core handler with its canonical message.
105
+ if (!server.trim())
106
+ return undefined;
107
+ const entry = findMcpEntry(entries, server);
108
+ if (!entry)
109
+ return `Error: Unknown MCP server: ${server}`;
110
+ const raw = entry[1];
111
+ if (raw.disabled === true) {
112
+ return `Error: MCP server "${server}" is disabled in OpenCode configuration.`;
113
+ }
114
+ const normalized = normalizeOpencode2McpConfig(entries);
115
+ if (!Object.hasOwn(normalized.servers, server)) {
116
+ return `Error: MCP server "${server}" has an unsupported configuration (expected a local command array or a remote url).`;
117
+ }
118
+ if (status?.status === "needs_auth" && !serverHasHeaders(entry)) {
119
+ return `Error: MCP server "${server}" requires OAuth authentication. mcp-sentinel opens its own MCP connection and cannot reuse OpenCode's OAuth credentials; configure static headers for this server instead.${status.error ? ` (${status.error})` : ""}`;
120
+ }
121
+ return undefined;
122
+ }
123
+ /** Normalize one raw V2 server entry, or `null` when it is not usable. */
124
+ function normalizeServerEntry(raw) {
125
+ const disabled = raw.disabled === true;
126
+ if (raw.type === "local") {
127
+ const command = normalizeCommand(raw.command);
128
+ if (!command)
129
+ return null;
130
+ const local = {
131
+ type: "local",
132
+ command,
133
+ enabled: !disabled,
134
+ };
135
+ if (typeof raw.cwd === "string")
136
+ local.cwd = raw.cwd;
137
+ const env = normalizeStringMap(raw.environment);
138
+ if (env)
139
+ local.env = env;
140
+ return local;
141
+ }
142
+ if (raw.type === "remote") {
143
+ if (typeof raw.url !== "string" || raw.url.trim() === "")
144
+ return null;
145
+ const remote = {
146
+ type: "remote",
147
+ url: raw.url,
148
+ enabled: !disabled,
149
+ };
150
+ const headers = normalizeStringMap(raw.headers);
151
+ if (headers)
152
+ remote.headers = headers;
153
+ return remote;
154
+ }
155
+ return null;
156
+ }
157
+ /** Accept only a non-empty array of stringifiable command parts. */
158
+ function normalizeCommand(command) {
159
+ if (!Array.isArray(command) || command.length === 0)
160
+ return null;
161
+ if (!command.every((part) => typeof part === "string"))
162
+ return null;
163
+ return command.map((part) => part);
164
+ }
165
+ /** Normalize a `Record<string, string>`-shaped value, or `undefined` when absent. */
166
+ function normalizeStringMap(value) {
167
+ if (!isRecord(value))
168
+ return undefined;
169
+ const result = Object.create(null);
170
+ for (const [key, entry] of Object.entries(value)) {
171
+ if (typeof entry === "string")
172
+ result[key] = entry;
173
+ }
174
+ return result;
175
+ }
176
+ /** Whether `value` is a non-null, non-array object. */
177
+ function isRecord(value) {
178
+ return typeof value === "object" && value !== null && !Array.isArray(value);
179
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * OpenCode 2 (V2) harness — host binding registry.
3
+ *
4
+ * The core keeps its notifier and log sink in module-level state. V2 can load
5
+ * one plugin instance per location **in the same server process**, and all of
6
+ * those instances import the same core module. A naive
7
+ * `setNotifier(instanceNotifier)` would let the last-loaded instance clobber
8
+ * the others, and a per-instance `cleanup()` would wipe every location's
9
+ * tasks.
10
+ *
11
+ * This registry keeps each instance's channels and routes notifications by the
12
+ * session that started the sentinel (`task.request.sessionID`), falling back to
13
+ * the most recently registered instance when no owner is known. Global core
14
+ * cleanup is the caller's job and must only run when the registry is empty.
15
+ *
16
+ * @module
17
+ */
18
+ import type { LogSink, McpConfig, SentinelEvent, SentinelNotifier, SentinelTask } from "@gcszhn/mcp-sentinel-core";
19
+ /** A single plugin instance's notification and logging channels. */
20
+ export interface HostBinding {
21
+ /** Delivers a resolved sentinel to this instance's host session channel. */
22
+ readonly notifier: SentinelNotifier;
23
+ /** Writes a structured log entry through this instance's host log channel. */
24
+ readonly logSink: LogSink;
25
+ /**
26
+ * The MCP servers this instance's location sees, refreshed on every MCP
27
+ * transform replay. Used to reconcile the process-wide connection cache:
28
+ * a connection may only be closed once **no** live instance needs it.
29
+ */
30
+ mcpConfig?: McpConfig;
31
+ }
32
+ /** Return the most recently registered binding, if any. */
33
+ export declare function getActiveHostBinding(): HostBinding | undefined;
34
+ /**
35
+ * The MCP config of every live plugin instance.
36
+ *
37
+ * The core's connection cache is process-wide, while each V2 instance only sees
38
+ * the servers configured for its own location. Reconciliation must therefore
39
+ * run against every location's config; merging by name would drop a server two
40
+ * locations configure differently (for example a project overriding a global
41
+ * server), and closing it would break the other location's polls.
42
+ */
43
+ export declare function getCombinedMcpConfigs(): McpConfig[];
44
+ /** Number of currently registered plugin instances. */
45
+ export declare function getHostBindingCount(): number;
46
+ /**
47
+ * Record which plugin instance owns a session, so that session's sentinel
48
+ * completions are delivered through the same instance's `ctx.session`.
49
+ */
50
+ export declare function claimSession(sessionID: string | undefined, binding: HostBinding): void;
51
+ /**
52
+ * Dispatch a sentinel resolution to the binding that owns the task's session.
53
+ *
54
+ * Falls back to the most recently registered binding for tasks without an
55
+ * owner (for example, tasks recovered after a plugin reload). Exported so the
56
+ * routing behavior can be unit-tested directly.
57
+ */
58
+ export declare function dispatchNotification(task: SentinelTask, event: SentinelEvent): Promise<void>;
59
+ /**
60
+ * Register a plugin instance's channels and install the shared dispatchers.
61
+ *
62
+ * Idempotent for the same binding object.
63
+ */
64
+ export declare function registerHostBinding(binding: HostBinding): void;
65
+ /**
66
+ * Remove a plugin instance's channels.
67
+ *
68
+ * When the last instance unloads, the core notifier/log sink are cleared and
69
+ * all session ownership is forgotten. The caller decides whether to run the
70
+ * core's global `cleanup()`/`disconnectAll()`; see `src/index.ts`.
71
+ */
72
+ export declare function unregisterHostBinding(binding: HostBinding): void;
73
+ //# sourceMappingURL=host-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host-registry.d.ts","sourceRoot":"","sources":["../src/host-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,KAAK,EACV,OAAO,EACP,SAAS,EACT,aAAa,EACb,gBAAgB,EAChB,YAAY,EACb,MAAM,2BAA2B,CAAC;AAEnC,oEAAoE;AACpE,MAAM,WAAW,WAAW;IAC1B,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,CAAC;IACpC,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;;OAIG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB;AAKD,2DAA2D;AAC3D,wBAAgB,oBAAoB,IAAI,WAAW,GAAG,SAAS,CAE9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,IAAI,SAAS,EAAE,CAEnD;AAED,uDAAuD;AACvD,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI,CAEtF;AAED;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,IAAI,EAAE,YAAY,EAClB,KAAK,EAAE,aAAa,GACnB,OAAO,CAAC,IAAI,CAAC,CAKf;AAQD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAI9D;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAahE"}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * OpenCode 2 (V2) harness — host binding registry.
3
+ *
4
+ * The core keeps its notifier and log sink in module-level state. V2 can load
5
+ * one plugin instance per location **in the same server process**, and all of
6
+ * those instances import the same core module. A naive
7
+ * `setNotifier(instanceNotifier)` would let the last-loaded instance clobber
8
+ * the others, and a per-instance `cleanup()` would wipe every location's
9
+ * tasks.
10
+ *
11
+ * This registry keeps each instance's channels and routes notifications by the
12
+ * session that started the sentinel (`task.request.sessionID`), falling back to
13
+ * the most recently registered instance when no owner is known. Global core
14
+ * cleanup is the caller's job and must only run when the registry is empty.
15
+ *
16
+ * @module
17
+ */
18
+ import { setLogSink, setNotifier } from "@gcszhn/mcp-sentinel-core";
19
+ const bindings = [];
20
+ const sessionOwners = new Map();
21
+ /** Return the most recently registered binding, if any. */
22
+ export function getActiveHostBinding() {
23
+ return bindings[bindings.length - 1];
24
+ }
25
+ /**
26
+ * The MCP config of every live plugin instance.
27
+ *
28
+ * The core's connection cache is process-wide, while each V2 instance only sees
29
+ * the servers configured for its own location. Reconciliation must therefore
30
+ * run against every location's config; merging by name would drop a server two
31
+ * locations configure differently (for example a project overriding a global
32
+ * server), and closing it would break the other location's polls.
33
+ */
34
+ export function getCombinedMcpConfigs() {
35
+ return bindings.map((binding) => binding.mcpConfig ?? { servers: {} });
36
+ }
37
+ /** Number of currently registered plugin instances. */
38
+ export function getHostBindingCount() {
39
+ return bindings.length;
40
+ }
41
+ /**
42
+ * Record which plugin instance owns a session, so that session's sentinel
43
+ * completions are delivered through the same instance's `ctx.session`.
44
+ */
45
+ export function claimSession(sessionID, binding) {
46
+ if (sessionID)
47
+ sessionOwners.set(sessionID, binding);
48
+ }
49
+ /**
50
+ * Dispatch a sentinel resolution to the binding that owns the task's session.
51
+ *
52
+ * Falls back to the most recently registered binding for tasks without an
53
+ * owner (for example, tasks recovered after a plugin reload). Exported so the
54
+ * routing behavior can be unit-tested directly.
55
+ */
56
+ export async function dispatchNotification(task, event) {
57
+ const sessionID = task.request.sessionID;
58
+ const owner = sessionID ? sessionOwners.get(sessionID) : undefined;
59
+ const binding = owner && bindings.includes(owner) ? owner : getActiveHostBinding();
60
+ if (binding)
61
+ await binding.notifier(task, event);
62
+ }
63
+ /** Route a core log entry to the active plugin instance's host log channel. */
64
+ const dispatchLog = (level, message, extra) => {
65
+ const binding = getActiveHostBinding();
66
+ if (binding)
67
+ return binding.logSink(level, message, extra);
68
+ };
69
+ /**
70
+ * Register a plugin instance's channels and install the shared dispatchers.
71
+ *
72
+ * Idempotent for the same binding object.
73
+ */
74
+ export function registerHostBinding(binding) {
75
+ if (!bindings.includes(binding))
76
+ bindings.push(binding);
77
+ setNotifier(dispatchNotification);
78
+ setLogSink(dispatchLog);
79
+ }
80
+ /**
81
+ * Remove a plugin instance's channels.
82
+ *
83
+ * When the last instance unloads, the core notifier/log sink are cleared and
84
+ * all session ownership is forgotten. The caller decides whether to run the
85
+ * core's global `cleanup()`/`disconnectAll()`; see `src/index.ts`.
86
+ */
87
+ export function unregisterHostBinding(binding) {
88
+ const index = bindings.indexOf(binding);
89
+ if (index !== -1)
90
+ bindings.splice(index, 1);
91
+ for (const [sessionID, owner] of sessionOwners) {
92
+ if (owner === binding)
93
+ sessionOwners.delete(sessionID);
94
+ }
95
+ if (bindings.length === 0) {
96
+ setNotifier(null);
97
+ setLogSink(null);
98
+ sessionOwners.clear();
99
+ }
100
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * OpenCode 2 (V2) adapter — registers the four sentinel tools through the V2
3
+ * `Plugin.define` / `ctx.tool.transform` API and wires OpenCode-specific
4
+ * integration (MCP config discovery, synthetic session notifications, console
5
+ * logging) into the framework-agnostic core.
6
+ *
7
+ * All request/response logic lives in `@gcszhn/mcp-sentinel-core/tools`; this
8
+ * file owns only the V2 seams:
9
+ * - `ctx.mcp.transform(editor => editor.list())` for MCP config discovery
10
+ * - `ctx.tool.transform(editor => editor.add(...))` for JSON-Schema tools
11
+ * - `context.sessionID` / `context.signal` for task ownership and abort
12
+ * - `ctx.session.synthetic()` for completion delivery
13
+ * - a cleanup function returned from `setup` for resource release
14
+ *
15
+ * @module
16
+ */
17
+ import { Plugin } from "@opencode/plugin";
18
+ declare const _default: Plugin.Plugin;
19
+ export default _default;
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;;AAmK1C,wBAmOG"}
package/dist/index.js ADDED
@@ -0,0 +1,315 @@
1
+ /**
2
+ * OpenCode 2 (V2) adapter — registers the four sentinel tools through the V2
3
+ * `Plugin.define` / `ctx.tool.transform` API and wires OpenCode-specific
4
+ * integration (MCP config discovery, synthetic session notifications, console
5
+ * logging) into the framework-agnostic core.
6
+ *
7
+ * All request/response logic lives in `@gcszhn/mcp-sentinel-core/tools`; this
8
+ * file owns only the V2 seams:
9
+ * - `ctx.mcp.transform(editor => editor.list())` for MCP config discovery
10
+ * - `ctx.tool.transform(editor => editor.add(...))` for JSON-Schema tools
11
+ * - `context.sessionID` / `context.signal` for task ownership and abort
12
+ * - `ctx.session.synthetic()` for completion delivery
13
+ * - a cleanup function returned from `setup` for resource release
14
+ *
15
+ * @module
16
+ */
17
+ import { Plugin } from "@opencode/plugin";
18
+ import { ACTION_DESCRIPTION, ARGS_DESCRIPTION, ATTACH_ID_DESCRIPTION, ATTACH_TIMEOUT_DESCRIPTION, ATTACH_TOOL_DESCRIPTION, COMPLETION_NOTIFICATION_NOTE, INTERVAL_DESCRIPTION, POLL_TIMEOUT_DESCRIPTION, POLL_TOOL_DESCRIPTION, READ_ID_DESCRIPTION, READ_LIMIT_DESCRIPTION, READ_OFFSET_DESCRIPTION, READ_TOOL_DESCRIPTION, SERVER_DESCRIPTION, STATUS_ID_DESCRIPTION, STATUS_TOOL_DESCRIPTION, TOOL_DESCRIPTION, UNTIL_DESCRIPTION, cleanup, disconnectAll, handleAttach, handlePoll, handleRead, handleStatus, logError, logInfo, logWarn, makeConnectionInvoker, makeServerResolver, reconcileConnections, } from "@gcszhn/mcp-sentinel-core";
19
+ import { describeServerProblem, normalizeOpencode2McpConfig } from "./config.js";
20
+ import { claimSession, getCombinedMcpConfigs, getHostBindingCount, registerHostBinding, unregisterHostBinding, } from "./host-registry.js";
21
+ import { buildNotificationText } from "./notify.js";
22
+ /** Stable plugin identifier used by OpenCode diagnostics and plugin storage. */
23
+ const PLUGIN_ID = "mcp-sentinel-opencode2";
24
+ function parsePollInput(raw) {
25
+ if (!isRecord(raw))
26
+ return "Error: invalid poll input; expected a JSON object.";
27
+ if (typeof raw.server !== "string")
28
+ return "Error: server must be a string.";
29
+ if (typeof raw.tool !== "string")
30
+ return "Error: tool must be a string.";
31
+ if (raw.args !== undefined && !isRecord(raw.args)) {
32
+ return "Error: args must be a JSON object.";
33
+ }
34
+ if (!isNonNegativeInteger(raw.interval)) {
35
+ return "Error: interval must be a non-negative integer.";
36
+ }
37
+ if (!isNonNegativeInteger(raw.timeout)) {
38
+ return "Error: timeout must be a non-negative integer.";
39
+ }
40
+ if (!isRecord(raw.until)) {
41
+ return "Error: until must be a JSON object describing a condition.";
42
+ }
43
+ return {
44
+ server: raw.server,
45
+ tool: raw.tool,
46
+ args: raw.args ?? {},
47
+ interval: raw.interval,
48
+ timeout: raw.timeout,
49
+ until: raw.until,
50
+ };
51
+ }
52
+ function parseStatusInput(raw) {
53
+ if (!isRecord(raw))
54
+ return "Error: invalid status input; expected a JSON object.";
55
+ if (raw.action !== undefined && typeof raw.action !== "string") {
56
+ return "Error: action must be a string.";
57
+ }
58
+ if (raw.id !== undefined && typeof raw.id !== "string") {
59
+ return "Error: id must be a string.";
60
+ }
61
+ return { action: raw.action, id: raw.id };
62
+ }
63
+ function parseAttachInput(raw) {
64
+ if (!isRecord(raw))
65
+ return "Error: invalid attach input; expected a JSON object.";
66
+ if (typeof raw.id !== "string" || raw.id.trim() === "") {
67
+ return "Error: id must be a non-empty string.";
68
+ }
69
+ if (!isNonNegativeInteger(raw.timeout)) {
70
+ return "Error: timeout must be a non-negative integer.";
71
+ }
72
+ return { id: raw.id, timeout: raw.timeout };
73
+ }
74
+ function parseReadInput(raw) {
75
+ if (!isRecord(raw))
76
+ return "Error: invalid read input; expected a JSON object.";
77
+ if (typeof raw.id !== "string" || raw.id.trim() === "") {
78
+ return "Error: id must be a non-empty string.";
79
+ }
80
+ if (!isNonNegativeInteger(raw.offset)) {
81
+ return "Error: offset must be a non-negative integer.";
82
+ }
83
+ if (!isNonNegativeInteger(raw.limit) || (raw.limit !== undefined && raw.limit < 1)) {
84
+ return "Error: limit must be a positive integer.";
85
+ }
86
+ return { id: raw.id, offset: raw.offset, limit: raw.limit };
87
+ }
88
+ function isRecord(value) {
89
+ return typeof value === "object" && value !== null && !Array.isArray(value);
90
+ }
91
+ function isNonNegativeInteger(value) {
92
+ return (value === undefined || (typeof value === "number" && Number.isInteger(value) && value >= 0));
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Plugin
96
+ // ---------------------------------------------------------------------------
97
+ export default Plugin.define({
98
+ id: PLUGIN_ID,
99
+ async setup(ctx) {
100
+ const delivery = ctx.options.delivery === "steer" ? "steer" : "queue";
101
+ const resume = ctx.options.resume !== false;
102
+ const logSink = (level, message, extra) => {
103
+ const line = `[mcp-sentinel] ${message}`;
104
+ const details = extra && Object.keys(extra).length > 0 ? extra : undefined;
105
+ switch (level) {
106
+ case "debug":
107
+ console.debug(line, details ?? "");
108
+ break;
109
+ case "info":
110
+ console.log(line, details ?? "");
111
+ break;
112
+ case "warn":
113
+ console.warn(line, details ?? "");
114
+ break;
115
+ case "error":
116
+ console.error(line, details ?? "");
117
+ break;
118
+ default:
119
+ console.log(line, details ?? "");
120
+ break;
121
+ }
122
+ };
123
+ const notifier = async (task, event) => {
124
+ const sessionID = task.request.sessionID;
125
+ if (!sessionID)
126
+ return;
127
+ try {
128
+ // Synthetic messages are background system notices, not user prompts:
129
+ // they bypass the prompt-admission hook and resume the session by
130
+ // default. `queue` avoids reordering work the user is already doing.
131
+ const synthetic = {
132
+ sessionID,
133
+ text: buildNotificationText(task, event),
134
+ description: "MCP Sentinel result",
135
+ delivery,
136
+ resume,
137
+ };
138
+ await ctx.session.synthetic(synthetic);
139
+ }
140
+ catch (err) {
141
+ // The task remains queryable via status/read; log the delivery failure
142
+ // so it can be diagnosed instead of disappearing silently.
143
+ logError("Failed to deliver sentinel notification", {
144
+ id: task.id,
145
+ sessionID,
146
+ error: String(err),
147
+ });
148
+ throw err;
149
+ }
150
+ };
151
+ const binding = { notifier, logSink };
152
+ registerHostBinding(binding);
153
+ // V2 exposes MCP *configuration* only inside a transform; `ctx.mcp.list()`
154
+ // returns connection status, not config. The callback is replayed whenever
155
+ // OpenCode rebuilds the MCP registry (config reload, another plugin's
156
+ // transform, `ctx.mcp.reload()`), so it doubles as the reload hook: refresh
157
+ // this location's config snapshot and reconcile the process-wide connection
158
+ // cache. Reconcile is fire-and-forget because the callback is synchronous.
159
+ let mcpEntries = [];
160
+ await ctx.mcp.transform((editor) => {
161
+ mcpEntries = editor.list();
162
+ binding.mcpConfig = normalizeOpencode2McpConfig(mcpEntries);
163
+ void reconcileConnections(getCombinedMcpConfigs()).catch((err) => {
164
+ logWarn("Failed to reconcile MCP connections", { error: String(err) });
165
+ });
166
+ });
167
+ logInfo("MCP Sentinel OpenCode 2 plugin initialized", { version: ctx.app.version });
168
+ /** Read a server's connection status from the host's MCP domain. */
169
+ const getMcpStatus = async (name) => {
170
+ const listed = await ctx.mcp.list();
171
+ const found = listed.data.find((server) => server.name === name);
172
+ if (!found)
173
+ return undefined;
174
+ const status = found.status;
175
+ if (status.status === "failed" || status.status === "needs_auth") {
176
+ return { status: status.status, error: status.error };
177
+ }
178
+ return { status: status.status };
179
+ };
180
+ await ctx.tool.transform((editor) => {
181
+ editor.add({
182
+ name: "mcp_sentinel_poll",
183
+ description: `${POLL_TOOL_DESCRIPTION}
184
+
185
+ ${COMPLETION_NOTIFICATION_NOTE}`,
186
+ input: {
187
+ type: "object",
188
+ properties: {
189
+ server: { type: "string", description: SERVER_DESCRIPTION },
190
+ tool: { type: "string", description: TOOL_DESCRIPTION },
191
+ args: { type: "object", description: ARGS_DESCRIPTION, additionalProperties: true },
192
+ interval: { type: "integer", minimum: 1000, description: INTERVAL_DESCRIPTION },
193
+ timeout: { type: "integer", minimum: 0, description: POLL_TIMEOUT_DESCRIPTION },
194
+ until: { type: "object", description: UNTIL_DESCRIPTION, additionalProperties: true },
195
+ },
196
+ required: ["server", "tool", "until"],
197
+ additionalProperties: false,
198
+ },
199
+ async execute(raw, context) {
200
+ const input = parsePollInput(raw);
201
+ if (typeof input === "string")
202
+ return { content: input };
203
+ // Snapshot the captured config for this call so a replay during the
204
+ // poll cannot split resolution and invocation across versions.
205
+ const entries = mcpEntries;
206
+ let status;
207
+ if (input.server.trim()) {
208
+ try {
209
+ status = await getMcpStatus(input.server);
210
+ }
211
+ catch (err) {
212
+ // Status is only a preflight aid. The sentinel opens its own
213
+ // connection, so proceed if the status lookup itself fails.
214
+ logWarn("Could not read MCP status before polling", {
215
+ server: input.server,
216
+ error: String(err),
217
+ });
218
+ }
219
+ }
220
+ const problem = describeServerProblem(entries, input.server, status);
221
+ if (problem)
222
+ return { content: problem };
223
+ claimSession(context.sessionID, binding);
224
+ const content = await handlePoll({
225
+ ...input,
226
+ sessionID: context.sessionID,
227
+ }, makeConnectionInvoker(makeServerResolver(normalizeOpencode2McpConfig(entries))));
228
+ return { content };
229
+ },
230
+ });
231
+ editor.add({
232
+ name: "mcp_sentinel_status",
233
+ description: STATUS_TOOL_DESCRIPTION,
234
+ input: {
235
+ type: "object",
236
+ properties: {
237
+ action: {
238
+ type: "string",
239
+ enum: ["status", "list", "cancel"],
240
+ description: ACTION_DESCRIPTION,
241
+ },
242
+ id: { type: "string", description: STATUS_ID_DESCRIPTION },
243
+ },
244
+ additionalProperties: false,
245
+ },
246
+ async execute(raw) {
247
+ const input = parseStatusInput(raw);
248
+ if (typeof input === "string")
249
+ return { content: input };
250
+ return { content: handleStatus(input.action, input.id) };
251
+ },
252
+ });
253
+ editor.add({
254
+ name: "mcp_sentinel_attach",
255
+ description: ATTACH_TOOL_DESCRIPTION,
256
+ input: {
257
+ type: "object",
258
+ properties: {
259
+ id: { type: "string", description: ATTACH_ID_DESCRIPTION },
260
+ timeout: { type: "integer", minimum: 0, description: ATTACH_TIMEOUT_DESCRIPTION },
261
+ },
262
+ required: ["id"],
263
+ additionalProperties: false,
264
+ },
265
+ async execute(raw, context) {
266
+ const input = parseAttachInput(raw);
267
+ if (typeof input === "string")
268
+ return { content: input };
269
+ return {
270
+ content: await handleAttach(input.id, input.timeout, () => context.signal.aborted),
271
+ };
272
+ },
273
+ });
274
+ editor.add({
275
+ name: "mcp_sentinel_read",
276
+ description: READ_TOOL_DESCRIPTION,
277
+ input: {
278
+ type: "object",
279
+ properties: {
280
+ id: { type: "string", description: READ_ID_DESCRIPTION },
281
+ offset: { type: "integer", minimum: 0, description: READ_OFFSET_DESCRIPTION },
282
+ limit: { type: "integer", minimum: 1, description: READ_LIMIT_DESCRIPTION },
283
+ },
284
+ required: ["id"],
285
+ additionalProperties: false,
286
+ },
287
+ async execute(raw) {
288
+ const input = parseReadInput(raw);
289
+ if (typeof input === "string")
290
+ return { content: input };
291
+ return { content: handleRead(input.id, input.offset, input.limit) };
292
+ },
293
+ });
294
+ });
295
+ // V2 disposes registrations automatically. Release only the process-wide
296
+ // core resources once the last plugin instance for this server unloads;
297
+ // otherwise one location would wipe another location's sentinels.
298
+ return async () => {
299
+ unregisterHostBinding(binding);
300
+ if (getHostBindingCount() === 0) {
301
+ cleanup();
302
+ await disconnectAll();
303
+ return;
304
+ }
305
+ // Other locations still share the process-wide connection cache: drop
306
+ // only the connections this location owned.
307
+ try {
308
+ await reconcileConnections(getCombinedMcpConfigs());
309
+ }
310
+ catch (err) {
311
+ logWarn("Failed to reconcile MCP connections on unload", { error: String(err) });
312
+ }
313
+ };
314
+ },
315
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * OpenCode 2 (V2) harness — completion notification text.
3
+ *
4
+ * The message format is host-specific and therefore lives in the adapter, not
5
+ * in the core. It is delivered through the V2 session inbox as a synthetic
6
+ * message (see `src/index.ts`), not as a user prompt.
7
+ *
8
+ * @module
9
+ */
10
+ import type { SentinelEvent, SentinelTask } from "@gcszhn/mcp-sentinel-core";
11
+ /**
12
+ * Build the Markdown notification delivered to the originating session when a
13
+ * sentinel resolves.
14
+ *
15
+ * @param task - The resolved task (already carries `lastResult`/`error`).
16
+ * @param event - The resolution event: `completed`, `failed`, or `timeout`.
17
+ */
18
+ export declare function buildNotificationText(task: SentinelTask, event: SentinelEvent): string;
19
+ //# sourceMappingURL=notify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../src/notify.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAE7E;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,aAAa,GAAG,MAAM,CAwCtF"}
package/dist/notify.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * OpenCode 2 (V2) harness — completion notification text.
3
+ *
4
+ * The message format is host-specific and therefore lives in the adapter, not
5
+ * in the core. It is delivered through the V2 session inbox as a synthetic
6
+ * message (see `src/index.ts`), not as a user prompt.
7
+ *
8
+ * @module
9
+ */
10
+ /**
11
+ * Build the Markdown notification delivered to the originating session when a
12
+ * sentinel resolves.
13
+ *
14
+ * @param task - The resolved task (already carries `lastResult`/`error`).
15
+ * @param event - The resolution event: `completed`, `failed`, or `timeout`.
16
+ */
17
+ export function buildNotificationText(task, event) {
18
+ switch (event) {
19
+ case "completed":
20
+ return [
21
+ "## Sentinel Complete",
22
+ "",
23
+ `**ID:** \`${task.id}\``,
24
+ `**Server:** ${task.request.server}`,
25
+ `**Tool:** ${task.request.tool}`,
26
+ `**Poll count:** ${task.pollCount}`,
27
+ `**Duration:** ${task.resolvedAt != null ? ((task.resolvedAt - task.createdAt) / 1000).toFixed(1) : "—"}s`,
28
+ "**Result:**",
29
+ "```json",
30
+ JSON.stringify(task.lastResult, null, 2),
31
+ "```",
32
+ ].join("\n");
33
+ case "failed":
34
+ return [
35
+ "## Sentinel Failed",
36
+ "",
37
+ `**ID:** \`${task.id}\``,
38
+ `**Server:** ${task.request.server}`,
39
+ `**Tool:** ${task.request.tool}`,
40
+ `**Poll count:** ${task.pollCount}`,
41
+ `**Error:** ${task.error}`,
42
+ ].join("\n");
43
+ case "timeout":
44
+ return [
45
+ "## Sentinel Timeout",
46
+ "",
47
+ `**ID:** \`${task.id}\``,
48
+ `**Server:** ${task.request.server}`,
49
+ `**Tool:** ${task.request.tool}`,
50
+ `**Poll count:** ${task.pollCount}`,
51
+ "**Last result:**",
52
+ "```json",
53
+ JSON.stringify(task.lastResult, null, 2),
54
+ "```",
55
+ ].join("\n");
56
+ }
57
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@gcszhn/mcp-sentinel-opencode2-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.6.0",
4
+ "description": "OpenCode 2 (V2) plugin that acts as a sentinel between the AI agent and MCP servers — polling long-running tasks on the agent's behalf so that token-costly status loops never enter the LLM inference path",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "rm -rf dist && bunx tsc",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "bun test",
18
+ "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
19
+ "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\""
20
+ },
21
+ "dependencies": {
22
+ "@gcszhn/mcp-sentinel-core": "1.6.0",
23
+ "@opencode/plugin": "2.3.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "^1.3.8",
27
+ "prettier": "^3.4.2",
28
+ "typescript": "^5.7.3"
29
+ },
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/GCS-ZHN/mcp-sentinel.git",
34
+ "directory": "packages/opencode2"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "package.json",
42
+ "README.md",
43
+ "LICENSE"
44
+ ]
45
+ }