@tacitai/mcp-server 0.1.1 → 0.2.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.
Files changed (68) hide show
  1. package/dist/auth.d.ts +32 -0
  2. package/dist/auth.js +64 -0
  3. package/dist/client.d.ts +60 -5
  4. package/dist/client.js +234 -53
  5. package/dist/index.js +128 -106
  6. package/dist/oauth.d.ts +115 -0
  7. package/dist/oauth.js +619 -0
  8. package/dist/prompts.d.ts +2 -0
  9. package/dist/prompts.js +12 -0
  10. package/dist/setup.d.ts +28 -5
  11. package/dist/setup.js +266 -59
  12. package/dist/tools/agents.d.ts +277 -0
  13. package/dist/tools/agents.js +335 -0
  14. package/dist/tools/annotations.d.ts +117 -0
  15. package/dist/tools/annotations.js +130 -0
  16. package/dist/tools/artifacts.d.ts +178 -8
  17. package/dist/tools/artifacts.js +372 -81
  18. package/dist/tools/chronicle.d.ts +3 -0
  19. package/dist/tools/chronicle.js +165 -0
  20. package/dist/tools/connector_write_back.d.ts +3 -0
  21. package/dist/tools/connector_write_back.js +27 -0
  22. package/dist/tools/converse.d.ts +10 -0
  23. package/dist/tools/converse.js +88 -0
  24. package/dist/tools/crews.d.ts +3 -0
  25. package/dist/tools/crews.js +115 -0
  26. package/dist/tools/drafts.d.ts +83 -0
  27. package/dist/tools/drafts.js +86 -0
  28. package/dist/tools/errors.d.ts +15 -0
  29. package/dist/tools/errors.js +21 -0
  30. package/dist/tools/export.d.ts +54 -0
  31. package/dist/tools/export.js +55 -0
  32. package/dist/tools/gm.d.ts +214 -0
  33. package/dist/tools/gm.js +198 -0
  34. package/dist/tools/ingest.d.ts +58 -4
  35. package/dist/tools/ingest.js +109 -76
  36. package/dist/tools/kb.d.ts +544 -0
  37. package/dist/tools/kb.js +687 -0
  38. package/dist/tools/lake-structure.d.ts +3 -0
  39. package/dist/tools/lake-structure.js +126 -0
  40. package/dist/tools/lake.d.ts +932 -0
  41. package/dist/tools/lake.js +1017 -0
  42. package/dist/tools/move.d.ts +200 -0
  43. package/dist/tools/move.js +96 -0
  44. package/dist/tools/outbox.d.ts +274 -0
  45. package/dist/tools/outbox.js +223 -0
  46. package/dist/tools/people.d.ts +3 -0
  47. package/dist/tools/people.js +59 -0
  48. package/dist/tools/pyramid.d.ts +554 -0
  49. package/dist/tools/pyramid.js +758 -0
  50. package/dist/tools/recall.d.ts +2 -0
  51. package/dist/tools/recall.js +28 -17
  52. package/dist/tools/registry.d.ts +17 -0
  53. package/dist/tools/registry.js +197 -0
  54. package/dist/tools/research.d.ts +47 -0
  55. package/dist/tools/research.js +575 -0
  56. package/dist/tools/search.d.ts +9 -0
  57. package/dist/tools/search.js +86 -25
  58. package/dist/tools/sharing.d.ts +63 -0
  59. package/dist/tools/sharing.js +157 -0
  60. package/dist/tools/synthesize.d.ts +54 -2
  61. package/dist/tools/synthesize.js +80 -23
  62. package/dist/tools/web-url.d.ts +57 -0
  63. package/dist/tools/web-url.js +74 -0
  64. package/dist/tools/workspaces.d.ts +3 -0
  65. package/dist/tools/workspaces.js +47 -0
  66. package/dist/transport.d.ts +9 -0
  67. package/dist/transport.js +209 -0
  68. package/package.json +5 -4
