@lightcone-ai/daemon 0.12.0 → 0.14.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,270 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ export const DEFAULT_LIMIT = 200;
4
+ export const MAX_LIMIT = 1000;
5
+
6
+ const READ_QUERY_PREFIX = /^(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN|WITH)\b/i;
7
+ const REQUIRED_DB_KEYS = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
8
+
9
+ function trimTrailingSemicolons(sql) {
10
+ return sql.replace(/;+\s*$/u, '');
11
+ }
12
+
13
+ export function normalizeSql(rawSql) {
14
+ if (typeof rawSql !== 'string') return '';
15
+ const trimmed = rawSql.trim();
16
+ if (!trimmed) return '';
17
+ return trimTrailingSemicolons(trimmed);
18
+ }
19
+
20
+ export function normalizeLimit(limit) {
21
+ const numeric = Number(limit);
22
+ if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_LIMIT;
23
+ return Math.min(Math.trunc(numeric), MAX_LIMIT);
24
+ }
25
+
26
+ export function isReadQuery(sql) {
27
+ return READ_QUERY_PREFIX.test(sql);
28
+ }
29
+
30
+ export function hasLimitClause(sql) {
31
+ return /\bLIMIT\b/i.test(sql);
32
+ }
33
+
34
+ export function prepareSql(sql, limit) {
35
+ const normalizedSql = normalizeSql(sql);
36
+ if (!normalizedSql) throw new Error('sql must be a non-empty string');
37
+
38
+ const effectiveLimit = normalizeLimit(limit);
39
+ if (!isReadQuery(normalizedSql) || hasLimitClause(normalizedSql)) {
40
+ return {
41
+ sql: normalizedSql,
42
+ limit: effectiveLimit,
43
+ limitParams: [],
44
+ limitInjected: false,
45
+ };
46
+ }
47
+
48
+ return {
49
+ sql: `${normalizedSql} LIMIT ?`,
50
+ limit: effectiveLimit,
51
+ limitParams: [effectiveLimit],
52
+ limitInjected: true,
53
+ };
54
+ }
55
+
56
+ function readRequiredKey(envVars, key) {
57
+ const raw = envVars?.[key];
58
+ if (raw == null || String(raw).trim() === '') return null;
59
+ return String(raw).trim();
60
+ }
61
+
62
+ export function parseDbConfig(envVars) {
63
+ const missing = [];
64
+ const values = {};
65
+ for (const key of REQUIRED_DB_KEYS) {
66
+ const value = readRequiredKey(envVars, key);
67
+ if (value == null) {
68
+ missing.push(key);
69
+ continue;
70
+ }
71
+ values[key] = value;
72
+ }
73
+ if (missing.length > 0) {
74
+ throw new Error(`credential payload missing required DB fields: ${missing.join(', ')}`);
75
+ }
76
+
77
+ const port = Number(values.DB_PORT);
78
+ if (!Number.isFinite(port) || port <= 0) {
79
+ throw new Error('credential payload has invalid DB_PORT');
80
+ }
81
+
82
+ return {
83
+ host: values.DB_HOST,
84
+ port,
85
+ user: values.DB_USER,
86
+ password: values.DB_PASSWORD,
87
+ database: values.DB_NAME,
88
+ waitForConnections: true,
89
+ connectionLimit: 3,
90
+ queueLimit: 0,
91
+ };
92
+ }
93
+
94
+ function fingerprintDbConfig(dbConfig) {
95
+ return createHash('sha256')
96
+ .update(JSON.stringify([
97
+ dbConfig.host,
98
+ dbConfig.port,
99
+ dbConfig.user,
100
+ dbConfig.password,
101
+ dbConfig.database,
102
+ ]))
103
+ .digest('hex');
104
+ }
105
+
106
+ export function createDataSourcePoolRegistry({ createPool, logger = () => {} } = {}) {
107
+ if (typeof createPool !== 'function') {
108
+ throw new Error('createPool function is required');
109
+ }
110
+
111
+ const pools = new Map();
112
+
113
+ return {
114
+ getOrCreate(dataSourceId, dbConfig) {
115
+ const id = String(dataSourceId ?? '').trim();
116
+ if (!id) throw new Error('data_source_id must be a non-empty string');
117
+ const nextFingerprint = fingerprintDbConfig(dbConfig);
118
+ const existing = pools.get(id);
119
+
120
+ if (existing && existing.fingerprint === nextFingerprint) {
121
+ return existing.pool;
122
+ }
123
+
124
+ if (existing) {
125
+ Promise.resolve(existing.pool.end())
126
+ .catch((error) => logger(`[mysql-mcp] failed to close previous pool for data_source_id=${id}: ${error.message}`));
127
+ }
128
+
129
+ const pool = createPool(dbConfig);
130
+ pools.set(id, { fingerprint: nextFingerprint, pool });
131
+ return pool;
132
+ },
133
+
134
+ async closeAll() {
135
+ const closing = [];
136
+ for (const { pool } of pools.values()) {
137
+ closing.push(
138
+ Promise.resolve(pool.end()).catch((error) => {
139
+ logger(`[mysql-mcp] failed to close pool: ${error.message}`);
140
+ })
141
+ );
142
+ }
143
+ pools.clear();
144
+ await Promise.all(closing);
145
+ },
146
+
147
+ size() {
148
+ return pools.size;
149
+ },
150
+ };
151
+ }
152
+
153
+ function normalizeServerUrl(serverUrl) {
154
+ return String(serverUrl ?? '').trim().replace(/\/+$/u, '');
155
+ }
156
+
157
+ export function createCredentialBrokerClient({
158
+ fetchFn = globalThis.fetch,
159
+ serverUrl = '',
160
+ machineApiKey = '',
161
+ agentId = '',
162
+ workspaceId = '',
163
+ bundleId = '',
164
+ } = {}) {
165
+ if (typeof fetchFn !== 'function') {
166
+ throw new Error('fetch function is required');
167
+ }
168
+
169
+ const normalizedServerUrl = normalizeServerUrl(serverUrl);
170
+ const normalizedMachineApiKey = String(machineApiKey ?? '').trim();
171
+ const normalizedAgentId = String(agentId ?? '').trim();
172
+ const normalizedWorkspaceId = String(workspaceId ?? '').trim();
173
+ const normalizedBundleId = String(bundleId ?? '').trim();
174
+
175
+ return async function fetchCredentialEnvVars(dataSourceId) {
176
+ const normalizedDataSourceId = String(dataSourceId ?? '').trim();
177
+ if (!normalizedDataSourceId) {
178
+ throw new Error('data_source_id is required');
179
+ }
180
+ if (!normalizedServerUrl || !normalizedMachineApiKey || !normalizedAgentId) {
181
+ throw new Error('mysql MCP missing SERVER_URL/MACHINE_API_KEY/AGENT_ID runtime context');
182
+ }
183
+
184
+ const response = await fetchFn(`${normalizedServerUrl}/governance/credential-broker`, {
185
+ method: 'POST',
186
+ headers: {
187
+ 'Content-Type': 'application/json',
188
+ 'Authorization': `Bearer ${normalizedMachineApiKey}`,
189
+ },
190
+ body: JSON.stringify({
191
+ agent_id: normalizedAgentId,
192
+ workspace_id: normalizedWorkspaceId || null,
193
+ required_credentials: [normalizedDataSourceId],
194
+ bundle_id: normalizedBundleId || null,
195
+ }),
196
+ });
197
+
198
+ let payload = null;
199
+ try {
200
+ payload = await response.json();
201
+ } catch {
202
+ payload = null;
203
+ }
204
+
205
+ if (!response.ok) {
206
+ const reason = typeof payload?.error === 'string'
207
+ ? payload.error
208
+ : `status_${response.status}`;
209
+ throw new Error(`credential_broker_failed:${reason}`);
210
+ }
211
+
212
+ const envVars = payload?.env_vars;
213
+ if (!envVars || typeof envVars !== 'object' || Array.isArray(envVars)) {
214
+ throw new Error('credential_broker_invalid_response');
215
+ }
216
+ return envVars;
217
+ };
218
+ }
219
+
220
+ export async function executeSql({
221
+ dataSourceId,
222
+ sql,
223
+ params = [],
224
+ limit,
225
+ fetchCredentialEnvVars,
226
+ poolRegistry,
227
+ } = {}) {
228
+ const normalizedDataSourceId = String(dataSourceId ?? '').trim();
229
+ if (!normalizedDataSourceId) {
230
+ throw new Error('data_source_id is required');
231
+ }
232
+ if (!Array.isArray(params)) {
233
+ throw new Error('params must be an array');
234
+ }
235
+ if (typeof fetchCredentialEnvVars !== 'function') {
236
+ throw new Error('fetchCredentialEnvVars function is required');
237
+ }
238
+ if (!poolRegistry || typeof poolRegistry.getOrCreate !== 'function') {
239
+ throw new Error('poolRegistry.getOrCreate is required');
240
+ }
241
+
242
+ const prepared = prepareSql(sql, limit);
243
+ const credentialEnv = await fetchCredentialEnvVars(normalizedDataSourceId);
244
+ const dbConfig = parseDbConfig(credentialEnv);
245
+ const pool = poolRegistry.getOrCreate(normalizedDataSourceId, dbConfig);
246
+
247
+ const queryParams = [...params, ...prepared.limitParams];
248
+ const [result] = await pool.query(prepared.sql, queryParams);
249
+
250
+ if (Array.isArray(result)) {
251
+ const rows = result.length > prepared.limit
252
+ ? result.slice(0, prepared.limit)
253
+ : result;
254
+ return {
255
+ data_source_id: normalizedDataSourceId,
256
+ row_count: rows.length,
257
+ limit: prepared.limit,
258
+ limit_applied: prepared.limitInjected || rows.length < result.length,
259
+ rows,
260
+ };
261
+ }
262
+
263
+ return {
264
+ data_source_id: normalizedDataSourceId,
265
+ affected_rows: Number(result?.affectedRows ?? 0),
266
+ changed_rows: Number(result?.changedRows ?? 0),
267
+ insert_id: result?.insertId ?? null,
268
+ warning_status: Number(result?.warningStatus ?? 0),
269
+ };
270
+ }
@@ -3,161 +3,89 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
5
  import mysql from 'mysql2/promise';
