@gcszhn/mcp-sentinel-opencode2-plugin 0.0.0-stage → 1.5.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 +21 -0
- package/README.md +134 -2
- package/dist/config.d.ts +93 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +179 -0
- package/dist/host-registry.d.ts +57 -0
- package/dist/host-registry.d.ts.map +1 -0
- package/dist/host-registry.js +88 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +300 -0
- package/dist/notify.d.ts +19 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +57 -0
- package/package.json +43 -4
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,135 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @gcszhn/mcp-sentinel-opencode2-plugin
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
Unlike V1, V2 config declares servers under `mcp.servers.{name}`:
|
|
36
|
+
|
|
37
|
+
```jsonc
|
|
38
|
+
{
|
|
39
|
+
"mcp": {
|
|
40
|
+
"servers": {
|
|
41
|
+
"my-server": {
|
|
42
|
+
"type": "local",
|
|
43
|
+
"command": ["bun", "run", "./server.ts"],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Tools
|
|
51
|
+
|
|
52
|
+
### `mcp_sentinel_poll`
|
|
53
|
+
|
|
54
|
+
Submit a long-running MCP tool call and poll it at regular intervals until a
|
|
55
|
+
condition is met. Returns a sentinel ID immediately; the agent is notified in
|
|
56
|
+
its session when the sentinel resolves.
|
|
57
|
+
|
|
58
|
+
| Parameter | Type | Default | Description |
|
|
59
|
+
| ---------- | ------ | ---------- | ------------------------------------------ |
|
|
60
|
+
| `server` | string | _required_ | MCP server name (from `mcp.servers`) |
|
|
61
|
+
| `tool` | string | _required_ | Tool name to call on the server |
|
|
62
|
+
| `args` | object | `{}` | JSON object of arguments for the tool |
|
|
63
|
+
| `interval` | number | `5000` | Poll interval in milliseconds |
|
|
64
|
+
| `timeout` | number | _optional_ | Max poll duration in ms (unset = no limit) |
|
|
65
|
+
| `until` | object | _required_ | JSON condition object |
|
|
66
|
+
|
|
67
|
+
### `mcp_sentinel_status`
|
|
68
|
+
|
|
69
|
+
Check status, list active tasks, or cancel a running task (`action` =
|
|
70
|
+
`status` | `list` | `cancel`).
|
|
71
|
+
|
|
72
|
+
### `mcp_sentinel_attach`
|
|
73
|
+
|
|
74
|
+
Block the agent, waiting for a sentinel to complete. Zero token cost during
|
|
75
|
+
the wait.
|
|
76
|
+
|
|
77
|
+
### `mcp_sentinel_read`
|
|
78
|
+
|
|
79
|
+
Read raw poll outputs with offset/limit pagination.
|
|
80
|
+
|
|
81
|
+
## Condition model
|
|
82
|
+
|
|
83
|
+
Conditions are pure declarative data:
|
|
84
|
+
|
|
85
|
+
```jsonc
|
|
86
|
+
{ "path": "status", "is": "eq", "value": "completed" }
|
|
87
|
+
{ "and": [
|
|
88
|
+
{ "path": "status", "is": "eq", "value": "completed" },
|
|
89
|
+
{ "path": "tasks[0].exit_code", "is": "eq", "value": 0 }
|
|
90
|
+
] }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
See the repository root `README.md` for the full operator and path syntax.
|
|
94
|
+
|
|
95
|
+
## Options
|
|
96
|
+
|
|
97
|
+
Options are read from the plugin entry in V2 config:
|
|
98
|
+
|
|
99
|
+
```jsonc
|
|
100
|
+
{
|
|
101
|
+
"plugins": [
|
|
102
|
+
{
|
|
103
|
+
"package": "@gcszhn/mcp-sentinel-opencode2-plugin",
|
|
104
|
+
"options": { "delivery": "queue", "resume": true },
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| Option | Type | Default | Description |
|
|
111
|
+
| ---------- | --------- | --------- | --------------------------------------------------------------------- |
|
|
112
|
+
| `delivery` | `string` | `"queue"` | How completion notices enter the session: `"queue"` or `"steer"`. |
|
|
113
|
+
| `resume` | `boolean` | `true` | Whether a completion notice resumes a session that is currently idle. |
|
|
114
|
+
|
|
115
|
+
Notifications are delivered with `ctx.session.synthetic`, so they appear as a
|
|
116
|
+
background system notice rather than a user prompt.
|
|
117
|
+
|
|
118
|
+
## OAuth limitation
|
|
119
|
+
|
|
120
|
+
The sentinel opens its **own** MCP connection using the connection details in
|
|
121
|
+
`mcp.servers`, so it cannot reuse credentials that OpenCode obtained through an
|
|
122
|
+
interactive OAuth flow. If a remote server is reported as `needs_auth` and has
|
|
123
|
+
no static `headers`, `mcp_sentinel_poll` returns an explicit error explaining
|
|
124
|
+
that static headers must be configured instead of failing later with an opaque 401.
|
|
125
|
+
|
|
126
|
+
## Environment variables
|
|
127
|
+
|
|
128
|
+
| Variable | Default | Description |
|
|
129
|
+
| ----------------------- | --------- | ----------------------------------------- |
|
|
130
|
+
| `SENTINEL_MAX_POLL_LOG` | unlimited | Max poll log entries per task (FIFO trim) |
|
|
131
|
+
| `SENTINEL_TASK_TTL_MS` | unlimited | Auto-cleanup completed tasks after N ms |
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
package/dist/config.d.ts
ADDED
|
@@ -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,57 @@
|
|
|
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, 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
|
+
/** Return the most recently registered binding, if any. */
|
|
27
|
+
export declare function getActiveHostBinding(): HostBinding | undefined;
|
|
28
|
+
/** Number of currently registered plugin instances. */
|
|
29
|
+
export declare function getHostBindingCount(): number;
|
|
30
|
+
/**
|
|
31
|
+
* Record which plugin instance owns a session, so that session's sentinel
|
|
32
|
+
* completions are delivered through the same instance's `ctx.session`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function claimSession(sessionID: string | undefined, binding: HostBinding): void;
|
|
35
|
+
/**
|
|
36
|
+
* Dispatch a sentinel resolution to the binding that owns the task's session.
|
|
37
|
+
*
|
|
38
|
+
* Falls back to the most recently registered binding for tasks without an
|
|
39
|
+
* owner (for example, tasks recovered after a plugin reload). Exported so the
|
|
40
|
+
* routing behavior can be unit-tested directly.
|
|
41
|
+
*/
|
|
42
|
+
export declare function dispatchNotification(task: SentinelTask, event: SentinelEvent): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Register a plugin instance's channels and install the shared dispatchers.
|
|
45
|
+
*
|
|
46
|
+
* Idempotent for the same binding object.
|
|
47
|
+
*/
|
|
48
|
+
export declare function registerHostBinding(binding: HostBinding): void;
|
|
49
|
+
/**
|
|
50
|
+
* Remove a plugin instance's channels.
|
|
51
|
+
*
|
|
52
|
+
* When the last instance unloads, the core notifier/log sink are cleared and
|
|
53
|
+
* all session ownership is forgotten. The caller decides whether to run the
|
|
54
|
+
* core's global `cleanup()`/`disconnectAll()`; see `src/index.ts`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function unregisterHostBinding(binding: HostBinding): void;
|
|
57
|
+
//# 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,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;CAC3B;AAKD,2DAA2D;AAC3D,wBAAgB,oBAAoB,IAAI,WAAW,GAAG,SAAS,CAE9D;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,88 @@
|
|
|
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
|
+
/** Number of currently registered plugin instances. */
|
|
26
|
+
export function getHostBindingCount() {
|
|
27
|
+
return bindings.length;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Record which plugin instance owns a session, so that session's sentinel
|
|
31
|
+
* completions are delivered through the same instance's `ctx.session`.
|
|
32
|
+
*/
|
|
33
|
+
export function claimSession(sessionID, binding) {
|
|
34
|
+
if (sessionID)
|
|
35
|
+
sessionOwners.set(sessionID, binding);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Dispatch a sentinel resolution to the binding that owns the task's session.
|
|
39
|
+
*
|
|
40
|
+
* Falls back to the most recently registered binding for tasks without an
|
|
41
|
+
* owner (for example, tasks recovered after a plugin reload). Exported so the
|
|
42
|
+
* routing behavior can be unit-tested directly.
|
|
43
|
+
*/
|
|
44
|
+
export async function dispatchNotification(task, event) {
|
|
45
|
+
const sessionID = task.request.sessionID;
|
|
46
|
+
const owner = sessionID ? sessionOwners.get(sessionID) : undefined;
|
|
47
|
+
const binding = owner && bindings.includes(owner) ? owner : getActiveHostBinding();
|
|
48
|
+
if (binding)
|
|
49
|
+
await binding.notifier(task, event);
|
|
50
|
+
}
|
|
51
|
+
/** Route a core log entry to the active plugin instance's host log channel. */
|
|
52
|
+
const dispatchLog = (level, message, extra) => {
|
|
53
|
+
const binding = getActiveHostBinding();
|
|
54
|
+
if (binding)
|
|
55
|
+
return binding.logSink(level, message, extra);
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Register a plugin instance's channels and install the shared dispatchers.
|
|
59
|
+
*
|
|
60
|
+
* Idempotent for the same binding object.
|
|
61
|
+
*/
|
|
62
|
+
export function registerHostBinding(binding) {
|
|
63
|
+
if (!bindings.includes(binding))
|
|
64
|
+
bindings.push(binding);
|
|
65
|
+
setNotifier(dispatchNotification);
|
|
66
|
+
setLogSink(dispatchLog);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Remove a plugin instance's channels.
|
|
70
|
+
*
|
|
71
|
+
* When the last instance unloads, the core notifier/log sink are cleared and
|
|
72
|
+
* all session ownership is forgotten. The caller decides whether to run the
|
|
73
|
+
* core's global `cleanup()`/`disconnectAll()`; see `src/index.ts`.
|
|
74
|
+
*/
|
|
75
|
+
export function unregisterHostBinding(binding) {
|
|
76
|
+
const index = bindings.indexOf(binding);
|
|
77
|
+
if (index !== -1)
|
|
78
|
+
bindings.splice(index, 1);
|
|
79
|
+
for (const [sessionID, owner] of sessionOwners) {
|
|
80
|
+
if (owner === binding)
|
|
81
|
+
sessionOwners.delete(sessionID);
|
|
82
|
+
}
|
|
83
|
+
if (bindings.length === 0) {
|
|
84
|
+
setNotifier(null);
|
|
85
|
+
setLogSink(null);
|
|
86
|
+
sessionOwners.clear();
|
|
87
|
+
}
|
|
88
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;;AAiK1C,wBAoNG"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
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, } from "@gcszhn/mcp-sentinel-core";
|
|
19
|
+
import { describeServerProblem, normalizeOpencode2McpConfig } from "./config.js";
|
|
20
|
+
import { claimSession, 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
|
+
// V2 exposes MCP *configuration* only inside a transform; `ctx.mcp.list()`
|
|
101
|
+
// returns connection status, not config. Capture the current entries from
|
|
102
|
+
// this cheap, synchronous, replayable callback. OpenCode replays it when
|
|
103
|
+
// the registry is rebuilt (for example after a config reload).
|
|
104
|
+
let mcpEntries = [];
|
|
105
|
+
await ctx.mcp.transform((editor) => {
|
|
106
|
+
mcpEntries = editor.list();
|
|
107
|
+
});
|
|
108
|
+
const delivery = ctx.options.delivery === "steer" ? "steer" : "queue";
|
|
109
|
+
const resume = ctx.options.resume !== false;
|
|
110
|
+
const logSink = (level, message, extra) => {
|
|
111
|
+
const line = `[mcp-sentinel] ${message}`;
|
|
112
|
+
const details = extra && Object.keys(extra).length > 0 ? extra : undefined;
|
|
113
|
+
switch (level) {
|
|
114
|
+
case "debug":
|
|
115
|
+
console.debug(line, details ?? "");
|
|
116
|
+
break;
|
|
117
|
+
case "info":
|
|
118
|
+
console.log(line, details ?? "");
|
|
119
|
+
break;
|
|
120
|
+
case "warn":
|
|
121
|
+
console.warn(line, details ?? "");
|
|
122
|
+
break;
|
|
123
|
+
case "error":
|
|
124
|
+
console.error(line, details ?? "");
|
|
125
|
+
break;
|
|
126
|
+
default:
|
|
127
|
+
console.log(line, details ?? "");
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const notifier = async (task, event) => {
|
|
132
|
+
const sessionID = task.request.sessionID;
|
|
133
|
+
if (!sessionID)
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
// Synthetic messages are background system notices, not user prompts:
|
|
137
|
+
// they bypass the prompt-admission hook and resume the session by
|
|
138
|
+
// default. `queue` avoids reordering work the user is already doing.
|
|
139
|
+
const synthetic = {
|
|
140
|
+
sessionID,
|
|
141
|
+
text: buildNotificationText(task, event),
|
|
142
|
+
description: "MCP Sentinel result",
|
|
143
|
+
delivery,
|
|
144
|
+
resume,
|
|
145
|
+
};
|
|
146
|
+
await ctx.session.synthetic(synthetic);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
// The task remains queryable via status/read; log the delivery failure
|
|
150
|
+
// so it can be diagnosed instead of disappearing silently.
|
|
151
|
+
logError("Failed to deliver sentinel notification", {
|
|
152
|
+
id: task.id,
|
|
153
|
+
sessionID,
|
|
154
|
+
error: String(err),
|
|
155
|
+
});
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const binding = { notifier, logSink };
|
|
160
|
+
registerHostBinding(binding);
|
|
161
|
+
logInfo("MCP Sentinel OpenCode 2 plugin initialized", { version: ctx.app.version });
|
|
162
|
+
/** Read a server's connection status from the host's MCP domain. */
|
|
163
|
+
const getMcpStatus = async (name) => {
|
|
164
|
+
const listed = await ctx.mcp.list();
|
|
165
|
+
const found = listed.data.find((server) => server.name === name);
|
|
166
|
+
if (!found)
|
|
167
|
+
return undefined;
|
|
168
|
+
const status = found.status;
|
|
169
|
+
if (status.status === "failed" || status.status === "needs_auth") {
|
|
170
|
+
return { status: status.status, error: status.error };
|
|
171
|
+
}
|
|
172
|
+
return { status: status.status };
|
|
173
|
+
};
|
|
174
|
+
await ctx.tool.transform((editor) => {
|
|
175
|
+
editor.add({
|
|
176
|
+
name: "mcp_sentinel_poll",
|
|
177
|
+
description: `${POLL_TOOL_DESCRIPTION}
|
|
178
|
+
|
|
179
|
+
${COMPLETION_NOTIFICATION_NOTE}`,
|
|
180
|
+
input: {
|
|
181
|
+
type: "object",
|
|
182
|
+
properties: {
|
|
183
|
+
server: { type: "string", description: SERVER_DESCRIPTION },
|
|
184
|
+
tool: { type: "string", description: TOOL_DESCRIPTION },
|
|
185
|
+
args: { type: "object", description: ARGS_DESCRIPTION, additionalProperties: true },
|
|
186
|
+
interval: { type: "integer", minimum: 1000, description: INTERVAL_DESCRIPTION },
|
|
187
|
+
timeout: { type: "integer", minimum: 0, description: POLL_TIMEOUT_DESCRIPTION },
|
|
188
|
+
until: { type: "object", description: UNTIL_DESCRIPTION, additionalProperties: true },
|
|
189
|
+
},
|
|
190
|
+
required: ["server", "tool", "until"],
|
|
191
|
+
additionalProperties: false,
|
|
192
|
+
},
|
|
193
|
+
async execute(raw, context) {
|
|
194
|
+
const input = parsePollInput(raw);
|
|
195
|
+
if (typeof input === "string")
|
|
196
|
+
return { content: input };
|
|
197
|
+
// Snapshot the captured config for this call so a replay during the
|
|
198
|
+
// poll cannot split resolution and invocation across versions.
|
|
199
|
+
const entries = mcpEntries;
|
|
200
|
+
let status;
|
|
201
|
+
if (input.server.trim()) {
|
|
202
|
+
try {
|
|
203
|
+
status = await getMcpStatus(input.server);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
// Status is only a preflight aid. The sentinel opens its own
|
|
207
|
+
// connection, so proceed if the status lookup itself fails.
|
|
208
|
+
logWarn("Could not read MCP status before polling", {
|
|
209
|
+
server: input.server,
|
|
210
|
+
error: String(err),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const problem = describeServerProblem(entries, input.server, status);
|
|
215
|
+
if (problem)
|
|
216
|
+
return { content: problem };
|
|
217
|
+
claimSession(context.sessionID, binding);
|
|
218
|
+
const content = await handlePoll({
|
|
219
|
+
...input,
|
|
220
|
+
sessionID: context.sessionID,
|
|
221
|
+
}, makeConnectionInvoker(makeServerResolver(normalizeOpencode2McpConfig(entries))));
|
|
222
|
+
return { content };
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
editor.add({
|
|
226
|
+
name: "mcp_sentinel_status",
|
|
227
|
+
description: STATUS_TOOL_DESCRIPTION,
|
|
228
|
+
input: {
|
|
229
|
+
type: "object",
|
|
230
|
+
properties: {
|
|
231
|
+
action: {
|
|
232
|
+
type: "string",
|
|
233
|
+
enum: ["status", "list", "cancel"],
|
|
234
|
+
description: ACTION_DESCRIPTION,
|
|
235
|
+
},
|
|
236
|
+
id: { type: "string", description: STATUS_ID_DESCRIPTION },
|
|
237
|
+
},
|
|
238
|
+
additionalProperties: false,
|
|
239
|
+
},
|
|
240
|
+
async execute(raw) {
|
|
241
|
+
const input = parseStatusInput(raw);
|
|
242
|
+
if (typeof input === "string")
|
|
243
|
+
return { content: input };
|
|
244
|
+
return { content: handleStatus(input.action, input.id) };
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
editor.add({
|
|
248
|
+
name: "mcp_sentinel_attach",
|
|
249
|
+
description: ATTACH_TOOL_DESCRIPTION,
|
|
250
|
+
input: {
|
|
251
|
+
type: "object",
|
|
252
|
+
properties: {
|
|
253
|
+
id: { type: "string", description: ATTACH_ID_DESCRIPTION },
|
|
254
|
+
timeout: { type: "integer", minimum: 0, description: ATTACH_TIMEOUT_DESCRIPTION },
|
|
255
|
+
},
|
|
256
|
+
required: ["id"],
|
|
257
|
+
additionalProperties: false,
|
|
258
|
+
},
|
|
259
|
+
async execute(raw, context) {
|
|
260
|
+
const input = parseAttachInput(raw);
|
|
261
|
+
if (typeof input === "string")
|
|
262
|
+
return { content: input };
|
|
263
|
+
return {
|
|
264
|
+
content: await handleAttach(input.id, input.timeout, () => context.signal.aborted),
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
editor.add({
|
|
269
|
+
name: "mcp_sentinel_read",
|
|
270
|
+
description: READ_TOOL_DESCRIPTION,
|
|
271
|
+
input: {
|
|
272
|
+
type: "object",
|
|
273
|
+
properties: {
|
|
274
|
+
id: { type: "string", description: READ_ID_DESCRIPTION },
|
|
275
|
+
offset: { type: "integer", minimum: 0, description: READ_OFFSET_DESCRIPTION },
|
|
276
|
+
limit: { type: "integer", minimum: 1, description: READ_LIMIT_DESCRIPTION },
|
|
277
|
+
},
|
|
278
|
+
required: ["id"],
|
|
279
|
+
additionalProperties: false,
|
|
280
|
+
},
|
|
281
|
+
async execute(raw) {
|
|
282
|
+
const input = parseReadInput(raw);
|
|
283
|
+
if (typeof input === "string")
|
|
284
|
+
return { content: input };
|
|
285
|
+
return { content: handleRead(input.id, input.offset, input.limit) };
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
// V2 disposes registrations automatically. Release only the process-wide
|
|
290
|
+
// core resources once the last plugin instance for this server unloads;
|
|
291
|
+
// otherwise one location would wipe another location's sentinels.
|
|
292
|
+
return async () => {
|
|
293
|
+
unregisterHostBinding(binding);
|
|
294
|
+
if (getHostBindingCount() === 0) {
|
|
295
|
+
cleanup();
|
|
296
|
+
await disconnectAll();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
});
|
package/dist/notify.d.ts
ADDED
|
@@ -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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.5.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.5.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
|
+
}
|