From 45922ffd29af7057b2e3b6655ab0060f38cc4f55 Mon Sep 17 00:00:00 2001 From: hansjone Date: Fri, 4 Sep 2026 22:01:04 +0800 Subject: [PATCH] Move netx URL/token into DSH host settings and credentials. Mount MCP from dsh-netxops instead of OS env in the agent preset. --- README.md | 59 +++++------ cordis.patch.yml | 22 ++-- docs/INSTALL.md | 95 ++++++++++------- docs/PORTING.md | 9 +- examples/local-debug/README.md | 32 +++--- examples/local-debug/patch.cordis.yml | 16 ++- package.json | 15 ++- presets/netxops/agent.cordis.yml | 20 +--- scripts/set-netx-token.ps1 | 38 +++++++ scripts/set-netx-token.sh | 43 ++++++++ src/index.ts | 142 ++++++++++++++++++++++++++ 11 files changed, 374 insertions(+), 117 deletions(-) create mode 100644 scripts/set-netx-token.ps1 create mode 100644 scripts/set-netx-token.sh create mode 100644 src/index.ts diff --git a/README.md b/README.md index aaa7ad5..358b9b2 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,46 @@ # netxops — Netx Ops for DeepSeek Harness -Public **DeepSeek Harness agent preset** for network operations against [netx](https://github.com/hansjone/netx) (ZTE UME alarms, NE inventory, managed read-only CLI). +Public **DeepSeek Harness** package: host bridge (settings + credentials → netx MCP) and an **ops agent preset** (persona + playbooks). -- GitHub topic: [`dsh-plugin`](https://github.com/topics/dsh-plugin) -- npm package name: `dsh-netxops` -- Brand: **Netx Ops** (not oclaw) +- GitHub: https://github.com/hansjone/netxops +- Topic: [`dsh-plugin`](https://github.com/topics/dsh-plugin) +- npm name: `dsh-netxops` +- Brand: **Netx Ops** -## What you get (v1) +## What you get -| Piece | Location | -|-------|----------| -| Agent preset | [`presets/netxops/`](presets/netxops/) | -| Persona | `PERSONA.md` + `agent.cordis.yml` | -| Skills | `ops-netx-ume-playbook`, `ops-netx-managed-ne-playbook` | -| Tools | `@deepseek-ai/dsh-mcp-client` → `python -m netx_mcp` (`mcp__netx__*`) | +| Piece | Where | Purpose | +|-------|--------|---------| +| Host plugin `dsh-netxops` | `src/index.ts` + `cordis.patch.yml` | `apiUrl` / `lang` in settings; token as credential `NETX_API_TOKEN`; mounts `mcp__netx__*` | +| Agent preset | `presets/netxops/` | Persona + UME / managed-NE skills | -**Not in v1:** topology canvas MCP, Excel one-shot report plugin, oclaw channels/Admin. +**Not OS env:** do not put `NETX_API_URL` / `NETX_API_TOKEN` in system environment for day-to-day use — configure inside DSH (settings + credentials). See [docs/INSTALL.md](docs/INSTALL.md). -## Quick install +**Not in v1:** topology canvas MCP, Excel report plugin, Plugins settings **card** UI (Host namespace is ready; browser card follows DSH cookbook). + +## Quick start ```powershell -git clone https://github.com/hansjone/netxops.git -cd netxops +# 1) Host bridge + MCP tools +dsh plugin --profile web add github:hansjone/netxops + +# 2) Token (credentials store, same family as model keys) +powershell -File .\scripts\set-netx-token.ps1 -Token "nxt_…" + +# 3) Ops preset (persona + skills) powershell -File .\scripts\link-preset.ps1 ``` -Set `NETX_API_URL` if needed, ensure `pip install` of `netx-mcp`, then in DeepSeek Harness pick preset **Netx Ops**. +Then open DSH → session preset **Netx Ops**. -Full steps: [docs/INSTALL.md](docs/INSTALL.md) · Tool list: [docs/TOOL_MAP.md](docs/TOOL_MAP.md) +## Local debug -## Local debug (DeepSeekHarness checkout) - -```powershell -powershell -File .\scripts\link-preset.ps1 -# or: -# cd D:\project\DeepSeekHarness -# pnpm dsh web --patch D:\project\chatgpt\netxops\examples\local-debug\patch.cordis.yml -``` - -See [examples/local-debug/README.md](examples/local-debug/README.md). - -## Smoke checks - -1. Tools visible: `mcp__netx__aggregateUmeAlarms`, `mcp__netx__queryUmeAlarms`, … -2. “Critical Top by host” → aggregate + Result/Evidence reply shell -3. “Alarms on ``” → host-scoped query only +Against [`DeepSeekHarness`](https://github.com/deepseek-ai/deepseek-harness) checkout: [examples/local-debug/README.md](examples/local-debug/README.md). ## Related - Data plane: [hansjone/netx](https://github.com/hansjone/netx) -- Porting from oclaw ops: [docs/PORTING.md](docs/PORTING.md) +- Porting from oclaw: [docs/PORTING.md](docs/PORTING.md) ## License diff --git a/cordis.patch.yml b/cordis.patch.yml index 9cf3c82..0ac53c0 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,9 +1,15 @@ -# dsh-netxops host bundle (v1) +# Host-plane Netx Ops bridge: settings + credentials → mcp__netx__* tools. # -# Agent composition lives in presets/netxops/ (install into ~/.dsh/.agent-presets). -# This host-plane patch is intentionally empty: MCP and persona are scoped to the -# netxops agent preset so other agents are not flooded with mcp__netx__* tools. -# -# Install: see README.md / docs/INSTALL.md -# Optional: dsh plugin add github:hansjone/netxops (registers this bundle; still link the preset) -[] +# After `dsh plugin add` / link, open Settings → Plugins to edit apiUrl / lang +# (settings card client half; see docs/INSTALL.md). Token: credential NETX_API_TOKEN. + +- insert: + - id: netxops + name: dsh-netxops + config: + apiUrl: http://127.0.0.1:8890 + lang: zh + pythonCommand: python + tokenCredentialRef: NETX_API_TOKEN + toolCallTimeoutMs: 120000 + failOnStartupError: false diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 665370a..427725e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -2,7 +2,7 @@ ## Prerequisites -1. **DeepSeek Harness** (`dsh`) installed or a local checkout (see [local debug](../examples/local-debug/README.md)). +1. **DeepSeek Harness** (`dsh`) installed or a local checkout. 2. **netx API** reachable (default `http://127.0.0.1:8890`). 3. **Python 3.11+** on the same machine as `dsh`, with `netx_mcp` importable: @@ -11,48 +11,73 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx python -c "import netx_mcp; print('ok')" ``` -4. Environment (optional if defaults match): +You do **not** need to export `NETX_API_URL` / `NETX_API_TOKEN` in the OS environment for normal use. -| Variable | Default | Purpose | -|----------|---------|---------| -| `NETX_API_URL` | `http://127.0.0.1:8890` | netx REST root | -| `NETX_API_TOKEN` | (empty / auto file) | Bearer token | -| `NETX_LANG` | `zh` | API locale | - -## Install the agent preset (recommended) - -Clone this repo, then link or copy the preset directory into the DSH user preset root: - -```powershell -git clone https://github.com/hansjone/netxops.git -# Windows (developer junction — editable in place): -powershell -File .\scripts\link-preset.ps1 -# Or copy: -# Copy-Item -Recurse .\presets\netxops $env:USERPROFILE\.dsh\.agent-presets\netxops -``` - -Unix: - -```bash -git clone https://github.com/hansjone/netxops.git -./scripts/link-preset.sh -# Or: cp -R presets/netxops ~/.dsh/.agent-presets/netxops -``` - -Restart / open `dsh web`, create a session, choose preset **Netx Ops** (`netxops`). - -## Optional: `dsh plugin add` +## 1) Install host bridge (URL + token + MCP tools) ```bash dsh plugin --profile add github:hansjone/netxops +# or from a checkout: +dsh plugin --profile add /path/to/netxops ``` -v1’s host `cordis.patch.yml` is empty (MCP stays inside the preset). You still need the preset link/copy above. +This applies `cordis.patch.yml`, which mounts package `dsh-netxops`. That plugin: + +- Registers settings namespace **`netxops`** (`apiUrl`, `lang`, `pythonCommand`, …) +- Reads bearer token from credentials reference **`NETX_API_TOKEN`** (same store as model keys: `~/.dsh/.credentials.yaml`) +- Spawns `python -m netx_mcp` and registers `mcp__netx__*` on the **host** tool registry + +### Fill API URL / language + +**Target UX:** Settings → **Plugins** → **Netx Ops** card (settings `apiUrl` / `lang`). +The Host settings namespace is already registered; a dedicated Plugins settings card (browser half) follows the [DSH settings-card cookbook](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-settings-card.md) and will land in a follow-up. + +**Until the card ships**, change composition defaults in the profile patch / plugin config: + +```yaml +# ~/.dsh/profiles//… or override the dsh-netxops row +config: + apiUrl: http://10.0.0.5:8890 + lang: zh + pythonCommand: python +``` + +Or edit the installed bundle row after `dsh plugin add`. + +### Fill netx API token (secret) + +Same credential store as DeepSeek API keys — **not** a process env var: + +```powershell +powershell -File .\scripts\set-netx-token.ps1 -Token "nxt_…" +``` + +```bash +./scripts/set-netx-token.sh 'nxt_…' +``` + +This writes / updates `refs.NETX_API_TOKEN` under `%USERPROFILE%\.dsh\.credentials.yaml` (or `$DSH_HOME`). Restart is not required if DSH is watching credentials; otherwise restart `dsh web`. + +## 2) Install the agent preset (persona + skills) + +```powershell +git clone https://github.com/hansjone/netxops.git +cd netxops +powershell -File .\scripts\link-preset.ps1 +``` + +Unix: `./scripts/link-preset.sh` + +Open `dsh web`, new session → preset **Netx Ops**. ## Verify -1. Session tools include `mcp__netx__queryUmeAlarms` (and siblings). -2. Ask: “Critical Top 10 by host” → expect `aggregateUmeAlarms` + Result/Evidence shell. -3. Ask a single `host_name` alarm query → host-scoped tools only. +1. Tools include `mcp__netx__queryUmeAlarms` (and siblings). +2. “Critical Top by host” → aggregate + Result/Evidence shell. +3. Single `host_name` alarm query → host-scoped tools only. See [TOOL_MAP.md](TOOL_MAP.md). + +## Local debug (DeepSeekHarness checkout) + +See [examples/local-debug/README.md](../examples/local-debug/README.md). diff --git a/docs/PORTING.md b/docs/PORTING.md index 7ecfa53..4e83f81 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -4,12 +4,14 @@ | Source (oclaw) | Destination | |----------------|-------------| -| `runtime/workspaces/ops/ROLE_SYSTEM.md` | `presets/netxops/PERSONA.md` + persona text in `agent.cordis.yml` (brand **Netx Ops**) | +| `runtime/workspaces/ops/ROLE_SYSTEM.md` | `presets/netxops/PERSONA.md` + persona in `agent.cordis.yml` (brand **Netx Ops**) | | `skills/_workspace/ops/ops-netx-ume-playbook/` | `presets/netxops/skills/ops-netx-ume-playbook/` | | `skills/_workspace/ops/ops-netx-managed-ne-playbook/` | `presets/netxops/skills/ops-netx-managed-ne-playbook/` | -| netx MCP contract | `agent.cordis.yml` → `dsh-mcp-client` | +| netx MCP + API URL/token | Host plugin `src/index.ts` (`dsh-netxops`): settings namespace `netxops` + credential `NETX_API_TOKEN` → dynamic `dsh-mcp-client` | -Adaptations: removed oclaw Admin / WhatsApp / `ume_alarm_xlsx_report` / wiki capture / skill_auto_install lanes; fiber/offline recipes use Raw/aggregate MCP only. +Adaptations: removed oclaw Admin / WhatsApp / `ume_alarm_xlsx_report` / wiki capture / skill_auto_install; fiber/offline recipes use Raw/aggregate MCP only. + +**Config UX:** not OS env. Token via DSH credentials (same as model keys); `apiUrl` via settings / composition config. Plugins settings **card** (browser half) TBD per DSH cookbook. ## Stay in oclaw @@ -17,6 +19,7 @@ Gateway, WhatsApp/Weixin, Admin MCP UI, specialist router, memory-wiki, ops-ai H ## Phase 2 candidates +- Plugins settings card (`dsh.client`) for apiUrl + token on Settings → Plugins - `ume_alarm_xlsx_report` + thin HTTP client as a DSH tool plugin - UME sync context inject - `ops-ip-knowledge-playbook` + KB content diff --git a/examples/local-debug/README.md b/examples/local-debug/README.md index f80689b..f09c254 100644 --- a/examples/local-debug/README.md +++ b/examples/local-debug/README.md @@ -1,31 +1,33 @@ # Local debug against DeepSeekHarness -## Link the preset - -From this repo root: +## A. Link preset + install host bridge ```powershell +cd D:\project\chatgpt\netxops powershell -File .\scripts\link-preset.ps1 +powershell -File .\scripts\set-netx-token.ps1 -Token (Get-Content D:\project\chatgpt\netx\data\auth\mcp_token -Raw).Trim() ``` -This junctions `presets\netxops` → `%USERPROFILE%\.dsh\.agent-presets\netxops` so DSH’s default `includeUserRoot` discovers it. - -## Optional patch (extra preset root) - -If you prefer not to touch `~/.dsh/.agent-presets`, start DSH with: +From DeepSeekHarness (source): ```powershell cd D:\project\DeepSeekHarness +pnpm dsh plugin --profile web add D:\project\chatgpt\netxops +# or one-shot overlay: pnpm dsh web --patch D:\project\chatgpt\netxops\examples\local-debug\patch.cordis.yml ``` -`patch.cordis.yml` replaces the `agent-presets` row to keep shipped + user roots and add this repo’s `presets/` directory. Adjust the path if your checkout differs. +## B. What the debug patch does -## Env +[`patch.cordis.yml`](patch.cordis.yml): -```powershell -$env:NETX_API_URL = "http://127.0.0.1:8890" -# $env:NETX_API_TOKEN = "nxt_..." -``` +1. Ensures agent-presets can see `D:/project/chatgpt/netxops/presets` +2. Inserts host plugin `dsh-netxops` via **absolute path** to `src/index.ts` (no npm link required) -Ensure `python -m netx_mcp` works in the same environment PATH that DSH will spawn. +Adjust paths if your checkout differs. + +## C. Verify + +1. New session → preset **Netx Ops** +2. Tools include `mcp__netx__aggregateUmeAlarms` +3. Ask Critical Top / single host alarms diff --git a/examples/local-debug/patch.cordis.yml b/examples/local-debug/patch.cordis.yml index 69c131c..77b79e6 100644 --- a/examples/local-debug/patch.cordis.yml +++ b/examples/local-debug/patch.cordis.yml @@ -1,10 +1,9 @@ -# Local DeepSeekHarness overlay: discover netxops preset from this checkout. +# Local DeepSeekHarness overlay: discover netxops preset + mount host bridge. # # Usage (from DeepSeekHarness root): # pnpm dsh web --patch D:/project/chatgpt/netxops/examples/local-debug/patch.cordis.yml # -# Prefer scripts/link-preset.ps1 when possible — it uses the default user preset root -# and does not replace host agent-presets config. +# Prefer also: scripts/link-preset.ps1 and scripts/set-netx-token.ps1 - replace: - id: agent-presets @@ -16,3 +15,14 @@ roots: - path: D:/project/chatgpt/netxops/presets trust: user + +- insert: + - id: netxops + name: D:/project/chatgpt/netxops/src/index.ts + config: + apiUrl: http://127.0.0.1:8890 + lang: zh + pythonCommand: python + tokenCredentialRef: NETX_API_TOKEN + toolCallTimeoutMs: 120000 + failOnStartupError: false diff --git a/package.json b/package.json index 501b1c3..7f364c8 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,17 @@ { "name": "dsh-netxops", - "version": "0.1.0", - "description": "DeepSeek Harness agent preset for Netx Ops (UME alarms, NE inventory, managed CLI)", + "version": "0.1.1", + "description": "DeepSeek Harness Netx Ops: host bridge (settings/credentials → netx MCP) + agent preset", "license": "MIT", "type": "module", "private": false, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, "files": [ + "src/", "presets/", "cordis.patch.yml", "docs/", @@ -36,7 +42,12 @@ } }, "peerDependencies": { + "@deepseek-ai/cordis": "*", + "@deepseek-ai/schemastery": "*", + "@deepseek-ai/dsh-credentials": "*", + "@deepseek-ai/dsh-settings": "*", "@deepseek-ai/dsh-mcp-client": "*", + "@deepseek-ai/dsh-tools": "*", "@deepseek-ai/dsh-persona": "*", "@deepseek-ai/dsh-skill-filesystem": "*", "@deepseek-ai/dsh-tool-skill": "*", diff --git a/presets/netxops/agent.cordis.yml b/presets/netxops/agent.cordis.yml index 563a864..d09b7d2 100644 --- a/presets/netxops/agent.cordis.yml +++ b/presets/netxops/agent.cordis.yml @@ -63,20 +63,6 @@ - id: tool-skill name: '@deepseek-ai/dsh-tool-skill' -# ── netx MCP (UME + managed CLI; no topology canvas in v1) ────────────────── - -- id: mcp-netx - name: '@deepseek-ai/dsh-mcp-client' - config: - serverName: netx - transport: stdio - command: python - args: - - -m - - netx_mcp - env: - NETX_API_URL: !!js process.env.NETX_API_URL || 'http://127.0.0.1:8890' - NETX_API_TOKEN: !!js process.env.NETX_API_TOKEN || '' - NETX_LANG: !!js process.env.NETX_LANG || 'zh' - toolCallTimeoutMs: 120000 - failOnStartupError: false +# netx MCP is mounted on the HOST by package `dsh-netxops` (cordis.patch.yml): +# Settings → Plugins / credentials NETX_API_TOKEN — not OS env, not this preset. +# Tools appear as mcp__netx__* for sessions that can see the host tool registry. diff --git a/scripts/set-netx-token.ps1 b/scripts/set-netx-token.ps1 new file mode 100644 index 0000000..3b2bfb2 --- /dev/null +++ b/scripts/set-netx-token.ps1 @@ -0,0 +1,38 @@ +# Set NETX_API_TOKEN in the DSH credentials store (no OS env required). + +param( + [Parameter(Mandatory = $true)] + [string] $Token, + [string] $DshHome = $(if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE ".dsh" }) +) + +$ErrorActionPreference = "Stop" +if ([string]::IsNullOrWhiteSpace($Token)) { throw "Token is empty" } + +New-Item -ItemType Directory -Force -Path $DshHome | Out-Null +$path = Join-Path $DshHome ".credentials.yaml" + +# Minimal merge: preserve other refs if file exists and is simple version:1 docs. +$escaped = $Token.Replace("'", "''") +if (-not (Test-Path $path)) { + @" +version: 1 + +refs: + NETX_API_TOKEN: '$escaped' +"@ | Set-Content -Path $path -Encoding utf8 + Write-Host "Created $path with NETX_API_TOKEN" + exit 0 +} + +$raw = Get-Content -Path $path -Raw +if ($raw -match '(?m)^\s*NETX_API_TOKEN\s*:') { + $raw = [regex]::Replace($raw, '(?m)^(\s*NETX_API_TOKEN\s*:\s*).*$', "`${1}'$escaped'") +} elseif ($raw -match '(?m)^refs\s*:') { + $raw = [regex]::Replace($raw, '(?m)^(refs\s*:\s*\r?\n)', "`${1} NETX_API_TOKEN: '$escaped'`n") +} else { + $raw = $raw.TrimEnd() + "`n`nrefs:`n NETX_API_TOKEN: '$escaped'`n" +} +Set-Content -Path $path -Value $raw -Encoding utf8 +Write-Host "Updated NETX_API_TOKEN in $path" +Write-Host "If dsh is running, wait for credential reload or restart dsh web." diff --git a/scripts/set-netx-token.sh b/scripts/set-netx-token.sh new file mode 100644 index 0000000..1456101 --- /dev/null +++ b/scripts/set-netx-token.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail +TOKEN="${1:-}" +if [[ -z "$TOKEN" ]]; then + echo "usage: $0 " >&2 + exit 1 +fi +DSH_HOME="${DSH_HOME:-$HOME/.dsh}" +mkdir -p "$DSH_HOME" +PATH_FILE="$DSH_HOME/.credentials.yaml" +ESC=${TOKEN//\'/\'\'} + +if [[ ! -f "$PATH_FILE" ]]; then + cat >"$PATH_FILE" <"$tmp" + mv "$tmp" "$PATH_FILE" +else + if grep -qE '^refs[[:space:]]*:' "$PATH_FILE"; then + tmp="$(mktemp)" + awk -v tok="$ESC" ' + BEGIN{done=0} + /^refs[[:space:]]*:/ && !done { print; print " NETX_API_TOKEN: '\''" tok "'\''"; done=1; next } + { print } + ' "$PATH_FILE" >"$tmp" + mv "$tmp" "$PATH_FILE" + else + printf '\nrefs:\n NETX_API_TOKEN: '\''%s'\''\n' "$ESC" >>"$PATH_FILE" + fi +fi +echo "Updated NETX_API_TOKEN in $PATH_FILE" +echo "If dsh is running, wait for credential reload or restart dsh web." diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d8455fc --- /dev/null +++ b/src/index.ts @@ -0,0 +1,142 @@ +/** + * Host-plane Netx Ops bridge: settings (apiUrl / lang / python) + credentials + * (NETX_API_TOKEN) drive a dynamically mounted `@deepseek-ai/dsh-mcp-client`. + * + * Configure in DSH Settings → Plugins → Netx Ops (settings card when client + * half is installed). Token is stored as credential `NETX_API_TOKEN` (same + * store as model API keys). Helper: `scripts/set-netx-token.ps1`. + * + * @module dsh-netxops + */ + +import type { Context, Fiber } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type {} from '@deepseek-ai/dsh-credentials' +import type {} from '@deepseek-ai/dsh-settings' +import * as McpClient from '@deepseek-ai/dsh-mcp-client' + +/** Cordis plugin name. */ +export const name = 'netxops' + +/** MCP tools registry must exist to mount the child client. */ +export const inject = ['tools'] + +/** Settings / composition namespace (Plugins page join key). */ +export const NETXOPS_SETTINGS_NAMESPACE = 'netxops' + +/** Default credential reference for the netx API bearer token. */ +export const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN' + +/** Plugin / settings section shape. */ +export interface Config { + /** netx REST root (no trailing slash required). */ + apiUrl: string + /** Passed to MCP as `NETX_LANG`. */ + lang: string + /** Executable that can run `python -m netx_mcp`. */ + pythonCommand: string + /** Credential reference for the bearer token (never store the secret here). */ + tokenCredentialRef: string + /** Per MCP tool-call timeout (ms). */ + toolCallTimeoutMs: number + /** Fail activation when MCP cannot connect / sync tools. */ + failOnStartupError: boolean +} + +export const Config: z = z.object({ + apiUrl: z.string().default('http://127.0.0.1:8890'), + lang: z.string().default('zh'), + pythonCommand: z.string().default('python'), + tokenCredentialRef: z.string().role('credential-ref').default(DEFAULT_TOKEN_REF), + toolCallTimeoutMs: z.number().step(1).min(1000).default(120_000), + failOnStartupError: z.boolean().default(false), +}) + +/** + * Resolve bearer token from the credentials seam (or empty when unset). + */ +async function resolveToken(ctx: Context, refName: string): Promise { + const credentials = ctx.get('credentials') + if (credentials === undefined) return '' + const hit = await credentials.resolve(credentialRef(refName)) + return hit?.value ?? '' +} + +/** + * Apply the Netx Ops host bridge. + */ +export function apply(ctx: Context, config: Config = Config({})): void { + let source: () => Config = () => config + let mcpFiber: Fiber | undefined + let remounting: Promise = Promise.resolve() + let generation = 0 + + const remount = (): void => { + remounting = remounting.then(async () => { + const gen = ++generation + const previous = mcpFiber + mcpFiber = undefined + if (previous !== undefined) { + try { + await previous.dispose() + } catch (error) { + ctx.logger.warn('netxops: disposing previous mcp-client failed: %s', error) + } + } + if (gen !== generation) return + + const current = source() + const token = await resolveToken(ctx, current.tokenCredentialRef) + if (gen !== generation) return + + const mcpConfig = McpClient.Config({ + transport: 'stdio', + serverName: 'netx', + command: current.pythonCommand, + args: ['-m', 'netx_mcp'], + env: { + NETX_API_URL: current.apiUrl.replace(/\/$/, ''), + NETX_API_TOKEN: token, + NETX_LANG: current.lang, + }, + toolCallTimeoutMs: current.toolCallTimeoutMs, + failOnStartupError: current.failOnStartupError, + }) + + try { + mcpFiber = await ctx.plugin(McpClient, mcpConfig) + } catch (error) { + ctx.logger.error('netxops: failed to mount mcp-client: %s', error) + if (current.failOnStartupError) throw error + } + }).catch((error) => { + ctx.logger.error('netxops: remount error: %s', error) + }) + } + + // Composition defaults first (works even when settings provider is absent). + remount() + + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, config, { + setSource: (current) => { + source = current + }, + onChange: () => { + remount() + }, + }) + }) + + ctx.on('credentials/reference-updated', (ref) => { + if (String(ref) === source().tokenCredentialRef) remount() + }) + + ctx.effect(() => () => { + generation += 1 + const fiber = mcpFiber + mcpFiber = undefined + void fiber?.dispose() + }, 'netxops: dispose mcp-client') +}