@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.
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Config tab: role, billing rates, and a generic editor for every MCP server
3
- * in config.servers (stdio and streamable-http), with one-click tests.
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 { RezMcpServerSpec, RezPublicConfig, RezRoleId, RezTestResult } from '../../protocol.ts'
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 parseRecord(text: string): Record<string, string> {
38
- const value: unknown = JSON.parse(text)
39
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a JSON object')
40
- const out: Record<string, string> = {}
41
- for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
42
- out[key] = String(item)
43
- }
44
- return out
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
- const next = await api.getConfig()
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 updateServer = (id: string, patch: Partial<RezMcpServerSpec>): void => {
144
+ const patchConnection = (id: keyof RezPublicConfig['connections'], patch: Partial<RezPublicConnection>): void => {
95
145
  setConfig(prev => prev === null ? prev : {
96
146
  ...prev,
97
- servers: { ...prev.servers, [id]: { ...prev.servers[id], ...patch } },
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 saved = await api.saveConfig(config)
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
- initTexts(saved)
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) return tt('config.testOk', { tools: result.toolCount ?? 0, latency: result.latencyMs ?? 0 })
133
- return tt('config.testFail', { error: result.error ?? '' })
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
- {Object.entries(config.servers).map(([id, server]) => (
165
- <Section key={id} title={id}>
166
- <Field label={tt('config.enabled')}>
167
- <input className={css.checkbox} type="checkbox" checked={server.enabled} onChange={event => { updateServer(id, { enabled: event.target.checked }) }} />
168
- </Field>
169
- <Field label={tt('config.transport')}>
170
- <select className={css.select} value={server.transport} onChange={event => { updateServer(id, { transport: event.target.value as RezMcpServerSpec['transport'] }) }}>
171
- <option value="stdio">stdio</option>
172
- <option value="streamable-http">streamable-http</option>
173
- </select>
174
- </Field>
175
- {server.transport === 'stdio' ? (
176
- <>
177
- <Field label={tt('config.command')}>
178
- <input className={css.input} value={server.command ?? ''} onChange={event => { updateServer(id, { command: event.target.value }) }} />
179
- </Field>
180
- <Field label={tt('config.args')}>
181
- <textarea className={css.input} rows={3} value={argsText[id] ?? ''} onChange={event => { setArgsText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitArgs(id) }} />
182
- </Field>
183
- <Field label={tt('config.env')}>
184
- <textarea className={css.input} rows={4} value={envText[id] ?? '{}'} onChange={event => { setEnvText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitRecord(id, 'env') }} />
185
- </Field>
186
- </>
187
- ) : (
188
- <>
189
- <Field label={tt('config.url')}>
190
- <input className={css.input} value={server.url ?? ''} onChange={event => { updateServer(id, { url: event.target.value }) }} />
191
- </Field>
192
- <Field label={tt('config.headers')}>
193
- <textarea className={css.input} rows={4} value={headerText[id] ?? '{}'} onChange={event => { setHeaderText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitRecord(id, 'headers') }} />
194
- </Field>
195
- </>
196
- )}
197
- </Section>
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, RezServerStatus, RezTestResult } from './protocol.ts'
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 status = (): RezServerStatus[] => COMPOSED_SERVERS.map(server => ({
74
- server,
75
- enabled: true,
76
- state: 'disconnected',
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
  },