@@ -0,0 +1,575 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * Research tools (MV-1525) — scope discovery + grounded prompt assembly,
4
+ * pyramid/KB placement with T3 confirmation, and async Gemini launch.
5
+ *
6
+ * MV-1138b: principal-scoped task store, TTL eviction, per-principal rate
7
+ * limits, configurable model, hard timeout for running tasks.
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ import { z } from 'zod';
11
+ import { apiErrorResult } from './errors.js';
12
+ function ok(data) {
13
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
14
+ }
15
+ function fail(message) {
16
+ return { content: [{ type: 'text', text: message }], isError: true };
17
+ }
18
+ function asRecords(value, ...keys) {
19
+ if (Array.isArray(value))
20
+ return value;
21
+ if (value && typeof value === 'object') {
22
+ for (const k of keys) {
23
+ const v = value[k];
24
+ if (Array.isArray(v))
25
+ return v;
26
+ }
27
+ }
28
+ return [];
29
+ }
30
+ function str(v) {
31
+ return typeof v === 'string' ? v : v == null ? '' : String(v);
32
+ }
33
+ function excerptOf(r) {
34
+ const raw = str(r['excerpt'] ?? r['snippet'] ?? r['summary'] ?? r['description'] ?? r['content']);
35
+ return raw.length > 400 ? `${raw.slice(0, 400)}...` : raw;
36
+ }
37
+ function toSource(r, type) {
38
+ return {
39
+ title: str(r['title'] ?? r['name'] ?? r['file_name'] ?? r['path']) || '(untitled)',
40
+ excerpt: excerptOf(r),
41
+ source_id: str(r['id'] ?? r['source_id'] ?? r['article_id'] ?? r['entity_id']),
42
+ source_type: type,
43
+ };
44
+ }
45
+ const VERIFICATION_DIRECTIVE = `IMPORTANT: The following company knowledge is provided as research leads to verify,
46
+ not facts to repeat. Before citing any company-sourced information in your report,
47
+ verify it by calling the relevant TACIT MCP tool (tacit_search, tacit_kb_get_article, etc.).
48
+ Verified facts should be cited from the tool response. Do not state unverified company
49
+ information as fact.`;
50
+ function buildGroundedPrompt(topic, sources) {
51
+ const leads = sources.length
52
+ ? sources
53
+ .map((s, i) => `${i + 1}. [${s.source_type}:${s.source_id}] ${s.title}${s.excerpt ? ` — ${s.excerpt}` : ''}`)
54
+ .join('\n')
55
+ : '(no company context found for the selected scope)';
56
+ return [
57
+ `# Research Topic\n${topic}`,
58
+ VERIFICATION_DIRECTIVE,
59
+ `## Research leads to verify\n${leads}`,
60
+ ].join('\n\n');
61
+ }
62
+ function envInt(key, fallback) {
63
+ const v = Number.parseInt(process.env[key] ?? '', 10);
64
+ return Number.isFinite(v) && v > 0 ? v : fallback;
65
+ }
66
+ const MAX_TASKS = envInt('MCP_RESEARCH_MAX_TASKS', 500);
67
+ const MAX_CONCURRENT = envInt('MCP_RESEARCH_MAX_CONCURRENT', 3);
68
+ const DAILY_CAP = envInt('MCP_RESEARCH_DAILY_CAP', 50);
69
+ const COMPLETED_TTL_MS = 60 * 60 * 1000; // 1 hour
70
+ const RUNNING_HARD_TIMEOUT_MS = 2 * 60 * 60 * 1000; // 2 hours
71
+ export const _testInternals = {
72
+ get tasks() { return tasks; },
73
+ get dailyLaunches() { return dailyLaunches; },
74
+ evictExpired,
75
+ resetForTest() {
76
+ tasks.clear();
77
+ dailyLaunches.clear();
78
+ },
79
+ };
80
+ const tasks = new Map();
81
+ const dailyLaunches = new Map();
82
+ function midnightUtc() {
83
+ const now = new Date();
84
+ const tomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
85
+ return tomorrow.getTime();
86
+ }
87
+ function getDailyCount(principal) {
88
+ const entry = dailyLaunches.get(principal);
89
+ if (!entry)
90
+ return 0;
91
+ if (Date.now() >= entry.resetAt) {
92
+ dailyLaunches.delete(principal);
93
+ return 0;
94
+ }
95
+ return entry.count;
96
+ }
97
+ function incrementDailyCount(principal) {
98
+ const entry = dailyLaunches.get(principal);
99
+ if (!entry || Date.now() >= entry.resetAt) {
100
+ dailyLaunches.set(principal, { count: 1, resetAt: midnightUtc() });
101
+ }
102
+ else {
103
+ entry.count += 1;
104
+ }
105
+ }
106
+ function countRunning(principal) {
107
+ let count = 0;
108
+ for (const entry of tasks.values()) {
109
+ if (entry.principal === principal && entry.task.status === 'running')
110
+ count++;
111
+ }
112
+ return count;
113
+ }
114
+ function evictExpired() {
115
+ const now = Date.now();
116
+ for (const [id, entry] of tasks) {
117
+ const t = entry.task;
118
+ if (t.status === 'running' && now - t.startedAt > RUNNING_HARD_TIMEOUT_MS) {
119
+ tasks.set(id, {
120
+ principal: entry.principal,
121
+ task: { status: 'failed', error: 'task timed out (exceeded 2 hour limit)', failedAt: now },
122
+ });
123
+ }
124
+ else if (t.status === 'completed' && now - t.completedAt > COMPLETED_TTL_MS) {
125
+ tasks.delete(id);
126
+ }
127
+ else if (t.status === 'failed' && now - t.failedAt > COMPLETED_TTL_MS) {
128
+ tasks.delete(id);
129
+ }
130
+ }
131
+ }
132
+ function evictForCapacity() {
133
+ evictExpired();
134
+ if (tasks.size < MAX_TASKS)
135
+ return true;
136
+ // Evict oldest completed/failed first
137
+ let oldestId = null;
138
+ let oldestTime = Infinity;
139
+ for (const [id, entry] of tasks) {
140
+ const t = entry.task;
141
+ if (t.status === 'completed' && t.completedAt < oldestTime) {
142
+ oldestTime = t.completedAt;
143
+ oldestId = id;
144
+ }
145
+ else if (t.status === 'failed' && t.failedAt < oldestTime) {
146
+ oldestTime = t.failedAt;
147
+ oldestId = id;
148
+ }
149
+ }
150
+ if (oldestId) {
151
+ tasks.delete(oldestId);
152
+ return true;
153
+ }
154
+ return tasks.size < MAX_TASKS;
155
+ }
156
+ // Periodic cleanup every 60s
157
+ let _cleanupInterval = null;
158
+ function ensureCleanupInterval() {
159
+ if (_cleanupInterval)
160
+ return;
161
+ _cleanupInterval = setInterval(evictExpired, 60_000);
162
+ if (_cleanupInterval && typeof _cleanupInterval === 'object' && 'unref' in _cleanupInterval) {
163
+ _cleanupInterval.unref();
164
+ }
165
+ }
166
+ function resolveModel(override) {
167
+ return override ?? process.env['MCP_RESEARCH_MODEL'] ?? 'deep-research-max-preview-04-2026';
168
+ }
169
+ function isDeepResearchModel(model) {
170
+ return model.includes('deep-research');
171
+ }
172
+ async function runGemini(taskId, principal, apiKey, model, prompt) {
173
+ const startedAt = Date.now();
174
+ try {
175
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`, {
176
+ method: 'POST',
177
+ headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },
178
+ body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
179
+ });
180
+ if (!res.ok) {
181
+ const body = await res.text().catch(() => '');
182
+ tasks.set(taskId, {
183
+ principal,
184
+ task: { status: 'failed', error: `Gemini API error ${res.status}: ${body.slice(0, 500)}`, failedAt: Date.now() },
185
+ });
186
+ return;
187
+ }
188
+ const data = (await res.json());
189
+ const content = (data.candidates?.[0]?.content?.parts ?? []).map((p) => p.text ?? '').join('');
190
+ tasks.set(taskId, {
191
+ principal,
192
+ task: {
193
+ status: 'completed',
194
+ content,
195
+ token_usage: data.usageMetadata ?? null,
196
+ model,
197
+ duration_seconds: Math.round((Date.now() - startedAt) / 100) / 10,
198
+ completedAt: Date.now(),
199
+ },
200
+ });
201
+ }
202
+ catch (err) {
203
+ tasks.set(taskId, {
204
+ principal,
205
+ task: { status: 'failed', error: err instanceof Error ? err.message : String(err), failedAt: Date.now() },
206
+ });
207
+ }
208
+ }
209
+ const DEEP_RESEARCH_POLL_INTERVAL_MS = 5_000;
210
+ const DEEP_RESEARCH_MAX_POLL_MS = 30 * 60 * 1000;
211
+ const DEEP_RESEARCH_MAX_POLL_FAILURES = 3;
212
+ const DEEP_RESEARCH_FETCH_TIMEOUT_MS = 30_000;
213
+ const DEEP_RESEARCH_CONTINUABLE = new Set(['in_progress', 'queued', 'pending']);
214
+ function safeErrorString(err) {
215
+ if (typeof err === 'string')
216
+ return err;
217
+ if (err instanceof Error)
218
+ return err.message;
219
+ try {
220
+ return JSON.stringify(err);
221
+ }
222
+ catch {
223
+ return String(err);
224
+ }
225
+ }
226
+ async function runDeepResearch(taskId, principal, apiKey, agent, prompt) {
227
+ const startedAt = Date.now();
228
+ try {
229
+ const startRes = await fetch('https://generativelanguage.googleapis.com/v1beta/interactions', {
230
+ method: 'POST',
231
+ headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },
232
+ body: JSON.stringify({ input: prompt, agent, background: true }),
233
+ signal: AbortSignal.timeout(DEEP_RESEARCH_FETCH_TIMEOUT_MS),
234
+ });
235
+ if (!startRes.ok) {
236
+ const body = await startRes.text().catch(() => '');
237
+ tasks.set(taskId, {
238
+ principal,
239
+ task: { status: 'failed', error: `Deep Research API error ${startRes.status}: ${body.slice(0, 500)}`, failedAt: Date.now() },
240
+ });
241
+ return;
242
+ }
243
+ const startData = (await startRes.json());
244
+ const interactionId = startData.id;
245
+ if (!interactionId) {
246
+ tasks.set(taskId, {
247
+ principal,
248
+ task: { status: 'failed', error: 'Deep Research API returned no interaction ID', failedAt: Date.now() },
249
+ });
250
+ return;
251
+ }
252
+ const deadline = startedAt + DEEP_RESEARCH_MAX_POLL_MS;
253
+ let consecutiveFailures = 0;
254
+ while (Date.now() < deadline) {
255
+ await new Promise(resolve => setTimeout(resolve, DEEP_RESEARCH_POLL_INTERVAL_MS));
256
+ let pollRes;
257
+ try {
258
+ pollRes = await fetch(`https://generativelanguage.googleapis.com/v1beta/interactions/${encodeURIComponent(interactionId)}`, {
259
+ headers: { 'x-goog-api-key': apiKey },
260
+ signal: AbortSignal.timeout(DEEP_RESEARCH_FETCH_TIMEOUT_MS),
261
+ });
262
+ }
263
+ catch (fetchErr) {
264
+ consecutiveFailures++;
265
+ if (consecutiveFailures >= DEEP_RESEARCH_MAX_POLL_FAILURES) {
266
+ tasks.set(taskId, {
267
+ principal,
268
+ task: { status: 'failed', error: `Deep Research poll failed ${consecutiveFailures} times: ${safeErrorString(fetchErr)}`, failedAt: Date.now() },
269
+ });
270
+ return;
271
+ }
272
+ continue;
273
+ }
274
+ if (!pollRes.ok) {
275
+ consecutiveFailures++;
276
+ if (consecutiveFailures >= DEEP_RESEARCH_MAX_POLL_FAILURES) {
277
+ const body = await pollRes.text().catch(() => '');
278
+ tasks.set(taskId, {
279
+ principal,
280
+ task: { status: 'failed', error: `Deep Research poll error ${pollRes.status} (${consecutiveFailures} consecutive): ${body.slice(0, 500)}`, failedAt: Date.now() },
281
+ });
282
+ return;
283
+ }
284
+ continue;
285
+ }
286
+ consecutiveFailures = 0;
287
+ const pollData = (await pollRes.json());
288
+ if (pollData.status === 'failed') {
289
+ tasks.set(taskId, {
290
+ principal,
291
+ task: { status: 'failed', error: safeErrorString(pollData.error) || 'Deep Research task failed', failedAt: Date.now() },
292
+ });
293
+ return;
294
+ }
295
+ if (pollData.status === 'completed') {
296
+ const steps = pollData.steps ?? [];
297
+ const content = steps
298
+ .flatMap(s => s.content ?? [])
299
+ .filter(p => p.type === 'text')
300
+ .map(p => p.text ?? '')
301
+ .join('');
302
+ if (!content) {
303
+ tasks.set(taskId, {
304
+ principal,
305
+ task: { status: 'failed', error: 'Deep Research completed but returned no text content', failedAt: Date.now() },
306
+ });
307
+ return;
308
+ }
309
+ tasks.set(taskId, {
310
+ principal,
311
+ task: {
312
+ status: 'completed',
313
+ content,
314
+ token_usage: null,
315
+ model: agent,
316
+ duration_seconds: Math.round((Date.now() - startedAt) / 100) / 10,
317
+ completedAt: Date.now(),
318
+ },
319
+ });
320
+ return;
321
+ }
322
+ if (!DEEP_RESEARCH_CONTINUABLE.has(pollData.status ?? '')) {
323
+ tasks.set(taskId, {
324
+ principal,
325
+ task: { status: 'failed', error: `Deep Research returned unexpected status: ${pollData.status}`, failedAt: Date.now() },
326
+ });
327
+ return;
328
+ }
329
+ }
330
+ tasks.set(taskId, {
331
+ principal,
332
+ task: { status: 'failed', error: 'Deep Research task timed out (exceeded 30 min polling limit)', failedAt: Date.now() },
333
+ });
334
+ }
335
+ catch (err) {
336
+ tasks.set(taskId, {
337
+ principal,
338
+ task: { status: 'failed', error: safeErrorString(err), failedAt: Date.now() },
339
+ });
340
+ }
341
+ }
342
+ // ─── Registration ────────────────────────────────────────────────────────────
343
+ export function registerResearchTools(server, getClientFn, getPrincipalFn) {
344
+ ensureCleanupInterval();
345
+ // tacit_research_prepare
346
+ server.tool('tacit_research_prepare', 'Prepare a grounded research prompt. Without scope params, returns available scopes (collections, programs, portfolios) to choose from. With scope params, gathers KB/pyramid/lake context and returns a prompt that treats company knowledge as leads to verify.', {
347
+ topic: z.string().describe('Research topic'),
348
+ collection_ids: z.array(z.string()).optional().describe('KB collection IDs to scope'),
349
+ program_ids: z.array(z.string()).optional().describe('Pyramid program IDs to scope'),
350
+ portfolio_ids: z.array(z.string()).optional().describe('Pyramid portfolio IDs to scope'),
351
+ }, async (args) => {
352
+ const { topic } = args;
353
+ const collection_ids = args.collection_ids ?? [];
354
+ const program_ids = args.program_ids ?? [];
355
+ const portfolio_ids = args.portfolio_ids ?? [];
356
+ const hasScope = collection_ids.length + program_ids.length + portfolio_ids.length > 0;
357
+ try {
358
+ const client = await getClientFn();
359
+ if (!hasScope) {
360
+ const [colRes, portRes, progRes] = await Promise.allSettled([
361
+ client.get('/api/v1/kb/collections'),
362
+ client.get('/api/v1/portfolios'),
363
+ client.get('/api/v1/programs'),
364
+ ]);
365
+ return ok({
366
+ requires_scope: true,
367
+ available_scopes: {
368
+ collections: colRes.status === 'fulfilled' ? asRecords(colRes.value, 'collections', 'items') : [],
369
+ portfolios: portRes.status === 'fulfilled' ? asRecords(portRes.value, 'portfolios', 'items') : [],
370
+ programs: progRes.status === 'fulfilled' ? asRecords(progRes.value, 'programs', 'items') : [],
371
+ },
372
+ });
373
+ }
374
+ const sources = [];
375
+ try {
376
+ const searchRes = await client.post('/api/v1/search', { query: topic });
377
+ for (const r of asRecords(searchRes, 'results', 'items')) {
378
+ sources.push(toSource(r, 'kb'));
379
+ }
380
+ }
381
+ catch { /* search endpoint unavailable — continue without it */ }
382
+ for (const id of collection_ids) {
383
+ try {
384
+ const articles = await client.get(`/api/v1/kb/collections/${encodeURIComponent(id)}/articles`);
385
+ for (const r of asRecords(articles, 'articles', 'items'))
386
+ sources.push(toSource(r, 'kb'));
387
+ }
388
+ catch { /* collection unavailable */ }
389
+ }
390
+ for (const id of portfolio_ids) {
391
+ try {
392
+ const programs = await client.get(`/api/v1/portfolios/${encodeURIComponent(id)}/programs`);
393
+ for (const r of asRecords(programs, 'programs', 'items'))
394
+ sources.push(toSource(r, 'pyramid'));
395
+ }
396
+ catch { /* portfolio unavailable */ }
397
+ }
398
+ try {
399
+ const lake = await client.get('/api/v1/org/lake/catalog');
400
+ for (const r of asRecords(lake, 'items', 'catalog', 'entries'))
401
+ sources.push(toSource(r, 'lake'));
402
+ }
403
+ catch { /* lake endpoint unavailable */ }
404
+ return ok({
405
+ prompt: buildGroundedPrompt(topic, sources),
406
+ context_sources: sources,
407
+ metadata: { topic, scope_used: { collection_ids, program_ids, portfolio_ids } },
408
+ });
409
+ }
410
+ catch (err) {
411
+ return apiErrorResult(err, 'tacit_research_prepare');
412
+ }
413
+ });
414
+ // tacit_research_place
415
+ server.tool('tacit_research_place', 'Place a research report. No pyramid_target: lists available targets. content + pyramid_target: creates a DRAFT artifact and asks for confirmation. artifact_id + confirm_promote=true (T3): promotes to KB and links into the pyramid.', {
416
+ content: z.string().optional().describe('Research content (HTML)'),
417
+ title: z.string().optional().describe('Artifact title'),
418
+ pyramid_target: z
419
+ .object({
420
+ portfolio_id: z.string(),
421
+ program_id: z.string(),
422
+ dossier_id: z.string().optional(),
423
+ })
424
+ .optional()
425
+ .describe('Target placement in the pyramid'),
426
+ artifact_id: z.string().optional().describe('Existing artifact ID for confirmation'),
427
+ confirm_promote: z.boolean().optional().describe('Confirm KB promotion (T3)'),
428
+ }, async (args) => {
429
+ const { content, title, pyramid_target: target, artifact_id, confirm_promote } = args;
430
+ try {
431
+ const client = await getClientFn();
432
+ if (!target) {
433
+ const [portRes, progRes] = await Promise.allSettled([
434
+ client.get('/api/v1/portfolios'),
435
+ client.get('/api/v1/programs'),
436
+ ]);
437
+ return ok({
438
+ requires_placement: true,
439
+ available_targets: {
440
+ portfolios: portRes.status === 'fulfilled' ? asRecords(portRes.value, 'portfolios', 'items') : [],
441
+ programs: progRes.status === 'fulfilled' ? asRecords(progRes.value, 'programs', 'items') : [],
442
+ },
443
+ });
444
+ }
445
+ if (artifact_id && confirm_promote === true) {
446
+ let kbArticleId;
447
+ let pyramidMoved = false;
448
+ const placements = [];
449
+ try {
450
+ const kbArticle = await client.post('/api/v1/kb/promote-from-artifact', { artifact_id });
451
+ kbArticleId = str(kbArticle['article_id'] ?? kbArticle['id']) || undefined;
452
+ }
453
+ catch { /* KB promote not available */ }
454
+ try {
455
+ const moved = await client.post('/api/v1/move', {
456
+ artifact_id,
457
+ placement: { pyramid: { portfolio_id: target.portfolio_id, program_id: target.program_id } },
458
+ });
459
+ placements.push(moved);
460
+ pyramidMoved = true;
461
+ }
462
+ catch { /* move endpoint not available */ }
463
+ if (target.dossier_id) {
464
+ try {
465
+ const dossierPath = `/api/v1/dossiers/${encodeURIComponent(target.dossier_id)}`;
466
+ const dossier = await client.get(dossierPath);
467
+ const existing = Array.isArray(dossier['document_ids']) ? dossier['document_ids'] : [];
468
+ const documentIds = existing.includes(artifact_id) ? existing : [...existing, artifact_id];
469
+ const updated = await client.put(dossierPath, { ...dossier, document_ids: documentIds });
470
+ placements.push(updated);
471
+ }
472
+ catch { /* dossier link not available */ }
473
+ }
474
+ return ok({
475
+ artifact_id,
476
+ kb_article_id: kbArticleId ?? null,
477
+ pyramid_path: {
478
+ portfolio_id: target.portfolio_id,
479
+ program_id: target.program_id,
480
+ dossier_id: target.dossier_id,
481
+ },
482
+ pyramid_moved: pyramidMoved,
483
+ placements,
484
+ });
485
+ }
486
+ if (content) {
487
+ const created = await client.post('/api/v1/artifacts', {
488
+ title: title ?? 'Research Report',
489
+ content,
490
+ domain: 'research',
491
+ output_format: 'DOCUMENT',
492
+ portfolio_id: target.portfolio_id,
493
+ artifact_kind: 'research_report',
494
+ });
495
+ return ok({
496
+ artifact_id: created['artifact_id'] ?? created['id'],
497
+ requires_confirmation: true,
498
+ pending_operations: ['kb_promote', 'pyramid_link'],
499
+ status: 'DRAFT',
500
+ });
501
+ }
502
+ return fail('Provide content (to create a DRAFT) or artifact_id with confirm_promote=true (to confirm placement).');
503
+ }
504
+ catch (err) {
505
+ return apiErrorResult(err, 'tacit_research_place');
506
+ }
507
+ });
508
+ // tacit_research_launch
509
+ server.tool('tacit_research_launch', 'Launch a research run asynchronously (returns task_id), or poll an existing run with task_id. Requires GEMINI_API_KEY. Default: Deep Research (Interactions API, deep-research-max-preview-04-2026). Pass a regular gemini model name to use generateContent instead. Model configurable via MCP_RESEARCH_MODEL env var.', {
510
+ prompt: z.string().optional().describe('Research prompt'),
511
+ model: z.string().optional().describe('Model or agent ID (default: deep-research-max-preview-04-2026; pass a gemini-* model to use generateContent instead)'),
512
+ output_format: z.string().optional().describe('Output format (default: text)'),
513
+ task_id: z.string().optional().describe('Task ID to check status'),
514
+ }, async (args) => {
515
+ const { prompt, model, output_format, task_id } = args;
516
+ const principal = getPrincipalFn?.() ?? 'anonymous';
517
+ if (task_id) {
518
+ evictExpired();
519
+ const entry = tasks.get(task_id);
520
+ if (!entry)
521
+ return fail(`Unknown task_id: ${task_id}`);
522
+ if (entry.principal !== principal) {
523
+ return fail(`Access denied: task belongs to a different principal`);
524
+ }
525
+ const t = entry.task;
526
+ if (t.status === 'running') {
527
+ if (Date.now() - t.startedAt > RUNNING_HARD_TIMEOUT_MS) {
528
+ const failedTask = { status: 'failed', error: 'task timed out (exceeded 2 hour limit)', failedAt: Date.now() };
529
+ tasks.set(task_id, { principal: entry.principal, task: failedTask });
530
+ return ok({ error: failedTask.error, status: 'failed' });
531
+ }
532
+ return ok({ task_id, status: 'running' });
533
+ }
534
+ if (t.status === 'failed')
535
+ return ok({ error: t.error, status: 'failed' });
536
+ return ok({
537
+ content: t.content,
538
+ token_usage: t.token_usage,
539
+ model: t.model,
540
+ duration_seconds: t.duration_seconds,
541
+ status: 'completed',
542
+ });
543
+ }
544
+ if (!prompt)
545
+ return fail('Provide prompt to start a research run, or task_id to check status.');
546
+ const apiKey = process.env['GEMINI_API_KEY'];
547
+ if (!apiKey) {
548
+ return fail('GEMINI_API_KEY is not set in the MCP server environment. Set it and restart the server to use tacit_research_launch.');
549
+ }
550
+ // Per-principal concurrent limit
551
+ if (countRunning(principal) >= MAX_CONCURRENT) {
552
+ return fail(`Concurrent research limit reached (${MAX_CONCURRENT} running). Wait for existing tasks to complete.`);
553
+ }
554
+ // Per-principal daily cap
555
+ if (getDailyCount(principal) >= DAILY_CAP) {
556
+ return fail(`Daily research launch limit reached (${DAILY_CAP}). Try again tomorrow.`);
557
+ }
558
+ // Task store capacity
559
+ if (!evictForCapacity()) {
560
+ return fail('Task store is full. Wait for existing tasks to complete or expire.');
561
+ }
562
+ const newId = randomUUID();
563
+ const resolvedModel = resolveModel(model);
564
+ const finalPrompt = output_format && output_format !== 'text' ? `${prompt}\n\nOutput format: ${output_format}` : prompt;
565
+ tasks.set(newId, { principal, task: { status: 'running', startedAt: Date.now() } });
566
+ incrementDailyCount(principal);
567
+ if (isDeepResearchModel(resolvedModel)) {
568
+ void runDeepResearch(newId, principal, apiKey, resolvedModel, finalPrompt);
569
+ }
570
+ else {
571
+ void runGemini(newId, principal, apiKey, resolvedModel, finalPrompt);
572
+ }
573
+ return ok({ task_id: newId, status: 'running' });
574
+ });
575
+ }
@@ -4,22 +4,26 @@
4
4
  * JWT-scoped; workspace resolved by TacitClient.