6
-
7
- function requiredEnv(name) {
8
- const value = process.env[name];
9
- if (!value) {
10
- throw new Error(`${name} is required; configure the remote Tencent MySQL connection in .env`);
6
+ import { pathToFileURL } from 'url';
7
+ import {
8
+ createCredentialBrokerClient,
9
+ createDataSourcePoolRegistry,
10
+ executeSql,
11
+ } from './core.js';
12
+
13
+ const TOOL_SCHEMA = {
14
+ data_source_id: z.string().describe('数据源 ID(当前实现映射为 credential_id)'),
15
+ sql: z.string().describe('要执行的 SQL。支持参数占位符 ?'),
16
+ params: z.array(z.any()).optional().describe('SQL 参数数组(按顺序对应 ? 占位符)'),
17
+ limit: z.number().int().positive().optional().describe('结果行数上限。默认 200,最大 1000'),
18
+ };
19
+
20
+ function isExecutedDirectly(metaUrl) {
21
+ const entry = process.argv[1];
22
+ if (!entry) return false;
23
+ try {
24
+ return pathToFileURL(entry).href === metaUrl;
25
+ } catch {
26
+ return false;
11
27
  }
12
- return value;
13
28
  }
14
29
 
15
- const pool = mysql.createPool({
16
- host: requiredEnv('DB_HOST'),
17
- port: Number(requiredEnv('DB_PORT')),
18
- user: requiredEnv('DB_USER'),
19
- password: requiredEnv('DB_PASSWORD'),
20
- database: requiredEnv('DB_NAME'),
21
- waitForConnections: true,
22
- connectionLimit: 3,
23
- });
24
-
25
- const server = new McpServer({ name: 'mysql-jobs', version: '0.1.0' });
26
-
27
- // ── search_jobs ───────────────────────────────────────────────────────────────
28
- server.tool('search_jobs',
29
- '搜索职位。支持按关键词、公司名、职位类型、工作地点过滤。返回匹配的职位列表(不含完整描述)。',
30
- {
31
- keyword: z.string().optional().describe('搜索关键词,匹配职位名称或描述'),
32
- company: z.string().optional().describe('公司名(模糊匹配)'),
33
- job_type: z.string().optional().describe('职位类型,如 实习、校招、社招'),
34
- location: z.string().optional().describe('工作地点,如 北京、上海'),
35
- limit: z.number().optional().describe('返回数量,默认 10,最大 50'),
36
- },
37
- async ({ keyword, company, job_type, location, limit = 10 }) => {
38
- const max = Math.min(limit, 50);
39
- const conditions = ['is_valid != "false"'];
40
- const params = [];
41
-
42
- if (keyword) {
43
- conditions.push('(job_title LIKE ? OR job_description LIKE ?)');
44
- params.push(`%${keyword}%`, `%${keyword}%`);
45
- }
46
- if (company) {
47
- conditions.push('company_name LIKE ?');
48
- params.push(`%${company}%`);
49
- }
50
- if (job_type) {
51
- conditions.push('job_type LIKE ?');
52
- params.push(`%${job_type}%`);
53
- }
54
- if (location) {
55
- conditions.push('work_location LIKE ?');
56
- params.push(`%${location}%`);
57
- }
58
-
59
- params.push(max);
60
- const sql = `
61
- SELECT job_id, job_title, company_name, salary, work_location, job_type, degree, tags, publish_date
62
- FROM job_details
63
- WHERE ${conditions.join(' AND ')}
64
- ORDER BY created_at DESC
65
- LIMIT ?
66
- `;
67
-
68
- console.error(`[mysql-mcp] search_jobs: ${sql.replace(/\s+/g, ' ').trim()} | params: ${JSON.stringify(params)}`);
69
- const [rows] = await pool.query(sql, params);
70
- if (rows.length === 0) {
71
- return { content: [{ type: 'text', text: '未找到匹配的职位。' }] };
72
- }
73
-
74
- const text = rows.map(r => [
75
- `job_id: ${r.job_id}`,
76
- `职位: ${r.job_title} @ ${r.company_name}`,
77
- `类型: ${r.job_type ?? '-'} 地点: ${r.work_location ?? '-'} 学历: ${r.degree ?? '-'}`,
78
- `薪资: ${r.salary || '未披露'}`,
79
- `标签: ${(() => { try { return r.tags ? JSON.parse(r.tags).join(', ') : '-'; } catch { return r.tags ?? '-'; } })()}`,
80
- `发布日期: ${r.publish_date ?? '-'}`,
81
- ].join('\n')).join('\n\n---\n\n');
82
-
83
- return { content: [{ type: 'text', text: `共 ${rows.length} 条结果:\n\n${text}` }] };
84
- }
85
- );
86
-
87
- // ── get_job_detail ────────────────────────────────────────────────────────────
88
- server.tool('get_job_detail',
89
- '根据 job_id 获取职位完整详情,包括职位描述全文。用于写手生成文案前获取完整信息。',
90
- {
91
- job_id: z.string().describe('职位 ID(从 search_jobs 结果中获取)'),
92
- },
93
- async ({ job_id }) => {
94
- const detailSql = `SELECT jd.*, c.industry, c.scale, c.type as company_type, c.logo_url FROM job_details jd LEFT JOIN companies c ON jd.company_id = c.company_id WHERE jd.job_id = ?`;
95
- console.error(`[mysql-mcp] get_job_detail: ${detailSql} | params: ${JSON.stringify([job_id])}`);
96
- const [rows] = await pool.query(detailSql, [job_id]);
97
-
98
- if (rows.length === 0) {
99
- return { content: [{ type: 'text', text: `未找到 job_id=${job_id} 的职位。` }] };
100
- }
101
-
102
- const r = rows[0];
103
- const tags = (() => { try { return r.tags ? JSON.parse(r.tags) : []; } catch { return []; } })();
104
- const text = [
105
- `【职位详情】`,
106
- `职位名称: ${r.job_title}`,
107
- `公司: ${r.company_name}${r.industry ? `(${r.industry})` : ''}`,
108
- `公司规模: ${r.scale ?? '-'} 公司类型: ${r.company_type ?? '-'}`,
109
- `薪资: ${r.salary || '未披露'}`,
110
- `工作地点: ${r.work_location ?? '-'}`,
111
- `职位类型: ${r.job_type ?? '-'}`,
112
- `学历要求: ${r.degree ?? '-'}`,
113
- `技能标签: ${tags.join(', ') || '-'}`,
114
- `发布日期: ${r.publish_date ?? '-'}`,
115
- `截止日期: ${r.close_date ?? '-'}`,
116
- `原始链接: ${r.job_detail_url}`,
117
- ``,
118
- `【职位描述】`,
119
- r.job_description ?? '(无描述)',
120
- ].join('\n');
121
-
122
- return { content: [{ type: 'text', text }] };
123
- }
124
- );
125
-
126
- // ── list_jobs ─────────────────────────────────────────────────────────────────
127
- server.tool('list_jobs',
128
- '列出最新职位,支持按类型、地点过滤。用于浏览近期职位。',
129
- {
130
- job_type: z.string().optional().describe('职位类型过滤,如 实习、校招、社招'),
131
- location: z.string().optional().describe('工作地点过滤'),
132
- limit: z.number().optional().describe('返回数量,默认 20,最大 50'),
133
- offset: z.number().optional().describe('跳过前 N 条,用于分页'),
134
- },
135
- async ({ job_type, location, limit = 20, offset = 0 }) => {
136
- const max = Math.min(limit, 50);
137
- const conditions = ['is_valid != "false"'];
138
- const params = [];
139
-
140
- if (job_type) { conditions.push('job_type LIKE ?'); params.push(`%${job_type}%`); }
141
- if (location) { conditions.push('work_location LIKE ?'); params.push(`%${location}%`); }
142
-
143
- params.push(max, offset);
144
- const listSql = `SELECT job_id, job_title, company_name, salary, work_location, job_type, degree, tags FROM job_details WHERE ${conditions.join(' AND ')} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
145
- console.error(`[mysql-mcp] list_jobs: ${listSql} | params: ${JSON.stringify(params)}`);
146
- const [rows] = await pool.query(listSql, params);
147
-
148
- if (rows.length === 0) {
149
- return { content: [{ type: 'text', text: '暂无职位数据。' }] };
30
+ export function createMysqlMcpServer({
31
+ env = process.env,
32
+ fetchFn = globalThis.fetch,
33
+ mysqlModule = mysql,
34
+ logger = console.error,
35
+ } = {}) {
36
+ const brokerClient = createCredentialBrokerClient({
37
+ fetchFn,
38
+ serverUrl: env.SERVER_URL,
39
+ machineApiKey: env.MACHINE_API_KEY,
40
+ agentId: env.AGENT_ID,
41
+ workspaceId: env.WORKSPACE_ID,
42
+ bundleId: env.GOVERNANCE_SPAWN_BUNDLE_ID,
43
+ });
44
+ const poolRegistry = createDataSourcePoolRegistry({
45
+ createPool: mysqlModule.createPool.bind(mysqlModule),
46
+ logger,
47
+ });
48
+
49
+ const server = new McpServer({ name: 'mysql-universal-sql', version: '0.2.0' });
50
+
51
+ server.tool(
52
+ 'query',
53
+ '执行通用 SQL。调用时通过 data_source_id 走 credential broker 动态获取 DB 凭证并路由连接。',
54
+ TOOL_SCHEMA,
55
+ async ({ data_source_id, sql, params = [], limit }) => {
56
+ try {
57
+ const payload = await executeSql({
58
+ dataSourceId: data_source_id,
59
+ sql,
60
+ params,
61
+ limit,
62
+ fetchCredentialEnvVars: brokerClient,
63
+ poolRegistry,
64
+ });
65
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
66
+ } catch (error) {
67
+ logger(`[mysql-mcp] query failed for data_source_id=${data_source_id}: ${error.message}`);
68
+ return {
69
+ isError: true,
70
+ content: [{ type: 'text', text: `query failed: ${error.message}` }],
71
+ };
72
+ }
150
73
  }
74
+ );
151
75
 
152
- const text = rows.map(r =>
153
- `${r.job_id} | ${r.job_title} @ ${r.company_name} | ${r.job_type ?? '-'} | ${r.work_location ?? '-'} | ${r.salary || '薪资未披露'}`
154
- ).join('\n');
76
+ return { server, poolRegistry };
77
+ }
155
78
 
156
- return { content: [{ type: 'text', text }] };
157
- }
158
- );
79
+ export async function startMysqlMcpServer(options = {}) {
80
+ const { server } = createMysqlMcpServer(options);
81
+ const transport = new StdioServerTransport();
82
+ await server.connect(transport);
83
+ console.error('[mysql-mcp] Server started (universal SQL mode)');
84
+ }
159
85
 
160
- // ── start ─────────────────────────────────────────────────────────────────────
161
- const transport = new StdioServerTransport();
162
- await server.connect(transport);
163
- console.error('[mysql-mcp] Server started');
86
+ if (isExecutedDirectly(import.meta.url)) {
87
+ startMysqlMcpServer().catch((error) => {
88
+ console.error(`[mysql-mcp] failed to start: ${error.message}`);
89
+ process.exitCode = 1;
90
+ });
91
+ }
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "id": "mysql",
3
3
  "name": "MySQL MCP Server",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "runtime": "node",
6
6
  "entrypoint": "index.js",
7
7
  "tool_declarations": [
8
- { "name": "search_jobs", "classification": "cacheable" },
9
- { "name": "get_job_detail", "classification": "cacheable" },
10
- { "name": "list_jobs", "classification": "cacheable" }
8
+ { "name": "query", "classification": "mandatory" }
11
9
  ],
12
10
  "smoke_test": {
13
- "tool": "list_jobs",
14
- "arguments": { "limit": 1 }
11
+ "tool": "query",
12
+ "arguments": {
13
+ "data_source_id": "smoke-test-data-source",
14
+ "sql": "SELECT 1",
15
+ "limit": 1
16
+ }
15
17
  }
16
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -14,9 +14,19 @@ function getArg(name) {
14
14
  return idx !== -1 && cliArgs[idx + 1] ? cliArgs[idx + 1] : '';
15
15
  }
16
16
 
17
+ function parseBooleanFlag(value, fallback = false) {
18
+ if (value == null) return fallback;
19
+ if (typeof value === 'boolean') return value;
20
+ const normalized = String(value).trim().toLowerCase();
21
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true;
22
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false;
23
+ return fallback;
24
+ }
25
+
17
26
  const SERVER_URL = process.env.SERVER_URL || getArg('--server-url') || 'http://localhost:9779';
18
27
  const MACHINE_API_KEY = process.env.MACHINE_API_KEY || getArg('--auth-token') || '';
19
28
  const AGENT_ID = process.env.AGENT_ID || getArg('--agent-id') || '';
29
+ const HOST_AGENT_HINT = process.env.IS_HOST_AGENT || getArg('--is-host-agent') || '';
20
30
  const WORKSPACE_ID = process.env.WORKSPACE_ID || getArg('--workspace-id') || ''; // injected per-workspace at spawn time
21
31
  const WORKSPACE_DIR = path.resolve(process.env.WORKSPACE_DIR || getArg('--workspace-dir') || process.cwd());
22
32
  const WORKSPACE_ROOT_DIR = path.dirname(WORKSPACE_DIR);
@@ -102,6 +112,19 @@ const DEFAULT_TOOL_CLASSIFICATION = {
102
112
  request_approval: 'mandatory',
103
113
  execute_approved_action: 'mandatory',
104
114
  promote_context: 'mandatory',
115
+ update_goal_field: 'mandatory',
116
+ supersede_goal_field: 'mandatory',
117
+ request_credential_auth: 'mandatory',
118
+ register_data_source: 'mandatory',
119
+ bind_workspace_scenario: 'mandatory',
120
+ create_workspace: 'mandatory',
121
+ rename_workspace: 'mandatory',
122
+ delete_workspace: 'mandatory',
123
+ revoke_credential: 'mandatory',
124
+ submit_feedback: 'mandatory',
125
+ escalate_to_human: 'mandatory',
126
+ navigate_to_workspace: 'mandatory',
127
+ navigate_to_settings: 'mandatory',
105
128
 
106
129
  search_messages: 'cacheable',
107
130
  list_server: 'cacheable',
@@ -111,6 +134,19 @@ const DEFAULT_TOOL_CLASSIFICATION = {
111
134
  skill_list: 'cacheable',
112
135
  skill_read: 'cacheable',
113
136
  skill_search: 'cacheable',
137
+ search_scenarios: 'cacheable',
138
+ get_scenario: 'cacheable',
139
+ list_scenarios: 'cacheable',
140
+ recommend_scenario: 'cacheable',
141
+ list_workspaces: 'cacheable',
142
+ get_workspace_summary: 'cacheable',
143
+ query_user_history: 'cacheable',
144
+ list_accounts: 'cacheable',
145
+ list_credentials: 'cacheable',
146
+ get_data_locations: 'cacheable',
147
+ explain_concept: 'cacheable',
148
+ get_release_notes: 'cacheable',
149
+ get_known_issues: 'cacheable',
114
150
  };
115
151
 
116
152
  const TOOL_CLASSIFICATION = Object.freeze({
@@ -132,6 +168,19 @@ const CACHE_INVALIDATION_TOOLS = new Set([
132
168
  'request_approval',
133
169
  'execute_approved_action',
134
170
  'promote_context',
171
+ 'update_goal_field',
172
+ 'supersede_goal_field',
173
+ 'request_credential_auth',
174
+ 'register_data_source',
175
+ 'bind_workspace_scenario',
176
+ 'create_workspace',
177
+ 'rename_workspace',
178
+ 'delete_workspace',
179
+ 'revoke_credential',
180
+ 'submit_feedback',
181
+ 'escalate_to_human',
182
+ 'navigate_to_workspace',
183
+ 'navigate_to_settings',
135
184
  ]);
136
185
 
137
186
  const governanceContext = {
@@ -196,11 +245,38 @@ function inferToolForApi(method, apiPath, body) {
196
245
  if (method === 'PATCH' && cleanPath.startsWith('/skills/')) return 'skill_update';
197
246
  if (method === 'POST' && cleanPath === '/actions/request') return 'request_approval';
198
247
  if (method === 'POST' && /^\/actions\/[^/]+\/execute$/.test(cleanPath)) return 'execute_approved_action';
248
+ if (method === 'POST' && cleanPath === '/goal-fields/update') return 'update_goal_field';
249
+ if (method === 'POST' && cleanPath === '/goal-fields/supersede') return 'supersede_goal_field';
250
+ if (method === 'POST' && cleanPath === '/credential-auth/request') return 'request_credential_auth';
251
+ if (method === 'POST' && cleanPath === '/api/data-sources') return 'register_data_source';
199
252
  if (method === 'POST' && cleanPath === '/orchestrate/decision') return 'write_governance_decision';
200
253
  if (method === 'POST' && cleanPath === '/orchestrate/correction') return 'write_governance_correction';
201
254
  if (method === 'GET' && cleanPath === '/orchestrate/context') return 'get_orchestrate_context';
202
255
  if (method === 'POST' && cleanPath === '/orchestrate/complete') return 'complete_orchestrate_trigger';
203
256
  if (method === 'POST' && cleanPath === '/context-proposals') return 'promote_context';
257
+
258
+ if (method === 'GET' && cleanPath === '/host/scenarios/search') return 'search_scenarios';
259
+ if (method === 'POST' && cleanPath === '/host/scenarios/recommend') return 'recommend_scenario';
260
+ if (method === 'GET' && /^\/host\/scenarios\/[^/]+$/.test(cleanPath)) return 'get_scenario';
261
+ if (method === 'GET' && cleanPath === '/host/scenarios') return 'list_scenarios';
262
+ if (method === 'POST' && cleanPath === '/host/workspaces/bind-scenario') return 'bind_workspace_scenario';
263
+ if (method === 'GET' && cleanPath === '/host/workspaces') return 'list_workspaces';
264
+ if (method === 'POST' && cleanPath === '/host/workspaces') return 'create_workspace';
265
+ if (method === 'PATCH' && /^\/host\/workspaces\/[^/]+$/.test(cleanPath)) return 'rename_workspace';
266
+ if (method === 'DELETE' && /^\/host\/workspaces\/[^/]+$/.test(cleanPath)) return 'delete_workspace';
267
+ if (method === 'GET' && /^\/host\/workspaces\/[^/]+\/summary$/.test(cleanPath)) return 'get_workspace_summary';
268
+ if (method === 'GET' && cleanPath === '/host/history') return 'query_user_history';
269
+ if (method === 'GET' && cleanPath === '/host/accounts') return 'list_accounts';
270
+ if (method === 'GET' && cleanPath === '/host/credentials') return 'list_credentials';
271
+ if (method === 'POST' && /^\/host\/credentials\/[^/]+\/revoke$/.test(cleanPath)) return 'revoke_credential';
272
+ if (method === 'GET' && cleanPath === '/host/data-locations') return 'get_data_locations';
273
+ if (method === 'GET' && cleanPath === '/host/docs/explain') return 'explain_concept';
274
+ if (method === 'GET' && cleanPath === '/host/docs/release-notes') return 'get_release_notes';
275
+ if (method === 'GET' && cleanPath === '/host/docs/known-issues') return 'get_known_issues';
276
+ if (method === 'POST' && cleanPath === '/host/feedback') return 'submit_feedback';
277
+ if (method === 'POST' && cleanPath === '/host/escalate') return 'escalate_to_human';
278
+ if (method === 'POST' && cleanPath === '/host/navigation/workspace') return 'navigate_to_workspace';
279
+ if (method === 'POST' && cleanPath === '/host/navigation/settings') return 'navigate_to_settings';
204
280
  return null;
205
281
  }
206
282
 
@@ -584,6 +660,29 @@ async function api(method, apiPath, body) {
584
660
  }
585
661
  }
586
662
 
663
+ async function resolveHostAgentFlag() {
664
+ const hinted = parseBooleanFlag(HOST_AGENT_HINT, false);
665
+ if (!AGENT_ID || !SERVER_URL || !MACHINE_API_KEY) return hinted;
666
+
667
+ try {
668
+ const res = await fetch(`${SERVER_URL}/internal/agent/${AGENT_ID}/profile`, {
669
+ method: 'GET',
670
+ headers: {
671
+ 'Content-Type': 'application/json',
672
+ 'Authorization': `Bearer ${MACHINE_API_KEY}`,
673
+ },
674
+ });
675
+ if (!res.ok) return hinted;
676
+ const data = await res.json();
677
+ if (typeof data?.isHostAgent === 'boolean') return data.isHostAgent;
678
+ return hinted;
679
+ } catch {
680
+ return hinted;
681
+ }
682
+ }
683
+
684
+ const IS_HOST_AGENT = await resolveHostAgentFlag();
685
+
587
686
  const server = new McpServer({ name: 'chat', version: '0.1.0' });
588
687
 
589
688
  // ── check_messages ────────────────────────────────────────────────────────────
@@ -921,6 +1020,145 @@ server.tool('upload_image',
921
1020
  }
922
1021
  );
923
1022
 
1023
+ // ── update_goal_field ─────────────────────────────────────────────────────────
1024
+ server.tool('update_goal_field',
1025
+ 'Incrementally update one goal field in workspace context. Use this during IM dialogue to capture user intent continuously, instead of waiting for a full goal form.',
1026
+ {
1027
+ field_path: z.string().describe('Dot path under goal context, e.g. "brand_positioning.voice" or "audience.primary".'),
1028
+ value: z.any().describe('Field value (string/number/object/array).'),
1029
+ confidence: z.number().min(0).max(1).optional().describe('Confidence in [0,1].'),
1030
+ status: z.enum(['candidate', 'user_confirmed']).optional().describe('candidate=tentative, user_confirmed=explicitly confirmed by user.'),
1031
+ source_turn_id: z.string().optional().describe('Optional conversation turn id for traceability.'),
1032
+ },
1033
+ async ({ field_path, value, confidence, status, source_turn_id }) => {
1034
+ if (!currentWorkspaceId) {
1035
+ return { isError: true, content: [{ type: 'text', text: 'No workspace context. Call check_messages first or specify workspace context in the current conversation.' }] };
1036
+ }
1037
+ const data = await api('POST', '/goal-fields/update', {
1038
+ workspace_id: currentWorkspaceId,
1039
+ field_path,
1040
+ value,
1041
+ confidence,
1042
+ status,
1043
+ source_turn_id,
1044
+ });
1045
+ return {
1046
+ content: [{
1047
+ type: 'text',
1048
+ text:
1049
+ `Goal field updated.\n` +
1050
+ `workspace=${data.workspaceId ?? currentWorkspaceId}\n` +
1051
+ `field_path=${data.fieldPath ?? field_path}\n` +
1052
+ `status=${data.status ?? status ?? 'candidate'}\n` +
1053
+ `context_item_id=${data.contextItemId ?? 'unknown'} version=${data.contextItemVersion ?? 'unknown'}`,
1054
+ }],
1055
+ };
1056
+ }
1057
+ );
1058
+
1059
+ // ── supersede_goal_field ──────────────────────────────────────────────────────
1060
+ server.tool('supersede_goal_field',
1061
+ 'Mark an existing goal field value as superseded when the user explicitly changes their mind.',
1062
+ {
1063
+ field_path: z.string().describe('Dot path to supersede, e.g. "audience.primary".'),
1064
+ reason: z.string().optional().describe('Why this field is superseded (optional).'),
1065
+ source_turn_id: z.string().optional().describe('Optional conversation turn id for traceability.'),
1066
+ },
1067
+ async ({ field_path, reason, source_turn_id }) => {
1068
+ if (!currentWorkspaceId) {
1069
+ return { isError: true, content: [{ type: 'text', text: 'No workspace context. Call check_messages first or specify workspace context in the current conversation.' }] };
1070
+ }
1071
+ const data = await api('POST', '/goal-fields/supersede', {
1072
+ workspace_id: currentWorkspaceId,
1073
+ field_path,
1074
+ reason,
1075
+ source_turn_id,
1076
+ });
1077
+ return {
1078
+ content: [{
1079
+ type: 'text',
1080
+ text:
1081
+ `Goal field superseded.\n` +
1082
+ `workspace=${data.workspaceId ?? currentWorkspaceId}\n` +
1083
+ `field_path=${data.fieldPath ?? field_path}\n` +
1084
+ `context_item_id=${data.contextItemId ?? 'unknown'} version=${data.contextItemVersion ?? 'unknown'}`,
1085
+ }],
1086
+ };
1087
+ }
1088
+ );
1089
+
1090
+ // ── request_credential_auth ───────────────────────────────────────────────────
1091
+ server.tool('request_credential_auth',
1092
+ 'Request just-in-time credential authorization for a platform. Returns a one-time OAuth URL that should be sent to the user as a clickable IM message.',
1093
+ {
1094
+ platform: z.string().describe('Target platform, e.g. "x".'),
1095
+ reason: z.string().describe('Human-readable reason why this credential is needed right now.'),
1096
+ source_turn_id: z.string().optional().describe('Optional conversation turn id for traceability.'),
1097
+ },
1098
+ async ({ platform, reason, source_turn_id }) => {
1099
+ if (!currentWorkspaceId) {
1100
+ return { isError: true, content: [{ type: 'text', text: 'No workspace context. Call check_messages first or specify workspace context in the current conversation.' }] };
1101
+ }
1102
+ const data = await api('POST', '/credential-auth/request', {
1103
+ workspace_id: currentWorkspaceId,
1104
+ platform,
1105
+ reason,
1106
+ source_turn_id,
1107
+ });
1108
+ return {
1109
+ content: [{
1110
+ type: 'text',
1111
+ text:
1112
+ `Credential auth requested.\n` +
1113
+ `platform=${data.platform ?? platform}\n` +
1114
+ `expires_at=${data.expiresAt ?? 'unknown'}\n` +
1115
+ `auth_url=${data.authUrl}\n` +
1116
+ `im_message_id=${data.messageId ?? 'unknown'}\n\n` +
1117
+ `A structured credential auth message has been posted to IM. Ask the user to click the authorization button there.`,
1118
+ }],
1119
+ };
1120
+ }
1121
+ );
1122
+
1123
+ // ── register_data_source ───────────────────────────────────────────────────────
1124
+ server.tool('register_data_source',
1125
+ 'Register a workspace data source without binding credential yet. Returns a one-time secure auth URL (10-minute expiry) that should be sent to the user via IM.',
1126
+ {
1127
+ workspace_id: z.string().optional().describe('Target workspace id. Defaults to current workspace context if omitted.'),
1128
+ display_name: z.string().describe('Human-readable data source name.'),
1129
+ source_type: z.string().describe('Data source type, e.g. "mysql", "postgresql", "api", "csv", "rss", "google_sheets".'),
1130
+ schema_hint: z.any().optional().describe('Optional schema hint JSON for downstream query planning.'),
1131
+ },
1132
+ async ({ workspace_id, display_name, source_type, schema_hint }) => {
1133
+ const targetWorkspaceId = (workspace_id ?? currentWorkspaceId ?? WORKSPACE_ID ?? '').trim();
1134
+ if (!targetWorkspaceId) {
1135
+ return { isError: true, content: [{ type: 'text', text: 'workspace_id is required (no current workspace context).' }] };
1136
+ }
1137
+
1138
+ const body = {
1139
+ workspace_id: targetWorkspaceId,
1140
+ display_name,
1141
+ source_type,
1142
+ };
1143
+ if (schema_hint !== undefined) body.schema_hint = schema_hint;
1144
+
1145
+ const data = await api('POST', '/api/data-sources', body);
1146
+ return {
1147
+ content: [{
1148
+ type: 'text',
1149
+ text:
1150
+ `Data source registered.\n` +
1151
+ `workspace_id=${data.workspace_id ?? targetWorkspaceId}\n` +
1152
+ `data_source_id=${data.data_source_id}\n` +
1153
+ `source_type=${data.source_type ?? source_type}\n` +
1154
+ `expires_at=${data.expires_at ?? 'unknown'}\n` +
1155
+ `secure_auth_url=${data.secure_auth_url}\n\n` +
1156
+ `Send secure_auth_url to the user in IM so they can complete credential binding securely.`,
1157
+ }],
1158
+ };
1159
+ }
1160
+ );
1161
+
924
1162
  // ── request_approval ──────────────────────────────────────────────────────────
925
1163
  server.tool('request_approval',
926
1164
  'Request human approval before executing a sensitive platform action (posting, sending, publishing). Returns an action_id. After the human approves, call execute_approved_action with that ID.',
@@ -1122,7 +1360,424 @@ server.tool('complete_orchestrate_trigger',
1122
1360
  }
1123
1361
  );
1124
1362
 
1363
+ function hostJsonResult(data) {
1364
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1365
+ }
1366
+
1367
+ function hostErrorResult(err) {
1368
+ return { isError: true, content: [{ type: 'text', text: `Error: ${err.message}` }] };
1369
+ }
1370
+
1371
+ function buildQuery(params = {}) {
1372
+ const query = new URLSearchParams();
1373
+ for (const [key, value] of Object.entries(params)) {
1374
+ if (value == null) continue;
1375
+ if (typeof value === 'string' && !value.trim()) continue;
1376
+ query.set(key, String(value));
1377
+ }
1378
+ const text = query.toString();
1379
+ return text ? `?${text}` : '';
1380
+ }
1381
+
1382
+ if (IS_HOST_AGENT) {
1383
+ server.tool('search_scenarios',
1384
+ 'Search scenario candidates by user intent for host routing decisions.',
1385
+ {
1386
+ intent_text: z.string().describe('User intent text to match against scenario catalog.'),
1387
+ category: z.string().optional().describe('Optional category filter.'),
1388
+ visibility: z.string().optional().describe('Optional visibility filter (public/system/user_private/org_shared).'),
1389
+ status: z.string().optional().describe('Optional status filter (draft/published/active/deprecated/archived).'),
1390
+ limit: z.number().int().min(1).max(20).optional().describe('Maximum number of returned scenarios.'),
1391
+ },
1392
+ async ({ intent_text, category, visibility, status, limit }) => {
1393
+ try {
1394
+ const query = buildQuery({ intent_text, category, visibility, status, limit });
1395
+ const data = await api('GET', `/host/scenarios/search${query}`);
1396
+ return hostJsonResult(data);
1397
+ } catch (err) {
1398
+ return hostErrorResult(err);
1399
+ }
1400
+ }
1401
+ );
1402
+
1403
+ server.tool('get_scenario',
1404
+ 'Get full scenario details (manifest + roles) for host explanation.',
1405
+ {
1406
+ scenario_id: z.string().describe('Scenario ID to inspect.'),
1407
+ },
1408
+ async ({ scenario_id }) => {
1409
+ try {
1410
+ const data = await api('GET', `/host/scenarios/${encodeURIComponent(scenario_id)}`);
1411
+ return hostJsonResult(data);
1412
+ } catch (err) {
1413
+ return hostErrorResult(err);
1414
+ }
1415
+ }
1416
+ );
1417
+
1418
+ server.tool('list_scenarios',
1419
+ 'List scenarios visible to host agent (public/system + owner-accessible).',
1420
+ {
1421
+ visibility: z.string().optional().describe('Optional visibility filter.'),
1422
+ category: z.string().optional().describe('Optional category filter.'),
1423
+ status: z.string().optional().describe('Optional status filter.'),
1424
+ include_archived: z.boolean().optional().describe('Include archived scenarios when true.'),
1425
+ limit: z.number().int().min(1).max(200).optional().describe('Maximum rows to return.'),
1426
+ },
1427
+ async ({ visibility, category, status, include_archived, limit }) => {
1428
+ try {
1429
+ const query = buildQuery({
1430
+ visibility,
1431
+ category,
1432
+ status,
1433
+ includeArchived: include_archived,
1434
+ limit,
1435
+ });
1436
+ const data = await api('GET', `/host/scenarios${query}`);
1437
+ return hostJsonResult(data);
1438
+ } catch (err) {
1439
+ return hostErrorResult(err);
1440
+ }
1441
+ }
1442
+ );
1443
+
1444
+ server.tool('bind_workspace_scenario',
1445
+ 'Bind a scenario to an existing workspace and ensure a primary agent.',
1446
+ {
1447
+ workspace_id: z.string().describe('Workspace ID to bind.'),
1448
+ scenario_id: z.string().describe('Scenario ID to bind.'),
1449
+ role_id: z.string().optional().describe('Optional preferred role id/name for primary agent binding.'),
1450
+ repair_role_binding: z.boolean().optional().describe('When true, ignore existing workspace role binding once and re-resolve primary agent for cleanup.'),
1451
+ },
1452
+ async ({ workspace_id, scenario_id, role_id, repair_role_binding }) => {
1453
+ try {
1454
+ const data = await api('POST', '/host/workspaces/bind-scenario', {
1455
+ workspace_id,
1456
+ scenario_id,
1457
+ role_id,
1458
+ repair_role_binding,
1459
+ });
1460
+ return hostJsonResult(data);
1461
+ } catch (err) {
1462
+ return hostErrorResult(err);
1463
+ }
1464
+ }
1465
+ );
1466
+
1467
+ server.tool('recommend_scenario',
1468
+ 'Recommend best-fit scenarios for a given user intent.',
1469
+ {
1470
+ user_intent: z.string().describe('Natural-language user intent.'),
1471
+ },
1472
+ async ({ user_intent }) => {
1473
+ try {
1474
+ const data = await api('POST', '/host/scenarios/recommend', { user_intent });
1475
+ return hostJsonResult(data);
1476
+ } catch (err) {
1477
+ return hostErrorResult(err);
1478
+ }
1479
+ }
1480
+ );
1481
+
1482
+ server.tool('list_workspaces',
1483
+ 'List all owner workspaces with scenario/mode/last activity for host overview.',
1484
+ {
1485
+ owner_id: z.string().optional().describe('Optional owner id (must match current host owner).'),
1486
+ limit: z.number().int().min(1).max(300).optional().describe('Maximum rows to return.'),
1487
+ },
1488
+ async ({ owner_id, limit }) => {
1489
+ try {
1490
+ const query = buildQuery({ owner_id, limit });
1491
+ const data = await api('GET', `/host/workspaces${query}`);
1492
+ return hostJsonResult(data);
1493
+ } catch (err) {
1494
+ return hostErrorResult(err);
1495
+ }
1496
+ }
1497
+ );
1498
+
1499
+ server.tool('create_workspace',
1500
+ 'Create a workspace and optionally bind scenario + primary role.',
1501
+ {
1502
+ name: z.string().optional().describe('Optional workspace name.'),
1503
+ description: z.string().optional().describe('Optional workspace description.'),
1504
+ scenario_id: z.string().optional().describe('Optional scenario id to bind immediately.'),
1505
+ role_id: z.string().optional().describe('Optional preferred role id/name when scenario is bound.'),
1506
+ },
1507
+ async ({ name, description, scenario_id, role_id }) => {
1508
+ try {
1509
+ const data = await api('POST', '/host/workspaces', {
1510
+ name,
1511
+ description,
1512
+ scenario_id,
1513
+ role_id,
1514
+ });
1515
+ return hostJsonResult(data);
1516
+ } catch (err) {
1517
+ return hostErrorResult(err);
1518
+ }
1519
+ }
1520
+ );
1521
+
1522
+ server.tool('rename_workspace',
1523
+ 'Rename an existing workspace.',
1524
+ {
1525
+ workspace_id: z.string().describe('Workspace ID to rename.'),
1526
+ new_name: z.string().describe('New workspace name.'),
1527
+ },
1528
+ async ({ workspace_id, new_name }) => {
1529
+ try {
1530
+ const data = await api('PATCH', `/host/workspaces/${encodeURIComponent(workspace_id)}`, {
1531
+ new_name,
1532
+ });
1533
+ return hostJsonResult(data);
1534
+ } catch (err) {
1535
+ return hostErrorResult(err);
1536
+ }
1537
+ }
1538
+ );
1539
+
1540
+ server.tool('delete_workspace',
1541
+ 'Delete (soft-delete) a workspace owned by the current user.',
1542
+ {
1543
+ workspace_id: z.string().describe('Workspace ID to delete.'),
1544
+ confirm: z.boolean().describe('Must be true to confirm deletion.'),
1545
+ },
1546
+ async ({ workspace_id, confirm }) => {
1547
+ try {
1548
+ const query = buildQuery({ confirm });
1549
+ const data = await api('DELETE', `/host/workspaces/${encodeURIComponent(workspace_id)}${query}`);
1550
+ return hostJsonResult(data);
1551
+ } catch (err) {
1552
+ return hostErrorResult(err);
1553
+ }
1554
+ }
1555
+ );
1556
+
1557
+ server.tool('get_workspace_summary',
1558
+ 'Get summary of workspace scenario, active agents, recent messages and publish stats.',
1559
+ {
1560
+ workspace_id: z.string().describe('Workspace ID to summarize.'),
1561
+ },
1562
+ async ({ workspace_id }) => {
1563
+ try {
1564
+ const data = await api('GET', `/host/workspaces/${encodeURIComponent(workspace_id)}/summary`);
1565
+ return hostJsonResult(data);
1566
+ } catch (err) {
1567
+ return hostErrorResult(err);
1568
+ }
1569
+ }
1570
+ );
1571
+
1572
+ server.tool('query_user_history',
1573
+ 'Search user history across owner workspaces for prior activities/messages.',
1574
+ {
1575
+ topic: z.string().describe('Topic keyword to search across workspaces.'),
1576
+ limit: z.number().int().min(1).max(50).optional().describe('Maximum number of results.'),
1577
+ },
1578
+ async ({ topic, limit }) => {
1579
+ try {
1580
+ const query = buildQuery({ topic, limit });
1581
+ const data = await api('GET', `/host/history${query}`);
1582
+ return hostJsonResult(data);
1583
+ } catch (err) {
1584
+ return hostErrorResult(err);
1585
+ }
1586
+ }
1587
+ );
1588
+
1589
+ server.tool('list_accounts',
1590
+ 'List platform accounts owned by the current user.',
1591
+ {
1592
+ owner_id: z.string().optional().describe('Optional owner id (must match current owner).'),
1593
+ workspace_id: z.string().optional().describe('Optional workspace filter; pass "null" for unbound accounts.'),
1594
+ platform: z.string().optional().describe('Optional platform filter.'),
1595
+ status: z.string().optional().describe('Optional status filter.'),
1596
+ include_archived: z.boolean().optional().describe('Include archived accounts.'),
1597
+ },
1598
+ async ({ owner_id, workspace_id, platform, status, include_archived }) => {
1599
+ try {
1600
+ const query = buildQuery({
1601
+ owner_id,
1602
+ workspace_id,
1603
+ platform,
1604
+ status,
1605
+ include_archived,
1606
+ });
1607
+ const data = await api('GET', `/host/accounts${query}`);
1608
+ return hostJsonResult(data);
1609
+ } catch (err) {
1610
+ return hostErrorResult(err);
1611
+ }
1612
+ }
1613
+ );
1614
+
1615
+ server.tool('list_credentials',
1616
+ 'List credentials as masked metadata (no plaintext).',
1617
+ {
1618
+ owner_id: z.string().optional().describe('Optional owner id (must match current owner).'),
1619
+ },
1620
+ async ({ owner_id }) => {
1621
+ try {
1622
+ const query = buildQuery({ owner_id });
1623
+ const data = await api('GET', `/host/credentials${query}`);
1624
+ return hostJsonResult(data);
1625
+ } catch (err) {
1626
+ return hostErrorResult(err);
1627
+ }
1628
+ }
1629
+ );
1630
+
1631
+ server.tool('revoke_credential',
1632
+ 'Revoke a credential with explicit confirmation.',
1633
+ {
1634
+ credential_id: z.string().describe('Credential ID to revoke.'),
1635
+ confirm: z.boolean().describe('Must be true to confirm revocation.'),
1636
+ },
1637
+ async ({ credential_id, confirm }) => {
1638
+ try {
1639
+ const data = await api('POST', `/host/credentials/${encodeURIComponent(credential_id)}/revoke`, {
1640
+ confirm,
1641
+ });
1642
+ return hostJsonResult(data);
1643
+ } catch (err) {
1644
+ return hostErrorResult(err);
1645
+ }
1646
+ }
1647
+ );
1648
+
1649
+ server.tool('get_data_locations',
1650
+ 'Describe where user/platform data is stored.',
1651
+ {},
1652
+ async () => {
1653
+ try {
1654
+ const data = await api('GET', '/host/data-locations');
1655
+ return hostJsonResult(data);
1656
+ } catch (err) {
1657
+ return hostErrorResult(err);
1658
+ }
1659
+ }
1660
+ );
1661
+
1662
+ server.tool('explain_concept',
1663
+ 'Explain a product concept from docs corpus.',
1664
+ {
1665
+ topic: z.string().describe('Concept/topic text to explain.'),
1666
+ },
1667
+ async ({ topic }) => {
1668
+ try {
1669
+ const query = buildQuery({ topic });
1670
+ const data = await api('GET', `/host/docs/explain${query}`);
1671
+ return hostJsonResult(data);
1672
+ } catch (err) {
1673
+ return hostErrorResult(err);
1674
+ }
1675
+ }
1676
+ );
1677
+
1678
+ server.tool('get_release_notes',
1679
+ 'Get release notes/events since a specified timestamp.',
1680
+ {
1681
+ since: z.string().optional().describe('Optional ISO timestamp (default: last 30 days).'),
1682
+ },
1683
+ async ({ since }) => {
1684
+ try {
1685
+ const query = buildQuery({ since });
1686
+ const data = await api('GET', `/host/docs/release-notes${query}`);
1687
+ return hostJsonResult(data);
1688
+ } catch (err) {
1689
+ return hostErrorResult(err);
1690
+ }
1691
+ }
1692
+ );
1693
+
1694
+ server.tool('get_known_issues',
1695
+ 'Get current known issues and limitations.',
1696
+ {},
1697
+ async () => {
1698
+ try {
1699
+ const data = await api('GET', '/host/docs/known-issues');
1700
+ return hostJsonResult(data);
1701
+ } catch (err) {
1702
+ return hostErrorResult(err);
1703
+ }
1704
+ }
1705
+ );
1706
+
1707
+ server.tool('submit_feedback',
1708
+ 'Submit user feedback into internal backlog workflow.',
1709
+ {
1710
+ category: z.enum(['bug', 'feature', 'ux', 'performance', 'security', 'other']).describe('Feedback category.'),
1711
+ content: z.string().describe('Feedback content.'),
1712
+ workspace_id: z.string().optional().describe('Optional workspace context for this feedback.'),
1713
+ },
1714
+ async ({ category, content, workspace_id }) => {
1715
+ try {
1716
+ const data = await api('POST', '/host/feedback', {
1717
+ category,
1718
+ content,
1719
+ workspace_id,
1720
+ });
1721
+ return hostJsonResult(data);
1722
+ } catch (err) {
1723
+ return hostErrorResult(err);
1724
+ }
1725
+ }
1726
+ );
1727
+
1728
+ server.tool('escalate_to_human',
1729
+ 'Escalate severe issue to human operators.',
1730
+ {
1731
+ issue: z.string().describe('Issue details to escalate.'),
1732
+ priority: z.enum(['low', 'medium', 'high', 'critical']).optional().describe('Escalation priority.'),
1733
+ workspace_id: z.string().optional().describe('Optional workspace context for escalation.'),
1734
+ },
1735
+ async ({ issue, priority, workspace_id }) => {
1736
+ try {
1737
+ const data = await api('POST', '/host/escalate', {
1738
+ issue,
1739
+ priority,
1740
+ workspace_id,
1741
+ });
1742
+ return hostJsonResult(data);
1743
+ } catch (err) {
1744
+ return hostErrorResult(err);
1745
+ }
1746
+ }
1747
+ );
1748
+
1749
+ server.tool('navigate_to_workspace',
1750
+ 'Emit navigation intent for UI to open a target workspace.',
1751
+ {
1752
+ workspace_id: z.string().describe('Target workspace ID.'),
1753
+ },
1754
+ async ({ workspace_id }) => {
1755
+ try {
1756
+ const data = await api('POST', '/host/navigation/workspace', { workspace_id });
1757
+ return hostJsonResult(data);
1758
+ } catch (err) {
1759
+ return hostErrorResult(err);
1760
+ }
1761
+ }
1762
+ );
1763
+
1764
+ server.tool('navigate_to_settings',
1765
+ 'Emit navigation intent for UI to open a settings section.',
1766
+ {
1767
+ section: z.string().describe('Settings section key, e.g. "credentials", "profile", "workspace".'),
1768
+ },
1769
+ async ({ section }) => {
1770
+ try {
1771
+ const data = await api('POST', '/host/navigation/settings', { section });
1772
+ return hostJsonResult(data);
1773
+ } catch (err) {
1774
+ return hostErrorResult(err);
1775
+ }
1776
+ }
1777
+ );
1778
+ }
1779
+
1125
1780
  // ── start ─────────────────────────────────────────────────────────────────────
1126
1781
  const transport = new StdioServerTransport();
1127
1782
  await server.connect(transport);
1128
- console.error(`[chat-bridge] MCP Server started (agentId=${AGENT_ID})`);
1783
+ console.error(`[chat-bridge] MCP Server started (agentId=${AGENT_ID}, host=${IS_HOST_AGENT ? 'true' : 'false'})`);
package/src/mcp-config.js CHANGED
@@ -58,7 +58,8 @@ function baseEnvForServer(serverKey, { serverUrl, authToken, agentId, workspaceI
58
58
  return { SERVER_URL: serverUrl, MACHINE_API_KEY: authToken, AGENT_ID: agentId };
59
59
  }
60
60
  if (
61
- serverKey === 'publisher'
61
+ serverKey === 'mysql'
62
+ || serverKey === 'publisher'
62
63
  || serverKey === 'platform'
63
64
  || serverKey === 'research-fetch'
64
65
  || serverKey === 'market-data-query'