mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 10:00:45 +08:00
- Drop the TypeScript netx tool implementations and the hard-coded TOOLS_BY_GROUP list; register netx__* tools from the server's tools/list (group from _meta.netx.group) and forward tools/call. - Thin Streamable HTTP client on standard fetch (fetchImpl injectable); cached catalog when netx is unreachable, refresh to reconnect. - disabledTools (deny list; new server tools follow their group) and readOnlyOnly (only readOnlyHint=true tools), re-registered live. - Settings card: per-group expandable tool rows with switches and a write marker; skills append a disabled-tools note. - Toggles only control model visibility; netx enforces security. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
7.8 KiB
TypeScript
237 lines
7.8 KiB
TypeScript
/**
|
|
* Register native DSH tools (`netx__*`) by forwarding to netx `/mcp`.
|
|
*
|
|
* Tool visibility (groups / disabledTools / readOnlyOnly) is an operator UX
|
|
* control only — not a security boundary. Server RBAC still applies on every
|
|
* tools/call.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
import type { NetxCapabilityGroupId, NetxCapabilityGroups } from './capability-groups.ts'
|
|
import { resolveEnabledTools } from './mcp-catalog.ts'
|
|
import { callMcpTool, mcpContentText, mcpEndpointUrl } from './mcp-remote.ts'
|
|
import { getMcpCatalogTools } from './mcp-store.ts'
|
|
import { publicToolName, type NetxMcpToolDescriptor } from './mcp-types.ts'
|
|
import { getNetxConnection } from './runtime.ts'
|
|
import { getToolMountId, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
|
|
|
|
function packageVersion(): string {
|
|
try {
|
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as { version?: string }
|
|
return typeof pkg.version === 'string' ? pkg.version : '0'
|
|
} catch {
|
|
return '0'
|
|
}
|
|
}
|
|
|
|
export interface NetxToolConnection {
|
|
toolsEnabled?: boolean
|
|
groupKbPresets?: string
|
|
apiUrl: string
|
|
token: string
|
|
lang: string
|
|
toolCallTimeoutMs: number
|
|
/** Capability-group exposure; omitted → package defaults. */
|
|
groups?: NetxCapabilityGroups
|
|
/**
|
|
* Server tool names turned off by the operator (opt-out). New server tools
|
|
* follow the group switch unless listed here.
|
|
*/
|
|
disabledTools?: readonly string[]
|
|
/** When true, only register tools with readOnlyHint === true. */
|
|
readOnlyOnly?: boolean
|
|
/**
|
|
* Mount operator-subset `_skills/kb-*` into the Netx Ops preset (default true).
|
|
* Gated also by KB configured + content.hasSkills.
|
|
*/
|
|
groupKbInPreset?: boolean
|
|
/** Mount kb pack skills on the host public skill layer (default false). */
|
|
groupKbPublic?: boolean
|
|
}
|
|
|
|
function renderMcpResult(_args: unknown, value: unknown) {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
const row = value as { text?: unknown; content?: unknown[] }
|
|
if (typeof row.text === 'string' && row.text.length > 0) {
|
|
return [{ type: 'text' as const, text: row.text }]
|
|
}
|
|
if (Array.isArray(row.content)) {
|
|
return [{ type: 'text' as const, text: mcpContentText(row.content, JSON.stringify(value)) }]
|
|
}
|
|
}
|
|
return [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value ?? null) }]
|
|
}
|
|
|
|
/** Free-form object output — same wire shape as the former REST tools. */
|
|
const mcpOut = {
|
|
schema: { type: 'object' as const, additionalProperties: true as const },
|
|
render: renderMcpResult,
|
|
}
|
|
|
|
/**
|
|
* `defineTool` expects a property map (DSH ParameterSchemaSpec), not a full
|
|
* JSON Schema document. MCP `inputSchema` is usually `{ type:'object', properties }`.
|
|
*/
|
|
function parametersFromInputSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
|
if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
|
|
const props = schema.properties
|
|
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
|
return props as Record<string, unknown>
|
|
}
|
|
// Already a property map (legacy / odd servers).
|
|
if (schema.type === undefined && schema.properties === undefined) {
|
|
return schema
|
|
}
|
|
}
|
|
return {}
|
|
}
|
|
|
|
function defineRemoteTool(
|
|
tool: NetxMcpToolDescriptor,
|
|
timeoutMs: number,
|
|
): ReturnType<typeof defineTool> {
|
|
const name = publicToolName(tool.name)
|
|
return defineTool({
|
|
name,
|
|
description: tool.description || name,
|
|
parameters: parametersFromInputSchema(tool.inputSchema) as never,
|
|
output: mcpOut,
|
|
timeoutMs,
|
|
isConcurrencySafe: () => tool.readOnlyHint === true,
|
|
async execute(args, exec) {
|
|
const live = getNetxConnection()
|
|
const apiUrl = live?.apiUrl ?? ''
|
|
const token = live?.token ?? ''
|
|
const lang = live?.lang ?? 'zh'
|
|
const mcpUrl = mcpEndpointUrl(apiUrl)
|
|
if (!mcpUrl) throw new Error('netx mcp: apiUrl empty')
|
|
const result = await callMcpTool(
|
|
{
|
|
mcpUrl,
|
|
token,
|
|
lang,
|
|
timeoutMs,
|
|
signal: exec.signal,
|
|
},
|
|
tool.name,
|
|
(typeof args === 'object' && args !== null ? args : {}) as Record<string, unknown>,
|
|
)
|
|
const text = mcpContentText(result.content, '')
|
|
// Pass business failures through as ToolRuntime errors (model sees isError).
|
|
if (result.isError === true) {
|
|
throw new Error(text || `Tool "${tool.name}" failed`)
|
|
}
|
|
// Round-trip keeps DSH tool results lossless JSON-compatible.
|
|
return JSON.parse(JSON.stringify({
|
|
content: result.content,
|
|
...(result.structuredContent !== undefined
|
|
? { structuredContent: result.structuredContent }
|
|
: {}),
|
|
...(text ? { text } : {}),
|
|
})) as Record<string, never>
|
|
},
|
|
})
|
|
}
|
|
|
|
export interface RegisterNetxToolsOptions {
|
|
/**
|
|
* `preset` — groups with `inPreset` (Netx Ops preset scope).
|
|
* `public` — groups with `public` only.
|
|
* `host` — same as `public` (Host-global registry for other presets).
|
|
*/
|
|
plane: 'preset' | 'public' | 'host'
|
|
/** Optional intersect with settings-enabled groups. */
|
|
only?: readonly NetxCapabilityGroupId[]
|
|
/** When set, register exactly these groups (ignore plane flags). */
|
|
forceGroups?: readonly NetxCapabilityGroupId[]
|
|
source?: string
|
|
presetId?: string
|
|
}
|
|
|
|
/**
|
|
* Register Netx Ops tools for one exposure plane from the cached MCP catalog.
|
|
* @returns disposer that unregisters every tool.
|
|
*/
|
|
export function registerNetxTools(
|
|
ctx: Context,
|
|
connection: NetxToolConnection,
|
|
options: RegisterNetxToolsOptions,
|
|
): () => void {
|
|
const id = getToolMountId(ctx, options.source ?? options.plane)
|
|
const enabled = resolveEnabledTools(getMcpCatalogTools(), {
|
|
toolsEnabled: connection.toolsEnabled,
|
|
groups: connection.groups,
|
|
disabledTools: connection.disabledTools,
|
|
readOnlyOnly: connection.readOnlyOnly,
|
|
plane: options.plane,
|
|
forceGroups: options.forceGroups,
|
|
only: options.only,
|
|
presetId: options.presetId,
|
|
})
|
|
const groups = [...new Set(enabled.map(tool => tool.group).filter(Boolean))] as NetxCapabilityGroupId[]
|
|
|
|
if (enabled.length === 0) {
|
|
publishToolMountStatus({
|
|
id,
|
|
version: packageVersion(),
|
|
plane: options.plane,
|
|
groups,
|
|
names: [],
|
|
count: 0,
|
|
at: new Date().toISOString(),
|
|
source: options.source ?? options.plane,
|
|
})
|
|
return () => { removeToolMountStatus(id) }
|
|
}
|
|
|
|
const t = typeof connection.toolCallTimeoutMs === 'number'
|
|
&& Number.isFinite(connection.toolCallTimeoutMs)
|
|
&& connection.toolCallTimeoutMs > 0
|
|
? connection.toolCallTimeoutMs
|
|
: 120_000
|
|
|
|
const disposers: Array<() => void> = []
|
|
const names: string[] = []
|
|
try {
|
|
for (const tool of enabled) {
|
|
const definition = defineRemoteTool(tool, t)
|
|
disposers.push(ctx.tools.register(definition))
|
|
names.push(publicToolName(tool.name))
|
|
}
|
|
} catch (error) {
|
|
for (const dispose of disposers.splice(0)) dispose()
|
|
publishToolMountStatus({
|
|
id,
|
|
version: packageVersion(),
|
|
plane: options.plane,
|
|
groups,
|
|
names: [],
|
|
count: 0,
|
|
at: new Date().toISOString(),
|
|
error: String(error),
|
|
source: options.source ?? options.plane,
|
|
})
|
|
throw error
|
|
}
|
|
|
|
publishToolMountStatus({
|
|
id,
|
|
version: packageVersion(),
|
|
plane: options.plane,
|
|
groups,
|
|
names,
|
|
count: names.length,
|
|
at: new Date().toISOString(),
|
|
source: options.source ?? options.plane,
|
|
})
|
|
|
|
return () => {
|
|
for (const dispose of disposers) dispose()
|
|
removeToolMountStatus(id)
|
|
}
|
|
}
|