@rezti/dsh-rez-suite 0.1.5 → 0.1.7
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/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.d.ts +11 -3
- package/lib/client.js +215 -161
- package/lib/index.d.ts +20 -0
- package/lib/index.js +182 -29
- package/package.json +6 -6
- package/src/client/locales.ts +23 -7
- package/src/client/panel/ConfigTab.tsx +141 -86
- package/src/client/panel/StatusTab.tsx +1 -0
- package/src/index.ts +40 -20
- package/src/observe-mcp.ts +121 -0
- package/src/protocol.ts +23 -0
- package/src/routes.ts +32 -4
- package/src/store.ts +37 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Config tab:
|
|
3
|
-
*
|
|
2
|
+
* Config tab: company MCP connections (Odoo / Nextcloud / WeCom / HA),
|
|
3
|
+
* role, and billing. Secrets are write-only and land in credentials.yaml.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { useEffect, useState, type ReactNode } from 'react'
|
|
7
7
|
import type { RezApi } from '../api.ts'
|
|
8
|
-
import type {
|
|
8
|
+
import type { RezPublicConfig, RezPublicConnection, RezRoleId, RezTestResult } from '../../protocol.ts'
|
|
9
9
|
import { errorMessage, tt } from './helpers.ts'
|
|
10
10
|
import css from './panel.module.css'
|
|
11
11
|
|
|
@@ -16,6 +16,15 @@ const ROLES: Array<{ value: RezRoleId; label: string }> = [
|
|
|
16
16
|
{ value: 'operations', label: 'role.operations' },
|
|
17
17
|
]
|
|
18
18
|
|
|
19
|
+
type SecretDrafts = {
|
|
20
|
+
odoo: string
|
|
21
|
+
nextcloud: string
|
|
22
|
+
wechat: string
|
|
23
|
+
homeassistant: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EMPTY_SECRETS: SecretDrafts = { odoo: '', nextcloud: '', wechat: '', homeassistant: '' }
|
|
27
|
+
|
|
19
28
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
|
20
29
|
return (
|
|
21
30
|
<div className={css.field}>
|
|
@@ -34,47 +43,88 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
|
|
|
34
43
|
)
|
|
35
44
|
}
|
|
36
45
|
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
function SecretField({
|
|
47
|
+
label,
|
|
48
|
+
configured,
|
|
49
|
+
value,
|
|
50
|
+
onChange,
|
|
51
|
+
}: {
|
|
52
|
+
label: string
|
|
53
|
+
configured: boolean
|
|
54
|
+
value: string
|
|
55
|
+
onChange: (value: string) => void
|
|
56
|
+
}) {
|
|
57
|
+
return (
|
|
58
|
+
<Field label={label}>
|
|
59
|
+
<input
|
|
60
|
+
className={css.input}
|
|
61
|
+
type="password"
|
|
62
|
+
autoComplete="off"
|
|
63
|
+
value={value}
|
|
64
|
+
placeholder={configured ? tt('config.secretConfigured') : tt('config.secretMissing')}
|
|
65
|
+
onChange={event => { onChange(event.target.value) }}
|
|
66
|
+
/>
|
|
67
|
+
</Field>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function McpSection({
|
|
72
|
+
title,
|
|
73
|
+
row,
|
|
74
|
+
secret,
|
|
75
|
+
onEnabled,
|
|
76
|
+
onUrl,
|
|
77
|
+
onUsername,
|
|
78
|
+
onSecret,
|
|
79
|
+
urlLabel,
|
|
80
|
+
secretLabel,
|
|
81
|
+
usernameLabel,
|
|
82
|
+
}: {
|
|
83
|
+
title: string
|
|
84
|
+
row: RezPublicConnection
|
|
85
|
+
secret: string
|
|
86
|
+
onEnabled: (enabled: boolean) => void
|
|
87
|
+
onUrl?: (url: string) => void
|
|
88
|
+
onUsername?: (username: string) => void
|
|
89
|
+
onSecret: (secret: string) => void
|
|
90
|
+
urlLabel?: string
|
|
91
|
+
secretLabel: string
|
|
92
|
+
usernameLabel?: string
|
|
93
|
+
}) {
|
|
94
|
+
return (
|
|
95
|
+
<Section title={title}>
|
|
96
|
+
<Field label={tt('config.enabled')}>
|
|
97
|
+
<input className={css.checkbox} type="checkbox" checked={row.enabled} onChange={event => { onEnabled(event.target.checked) }} />
|
|
98
|
+
</Field>
|
|
99
|
+
{onUrl !== undefined && urlLabel !== undefined && (
|
|
100
|
+
<Field label={urlLabel}>
|
|
101
|
+
<input className={css.input} value={row.url ?? ''} onChange={event => { onUrl(event.target.value) }} />
|
|
102
|
+
</Field>
|
|
103
|
+
)}
|
|
104
|
+
{onUsername !== undefined && usernameLabel !== undefined && (
|
|
105
|
+
<Field label={usernameLabel}>
|
|
106
|
+
<input className={css.input} value={row.username ?? ''} onChange={event => { onUsername(event.target.value) }} />
|
|
107
|
+
</Field>
|
|
108
|
+
)}
|
|
109
|
+
<SecretField label={secretLabel} configured={row.secretConfigured} value={secret} onChange={onSecret} />
|
|
110
|
+
<p className={css.message}>{row.secretRef}</p>
|
|
111
|
+
</Section>
|
|
112
|
+
)
|
|
45
113
|
}
|
|
46
114
|
|
|
47
115
|
export function ConfigTab({ api }: { api: RezApi }) {
|
|
48
116
|
const [config, setConfig] = useState<RezPublicConfig | null>(null)
|
|
117
|
+
const [secrets, setSecrets] = useState<SecretDrafts>(EMPTY_SECRETS)
|
|
49
118
|
const [loading, setLoading] = useState(true)
|
|
50
119
|
const [saving, setSaving] = useState(false)
|
|
51
120
|
const [testing, setTesting] = useState(false)
|
|
52
121
|
const [message, setMessage] = useState('')
|
|
53
122
|
const [error, setError] = useState('')
|
|
54
123
|
const [results, setResults] = useState<RezTestResult[]>([])
|
|
55
|
-
const [argsText, setArgsText] = useState<Record<string, string>>({})
|
|
56
|
-
const [envText, setEnvText] = useState<Record<string, string>>({})
|
|
57
|
-
const [headerText, setHeaderText] = useState<Record<string, string>>({})
|
|
58
|
-
|
|
59
|
-
const initTexts = (next: RezPublicConfig): void => {
|
|
60
|
-
const args: Record<string, string> = {}
|
|
61
|
-
const envs: Record<string, string> = {}
|
|
62
|
-
const headers: Record<string, string> = {}
|
|
63
|
-
for (const [id, server] of Object.entries(next.servers)) {
|
|
64
|
-
args[id] = (server.args ?? []).join('\n')
|
|
65
|
-
envs[id] = JSON.stringify(server.env ?? {}, null, 2)
|
|
66
|
-
headers[id] = JSON.stringify(server.headers ?? {}, null, 2)
|
|
67
|
-
}
|
|
68
|
-
setArgsText(args)
|
|
69
|
-
setEnvText(envs)
|
|
70
|
-
setHeaderText(headers)
|
|
71
|
-
}
|
|
72
124
|
|
|
73
125
|
const load = async (): Promise<void> => {
|
|
74
126
|
try {
|
|
75
|
-
|
|
76
|
-
setConfig(next)
|
|
77
|
-
initTexts(next)
|
|
127
|
+
setConfig(await api.getConfig())
|
|
78
128
|
} catch (err) {
|
|
79
129
|
setError(errorMessage(err))
|
|
80
130
|
} finally {
|
|
@@ -91,10 +141,10 @@ export function ConfigTab({ api }: { api: RezApi }) {
|
|
|
91
141
|
setConfig(prev => prev === null ? prev : { ...prev, [key]: value })
|
|
92
142
|
}
|
|
93
143
|
|
|
94
|
-
const
|
|
144
|
+
const patchConnection = (id: keyof RezPublicConfig['connections'], patch: Partial<RezPublicConnection>): void => {
|
|
95
145
|
setConfig(prev => prev === null ? prev : {
|
|
96
146
|
...prev,
|
|
97
|
-
|
|
147
|
+
connections: { ...prev.connections, [id]: { ...prev.connections[id], ...patch } },
|
|
98
148
|
})
|
|
99
149
|
}
|
|
100
150
|
|
|
@@ -103,9 +153,20 @@ export function ConfigTab({ api }: { api: RezApi }) {
|
|
|
103
153
|
setMessage('')
|
|
104
154
|
setError('')
|
|
105
155
|
try {
|
|
106
|
-
const
|
|
156
|
+
const payload = {
|
|
157
|
+
enabled: config.enabled,
|
|
158
|
+
role: config.role,
|
|
159
|
+
billing: config.billing,
|
|
160
|
+
connections: {
|
|
161
|
+
odoo: { ...config.connections.odoo, secret: secrets.odoo || undefined },
|
|
162
|
+
nextcloud: { ...config.connections.nextcloud, secret: secrets.nextcloud || undefined },
|
|
163
|
+
wechat: { ...config.connections.wechat, secret: secrets.wechat || undefined },
|
|
164
|
+
homeassistant: { ...config.connections.homeassistant, secret: secrets.homeassistant || undefined },
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
const saved = await api.saveConfig(payload)
|
|
107
168
|
setConfig(saved)
|
|
108
|
-
|
|
169
|
+
setSecrets(EMPTY_SECRETS)
|
|
109
170
|
setMessage(tt('config.saved'))
|
|
110
171
|
} catch (err) {
|
|
111
172
|
setError(errorMessage(err))
|
|
@@ -129,27 +190,16 @@ export function ConfigTab({ api }: { api: RezApi }) {
|
|
|
129
190
|
}
|
|
130
191
|
|
|
131
192
|
const renderResult = (result: RezTestResult): string => {
|
|
132
|
-
if (result.ok)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const commitArgs = (id: string): void => {
|
|
137
|
-
updateServer(id, { args: argsText[id] === '' ? [] : argsText[id].split('\n').map(line => line.trim()).filter(Boolean) })
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const commitRecord = (id: string, kind: 'env' | 'headers'): void => {
|
|
141
|
-
const text = kind === 'env' ? envText[id] : headerText[id]
|
|
142
|
-
try {
|
|
143
|
-
const record = parseRecord(text ?? '{}')
|
|
144
|
-
updateServer(id, kind === 'env' ? { env: record } : { headers: record })
|
|
145
|
-
setError('')
|
|
146
|
-
} catch (err) {
|
|
147
|
-
setError(tt('config.jsonInvalid', { error: errorMessage(err) }))
|
|
193
|
+
if (result.ok) {
|
|
194
|
+
if (result.serverInfo !== undefined) return result.serverInfo
|
|
195
|
+
return tt('config.testOk', { tools: result.toolCount ?? 0, latency: result.latencyMs ?? 0 })
|
|
148
196
|
}
|
|
197
|
+
return tt('config.testFail', { error: result.error ?? '' })
|
|
149
198
|
}
|
|
150
199
|
|
|
151
200
|
return (
|
|
152
201
|
<div className={css.tabBody}>
|
|
202
|
+
<p className={css.message}>{tt('config.mcpHint')}</p>
|
|
153
203
|
<Section title={tt('config.role')}>
|
|
154
204
|
<Field label={tt('config.role')}>
|
|
155
205
|
<select className={css.select} value={config.role} onChange={event => { setTop('role', event.target.value as RezRoleId) }}>
|
|
@@ -161,41 +211,46 @@ export function ConfigTab({ api }: { api: RezApi }) {
|
|
|
161
211
|
</Field>
|
|
162
212
|
</Section>
|
|
163
213
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
214
|
+
<McpSection
|
|
215
|
+
title={tt('config.odoo')}
|
|
216
|
+
row={config.connections.odoo}
|
|
217
|
+
secret={secrets.odoo}
|
|
218
|
+
onEnabled={enabled => { patchConnection('odoo', { enabled }) }}
|
|
219
|
+
onUrl={url => { patchConnection('odoo', { url }) }}
|
|
220
|
+
onSecret={value => { setSecrets(prev => ({ ...prev, odoo: value })) }}
|
|
221
|
+
urlLabel={tt('config.odoo.url')}
|
|
222
|
+
secretLabel={tt('config.odoo.apiKey')}
|
|
223
|
+
/>
|
|
224
|
+
<McpSection
|
|
225
|
+
title={tt('config.nextcloud')}
|
|
226
|
+
row={config.connections.nextcloud}
|
|
227
|
+
secret={secrets.nextcloud}
|
|
228
|
+
onEnabled={enabled => { patchConnection('nextcloud', { enabled }) }}
|
|
229
|
+
onUrl={url => { patchConnection('nextcloud', { url }) }}
|
|
230
|
+
onUsername={username => { patchConnection('nextcloud', { username }) }}
|
|
231
|
+
onSecret={value => { setSecrets(prev => ({ ...prev, nextcloud: value })) }}
|
|
232
|
+
urlLabel={tt('config.nextcloud.url')}
|
|
233
|
+
usernameLabel={tt('config.nextcloud.username')}
|
|
234
|
+
secretLabel={tt('config.nextcloud.appPassword')}
|
|
235
|
+
/>
|
|
236
|
+
<McpSection
|
|
237
|
+
title={tt('config.wecom')}
|
|
238
|
+
row={config.connections.wechat}
|
|
239
|
+
secret={secrets.wechat}
|
|
240
|
+
onEnabled={enabled => { patchConnection('wechat', { enabled }) }}
|
|
241
|
+
onSecret={value => { setSecrets(prev => ({ ...prev, wechat: value })) }}
|
|
242
|
+
secretLabel={tt('config.wecom.webhook')}
|
|
243
|
+
/>
|
|
244
|
+
<McpSection
|
|
245
|
+
title={tt('config.ha')}
|
|
246
|
+
row={config.connections.homeassistant}
|
|
247
|
+
secret={secrets.homeassistant}
|
|
248
|
+
onEnabled={enabled => { patchConnection('homeassistant', { enabled }) }}
|
|
249
|
+
onUrl={url => { patchConnection('homeassistant', { url }) }}
|
|
250
|
+
onSecret={value => { setSecrets(prev => ({ ...prev, homeassistant: value })) }}
|
|
251
|
+
urlLabel={tt('config.ha.url')}
|
|
252
|
+
secretLabel={tt('config.ha.token')}
|
|
253
|
+
/>
|
|
199
254
|
|
|
200
255
|
<Section title={tt('config.billing')}>
|
|
201
256
|
<Field label={tt('config.billing.input')}>
|
|
@@ -36,6 +36,7 @@ export function StatusTab({ api }: { api: RezApi }) {
|
|
|
36
36
|
<span className={css.message}>{tt('status.total', { role: status.role, count: status.totalRegisteredTools })}</span>
|
|
37
37
|
<button type="button" className={css.button} onClick={() => { void load() }}>{tt('common.refresh')}</button>
|
|
38
38
|
</div>
|
|
39
|
+
<p className={css.message}>{tt('status.hint')}</p>
|
|
39
40
|
{error !== '' && <div className={css.error}>{error}</div>}
|
|
40
41
|
<div className={css.tableWrap}>
|
|
41
42
|
<table className={css.table}>
|
package/src/index.ts
CHANGED
|
@@ -15,12 +15,18 @@ import z from '@deepseek-ai/schemastery'
|
|
|
15
15
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
16
16
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
17
17
|
import type {} from '@deepseek-ai/dsh-tools'
|
|
18
|
+
import { REZ_DEFAULT_CONNECTIONS } from '@rezti/dsh-rez-sso'
|
|
18
19
|
import type { McpHost } from './mcp-host.ts'
|
|
20
|
+
import {
|
|
21
|
+
listRegisteredToolNames,
|
|
22
|
+
observeComposedServers,
|
|
23
|
+
testComposedServers,
|
|
24
|
+
} from './observe-mcp.ts'
|
|
19
25
|
import { makeRoutes } from './routes.ts'
|
|
20
26
|
import { defaultConfig, mergeConfig, saveConfig } from './store.ts'
|
|
21
27
|
import { TokenManager } from './token-manager.ts'
|
|
22
28
|
import { rezConfigTool, rezStatusTool } from './tools.ts'
|
|
23
|
-
import type { RezConfig
|
|
29
|
+
import type { RezConfig } from './protocol.ts'
|
|
24
30
|
|
|
25
31
|
export const name = 'rez-suite'
|
|
26
32
|
|
|
@@ -45,6 +51,24 @@ export const Config: z<RezConfig> = z.object({
|
|
|
45
51
|
announceToAgent: z.boolean().default(true),
|
|
46
52
|
role: z.union(['engineer', 'sales', 'operations', 'all'] as const).default('all'),
|
|
47
53
|
servers: z.dict(serverSchema).default({}),
|
|
54
|
+
connections: z.object({
|
|
55
|
+
odoo: z.object({
|
|
56
|
+
enabled: z.boolean().default(true),
|
|
57
|
+
url: z.string().default(REZ_DEFAULT_CONNECTIONS.odoo.url),
|
|
58
|
+
}).default(REZ_DEFAULT_CONNECTIONS.odoo),
|
|
59
|
+
nextcloud: z.object({
|
|
60
|
+
enabled: z.boolean().default(true),
|
|
61
|
+
url: z.string().default(REZ_DEFAULT_CONNECTIONS.nextcloud.url),
|
|
62
|
+
username: z.string().default(REZ_DEFAULT_CONNECTIONS.nextcloud.username),
|
|
63
|
+
}).default(REZ_DEFAULT_CONNECTIONS.nextcloud),
|
|
64
|
+
wechat: z.object({
|
|
65
|
+
enabled: z.boolean().default(true),
|
|
66
|
+
}).default(REZ_DEFAULT_CONNECTIONS.wechat),
|
|
67
|
+
homeassistant: z.object({
|
|
68
|
+
enabled: z.boolean().default(true),
|
|
69
|
+
url: z.string().default(REZ_DEFAULT_CONNECTIONS.homeassistant.url),
|
|
70
|
+
}).default(REZ_DEFAULT_CONNECTIONS.homeassistant),
|
|
71
|
+
}).default(REZ_DEFAULT_CONNECTIONS),
|
|
48
72
|
billing: z.object({
|
|
49
73
|
inputCostPer1k: z.number().default(0.001),
|
|
50
74
|
outputCostPer1k: z.number().default(0.002),
|
|
@@ -54,8 +78,6 @@ export const Config: z<RezConfig> = z.object({
|
|
|
54
78
|
|
|
55
79
|
const SECTION_ORDER = 150
|
|
56
80
|
|
|
57
|
-
const COMPOSED_SERVERS = ['odoo', 'nextcloud', 'wechat', 'homeassistant'] as const
|
|
58
|
-
|
|
59
81
|
export const REZ_GUIDANCE = [
|
|
60
82
|
'本机已安装 ReZ-TI 套件(一次安装):工作身份由 dsh-rez-sso 提供,MCP 由官方 dsh-mcp-client 接入 Odoo / Nextcloud / 企业微信 / Home Assistant。',
|
|
61
83
|
'工具名 mcp__odoo__*、mcp__nextcloud__*、mcp__wechat__*、mcp__homeassistant__*。',
|
|
@@ -69,23 +91,17 @@ function normalize(input: Partial<RezConfig> | undefined): RezConfig {
|
|
|
69
91
|
return mergeConfig(defaultConfig(), input ?? {})
|
|
70
92
|
}
|
|
71
93
|
|
|
72
|
-
function delegatedHost(): McpHost {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
toolCount: 0,
|
|
78
|
-
registeredTools: 0,
|
|
79
|
-
lastError: 'owned by @deepseek-ai/dsh-mcp-client',
|
|
80
|
-
}))
|
|
81
|
-
const test = async (): Promise<RezTestResult[]> => COMPOSED_SERVERS.map(server => ({
|
|
82
|
-
server,
|
|
83
|
-
ok: false,
|
|
84
|
-
error: 'MCP is composed by dsh-mcp-client; set REZ_* credentials and check dsh logs',
|
|
85
|
-
}))
|
|
94
|
+
function delegatedHost(ctx: Context, getConfig: () => RezConfig): McpHost {
|
|
95
|
+
const snapshot = () => observeComposedServers({
|
|
96
|
+
config: getConfig(),
|
|
97
|
+
toolNames: listRegisteredToolNames(ctx.tools),
|
|
98
|
+
})
|
|
86
99
|
return {
|
|
87
|
-
status,
|
|
88
|
-
test
|
|
100
|
+
status: () => snapshot(),
|
|
101
|
+
test: async () => testComposedServers({
|
|
102
|
+
config: getConfig(),
|
|
103
|
+
toolNames: listRegisteredToolNames(ctx.tools),
|
|
104
|
+
}),
|
|
89
105
|
sync: async () => undefined,
|
|
90
106
|
dispose: async () => undefined,
|
|
91
107
|
} as unknown as McpHost
|
|
@@ -95,7 +111,7 @@ export function apply(ctx: Context, config?: RezConfig): void {
|
|
|
95
111
|
let current: () => RezConfig = () => normalize(config)
|
|
96
112
|
|
|
97
113
|
const tokens = new TokenManager(joinTokenDb())
|
|
98
|
-
const host = delegatedHost()
|
|
114
|
+
const host = delegatedHost(ctx, () => current())
|
|
99
115
|
ctx.effect(() => () => { tokens.close() }, 'dsh-rez-suite: ledger view')
|
|
100
116
|
|
|
101
117
|
const tools = [
|
|
@@ -119,6 +135,10 @@ export function apply(ctx: Context, config?: RezConfig): void {
|
|
|
119
135
|
},
|
|
120
136
|
host,
|
|
121
137
|
tokens,
|
|
138
|
+
onCredentialWritten: (ref) => {
|
|
139
|
+
const emit = ctx.emit as unknown as (event: string, value: string) => void
|
|
140
|
+
emit('credentials/updated', ref)
|
|
141
|
+
},
|
|
122
142
|
})
|
|
123
143
|
|
|
124
144
|
let disposeRoutes: (() => void) | undefined
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live MCP status without a second host.
|
|
3
|
+
*
|
|
4
|
+
* Official @deepseek-ai/dsh-mcp-client registers tools as mcp__<server>__*.
|
|
5
|
+
* The settings Status tab used to hard-code disconnected after MCP ownership
|
|
6
|
+
* moved; this module reads the same registry the agent sees.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { REZ_CREDENTIAL_REFS, secretConfigured, type RezSystem } from '@rezti/dsh-rez-sso'
|
|
10
|
+
import type { RezConfig, RezServerStatus, RezTestResult } from './protocol.ts'
|
|
11
|
+
|
|
12
|
+
export const COMPOSED_SERVERS = ['odoo', 'nextcloud', 'wechat', 'homeassistant'] as const
|
|
13
|
+
|
|
14
|
+
export type ComposedServer = (typeof COMPOSED_SERVERS)[number]
|
|
15
|
+
|
|
16
|
+
export function mcpToolPrefix(server: string): string {
|
|
17
|
+
return 'mcp__' + server + '__'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Count tools whose public name belongs to one composed MCP server. */
|
|
21
|
+
export function countMcpTools(toolNames: readonly string[], server: string): number {
|
|
22
|
+
const prefix = mcpToolPrefix(server)
|
|
23
|
+
let count = 0
|
|
24
|
+
for (const name of toolNames) {
|
|
25
|
+
if (name.startsWith(prefix)) count += 1
|
|
26
|
+
}
|
|
27
|
+
return count
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function listRegisteredToolNames(tools: { schemas?: () => ReadonlyArray<{ name: string }> } | undefined): string[] {
|
|
31
|
+
if (tools === undefined || typeof tools.schemas !== 'function') return []
|
|
32
|
+
try {
|
|
33
|
+
return tools.schemas().map(schema => schema.name)
|
|
34
|
+
} catch {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ObserveInput {
|
|
40
|
+
config: RezConfig
|
|
41
|
+
toolNames: readonly string[]
|
|
42
|
+
secretPresent?: (server: RezSystem) => boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isEnabled(config: RezConfig, server: ComposedServer): boolean {
|
|
46
|
+
return config.connections[server].enabled !== false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function observeServer(
|
|
50
|
+
server: ComposedServer,
|
|
51
|
+
input: ObserveInput,
|
|
52
|
+
): RezServerStatus {
|
|
53
|
+
const enabled = isEnabled(input.config, server)
|
|
54
|
+
const secret = (input.secretPresent ?? secretConfigured)(server)
|
|
55
|
+
const toolCount = countMcpTools(input.toolNames, server)
|
|
56
|
+
const ref = REZ_CREDENTIAL_REFS[server]
|
|
57
|
+
|
|
58
|
+
if (!enabled) {
|
|
59
|
+
return {
|
|
60
|
+
server,
|
|
61
|
+
enabled: false,
|
|
62
|
+
state: 'disconnected',
|
|
63
|
+
toolCount: 0,
|
|
64
|
+
registeredTools: 0,
|
|
65
|
+
lastError: '已在设置中关闭',
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (toolCount > 0) {
|
|
70
|
+
return {
|
|
71
|
+
server,
|
|
72
|
+
enabled: true,
|
|
73
|
+
state: 'connected',
|
|
74
|
+
toolCount,
|
|
75
|
+
registeredTools: toolCount,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!secret) {
|
|
80
|
+
return {
|
|
81
|
+
server,
|
|
82
|
+
enabled: true,
|
|
83
|
+
state: 'disconnected',
|
|
84
|
+
toolCount: 0,
|
|
85
|
+
registeredTools: 0,
|
|
86
|
+
lastError: '未配置 ' + ref,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
server,
|
|
92
|
+
enabled: true,
|
|
93
|
+
state: 'disconnected',
|
|
94
|
+
toolCount: 0,
|
|
95
|
+
registeredTools: 0,
|
|
96
|
+
lastError: ref + ' 已配置,但本机尚未注册 ' + mcpToolPrefix(server) + '*(dsh-mcp-client 未挂上)',
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function observeComposedServers(input: ObserveInput): RezServerStatus[] {
|
|
101
|
+
return COMPOSED_SERVERS.map(server => observeServer(server, input))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function testComposedServers(input: ObserveInput): RezTestResult[] {
|
|
105
|
+
return observeComposedServers(input).map(row => {
|
|
106
|
+
if (row.state === 'connected') {
|
|
107
|
+
return {
|
|
108
|
+
server: row.server,
|
|
109
|
+
ok: true,
|
|
110
|
+
toolCount: row.toolCount,
|
|
111
|
+
serverInfo: row.toolCount + ' tools registered (' + mcpToolPrefix(row.server) + '*)',
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
server: row.server,
|
|
116
|
+
ok: false,
|
|
117
|
+
toolCount: 0,
|
|
118
|
+
error: row.lastError ?? 'disconnected',
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -35,12 +35,29 @@ export interface RezMcpServerSpec {
|
|
|
35
35
|
toolCallTimeoutMs?: number
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Company MCP endpoints persisted under ~/.dsh/dsh-rez-suite.json. Secrets are not stored here. */
|
|
39
|
+
export interface RezConnections {
|
|
40
|
+
odoo: { enabled: boolean; url: string }
|
|
41
|
+
nextcloud: { enabled: boolean; url: string; username: string }
|
|
42
|
+
wechat: { enabled: boolean }
|
|
43
|
+
homeassistant: { enabled: boolean; url: string }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RezPublicConnection {
|
|
47
|
+
enabled: boolean
|
|
48
|
+
url?: string
|
|
49
|
+
username?: string
|
|
50
|
+
secretConfigured: boolean
|
|
51
|
+
secretRef: string
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
/** Full plugin configuration persisted under ~/.dsh/dsh-rez-suite.json. */
|
|
39
55
|
export interface RezConfig {
|
|
40
56
|
enabled: boolean
|
|
41
57
|
announceToAgent: boolean
|
|
42
58
|
role: RezRoleId
|
|
43
59
|
servers: Record<string, RezMcpServerSpec>
|
|
60
|
+
connections: RezConnections
|
|
44
61
|
billing: {
|
|
45
62
|
inputCostPer1k: number
|
|
46
63
|
outputCostPer1k: number
|
|
@@ -56,6 +73,12 @@ export interface RezPublicConfig {
|
|
|
56
73
|
enabled: boolean
|
|
57
74
|
role: RezRoleId
|
|
58
75
|
servers: Record<string, RezPublicServer>
|
|
76
|
+
connections: {
|
|
77
|
+
odoo: RezPublicConnection
|
|
78
|
+
nextcloud: RezPublicConnection
|
|
79
|
+
wechat: RezPublicConnection
|
|
80
|
+
homeassistant: RezPublicConnection
|
|
81
|
+
}
|
|
59
82
|
billing: { inputCostPer1k: number; outputCostPer1k: number; monthlyBudget: number }
|
|
60
83
|
}
|
|
61
84
|
|
package/src/routes.ts
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
8
8
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
9
|
+
import {
|
|
10
|
+
REZ_CREDENTIAL_REFS,
|
|
11
|
+
REZ_SYSTEMS,
|
|
12
|
+
mergeConnections,
|
|
13
|
+
writeManagedCredential,
|
|
14
|
+
type RezSystem,
|
|
15
|
+
} from '@rezti/dsh-rez-sso'
|
|
9
16
|
import type { RezConfig, RezMcpServerSpec, RezRoleId } from './protocol.ts'
|
|
10
17
|
import { REZ_API } from './protocol.ts'
|
|
11
18
|
import type { McpHost } from './mcp-host.ts'
|
|
@@ -74,11 +81,25 @@ function mergeMaskedRecord(base: Record<string, string> | undefined, incoming: u
|
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
/** Apply a browser-safe public patch without overwriting masked secrets. */
|
|
77
|
-
function applyPublicPatch(current: RezConfig, patch: Record<string, unknown>): RezConfig {
|
|
84
|
+
function applyPublicPatch(current: RezConfig, patch: Record<string, unknown>): { next: RezConfig; secrets: Array<{ system: RezSystem; value: string }> } {
|
|
78
85
|
const next: RezConfig = JSON.parse(JSON.stringify(current)) as RezConfig
|
|
86
|
+
const secrets: Array<{ system: RezSystem; value: string }> = []
|
|
79
87
|
if (typeof patch.enabled === 'boolean') next.enabled = patch.enabled
|
|
80
88
|
if (patch.role === 'engineer' || patch.role === 'sales' || patch.role === 'operations' || patch.role === 'all') next.role = patch.role as RezRoleId
|
|
81
89
|
|
|
90
|
+
if (typeof patch.connections === 'object' && patch.connections !== null) {
|
|
91
|
+
const incoming = patch.connections as Record<string, unknown>
|
|
92
|
+
next.connections = mergeConnections({ ...next.connections, ...incoming })
|
|
93
|
+
for (const system of Object.keys(REZ_CREDENTIAL_REFS) as RezSystem[]) {
|
|
94
|
+
const row = incoming[system]
|
|
95
|
+
if (typeof row !== 'object' || row === null) continue
|
|
96
|
+
const secret = (row as { secret?: unknown }).secret
|
|
97
|
+
if (typeof secret === 'string' && secret.length > 0 && secret !== SECRET_MASK) {
|
|
98
|
+
secrets.push({ system, value: secret })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
if (typeof patch.servers === 'object' && patch.servers !== null) {
|
|
83
104
|
for (const [id, raw] of Object.entries(patch.servers as Record<string, unknown>)) {
|
|
84
105
|
if (typeof raw !== 'object' || raw === null) continue
|
|
@@ -104,7 +125,7 @@ function applyPublicPatch(current: RezConfig, patch: Record<string, unknown>): R
|
|
|
104
125
|
if (typeof value.outputCostPer1k === 'number') next.billing.outputCostPer1k = value.outputCostPer1k
|
|
105
126
|
if (typeof value.monthlyBudget === 'number') next.billing.monthlyBudget = value.monthlyBudget
|
|
106
127
|
}
|
|
107
|
-
return next
|
|
128
|
+
return { next, secrets }
|
|
108
129
|
}
|
|
109
130
|
|
|
110
131
|
export interface RezRoutesDeps {
|
|
@@ -113,11 +134,12 @@ export interface RezRoutesDeps {
|
|
|
113
134
|
updateConfig: (next: RezConfig) => Promise<RezConfig>
|
|
114
135
|
host: McpHost
|
|
115
136
|
tokens: TokenManager
|
|
137
|
+
onCredentialWritten?: (ref: string) => void
|
|
116
138
|
}
|
|
117
139
|
|
|
118
140
|
/** Build every /api/dsh-rez-suite route (exact paths). */
|
|
119
141
|
export function makeRoutes(deps: RezRoutesDeps): WebRoute[] {
|
|
120
|
-
const { getConfig, updateConfig, host, tokens } = deps
|
|
142
|
+
const { getConfig, updateConfig, host, tokens, onCredentialWritten } = deps
|
|
121
143
|
|
|
122
144
|
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
123
145
|
if (!isLoopbackRequest(req)) {
|
|
@@ -146,8 +168,14 @@ export function makeRoutes(deps: RezRoutesDeps): WebRoute[] {
|
|
|
146
168
|
writeJson(res, 400, { error: 'invalid JSON body' })
|
|
147
169
|
return
|
|
148
170
|
}
|
|
149
|
-
const next = applyPublicPatch(getConfig(), body)
|
|
171
|
+
const { next, secrets } = applyPublicPatch(getConfig(), body)
|
|
150
172
|
const saved = await updateConfig(next)
|
|
173
|
+
for (const item of secrets) {
|
|
174
|
+
writeManagedCredential(REZ_CREDENTIAL_REFS[item.system], item.value)
|
|
175
|
+
}
|
|
176
|
+
for (const system of REZ_SYSTEMS) {
|
|
177
|
+
onCredentialWritten?.(REZ_CREDENTIAL_REFS[system])
|
|
178
|
+
}
|
|
151
179
|
writeJson(res, 200, { config: publicConfig(saved) })
|
|
152
180
|
},
|
|
153
181
|
},
|