5
5
  */
6
6
  import { z } from 'zod';
7
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
8
  import { TacitClient } from '../client.js';
8
9
  export declare const searchInputSchema: z.ZodObject<{
9
10
  query: z.ZodString;
10
11
  limit: z.ZodOptional<z.ZodNumber>;
11
12
  source_types: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
12
13
  entity_types: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
14
+ origin: z.ZodOptional<z.ZodString>;
13
15
  }, "strip", z.ZodTypeAny, {
14
16
  query: string;
15
17
  limit?: number | undefined;
16
18
  source_types?: string[] | undefined;
17
19
  entity_types?: string[] | undefined;
20
+ origin?: string | undefined;
18
21
  }, {
19
22
  query: string;
20
23
  limit?: number | undefined;
21
24
  source_types?: string[] | undefined;
22
25
  entity_types?: string[] | undefined;
26
+ origin?: string | undefined;
23
27
  }>;
24
28
  export type SearchInput = z.infer<typeof searchInputSchema>;
25
29
  export declare const SEARCH_TOOL_DEF: {
@@ -50,6 +54,10 @@ export declare const SEARCH_TOOL_DEF: {
50
54
  };
51
55
  description: string;
52
56
  };
57
+ origin: {
58
+ type: string;
59
+ description: string;
60
+ };
53
61
  };
54
62
  required: string[];
55
63
  };
@@ -75,3 +83,4 @@ export declare function handleSearch(args: unknown): Promise<{
75
83
  }>;
76
84
  isError?: boolean;
77
85
  }>;
86
+ export declare function registerSearchTools(server: McpServer, getClientFn: () => Promise<TacitClient>): void;