@decispher/mcp-server 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,225 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ import { registerTools, TOOL_DEFINITIONS } from '../tools.js';
8
+ import type { DecispherClient } from '../decispher-client.js';
9
+
10
+ type RegisteredTool = { description: string; handler: (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }> }> };
11
+ type ServerInternals = { _registeredTools: Record<string, RegisteredTool> };
12
+
13
+ function makeClient(overrides: Partial<DecispherClient> = {}): DecispherClient {
14
+ return {
15
+ searchDecisions: vi.fn().mockResolvedValue([
16
+ { id: 'd-1', title: 'Use Drizzle ORM', type: 'decision', decision: 'Use Drizzle', rationale: null, similarity: 0.92 },
17
+ ]),
18
+ getConstraints: vi.fn().mockResolvedValue([
19
+ { id: 'c-1', title: 'No raw SQL in business logic', decision: 'Always use the repository pattern', rationale: null, severity: 'HIGH' },
20
+ ]),
21
+ getConventions: vi.fn().mockResolvedValue([
22
+ { id: 'v-1', title: 'Pino for logging', decision: 'Use pino structured logging', rationale: null },
23
+ ]),
24
+ ask: vi.fn().mockResolvedValue({
25
+ answer: 'We use Drizzle ORM for type safety.',
26
+ sources: [{ id: 's-1', title: 'ORM Decision', similarity: 0.88 }],
27
+ hadContext: true,
28
+ }),
29
+ getContextForFile: vi.fn().mockResolvedValue([
30
+ { id: 'd-2', title: 'Auth middleware', type: 'convention', decision: 'Use jwt-auth', rationale: null, similarity: 0.75 },
31
+ ]),
32
+ checkIntent: vi.fn().mockResolvedValue({
33
+ verdict: 'CLEAR', conflicts: [], relevant: [], latencyMs: 0, creditsConsumed: 0,
34
+ }),
35
+ getDecision: vi.fn().mockResolvedValue({
36
+ id: 'd-1', type: 'decision', title: 'Use Drizzle ORM', problem: null,
37
+ decision: 'We chose Drizzle over Prisma for type safety.',
38
+ rationale: 'Drizzle generates typed SQL at compile time.',
39
+ severity: 'HIGH', status: 'active',
40
+ alternatives: [{ option: 'Prisma', reasonRejected: 'Runtime schema overhead' }],
41
+ affectedFiles: ['src/db/index.ts'], tags: ['orm', 'database'],
42
+ sourceType: null, supersedes: null, supersededBy: null,
43
+ createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', lastReviewedAt: null,
44
+ }),
45
+ captureDecision: vi.fn().mockResolvedValue({
46
+ id: 'cap-1', alreadyExists: false, similarityToExisting: null,
47
+ fusionPending: false, status: 'active', latencyMs: 12,
48
+ }),
49
+ ...overrides,
50
+ } as unknown as DecispherClient;
51
+ }
52
+
53
+ function getTools(server: McpServer): Record<string, RegisteredTool> {
54
+ return (server as unknown as ServerInternals)._registeredTools;
55
+ }
56
+
57
+ describe('MCP tools registration', () => {
58
+ let server: McpServer;
59
+ let client: DecispherClient;
60
+
61
+ beforeEach(() => {
62
+ server = new McpServer({ name: 'test', version: '0.0.0' });
63
+ client = makeClient();
64
+ registerTools(server, client);
65
+ });
66
+
67
+ it('registers exactly 8 tools', () => {
68
+ const names = Object.keys(getTools(server)).sort();
69
+ expect(names).toEqual([
70
+ 'ask_knowledge_base',
71
+ 'capture_decision',
72
+ 'check_conventions',
73
+ 'check_intent',
74
+ 'get_constraints',
75
+ 'get_context_for_file',
76
+ 'get_decision',
77
+ 'search_decisions',
78
+ ]);
79
+ });
80
+
81
+ it('search_decisions calls client.searchDecisions with query and options', async () => {
82
+ await getTools(server)['search_decisions']!.handler({ query: 'which ORM do we use?' });
83
+ expect(client.searchDecisions).toHaveBeenCalledWith('which ORM do we use?', { limit: undefined, types: undefined });
84
+ });
85
+
86
+ it('search_decisions passes limit and types to client', async () => {
87
+ await getTools(server)['search_decisions']!.handler({ query: 'auth', limit: 3, types: ['constraint'] });
88
+ expect(client.searchDecisions).toHaveBeenCalledWith('auth', { limit: 3, types: ['constraint'] });
89
+ });
90
+
91
+ it('ask_knowledge_base formats answer with sources', async () => {
92
+ const result = await getTools(server)['ask_knowledge_base']!.handler({ question: 'which ORM?' });
93
+ expect(result.content[0]!.text).toContain('We use Drizzle ORM');
94
+ expect(result.content[0]!.text).toContain('ORM Decision');
95
+ });
96
+
97
+ it('get_constraints calls client.getConstraints', async () => {
98
+ await getTools(server)['get_constraints']!.handler({});
99
+ expect(client.getConstraints).toHaveBeenCalled();
100
+ });
101
+
102
+ it('check_conventions calls client.getConventions', async () => {
103
+ await getTools(server)['check_conventions']!.handler({});
104
+ expect(client.getConventions).toHaveBeenCalled();
105
+ });
106
+
107
+ it('get_decision formats the full decision body', async () => {
108
+ const result = await getTools(server)['get_decision']!.handler({ decisionId: 'd-1' });
109
+ expect(client.getDecision).toHaveBeenCalledWith('d-1');
110
+ expect(result.content[0]!.text).toContain('Use Drizzle ORM');
111
+ expect(result.content[0]!.text).toContain('Prisma');
112
+ });
113
+
114
+ it('get_context_for_file reads the file and forwards content + language to client', async () => {
115
+ const dir = join(tmpdir(), `decispher-tool-${randomUUID()}`);
116
+ await fs.mkdir(dir, { recursive: true });
117
+ const path = join(dir, 'auth.ts');
118
+ try {
119
+ await fs.writeFile(path, 'export const handler = () => {};');
120
+
121
+ await getTools(server)['get_context_for_file']!.handler({ filePath: path });
122
+
123
+ expect(client.getContextForFile).toHaveBeenCalledWith(path, {
124
+ fileContent: 'export const handler = () => {};',
125
+ language: 'typescript',
126
+ });
127
+ } finally {
128
+ await fs.rm(dir, { recursive: true, force: true });
129
+ }
130
+ });
131
+
132
+ it('get_context_for_file falls back to path-only when the file cannot be read', async () => {
133
+ const missing = join(tmpdir(), `decispher-missing-${randomUUID()}.ts`);
134
+ await getTools(server)['get_context_for_file']!.handler({ filePath: missing });
135
+ expect(client.getContextForFile).toHaveBeenCalledWith(missing, undefined);
136
+ });
137
+
138
+ it('capture_decision forwards the input and reports a captured id', async () => {
139
+ const result = await getTools(server)['capture_decision']!.handler({
140
+ type: 'convention',
141
+ title: 'Pino for logging',
142
+ decision: 'Use pino structured logging.',
143
+ rationale: 'Structured JSON gives us queryable logs.',
144
+ });
145
+ expect(client.captureDecision).toHaveBeenCalledWith(expect.objectContaining({
146
+ type: 'convention',
147
+ title: 'Pino for logging',
148
+ }));
149
+ expect(result.content[0]!.text).toContain('captured: cap-1');
150
+ });
151
+
152
+ it('capture_decision reports already-existing units distinctly', async () => {
153
+ client = makeClient({
154
+ captureDecision: vi.fn().mockResolvedValue({
155
+ id: 'cap-existing', alreadyExists: true, similarityToExisting: 0.973,
156
+ fusionPending: false, status: 'active', latencyMs: 7,
157
+ }),
158
+ });
159
+ const localServer = new McpServer({ name: 'test', version: '0.0.0' });
160
+ registerTools(localServer, client);
161
+
162
+ const result = await getTools(localServer)['capture_decision']!.handler({
163
+ type: 'decision',
164
+ title: 'duplicate',
165
+ decision: 'body',
166
+ rationale: 'why',
167
+ });
168
+ expect(result.content[0]!.text).toContain('already-known unit: cap-existing');
169
+ expect(result.content[0]!.text).toContain('0.973');
170
+ });
171
+ });
172
+
173
+ describe('WS-E — capabilities-driven registration', () => {
174
+ let client: DecispherClient;
175
+
176
+ beforeEach(() => {
177
+ client = makeClient();
178
+ });
179
+
180
+ it('only registers tools the server advertised in enabledTools', () => {
181
+ const server = new McpServer({ name: 'test', version: '0.0.0' });
182
+ registerTools(server, client, {
183
+ enabledTools: new Set(['search_decisions', 'check_intent']),
184
+ });
185
+ expect(Object.keys(getTools(server)).sort()).toEqual(['check_intent', 'search_decisions']);
186
+ });
187
+
188
+ it('uses server-provided descriptions when supplied', () => {
189
+ const server = new McpServer({ name: 'test', version: '0.0.0' });
190
+ registerTools(server, client, {
191
+ descriptions: { search_decisions: 'OVERRIDDEN by server /capabilities' },
192
+ });
193
+ expect(getTools(server)['search_decisions']!.description).toBe('OVERRIDDEN by server /capabilities');
194
+ });
195
+
196
+ it('falls back to embedded description when server omits it', () => {
197
+ const server = new McpServer({ name: 'test', version: '0.0.0' });
198
+ registerTools(server, client, { descriptions: {} });
199
+ const embedded = TOOL_DEFINITIONS.find((d) => d.name === 'get_constraints')!.defaultDescription;
200
+ expect(getTools(server)['get_constraints']!.description).toBe(embedded);
201
+ });
202
+
203
+ it('TOOL_DEFINITIONS includes capture_decision', () => {
204
+ const def = TOOL_DEFINITIONS.find((d) => d.name === 'capture_decision');
205
+ expect(def).toBeDefined();
206
+ expect(def!.defaultDescription).toContain('Write a new context unit');
207
+ expect(def!.defaultDescription).toContain('decision');
208
+ expect(def!.defaultDescription).toContain('convention');
209
+ expect(def!.defaultDescription).toContain('constraint');
210
+ });
211
+
212
+ it('TOOL_DEFINITIONS covers every wire tool name', () => {
213
+ const definedNames = TOOL_DEFINITIONS.map((d) => d.name).sort();
214
+ expect(definedNames).toEqual([
215
+ 'ask_knowledge_base',
216
+ 'capture_decision',
217
+ 'check_conventions',
218
+ 'check_intent',
219
+ 'get_constraints',
220
+ 'get_context_for_file',
221
+ 'get_decision',
222
+ 'search_decisions',
223
+ ]);
224
+ });
225
+ });
@@ -0,0 +1,466 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ export type FreshnessTier = 'fresh' | 'aging' | 'stale' | 'fossil';
4
+
5
+ export interface ConstraintSummary {
6
+ id: string;
7
+ title: string;
8
+ decision: string;
9
+ rationale: string | null;
10
+ severity: string;
11
+ }
12
+
13
+ export interface ConventionSummary {
14
+ id: string;
15
+ title: string;
16
+ decision: string;
17
+ rationale: string | null;
18
+ }
19
+
20
+ export interface AskResponse {
21
+ answer: string;
22
+ sources: Array<{
23
+ id: string;
24
+ title: string;
25
+ similarity: number;
26
+ lastReviewedAt?: string | null;
27
+ ageInDays?: number;
28
+ freshnessTier?: FreshnessTier;
29
+ }>;
30
+ hadContext: boolean;
31
+ contextUnits?: Array<{
32
+ id: string;
33
+ title: string;
34
+ type: string;
35
+ lastReviewedAt?: string | null;
36
+ ageInDays?: number;
37
+ freshnessTier?: FreshnessTier;
38
+ }>;
39
+ }
40
+
41
+ export interface ContextMatch {
42
+ id: string;
43
+ title: string;
44
+ type: string;
45
+ decision: string;
46
+ rationale: string | null;
47
+ similarity: number;
48
+ }
49
+
50
+ export type IntentVerdict = 'BLOCKED' | 'WARN' | 'CLEAR';
51
+
52
+ export interface ConflictSummary {
53
+ severity: string;
54
+ decisionId: string;
55
+ title: string;
56
+ explanation: string;
57
+ }
58
+
59
+ export interface RelevantSummary {
60
+ decisionId: string;
61
+ title: string;
62
+ type: string;
63
+ linkScore: number;
64
+ }
65
+
66
+ export interface IntentCheckResult {
67
+ verdict: IntentVerdict;
68
+ conflicts: ConflictSummary[];
69
+ relevant: RelevantSummary[];
70
+ latencyMs: number;
71
+ creditsConsumed: number;
72
+ }
73
+
74
+ interface EnvelopeBudget {
75
+ requestedMaxTokens: number | null;
76
+ estimatedResponseTokens: number;
77
+ truncated: boolean;
78
+ }
79
+
80
+ interface EnvelopeMeta {
81
+ tool: string;
82
+ latencyMs: number;
83
+ apiVersion: 'v1';
84
+ }
85
+
86
+ interface EnvelopeShape<T> {
87
+ data?: T;
88
+ citations?: unknown[];
89
+ budget?: EnvelopeBudget;
90
+ meta?: EnvelopeMeta;
91
+ }
92
+
93
+ export interface EnvelopeNotices {
94
+ truncated: boolean;
95
+ citationCount: number;
96
+ }
97
+
98
+ export interface DecisionDetail {
99
+ id: string;
100
+ type: string;
101
+ title: string;
102
+ problem: string | null;
103
+ decision: string;
104
+ rationale: string;
105
+ severity: string;
106
+ status: string;
107
+ alternatives: Array<{ option: string; reasonRejected: string }> | null;
108
+ affectedFiles: string[];
109
+ tags: string[];
110
+ sourceType: string | null;
111
+ supersedes: string | null;
112
+ supersededBy: string | null;
113
+ createdAt: string;
114
+ updatedAt: string;
115
+ lastReviewedAt: string | null;
116
+ }
117
+
118
+ export type CaptureContextUnitType =
119
+ | 'decision' | 'convention' | 'constraint' | 'rationale'
120
+ | 'ownership' | 'history' | 'plan';
121
+
122
+ export interface CaptureDecisionInput {
123
+ type: CaptureContextUnitType;
124
+ title: string;
125
+ decision: string;
126
+ rationale: string;
127
+ problem?: string;
128
+ severity?: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
129
+ affectedFiles?: string[];
130
+ tags?: string[];
131
+ alternatives?: Array<{ option: string; reasonRejected: string }>;
132
+ /** Agent-reported tokens spent discovering this knowledge. Capped server-side at 100k. */
133
+ discoveryCostTokens?: number;
134
+ }
135
+
136
+ export interface CaptureDecisionResult {
137
+ id: string;
138
+ alreadyExists: boolean;
139
+ similarityToExisting: number | null;
140
+ fusionPending: boolean;
141
+ status: 'active';
142
+ latencyMs: number;
143
+ }
144
+
145
+ // Sprint 4 / ADR-041 — Topic surface ────────────────────────────────────────
146
+
147
+ export interface TopicSummary {
148
+ readonly id: string;
149
+ readonly label: string;
150
+ readonly description: string | null;
151
+ readonly unitCount: number;
152
+ }
153
+
154
+ export interface TopicSpineUnit {
155
+ readonly id: string;
156
+ readonly type: string;
157
+ readonly title: string;
158
+ readonly severity: string;
159
+ readonly decision: string;
160
+ readonly rationale: string | null;
161
+ }
162
+
163
+ export interface TopicExpansionUnit {
164
+ readonly id: string;
165
+ readonly type: string;
166
+ readonly title: string;
167
+ readonly severity: string;
168
+ readonly decision?: string;
169
+ readonly rationale?: string | null;
170
+ }
171
+
172
+ export interface TopicContextResult {
173
+ readonly topicId: string;
174
+ readonly label: string;
175
+ readonly description: string | null;
176
+ readonly spine: readonly TopicSpineUnit[];
177
+ readonly expansion: readonly TopicExpansionUnit[];
178
+ readonly projectId: string | null;
179
+ readonly includeBodies: boolean;
180
+ readonly spineRequest: boolean;
181
+ }
182
+
183
+ export interface CapabilityTool {
184
+ name: string;
185
+ description: string;
186
+ costCredits?: number;
187
+ scopes?: string[];
188
+ defaultMaxTokens?: number;
189
+ projectScopedAware?: boolean;
190
+ status?: string;
191
+ }
192
+
193
+ export interface CapabilitiesResponse {
194
+ tools: CapabilityTool[];
195
+ apiKey: {
196
+ hasProjectScope: boolean;
197
+ projectId: string | null;
198
+ allowedScopes: string[];
199
+ };
200
+ apiVersion: string;
201
+ }
202
+
203
+ export interface HealthResponse {
204
+ status: string;
205
+ apiVersion: string;
206
+ commit: string;
207
+ }
208
+
209
+ export class DecispherClient {
210
+ private readonly baseUrl: string;
211
+ private readonly apiKey: string;
212
+ private readonly companyId: string;
213
+ private readonly sessionId: string;
214
+ /** Notices from the most recent envelope, surfaced by tools.ts in `_meta`. */
215
+ public lastNotices: EnvelopeNotices | null = null;
216
+
217
+ constructor() {
218
+ this.baseUrl = process.env['DECISPHER_API_URL'] ?? 'http://localhost:3000';
219
+ this.apiKey = process.env['DECISPHER_API_KEY'] ?? '';
220
+ this.companyId = process.env['DECISPHER_COMPANY_ID'] ?? '';
221
+ this.sessionId = process.env['DECISPHER_SESSION_ID'] ?? randomUUID();
222
+
223
+ if (!this.apiKey || !this.companyId) {
224
+ throw new Error('DECISPHER_API_KEY and DECISPHER_COMPANY_ID environment variables are required');
225
+ }
226
+ }
227
+
228
+ async searchDecisions(query: string, options?: { limit?: number; types?: string[] }): Promise<ContextMatch[]> {
229
+ const response = await this.post(`/api/companies/${this.companyId}/mcp/search-decisions`, {
230
+ query,
231
+ ...(options?.limit !== undefined ? { limit: options.limit } : {}),
232
+ ...(options?.types ? { types: options.types } : {}),
233
+ });
234
+ const data = await this.unwrap<{ results?: ContextMatch[] }>(response);
235
+ return data?.results ?? [];
236
+ }
237
+
238
+ async getConstraints(options?: { maxTokens?: number; cursor?: string; expand?: string[] }): Promise<ConstraintSummary[]> {
239
+ const response = await this.post(`/api/companies/${this.companyId}/mcp/get-constraints`, {
240
+ ...(options?.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
241
+ ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}),
242
+ ...(options?.expand ? { expand: options.expand } : {}),
243
+ });
244
+ const data = await this.unwrap<{ constraints?: ConstraintSummary[] }>(response);
245
+ return data?.constraints ?? [];
246
+ }
247
+
248
+ async getConventions(options?: { maxTokens?: number; cursor?: string; expand?: string[] }): Promise<ConventionSummary[]> {
249
+ const response = await this.post(`/api/companies/${this.companyId}/mcp/check-conventions`, {
250
+ ...(options?.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
251
+ ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}),
252
+ ...(options?.expand ? { expand: options.expand } : {}),
253
+ });
254
+ const data = await this.unwrap<{ conventions?: ConventionSummary[] }>(response);
255
+ return data?.conventions ?? [];
256
+ }
257
+
258
+ async ask(query: string): Promise<AskResponse> {
259
+ const response = await this.post(`/api/companies/${this.companyId}/mcp/ask-knowledge-base`, { query });
260
+ const data = await this.unwrap<AskResponse>(response);
261
+ if (!data) {
262
+ const status = response.ok ? 'empty response' : `HTTP ${response.status}`;
263
+ throw new Error(`ask_knowledge_base: Decispher API unreachable or returned an error (${status}). Cannot answer from knowledge base.`);
264
+ }
265
+ return data;
266
+ }
267
+
268
+ async getDecision(decisionId: string): Promise<DecisionDetail> {
269
+ const response = await this.post(`/api/companies/${this.companyId}/mcp/get-decision`, { decisionId });
270
+ const data = await this.unwrap<DecisionDetail>(response);
271
+ if (!data) {
272
+ const status = response.ok ? 'empty response' : `HTTP ${response.status}`;
273
+ throw new Error(`get_decision: Decispher API returned an error (${status}) for decision ${decisionId}.`);
274
+ }
275
+ return data;
276
+ }
277
+
278
+ async checkIntent(description: string, files?: string[]): Promise<IntentCheckResult> {
279
+ const response = await this.post(
280
+ `/api/companies/${this.companyId}/mcp/check-intent`,
281
+ { description, files },
282
+ );
283
+ const data = await this.unwrap<IntentCheckResult>(response);
284
+ if (!data) {
285
+ const status = response.ok ? 'empty response' : `HTTP ${response.status}`;
286
+ throw new Error(`check_intent: Decispher API unreachable or returned an error (${status}). Cannot verify intent — do not proceed without manual review.`);
287
+ }
288
+ return data;
289
+ }
290
+
291
+ async getContextForFiles(
292
+ files: Array<{ filePath: string; fileContent?: string; language?: string }>,
293
+ options?: { limit?: number },
294
+ ): Promise<ContextMatch[]> {
295
+ const response = await this.post(
296
+ `/api/companies/${this.companyId}/mcp/get-context-for-file`,
297
+ {
298
+ files: files.map((f) => ({
299
+ filePath: f.filePath,
300
+ ...(f.fileContent !== undefined ? { fileContent: f.fileContent } : {}),
301
+ ...(f.language !== undefined ? { language: f.language } : {}),
302
+ })),
303
+ ...(options?.limit !== undefined ? { limit: options.limit } : {}),
304
+ },
305
+ );
306
+ const data = await this.unwrap<{ matches?: ContextMatch[] }>(response);
307
+ return data?.matches ?? [];
308
+ }
309
+
310
+ async getContextForFile(
311
+ filePath: string,
312
+ options?: { fileContent?: string; language?: string; limit?: number },
313
+ ): Promise<ContextMatch[]> {
314
+ const response = await this.post(
315
+ `/api/companies/${this.companyId}/mcp/get-context-for-file`,
316
+ {
317
+ filePath,
318
+ ...(options?.fileContent !== undefined ? { fileContent: options.fileContent } : {}),
319
+ ...(options?.language !== undefined ? { language: options.language } : {}),
320
+ ...(options?.limit !== undefined ? { limit: options.limit } : {}),
321
+ },
322
+ );
323
+ const data = await this.unwrap<{ matches?: ContextMatch[] }>(response);
324
+ return data?.matches ?? [];
325
+ }
326
+
327
+ async listTopics(): Promise<TopicSummary[]> {
328
+ const response = await this.post(`/api/companies/${this.companyId}/mcp/list-topics`, {});
329
+ const data = await this.unwrap<{ topics?: TopicSummary[] }>(response);
330
+ return data?.topics ?? [];
331
+ }
332
+
333
+ async getContextForTopic(
334
+ topic: string,
335
+ options?: { includeBodies?: boolean; maxTokens?: number },
336
+ ): Promise<TopicContextResult> {
337
+ const response = await this.post(
338
+ `/api/companies/${this.companyId}/mcp/get-context-for-topic`,
339
+ {
340
+ topic,
341
+ ...(options?.includeBodies !== undefined ? { includeBodies: options.includeBodies } : {}),
342
+ ...(options?.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
343
+ },
344
+ );
345
+ if (response.status === 404) {
346
+ throw new Error(`get_context_for_topic: topic "${topic}" not found in this project's scope. Call list_topics for the available set.`);
347
+ }
348
+ const data = await this.unwrap<TopicContextResult>(response);
349
+ if (!data) {
350
+ const status = response.ok ? 'empty response' : `HTTP ${response.status}`;
351
+ throw new Error(`get_context_for_topic: Decispher API returned an error (${status}).`);
352
+ }
353
+ return data;
354
+ }
355
+
356
+ async captureDecision(input: CaptureDecisionInput): Promise<CaptureDecisionResult> {
357
+ const response = await this.post(
358
+ `/api/companies/${this.companyId}/mcp/capture-decision`,
359
+ input,
360
+ );
361
+ if (response.status === 403) {
362
+ throw new Error('capture_decision: agent capture is disabled by the company administrator.');
363
+ }
364
+ if (response.status === 429) {
365
+ const retryAfterMs = Number(response.headers.get('retry-after-ms') ?? '0');
366
+ throw new Error(
367
+ `capture_decision: rate limit exceeded — retry in ~${Math.ceil(retryAfterMs / 1000)}s.`,
368
+ );
369
+ }
370
+ const data = await this.unwrap<CaptureDecisionResult>(response);
371
+ if (!data) {
372
+ const status = response.ok ? 'empty response' : `HTTP ${response.status}`;
373
+ throw new Error(`capture_decision: Decispher API returned an error (${status}).`);
374
+ }
375
+ return data;
376
+ }
377
+
378
+ /**
379
+ * Probe `/api/mcp/health` before the first tool call — no auth required.
380
+ * Returns null on any network/parse failure so the caller can fall through
381
+ * to the static tool list instead of crashing the MCP server at startup.
382
+ */
383
+ async fetchHealth(): Promise<HealthResponse | null> {
384
+ try {
385
+ const response = await fetch(`${this.baseUrl}/api/mcp/health`, { method: 'GET' });
386
+ if (!response.ok) return null;
387
+ return (await response.json()) as HealthResponse;
388
+ } catch {
389
+ return null;
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Pull the tool registry from the server. Returns null if the request fails
395
+ * or the response shape is unrecognised — caller falls back to the embedded
396
+ * static tool list so the MCP server still works offline / against an older
397
+ * API that does not yet expose `/capabilities`.
398
+ */
399
+ async fetchCapabilities(): Promise<CapabilitiesResponse | null> {
400
+ try {
401
+ const response = await fetch(
402
+ `${this.baseUrl}/api/companies/${this.companyId}/mcp/capabilities`,
403
+ { method: 'GET', headers: this.headers() },
404
+ );
405
+ if (!response.ok) return null;
406
+ const parsed = (await response.json()) as Partial<CapabilitiesResponse> | null;
407
+ if (!parsed || !Array.isArray(parsed.tools) || !parsed.apiKey || typeof parsed.apiVersion !== 'string') {
408
+ return null;
409
+ }
410
+ return parsed as CapabilitiesResponse;
411
+ } catch {
412
+ return null;
413
+ }
414
+ }
415
+
416
+ /** Parse an MCP envelope; transparently passes through legacy un-wrapped responses. */
417
+ private async unwrap<T>(response: Response): Promise<T | null> {
418
+ if (!response.ok) {
419
+ this.lastNotices = null;
420
+ return null;
421
+ }
422
+ let parsed: unknown;
423
+ try {
424
+ parsed = await response.json();
425
+ } catch {
426
+ this.lastNotices = null;
427
+ return null;
428
+ }
429
+ if (this.isEnvelope<T>(parsed)) {
430
+ this.lastNotices = {
431
+ truncated: parsed.budget?.truncated ?? false,
432
+ citationCount: parsed.citations?.length ?? 0,
433
+ };
434
+ return (parsed.data ?? null) as T | null;
435
+ }
436
+ this.lastNotices = null;
437
+ return parsed as T;
438
+ }
439
+
440
+ private isEnvelope<T>(value: unknown): value is EnvelopeShape<T> {
441
+ if (typeof value !== 'object' || value === null) return false;
442
+ const v = value as Record<string, unknown>;
443
+ return 'data' in v && 'meta' in v && 'budget' in v;
444
+ }
445
+
446
+ private async post(path: string, body: unknown): Promise<Response> {
447
+ try {
448
+ return await fetch(`${this.baseUrl}${path}`, {
449
+ method: 'POST',
450
+ headers: this.headers(),
451
+ body: JSON.stringify(body),
452
+ });
453
+ } catch (err) {
454
+ const msg = err instanceof Error ? err.message : String(err);
455
+ throw new Error(`Decispher API unreachable at ${this.baseUrl} — ${msg}`);
456
+ }
457
+ }
458
+
459
+ private headers(): Record<string, string> {
460
+ return {
461
+ 'Content-Type': 'application/json',
462
+ 'x-api-key': this.apiKey,
463
+ 'x-decispher-session-id': this.sessionId,
464
+ };
465
+ }
466
+ }