@rezti/dsh-rez-suite 0.1.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.
@@ -0,0 +1,418 @@
1
+ /**
2
+ * In-plugin MCP host. Connects every enabled server from config.servers over
3
+ * stdio or streamable-http, discovers tools, filters them through the role
4
+ * preset, and registers the survivors on ctx.tools as \`mcp__<server>__<rawName>\`.
5
+ */
6
+
7
+ import { createHash } from 'node:crypto'
8
+ import { setDefaultResultOrder } from 'node:dns'
9
+ import { existsSync } from 'node:fs'
10
+ import { dirname, join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
13
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
14
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
15
+ import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
16
+ import z from 'zod'
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
19
+ import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
20
+ import type { RezConfig, RezMcpTransport, RezRoleId, RezServerId, RezServerStatus, RezTestResult } from './protocol.ts'
21
+ import { isToolAllowed } from './presets.ts'
22
+ import { estimateTokens, type TokenManager } from './token-manager.ts'
23
+
24
+ const MAX_PUBLIC_NAME_LENGTH = 64
25
+ const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g
26
+ const HASH_LENGTH = 12
27
+
28
+ const RawCallToolResultSchema = z.record(z.string(), z.unknown())
29
+
30
+ interface McpToolInfo {
31
+ rawName: string
32
+ description: string
33
+ inputSchema: Record<string, unknown>
34
+ }
35
+
36
+ interface ServerDescriptor {
37
+ id: RezServerId
38
+ serverName: string
39
+ transport: RezMcpTransport
40
+ command?: string
41
+ args?: string[]
42
+ env?: Record<string, string>
43
+ cwd?: string
44
+ url?: string
45
+ headers?: Record<string, string>
46
+ toolCallTimeoutMs: number
47
+ }
48
+
49
+ interface LiveConnection {
50
+ descriptor: ServerDescriptor
51
+ client: Client
52
+ transport: StdioClientTransport | StreamableHTTPClientTransport
53
+ tools: McpToolInfo[]
54
+ disposers: Map<string, () => void>
55
+ startedAt: number
56
+ lastError?: string
57
+ }
58
+
59
+ /** Copy of dsh-mcp-client's deterministic public-name derivation. */
60
+ function publicToolName(serverName: string, rawName: string): string {
61
+ const joined = 'mcp__' + serverName + '__' + rawName
62
+ const normalized = joined.replace(INVALID_NAME_CHARS, '_')
63
+ if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized
64
+ const hash = createHash('sha256').update(serverName + '\0' + rawName).digest('hex').slice(0, HASH_LENGTH)
65
+ return normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1) + '_' + hash
66
+ }
67
+
68
+ function text(value: string): ContentBlock[] {
69
+ return [{ type: 'text', text: value }]
70
+ }
71
+
72
+ function extractText(mcpContent: unknown, toolName: string): string {
73
+ if (!Array.isArray(mcpContent)) return String(mcpContent ?? '')
74
+ const parts: string[] = []
75
+ for (const value of mcpContent) {
76
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
77
+ parts.push('[unsupported content type: unknown]')
78
+ continue
79
+ }
80
+ const block = value as Record<string, unknown>
81
+ switch (block.type) {
82
+ case 'text':
83
+ parts.push(block.text === undefined ? '' : String(block.text))
84
+ break
85
+ case 'image':
86
+ parts.push('[image: ' + String(block.mimeType ?? 'unknown') + ', content discarded]')
87
+ break
88
+ case 'audio':
89
+ parts.push('[audio: ' + String(block.mimeType ?? 'unknown') + ', content discarded]')
90
+ break
91
+ case 'resource':
92
+ parts.push('[resource content discarded]')
93
+ break
94
+ case 'resource_link':
95
+ parts.push('[resource_link: ' + String(block.uri ?? '') + ']')
96
+ break
97
+ default:
98
+ parts.push('[unsupported content type: ' + String(block.type ?? 'unknown') + ']')
99
+ }
100
+ }
101
+ if (parts.length === 0) parts.push('[tool ' + toolName + ' returned no text]')
102
+ return parts.join('\n')
103
+ }
104
+
105
+ /** Merge the parent process env with server overrides, dropping undefined values. */
106
+ function childEnv(extra: Record<string, string> | undefined): Record<string, string> {
107
+ const env: Record<string, string> = {}
108
+ for (const [key, value] of Object.entries(process.env)) {
109
+ if (value !== undefined) env[key] = value
110
+ }
111
+ Object.assign(env, extra ?? {})
112
+ return env
113
+ }
114
+
115
+ function serverScript(name: string): string {
116
+ const here = dirname(fileURLToPath(import.meta.url))
117
+ const fromSrc = join(here, '..', 'servers', name)
118
+ const fromLib = join(here, '..', '..', 'servers', name)
119
+ if (existsSync(fromSrc)) return fromSrc
120
+ if (existsSync(fromLib)) return fromLib
121
+ return fromSrc
122
+ }
123
+
124
+ /** Resolve config.servers to concrete transport descriptors. */
125
+ export function resolveServers(config: RezConfig): ServerDescriptor[] {
126
+ const descriptors: ServerDescriptor[] = []
127
+ for (const [id, server] of Object.entries(config.servers)) {
128
+ if (!server.enabled) continue
129
+ const descriptor: ServerDescriptor = {
130
+ id,
131
+ serverName: id,
132
+ transport: server.transport,
133
+ command: server.command,
134
+ args: server.args,
135
+ env: server.env,
136
+ cwd: server.cwd,
137
+ url: server.url,
138
+ headers: server.headers,
139
+ toolCallTimeoutMs: server.toolCallTimeoutMs ?? 60000,
140
+ }
141
+
142
+ // Bundled local servers: launch our own scripts unless the user overrode args.
143
+ if (id === 'fs' || id === 'sqlite') {
144
+ descriptor.command = descriptor.command ?? process.execPath
145
+ const script = id === 'fs' ? 'fs-mcp-server.mjs' : 'sqlite-mcp-server.mjs'
146
+ descriptor.args = descriptor.args !== undefined && descriptor.args.length > 0 ? descriptor.args : [serverScript(script)]
147
+ }
148
+
149
+ descriptors.push(descriptor)
150
+ }
151
+ return descriptors
152
+ }
153
+
154
+ export class McpHost {
155
+ private connections = new Map<RezServerId, LiveConnection>()
156
+ private enabledIds = new Set<RezServerId>()
157
+ private role: RezRoleId = 'all'
158
+ private billing: RezConfig['billing'] = { inputCostPer1k: 0.001, outputCostPer1k: 0.002, monthlyBudget: 0 }
159
+ private syncChain: Promise<void> = Promise.resolve()
160
+ private readonly ctx: Context
161
+ private readonly tokenManager: TokenManager
162
+
163
+ constructor(ctx: Context, tokenManager: TokenManager) {
164
+ this.ctx = ctx
165
+ this.tokenManager = tokenManager
166
+ }
167
+
168
+ /** Serialize reconciliation so concurrent settings callbacks cannot double-register tools. */
169
+ sync(config: RezConfig): Promise<void> {
170
+ this.syncChain = this.syncChain.then(() => this.syncInner(config)).catch(error => {
171
+ console.warn('[dsh-rez-suite] MCP sync failed:', error)
172
+ })
173
+ return this.syncChain
174
+ }
175
+
176
+ /** Reconcile live connections with the current configuration. */
177
+ private async syncInner(config: RezConfig): Promise<void> {
178
+ const roleChanged = this.role !== config.role
179
+ this.role = config.role
180
+ this.billing = config.billing
181
+ const wanted = resolveServers(config)
182
+ const wantedIds = new Set(wanted.map(descriptor => descriptor.id))
183
+ this.enabledIds = new Set(wantedIds)
184
+
185
+ for (const [id] of [...this.connections]) {
186
+ if (!wantedIds.has(id)) {
187
+ await this.disposeConnection(id)
188
+ }
189
+ }
190
+
191
+ for (const descriptor of wanted) {
192
+ const existing = this.connections.get(descriptor.id)
193
+ const signature = JSON.stringify([descriptor.transport, descriptor.command, descriptor.args, descriptor.env, descriptor.cwd, descriptor.url, descriptor.headers])
194
+ if (existing !== undefined) {
195
+ const oldSignature = JSON.stringify([existing.descriptor.transport, existing.descriptor.command, existing.descriptor.args, existing.descriptor.env, existing.descriptor.cwd, existing.descriptor.url, existing.descriptor.headers])
196
+ if (!roleChanged && oldSignature === signature) continue
197
+ await this.disposeConnection(descriptor.id)
198
+ }
199
+ try {
200
+ await this.startConnection(descriptor)
201
+ } catch (error) {
202
+ console.warn('[dsh-rez-suite] MCP ' + descriptor.id + ' start failed:', error)
203
+ }
204
+ }
205
+ }
206
+
207
+ private async startConnection(descriptor: ServerDescriptor): Promise<void> {
208
+ const connection = await this.connect(descriptor)
209
+ const definitions: Array<{ definition: ToolDefinition; publicName: string }> = []
210
+ for (const tool of connection.tools) {
211
+ if (!isToolAllowed(this.role, descriptor.serverName, tool.rawName)) continue
212
+ const publicName = publicToolName(descriptor.serverName, tool.rawName)
213
+ definitions.push({ definition: this.buildDefinition(descriptor, connection, tool, publicName), publicName })
214
+ }
215
+
216
+ const disposers = new Map<string, () => void>()
217
+ try {
218
+ for (const { definition, publicName } of definitions) {
219
+ disposers.set(publicName, this.ctx.tools.register(definition))
220
+ }
221
+ } catch (error) {
222
+ for (const dispose of disposers.values()) dispose()
223
+ await this.closeConnection(connection)
224
+ throw error
225
+ }
226
+ connection.disposers = disposers
227
+ this.connections.set(descriptor.id, connection)
228
+ console.info('[dsh-rez-suite] MCP ' + descriptor.id + ' connected, registered ' + disposers.size + ' tools (role=' + this.role + ')')
229
+ }
230
+
231
+ private buildDefinition(descriptor: ServerDescriptor, connection: LiveConnection, tool: McpToolInfo, publicName: string): ToolDefinition {
232
+ const rawName = tool.rawName
233
+ const serverId = descriptor.id
234
+ const execute = async (args: unknown, exec: { signal: AbortSignal }): Promise<unknown> => {
235
+ const started = Date.now()
236
+ const callArgs = typeof args === 'object' && args !== null ? (args as Record<string, unknown>) : {}
237
+ try {
238
+ const result = await (connection.client as unknown as {
239
+ request(method: unknown, schema: unknown, options?: { signal?: AbortSignal; timeout?: number }): Promise<Record<string, unknown>>
240
+ }).request(
241
+ { method: 'tools/call', params: { name: rawName, arguments: callArgs } },
242
+ RawCallToolResultSchema,
243
+ { signal: exec.signal, timeout: descriptor.toolCallTimeoutMs },
244
+ )
245
+ const content = Array.isArray(result.content) ? result.content : []
246
+ const rendered = extractText(content, rawName)
247
+ if (result.isError === true) {
248
+ throw new Error(rendered)
249
+ }
250
+ this.recordUsage(serverId, rawName, true, callArgs, rendered, Date.now() - started)
251
+ return {
252
+ content,
253
+ ...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}),
254
+ }
255
+ } catch (error) {
256
+ this.recordUsage(serverId, rawName, false, callArgs, error instanceof Error ? error.message : String(error), Date.now() - started)
257
+ throw error
258
+ }
259
+ }
260
+
261
+ return {
262
+ name: publicName,
263
+ description: tool.description,
264
+ parameters: tool.inputSchema,
265
+ output: {
266
+ schema: {
267
+ type: 'object',
268
+ properties: {
269
+ content: { type: 'array', items: {} },
270
+ structuredContent: {},
271
+ },
272
+ required: ['content'],
273
+ additionalProperties: false,
274
+ },
275
+ render: (_args: unknown, value: unknown): ContentBlock[] => {
276
+ const record = (value ?? {}) as Record<string, unknown>
277
+ return text(extractText(record.content, rawName))
278
+ },
279
+ },
280
+ execute,
281
+ } as unknown as ToolDefinition
282
+ }
283
+
284
+ private recordUsage(server: RezServerId, tool: string, ok: boolean, args: unknown, output: string, durationMs: number): void {
285
+ const inputTokens = estimateTokens(args)
286
+ const outputTokens = estimateTokens(output)
287
+ const cost = (inputTokens / 1000) * this.billing.inputCostPer1k + (outputTokens / 1000) * this.billing.outputCostPer1k
288
+ this.tokenManager.record({
289
+ server,
290
+ tool,
291
+ role: this.role,
292
+ inputTokens,
293
+ outputTokens,
294
+ cost,
295
+ durationMs,
296
+ ok,
297
+ error: ok ? undefined : output,
298
+ })
299
+ }
300
+
301
+ private async connect(descriptor: ServerDescriptor): Promise<LiveConnection> {
302
+ const client = new Client({ name: 'dsh-rez-suite', version: '0.1.0' })
303
+ const transport = descriptor.transport === 'stdio'
304
+ ? new StdioClientTransport({
305
+ command: descriptor.command ?? '',
306
+ args: descriptor.args ?? [],
307
+ env: childEnv(descriptor.env),
308
+ cwd: descriptor.cwd,
309
+ stderr: 'pipe',
310
+ })
311
+ : new StreamableHTTPClientTransport(new URL(descriptor.url ?? ''), {
312
+ requestInit: { headers: descriptor.headers ?? {} },
313
+ })
314
+ await client.connect(transport)
315
+ const tools = await listTools(client)
316
+ return {
317
+ descriptor,
318
+ client,
319
+ transport,
320
+ tools,
321
+ disposers: new Map(),
322
+ startedAt: Date.now(),
323
+ }
324
+ }
325
+
326
+ private async closeConnection(connection: LiveConnection): Promise<void> {
327
+ for (const dispose of connection.disposers.values()) {
328
+ try { dispose() } catch { /* ignore */ }
329
+ }
330
+ try { await connection.transport.close() } catch { /* ignore */ }
331
+ try { await connection.client.close() } catch { /* ignore */ }
332
+ }
333
+
334
+ private async disposeConnection(id: RezServerId): Promise<void> {
335
+ const connection = this.connections.get(id)
336
+ if (connection === undefined) return
337
+ this.connections.delete(id)
338
+ await this.closeConnection(connection)
339
+ }
340
+
341
+ /** Test every enabled server with a throwaway connection. */
342
+ async test(config: RezConfig): Promise<RezTestResult[]> {
343
+ const results: RezTestResult[] = []
344
+ for (const descriptor of resolveServers(config)) {
345
+ const started = Date.now()
346
+ let connection: LiveConnection | undefined
347
+ try {
348
+ connection = await this.connect(descriptor)
349
+ const serverInfo = (connection.client.getServerVersion?.() as { name?: string; version?: string } | undefined)
350
+ results.push({
351
+ server: descriptor.id,
352
+ ok: true,
353
+ toolCount: connection.tools.length,
354
+ serverInfo: serverInfo ? serverInfo.name + ' ' + serverInfo.version : undefined,
355
+ latencyMs: Date.now() - started,
356
+ })
357
+ } catch (error) {
358
+ results.push({
359
+ server: descriptor.id,
360
+ ok: false,
361
+ latencyMs: Date.now() - started,
362
+ error: error instanceof Error ? error.message : String(error),
363
+ })
364
+ } finally {
365
+ if (connection !== undefined) await this.closeConnection(connection)
366
+ }
367
+ }
368
+ return results
369
+ }
370
+
371
+ status(): RezServerStatus[] {
372
+ const ids = new Set<string>([...this.enabledIds, ...this.connections.keys()])
373
+ return [...ids].sort().map(id => {
374
+ const connection = this.connections.get(id)
375
+ if (connection === undefined) {
376
+ return { server: id, enabled: this.enabledIds.has(id), state: 'disconnected', toolCount: 0, registeredTools: 0 }
377
+ }
378
+ return {
379
+ server: id,
380
+ enabled: true,
381
+ state: 'connected',
382
+ toolCount: connection.tools.length,
383
+ registeredTools: connection.disposers.size,
384
+ lastError: connection.lastError,
385
+ startedAt: connection.startedAt,
386
+ }
387
+ })
388
+ }
389
+
390
+ async dispose(): Promise<void> {
391
+ for (const id of [...this.connections.keys()]) {
392
+ await this.disposeConnection(id)
393
+ }
394
+ }
395
+ }
396
+
397
+ /** Drain uncached tools/list pagination (mirrors dsh-mcp-client). */
398
+ async function listTools(client: Client): Promise<McpToolInfo[]> {
399
+ const tools: McpToolInfo[] = []
400
+ let cursor: string | undefined
401
+ do {
402
+ const response = await (client as unknown as {
403
+ request(method: unknown, schema: unknown): Promise<{ tools: Array<Record<string, unknown>>; nextCursor?: string }>
404
+ }).request(
405
+ { method: 'tools/list', ...(cursor === undefined ? {} : { params: { cursor } }) },
406
+ ListToolsResultSchema,
407
+ )
408
+ for (const raw of response.tools) {
409
+ tools.push({
410
+ rawName: String(raw.name),
411
+ description: raw.description === undefined ? '' : String(raw.description),
412
+ inputSchema: typeof raw.inputSchema === 'object' && raw.inputSchema !== null ? raw.inputSchema as Record<string, unknown> : { type: 'object' },
413
+ })
414
+ }
415
+ cursor = response.nextCursor
416
+ } while (cursor !== undefined)
417
+ return tools
418
+ }
package/src/presets.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Role-based MCP tool presets. MCP tools are registered under
3
+ * \`mcp__<server>__<rawName>\`; the filter matches the lowercase raw tool
4
+ * name against server-scoped wildcard patterns. A role with no matching rule
5
+ * denies the tool (fail closed); \`all\` registers everything.
6
+ */
7
+
8
+ import type { RezRoleId, RezServerId } from './protocol.ts'
9
+
10
+ interface RoleRule {
11
+ server: RezServerId
12
+ /** Case-insensitive wildcard pattern over the raw MCP tool name. */
13
+ pattern: string
14
+ }
15
+
16
+ const RULES: Record<Exclude<RezRoleId, 'all'>, RoleRule[]> = {
17
+ engineer: [
18
+ // Odoo lookups and reads.
19
+ { server: 'odoo', pattern: '*search*' },
20
+ { server: 'odoo', pattern: '*read*' },
21
+ { server: 'odoo', pattern: '*get*' },
22
+ { server: 'odoo', pattern: '*list*' },
23
+ // Home Assistant read-only state.
24
+ { server: 'homeassistant', pattern: '*get*' },
25
+ { server: 'homeassistant', pattern: '*list*' },
26
+ { server: 'homeassistant', pattern: '*state*' },
27
+ // TAPD read/query.
28
+ { server: 'tapd', pattern: '*query*' },
29
+ { server: 'tapd', pattern: '*get*' },
30
+ { server: 'tapd', pattern: '*list*' },
31
+ // Dify read/query.
32
+ { server: 'dify', pattern: '*query*' },
33
+ { server: 'dify', pattern: '*get*' },
34
+ // Local filesystem inspection.
35
+ { server: 'fs', pattern: '*read*' },
36
+ { server: 'fs', pattern: '*list*' },
37
+ { server: 'fs', pattern: '*stat*' },
38
+ // Local cache/log queries.
39
+ { server: 'sqlite', pattern: '*query*' },
40
+ { server: 'sqlite', pattern: '*select*' },
41
+ { server: 'sqlite', pattern: '*table*' },
42
+ // Read-only Nextcloud access for specs/documents.
43
+ { server: 'nextcloud', pattern: '*list*' },
44
+ { server: 'nextcloud', pattern: '*download*' },
45
+ { server: 'nextcloud', pattern: '*read*' },
46
+ ],
47
+ sales: [
48
+ // Odoo CRM / sales objects.
49
+ { server: 'odoo', pattern: '*crm*' },
50
+ { server: 'odoo', pattern: '*partner*' },
51
+ { server: 'odoo', pattern: '*lead*' },
52
+ { server: 'odoo', pattern: '*opportunity*' },
53
+ { server: 'odoo', pattern: '*sale*' },
54
+ { server: 'odoo', pattern: '*quotation*' },
55
+ // Upload / write sales collateral to Nextcloud.
56
+ { server: 'nextcloud', pattern: '*upload*' },
57
+ { server: 'nextcloud', pattern: '*write*' },
58
+ { server: 'nextcloud', pattern: '*mkdir*' },
59
+ { server: 'nextcloud', pattern: '*move*' },
60
+ // WeCom one-to-one / group sends.
61
+ { server: 'wecom', pattern: '*send*' },
62
+ { server: 'wecom', pattern: '*message*' },
63
+ ],
64
+ operations: [
65
+ // Odoo reporting and fulfilment.
66
+ { server: 'odoo', pattern: '*report*' },
67
+ { server: 'odoo', pattern: '*stock*' },
68
+ { server: 'odoo', pattern: '*purchase*' },
69
+ { server: 'odoo', pattern: '*inventory*' },
70
+ { server: 'odoo', pattern: '*invoice*' },
71
+ // Home Assistant control.
72
+ { server: 'homeassistant', pattern: '*call_service*' },
73
+ { server: 'homeassistant', pattern: '*control*' },
74
+ { server: 'homeassistant', pattern: '*set*' },
75
+ // TAPD write operations.
76
+ { server: 'tapd', pattern: '*create*' },
77
+ { server: 'tapd', pattern: '*update*' },
78
+ { server: 'tapd', pattern: '*delete*' },
79
+ // Dify workflow execution.
80
+ { server: 'dify', pattern: '*run*' },
81
+ { server: 'dify', pattern: '*workflow*' },
82
+ { server: 'dify', pattern: '*chat*' },
83
+ // Nextcloud management.
84
+ { server: 'nextcloud', pattern: '*list*' },
85
+ { server: 'nextcloud', pattern: '*delete*' },
86
+ { server: 'nextcloud', pattern: '*manage*' },
87
+ { server: 'nextcloud', pattern: '*move*' },
88
+ { server: 'nextcloud', pattern: '*upload*' },
89
+ // WeCom broadcasts.
90
+ { server: 'wecom', pattern: '*broadcast*' },
91
+ { server: 'wecom', pattern: '*send*' },
92
+ // Local audit cache.
93
+ { server: 'sqlite', pattern: '*query*' },
94
+ { server: 'sqlite', pattern: '*maintenance*' },
95
+ ],
96
+ }
97
+
98
+ /** Compile a simple \`*\` wildcard pattern to a RegExp without regex escaping. */
99
+ function compilePattern(pattern: string): RegExp {
100
+ const pieces = pattern.split('*')
101
+ let source = '^'
102
+ for (let index = 0; index < pieces.length; index++) {
103
+ if (index > 0) source += '.*'
104
+ const piece = pieces[index]
105
+ for (const char of piece) {
106
+ if ('[\\]{}()^$?.+|'.includes(char)) source += '\\' + char
107
+ else source += char
108
+ }
109
+ }
110
+ source += '$'
111
+ return new RegExp(source, 'i')
112
+ }
113
+
114
+ const COMPILED: Record<Exclude<RezRoleId, 'all'>, { server: RezServerId; test: RegExp }[]> = {
115
+ engineer: RULES.engineer.map(rule => ({ server: rule.server, test: compilePattern(rule.pattern) })),
116
+ sales: RULES.sales.map(rule => ({ server: rule.server, test: compilePattern(rule.pattern) })),
117
+ operations: RULES.operations.map(rule => ({ server: rule.server, test: compilePattern(rule.pattern) })),
118
+ }
119
+
120
+ /** Whether one MCP tool may be registered for the selected role. */
121
+ export function isToolAllowed(role: RezRoleId, server: string, rawName: string): boolean {
122
+ if (role === 'all') return true
123
+ const rules = COMPILED[role]
124
+ return rules.some(rule => rule.server === server && rule.test.test(rawName))
125
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Wire contract between the host half (routes.ts / mcp-host.ts) and the
3
+ * browser half (client/api.ts). Pure types only — imported by both halves,
4
+ * bundled into each, no runtime identity to share.
5
+ */
6
+
7
+ /** MCP server id: arbitrary stable key, e.g. "odoo", "homeassistant". */
8
+ export type RezServerId = string
9
+
10
+ /** Agent role, the preset-loader key. */
11
+ export type RezRoleId = 'engineer' | 'sales' | 'operations' | 'all'
12
+
13
+ /** Transport kinds the in-plugin MCP host can connect. */
14
+ export type RezMcpTransport = 'stdio' | 'streamable-http'
15
+
16
+ /** One MCP server launch descriptor (standard MCP-style config). */
17
+ export interface RezMcpServerSpec {
18
+ /** Master switch for this server. */
19
+ enabled: boolean
20
+ /** stdio or streamable-http. */
21
+ transport: RezMcpTransport
22
+ /** stdio executable (npx, uvx, node, absolute binary, ...). */
23
+ command?: string
24
+ /** stdio args passed without shell interpolation. */
25
+ args?: string[]
26
+ /** stdio env merged over the scrubbed parent env. */
27
+ env?: Record<string, string>
28
+ /** stdio working directory. */
29
+ cwd?: string
30
+ /** streamable-http endpoint URL. */
31
+ url?: string
32
+ /** streamable-http request headers (Authorization, ...). */
33
+ headers?: Record<string, string>
34
+ /** Per-tool-call timeout in milliseconds. */
35
+ toolCallTimeoutMs?: number
36
+ }
37
+
38
+ /** Full plugin configuration persisted under ~/.dsh/dsh-rez-suite.json. */
39
+ export interface RezConfig {
40
+ enabled: boolean
41
+ announceToAgent: boolean
42
+ role: RezRoleId
43
+ servers: Record<string, RezMcpServerSpec>
44
+ billing: {
45
+ inputCostPer1k: number
46
+ outputCostPer1k: number
47
+ monthlyBudget: number
48
+ }
49
+ }
50
+
51
+ /** One server entry projected for the browser (secret values masked). */
52
+ export type RezPublicServer = RezMcpServerSpec
53
+
54
+ /** Config projection safe for the browser (env/header values replaced by masks). */
55
+ export interface RezPublicConfig {
56
+ enabled: boolean
57
+ role: RezRoleId
58
+ servers: Record<string, RezPublicServer>
59
+ billing: { inputCostPer1k: number; outputCostPer1k: number; monthlyBudget: number }
60
+ }
61
+
62
+ /** One connectivity test outcome for one MCP server. */
63
+ export interface RezTestResult {
64
+ server: RezServerId
65
+ ok: boolean
66
+ /** Connected, discovered tools, then closed. */
67
+ toolCount?: number
68
+ /** Server-reported name/version when available. */
69
+ serverInfo?: string
70
+ latencyMs?: number
71
+ error?: string
72
+ }
73
+
74
+ /** Live status of one MCP server slot. */
75
+ export interface RezServerStatus {
76
+ server: RezServerId
77
+ enabled: boolean
78
+ state: 'disconnected' | 'connecting' | 'connected' | 'failed'
79
+ toolCount: number
80
+ registeredTools: number
81
+ lastError?: string
82
+ startedAt?: number
83
+ }
84
+
85
+ /** Complete /api/dsh-rez-suite/status response. */
86
+ export interface RezStatusResponse {
87
+ role: RezRoleId
88
+ servers: RezServerStatus[]
89
+ totalRegisteredTools: number
90
+ }
91
+
92
+ /** One audit ledger row. */
93
+ export interface RezAuditRecord {
94
+ id: number
95
+ ts: number
96
+ server: string
97
+ tool: string
98
+ role: string
99
+ inputTokens: number
100
+ outputTokens: number
101
+ cost: number
102
+ durationMs: number
103
+ ok: boolean
104
+ error?: string
105
+ }
106
+
107
+ /** /api/dsh-rez-suite/audit response. */
108
+ export interface RezAuditResponse {
109
+ totalCalls: number
110
+ totalInputTokens: number
111
+ totalOutputTokens: number
112
+ totalCost: number
113
+ monthlyBudget: number
114
+ monthCost: number
115
+ byServer: Record<string, { calls: number; tokens: number; cost: number }>
116
+ recent: RezAuditRecord[]
117
+ }
118
+
119
+ /** JSON error body used by every route. */
120
+ export interface ApiErrorBody {
121
+ error: string
122
+ }
123
+
124
+ /** Route paths the client calls (shared literals). */
125
+ export const REZ_API_BASE = '/api/dsh-rez-suite' as const
126
+
127
+ export const REZ_API = {
128
+ config: REZ_API_BASE + '/config',
129
+ test: REZ_API_BASE + '/test',
130
+ status: REZ_API_BASE + '/status',
131
+ audit: REZ_API_BASE + '/audit',
132
+ auditReset: REZ_API_BASE + '/audit/reset',
133
+ } as const