@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.
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { DecispherClient } from './decispher-client.js';
5
+ import { registerTools, type RegisterToolsOptions } from './tools.js';
6
+
7
+ async function resolveRegistration(client: DecispherClient): Promise<RegisterToolsOptions> {
8
+ const caps = await client.fetchCapabilities();
9
+ if (!caps) {
10
+ process.stderr.write(
11
+ 'Decispher MCP: /capabilities unreachable — registering embedded tool list.\n',
12
+ );
13
+ return {};
14
+ }
15
+
16
+ const enabledTools = new Set(caps.tools.map((t) => t.name));
17
+ const descriptions = Object.fromEntries(caps.tools.map((t) => [t.name, t.description]));
18
+ process.stderr.write(
19
+ `Decispher MCP: loaded ${caps.tools.length} tools from /capabilities (apiVersion=${caps.apiVersion}).\n`,
20
+ );
21
+ return { enabledTools, descriptions };
22
+ }
23
+
24
+ async function main(): Promise<void> {
25
+ const client = new DecispherClient();
26
+
27
+ const server = new McpServer({
28
+ name: 'decispher',
29
+ version: '0.1.0',
30
+ });
31
+
32
+ const options = await resolveRegistration(client);
33
+ registerTools(server, client, options);
34
+
35
+ const transport = new StdioServerTransport();
36
+ await server.connect(transport);
37
+ }
38
+
39
+ main().catch((error: unknown) => {
40
+ process.stderr.write(`Decispher MCP server error: ${String(error)}\n`);
41
+ process.exit(1);
42
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * WS-F — Client-side file reader for `get_context_for_file`.
3
+ *
4
+ * Reads the file from the developer's disk before calling the API so the
5
+ * server can embed the actual source (not just the path). Cap at 256 KB;
6
+ * over-cap files are sliced head-then-tail and flagged `truncated: true` so
7
+ * the server-side `FileSkeletonBuilder` (tree-sitter / regex extractors,
8
+ * WS-A) still has a representative chunk to extract symbols from.
9
+ *
10
+ * Binary files and unreadable paths return `null` — the caller passes only
11
+ * the path and the API falls back to path-only embedding (the legacy
12
+ * behaviour). We never throw out of this module: a bad file read should
13
+ * degrade gracefully, not surface as an MCP tool error to the agent.
14
+ */
15
+
16
+ import { promises as fs } from 'node:fs';
17
+ import { resolve, extname } from 'node:path';
18
+
19
+ export const DEFAULT_FILE_READ_MAX_BYTES = 256 * 1024;
20
+
21
+ export interface FileReadResult {
22
+ readonly content: string;
23
+ readonly language: string | null;
24
+ readonly truncated: boolean;
25
+ readonly bytesRead: number;
26
+ }
27
+
28
+ export interface FileReadOptions {
29
+ readonly maxBytes?: number;
30
+ /** Override the working directory used for resolving relative paths. */
31
+ readonly cwd?: string;
32
+ }
33
+
34
+ const TRUNCATION_MARKER = '\n\n/* … truncated by MCP file-reader … */\n\n';
35
+ const TRUNCATION_MARKER_BYTES = Buffer.byteLength(TRUNCATION_MARKER, 'utf8');
36
+ const HEAD_FRACTION = 0.75;
37
+ const UTF8_BOM = '';
38
+
39
+ // Extension → language tag understood by tree-sitter-wasms on the server.
40
+ // Anything unknown returns null and the server falls back to regex extractors.
41
+ const EXTENSION_TO_LANGUAGE: Readonly<Record<string, string>> = {
42
+ '.ts': 'typescript',
43
+ '.tsx': 'tsx',
44
+ '.cts': 'typescript',
45
+ '.mts': 'typescript',
46
+ '.js': 'javascript',
47
+ '.jsx': 'javascript',
48
+ '.cjs': 'javascript',
49
+ '.mjs': 'javascript',
50
+ '.py': 'python',
51
+ '.pyi': 'python',
52
+ '.go': 'go',
53
+ '.rs': 'rust',
54
+ '.java': 'java',
55
+ '.rb': 'ruby',
56
+ '.php': 'php',
57
+ '.cs': 'csharp',
58
+ '.kt': 'kotlin',
59
+ '.swift':'swift',
60
+ '.c': 'c',
61
+ '.h': 'c',
62
+ '.cc': 'cpp',
63
+ '.cpp': 'cpp',
64
+ '.hpp': 'cpp',
65
+ };
66
+
67
+ export function detectLanguage(filePath: string): string | null {
68
+ return EXTENSION_TO_LANGUAGE[extname(filePath).toLowerCase()] ?? null;
69
+ }
70
+
71
+ function looksBinary(sample: Buffer): boolean {
72
+ // Heuristic identical to git's: a NUL byte anywhere in the first 8 KB
73
+ // means it's binary. Catches images / archives / compiled binaries
74
+ // without misclassifying UTF-8 source code with multi-byte runes.
75
+ const probeLength = Math.min(sample.length, 8 * 1024);
76
+ for (let i = 0; i < probeLength; i++) {
77
+ if (sample[i] === 0x00) return true;
78
+ }
79
+ return false;
80
+ }
81
+
82
+ function stripBom(text: string): string {
83
+ return text.startsWith(UTF8_BOM) ? text.slice(UTF8_BOM.length) : text;
84
+ }
85
+
86
+ export async function readFileForContext(
87
+ filePath: string,
88
+ opts: FileReadOptions = {},
89
+ ): Promise<FileReadResult | null> {
90
+ const maxBytes = opts.maxBytes ?? DEFAULT_FILE_READ_MAX_BYTES;
91
+ const absolute = resolve(opts.cwd ?? process.cwd(), filePath);
92
+
93
+ let stat: Awaited<ReturnType<typeof fs.stat>>;
94
+ try {
95
+ stat = await fs.stat(absolute);
96
+ } catch {
97
+ return null;
98
+ }
99
+ if (!stat.isFile()) return null;
100
+
101
+ const size = stat.size;
102
+ const language = detectLanguage(filePath);
103
+
104
+ let handle: Awaited<ReturnType<typeof fs.open>>;
105
+ try {
106
+ handle = await fs.open(absolute, 'r');
107
+ } catch {
108
+ return null;
109
+ }
110
+
111
+ try {
112
+ if (size <= maxBytes) {
113
+ const buf = Buffer.alloc(size);
114
+ await handle.read(buf, 0, size, 0);
115
+ if (looksBinary(buf)) return null;
116
+ return {
117
+ content: stripBom(buf.toString('utf8')),
118
+ language,
119
+ truncated: false,
120
+ bytesRead: size,
121
+ };
122
+ }
123
+
124
+ // Reserve room for the marker so the emitted string stays ≤ maxBytes —
125
+ // the server route caps fileContent at exactly 256 KB and rejects more.
126
+ const budget = Math.max(maxBytes - TRUNCATION_MARKER_BYTES, 0);
127
+ const headBytes = Math.floor(budget * HEAD_FRACTION);
128
+ const tailBytes = budget - headBytes;
129
+
130
+ const head = Buffer.alloc(headBytes);
131
+ await handle.read(head, 0, headBytes, 0);
132
+ if (looksBinary(head)) return null;
133
+
134
+ const tail = Buffer.alloc(tailBytes);
135
+ await handle.read(tail, 0, tailBytes, size - tailBytes);
136
+
137
+ const content = stripBom(head.toString('utf8')) + TRUNCATION_MARKER + tail.toString('utf8');
138
+ return {
139
+ content,
140
+ language,
141
+ truncated: true,
142
+ bytesRead: Buffer.byteLength(content, 'utf8'),
143
+ };
144
+ } catch {
145
+ return null;
146
+ } finally {
147
+ await handle.close().catch(() => { /* swallow */ });
148
+ }
149
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,386 @@
1
+ import { z, type ZodTypeAny } from 'zod';
2
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import type {
4
+ DecispherClient, DecisionDetail,
5
+ CaptureDecisionInput, CaptureContextUnitType,
6
+ } from './decispher-client.js';
7
+ import { readFileForContext } from './lib/file-reader.js';
8
+
9
+ const CAPTURE_TYPE_VALUES: readonly CaptureContextUnitType[] = [
10
+ 'decision', 'convention', 'constraint', 'rationale',
11
+ 'ownership', 'history', 'plan',
12
+ ];
13
+
14
+ function buildResult(text: string, client: DecispherClient, extraMeta?: Record<string, unknown>) {
15
+ const notices = client.lastNotices;
16
+ const prefix = notices?.truncated
17
+ ? '⚠️ Result was truncated to fit the token budget. Refine the query or request a smaller scope.\n\n'
18
+ : '';
19
+ const meta: Record<string, unknown> = { ...extraMeta };
20
+ if (notices) {
21
+ meta['decispher'] = {
22
+ truncated: notices.truncated,
23
+ citationCount: notices.citationCount,
24
+ };
25
+ }
26
+ return {
27
+ content: [{ type: 'text' as const, text: `${prefix}${text}` }],
28
+ ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
29
+ };
30
+ }
31
+
32
+ type ToolHandler = (args: Record<string, unknown>, client: DecispherClient) => Promise<ReturnType<typeof buildResult>>;
33
+
34
+ interface ToolDefinition {
35
+ readonly name: string;
36
+ readonly defaultDescription: string;
37
+ readonly inputSchema: Record<string, ZodTypeAny>;
38
+ readonly handler: ToolHandler;
39
+ }
40
+
41
+ const askToolHandler: ToolHandler = async (args, client) => {
42
+ const { question } = args as { question: string };
43
+ const result = await client.ask(question);
44
+
45
+ const freshnessNote = (tier?: string, ageInDays?: number): string => {
46
+ if (!tier || tier === 'fresh') return '';
47
+ if (tier === 'fossil') return ` ⚠️ fossil (${ageInDays}d — last reviewed over a year ago; verify before relying on this)`;
48
+ if (tier === 'stale') return ` ⚠️ stale (${ageInDays}d)`;
49
+ if (tier === 'aging') return ` ⏳ aging (${ageInDays}d)`;
50
+ return '';
51
+ };
52
+
53
+ const sourceList = result.sources
54
+ .map((s) => `- ${s.title} (similarity: ${(s.similarity * 100).toFixed(0)}%)${freshnessNote(s.freshnessTier, s.ageInDays)}`)
55
+ .join('\n');
56
+
57
+ return buildResult(
58
+ `${result.answer}${sourceList ? `\n\nSources:\n${sourceList}` : ''}`,
59
+ client,
60
+ {
61
+ contextUnits: result.contextUnits ?? result.sources.map((s) => ({
62
+ id: s.id,
63
+ title: s.title,
64
+ lastReviewedAt: s.lastReviewedAt,
65
+ ageInDays: s.ageInDays,
66
+ freshnessTier: s.freshnessTier,
67
+ })),
68
+ },
69
+ );
70
+ };
71
+
72
+ const checkIntentHandler: ToolHandler = async (args, client) => {
73
+ const { description, files } = args as { description: string; files?: string[] };
74
+ const result = await client.checkIntent(description, files);
75
+
76
+ const lines: string[] = [`verdict: ${result.verdict}`];
77
+
78
+ if (result.conflicts.length > 0) {
79
+ lines.push(
80
+ '\nconflicts:',
81
+ ...result.conflicts.map((c) => ` [${c.severity}] ${c.title}\n ${c.explanation}`),
82
+ );
83
+ }
84
+
85
+ if (result.relevant.length > 0) {
86
+ lines.push(
87
+ '\nrelevant context:',
88
+ ...result.relevant.map((r) => ` - [${r.type}] ${r.title}`),
89
+ );
90
+ }
91
+
92
+ return buildResult(lines.join('\n'), client);
93
+ };
94
+
95
+ /**
96
+ * Tool definitions — input schemas + handlers live here because the wire
97
+ * protocol with the MCP runtime is typed via Zod and the call body shapes
98
+ * are code-defined. Descriptions can be overridden at registration time by
99
+ * the server's /capabilities response (WS-E) so docs and gating live in
100
+ * one place server-side, but the executable bits stay client-side.
101
+ */
102
+ export const TOOL_DEFINITIONS: ReadonlyArray<ToolDefinition> = [
103
+ {
104
+ name: 'search_decisions',
105
+ defaultDescription:
106
+ 'Semantic search across the team\'s context units (decisions, conventions, constraints, rationale, ownership, history, plans). Returns the units most similar to the query.',
107
+ inputSchema: {
108
+ query: z.string().describe('Search query — can be a question, topic, file path, or code snippet'),
109
+ limit: z.number().int().min(1).max(10).optional().describe('Maximum number of results to return (1–10, default 5)'),
110
+ types: z.array(z.enum(CAPTURE_TYPE_VALUES as unknown as [CaptureContextUnitType, ...CaptureContextUnitType[]]))
111
+ .min(1).max(7).optional()
112
+ .describe('Filter to one or more of the 7 context unit types: decision, convention, constraint, rationale, ownership, history, plan. Omit to search all types.'),
113
+ },
114
+ handler: async (args, client) => {
115
+ const { query, limit, types } = args as { query: string; limit?: number; types?: string[] };
116
+ const matches = await client.searchDecisions(query, { limit, types });
117
+ return buildResult(JSON.stringify(matches, null, 2), client);
118
+ },
119
+ },
120
+ {
121
+ name: 'get_constraints',
122
+ defaultDescription:
123
+ 'Get all active architectural constraints — rules the team has decided must not be violated (e.g. "Do not use Express.js", "All DB access via Repository pattern").',
124
+ inputSchema: {
125
+ maxTokens: z.number().int().min(100).max(32000).optional().describe('Token budget for the response body. When the full list exceeds this, items are summarised (body dropped) and a cursor is returned.'),
126
+ cursor: z.string().optional().describe('Opaque pagination cursor returned by a previous truncated call. Resumes from the next page.'),
127
+ expand: z.array(z.string()).optional().describe('List of constraint IDs whose full body should always be included even when summarising the rest.'),
128
+ },
129
+ handler: async (args, client) => {
130
+ const { maxTokens, cursor, expand } = args as { maxTokens?: number; cursor?: string; expand?: string[] };
131
+ const constraints = await client.getConstraints({ maxTokens, cursor, expand });
132
+ return buildResult(JSON.stringify(constraints, null, 2), client);
133
+ },
134
+ },
135
+ {
136
+ name: 'check_conventions',
137
+ defaultDescription:
138
+ 'Get all active coding conventions for this codebase. Use before writing new code to ensure you follow established patterns.',
139
+ inputSchema: {
140
+ maxTokens: z.number().int().min(100).max(32000).optional().describe('Token budget for the response body. When the full list exceeds this, items are summarised (body dropped) and a cursor is returned.'),
141
+ cursor: z.string().optional().describe('Opaque pagination cursor returned by a previous truncated call. Resumes from the next page.'),
142
+ expand: z.array(z.string()).optional().describe('List of convention IDs whose full body should always be included even when summarising the rest.'),
143
+ },
144
+ handler: async (args, client) => {
145
+ const { maxTokens, cursor, expand } = args as { maxTokens?: number; cursor?: string; expand?: string[] };
146
+ const conventions = await client.getConventions({ maxTokens, cursor, expand });
147
+ return buildResult(JSON.stringify(conventions, null, 2), client);
148
+ },
149
+ },
150
+ {
151
+ name: 'ask_knowledge_base',
152
+ defaultDescription:
153
+ 'Ask a natural language question about the codebase, architecture, or past decisions. Returns an AI-synthesized answer with cited sources from the knowledge base.',
154
+ inputSchema: {
155
+ question: z.string().describe('Your question about the codebase, architecture, or past decisions'),
156
+ },
157
+ handler: askToolHandler,
158
+ },
159
+ {
160
+ name: 'get_context_for_file',
161
+ defaultDescription:
162
+ 'Get relevant context units for a file (or up to 10 files in one call). Use before editing — pass a single `filePath` for one file, or `filePaths` (array) when refactoring a feature that spans multiple files. The MCP client reads each file from disk (256 KB cap per file; over-cap files are sliced head + tail) and the server fuses results into one ranked list (max similarity per match across files).',
163
+ inputSchema: {
164
+ filePath: z.string().optional().describe('Relative file path from the repo root (e.g. "src/services/auth.ts"). Use this for a single file; otherwise use filePaths.'),
165
+ filePaths: z.array(z.string()).min(1).max(10).optional().describe('Array of 1–10 relative file paths. Use this when a task spans multiple files; the server returns one fused, deduplicated, ranked match list — saves the round-trips of calling the tool N times.'),
166
+ limit: z.number().int().min(1).max(10).optional().describe('Maximum number of context matches to return (1–10, default 5)'),
167
+ },
168
+ handler: async (args, client) => {
169
+ const { filePath, filePaths, limit } = args as {
170
+ filePath?: string;
171
+ filePaths?: string[];
172
+ limit?: number;
173
+ };
174
+
175
+ const paths = filePaths && filePaths.length > 0
176
+ ? filePaths
177
+ : filePath
178
+ ? [filePath]
179
+ : null;
180
+ if (!paths) {
181
+ throw new Error('get_context_for_file: provide either `filePath` (single) or `filePaths` (1–10).');
182
+ }
183
+
184
+ const reads = await Promise.all(paths.map(async (p) => ({ path: p, read: await readFileForContext(p) })));
185
+ const files = reads.map(({ path, read }) => ({
186
+ filePath: path,
187
+ ...(read ? { fileContent: read.content, ...(read.language ? { language: read.language } : {}) } : {}),
188
+ }));
189
+
190
+ // Build the single-file options object only if at least one field is
191
+ // present — otherwise pass `undefined` so the wire contract matches
192
+ // the legacy "path-only" call shape (the client treats undefined +
193
+ // {} identically, but tests + downstream observers can tell them
194
+ // apart).
195
+ const singleOpts = paths.length === 1
196
+ ? (() => {
197
+ const opts: { fileContent?: string; language?: string; limit?: number } = {};
198
+ if (files[0]!.fileContent !== undefined) opts.fileContent = files[0]!.fileContent;
199
+ if (files[0]!.language !== undefined) opts.language = files[0]!.language;
200
+ if (limit !== undefined) opts.limit = limit;
201
+ return Object.keys(opts).length > 0 ? opts : undefined;
202
+ })()
203
+ : undefined;
204
+
205
+ const matches = paths.length === 1
206
+ ? await client.getContextForFile(paths[0]!, singleOpts)
207
+ : await client.getContextForFiles(files, limit !== undefined ? { limit } : undefined);
208
+
209
+ // Preserve the legacy single-file `_meta.fileRead` shape when called
210
+ // with a single path so existing consumers don't break. Batch calls
211
+ // get the new `_meta.fileReads` array.
212
+ if (paths.length === 1) {
213
+ const r = reads[0]!.read;
214
+ return buildResult(JSON.stringify(matches, null, 2), client, {
215
+ fileRead: r
216
+ ? { truncated: r.truncated, bytesRead: r.bytesRead, language: r.language }
217
+ : { truncated: false, bytesRead: 0, language: null, fallback: 'path-only' },
218
+ });
219
+ }
220
+
221
+ const fileReads = reads.map(({ path, read }) => ({
222
+ filePath: path,
223
+ ...(read
224
+ ? { truncated: read.truncated, bytesRead: read.bytesRead, language: read.language }
225
+ : { truncated: false, bytesRead: 0, language: null, fallback: 'path-only' }),
226
+ }));
227
+
228
+ return buildResult(JSON.stringify(matches, null, 2), client, { fileReads });
229
+ },
230
+ },
231
+ {
232
+ name: 'list_topics',
233
+ defaultDescription:
234
+ 'List all topics available in this project — stable canonical slugs from the team\'s taxonomy. Free; the discovery primitive of the list_topics → get_context_for_topic → get_decision chain. Use this when overview.md predates a newly-added topic or when you need to confirm a topic id before calling get_context_for_topic.',
235
+ inputSchema: {},
236
+ handler: async (_args, client) => {
237
+ const topics = await client.listTopics();
238
+ return buildResult(JSON.stringify(topics, null, 2), client);
239
+ },
240
+ },
241
+ {
242
+ name: 'get_context_for_topic',
243
+ defaultDescription:
244
+ 'Fetch the curated context cluster for a topic. Returns spine units inline (always-load CRITICAL rules) plus the IDs and titles of expansion units — call get_decision with any of those IDs for the full body. Use this when you are about to write code that touches a topic; it gives you everything the team has decided about that area without per-unit fetch churn. Pass `includeBodies: true` to inline expansion bodies in one shot (larger payload, higher cost).',
245
+ inputSchema: {
246
+ topic: z.string().min(1).max(128).describe('Topic ID — get from list_topics or from .decispher/overview.md'),
247
+ includeBodies: z.boolean().optional().describe('When true, includes full expansion bodies inline (more expensive). Default false.'),
248
+ maxTokens: z.number().int().min(500).max(32000).optional().describe('Token budget for the response. Default 8000.'),
249
+ },
250
+ handler: async (args, client) => {
251
+ const { topic, includeBodies, maxTokens } = args as {
252
+ topic: string;
253
+ includeBodies?: boolean;
254
+ maxTokens?: number;
255
+ };
256
+ const result = await client.getContextForTopic(topic, {
257
+ ...(includeBodies !== undefined ? { includeBodies } : {}),
258
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
259
+ });
260
+ return buildResult(JSON.stringify(result, null, 2), client);
261
+ },
262
+ },
263
+ {
264
+ name: 'get_decision',
265
+ defaultDescription:
266
+ 'Fetch the full body of a specific context unit by ID. Every other MCP tool returns '
267
+ + 'IDs (search_decisions, get_constraints, check_conventions, ask_knowledge_base, '
268
+ + 'get_context_for_file, check_intent conflicts/relevant). Use this when you need '
269
+ + 'the complete rationale, alternatives, and affected files for any of those IDs.',
270
+ inputSchema: {
271
+ decisionId: z.string().min(1).describe('The ID of the context unit to fetch (from conflicts[].decisionId or search results)'),
272
+ },
273
+ handler: async (args, client) => {
274
+ const { decisionId } = args as { decisionId: string };
275
+ const detail = await client.getDecision(decisionId);
276
+ const lines: string[] = [
277
+ `[${detail.type}] ${detail.title}`,
278
+ `severity: ${detail.severity} status: ${detail.status}`,
279
+ ];
280
+ if (detail.problem) lines.push(`\nproblem:\n${detail.problem}`);
281
+ lines.push(`\ndecision:\n${detail.decision}`);
282
+ if (detail.rationale) lines.push(`\nrationale:\n${detail.rationale}`);
283
+ if (detail.alternatives?.length) {
284
+ lines.push('\nalternatives considered:');
285
+ for (const alt of detail.alternatives as DecisionDetail['alternatives'] & Array<{ option: string; reasonRejected: string }>) {
286
+ lines.push(` - ${alt.option} (rejected: ${alt.reasonRejected})`);
287
+ }
288
+ }
289
+ if (detail.affectedFiles.length) lines.push(`\naffected files: ${detail.affectedFiles.join(', ')}`);
290
+ if (detail.tags.length) lines.push(`tags: ${detail.tags.join(', ')}`);
291
+ if (detail.supersedes) lines.push(`supersedes: ${detail.supersedes}`);
292
+ if (detail.supersededBy) lines.push(`superseded by: ${detail.supersededBy}`);
293
+ return buildResult(lines.join('\n'), client);
294
+ },
295
+ },
296
+ {
297
+ name: 'check_intent',
298
+ defaultDescription:
299
+ 'Check if a planned action conflicts with team constraints before proceeding. '
300
+ + 'Call this BEFORE generating code or making architectural changes. '
301
+ + 'Returns BLOCKED (hard conflict — stop), WARN (tension — proceed with caution), or CLEAR (no known conflicts).',
302
+ inputSchema: {
303
+ description: z.string().describe('What you are about to do — be specific (e.g. "Replace Redis cache with in-memory Map in src/cache/client.ts")'),
304
+ files: z.array(z.string()).optional().describe('Files you will touch — helps scope the conflict search'),
305
+ },
306
+ handler: checkIntentHandler,
307
+ },
308
+ {
309
+ name: 'capture_decision',
310
+ defaultDescription:
311
+ 'Write a new context unit back into the team\'s knowledge base. Use ONLY for '
312
+ + 'durable, reusable knowledge worth recalling next session — never to dump scratch '
313
+ + 'notes or restate what is already in the repository.\n\n'
314
+ + 'Choose ONE `type`:\n'
315
+ + ' • decision — "We chose X over Y because Z" (an architectural pick + rationale)\n'
316
+ + ' • convention — "We always do X this way" (a coding/process pattern the team follows)\n'
317
+ + ' • constraint — "We can\'t do X because Y" (a hard rule that must not be violated)\n'
318
+ + ' • rationale — "X works this way because Y" (the reason behind existing behaviour)\n'
319
+ + ' • ownership — "Person/team X owns Y" (who is responsible for what)\n'
320
+ + ' • history — "We tried X, it failed because Y" (a past attempt and its outcome)\n'
321
+ + ' • plan — "We\'re going to do X in the future" (a committed roadmap item)\n\n'
322
+ + 'If a near-duplicate already exists (cosine ≥ 0.95), the existing unit\'s id is '
323
+ + 'returned with `alreadyExists: true` and nothing is inserted. Captures are tagged '
324
+ + '`sourceType=agent_capture` and shown distinctly to humans in the dashboard.',
325
+ inputSchema: {
326
+ type: z.enum(CAPTURE_TYPE_VALUES as unknown as [CaptureContextUnitType, ...CaptureContextUnitType[]])
327
+ .describe('Which of the 7 context unit types this capture is. Pick the most precise fit.'),
328
+ title: z.string().min(1).max(200).describe('Short, declarative summary (e.g. "Use Drizzle ORM, not Prisma")'),
329
+ decision: z.string().min(1).max(4000).describe('The substance of the knowledge — what was decided / observed / committed'),
330
+ rationale: z.string().min(1).max(4000).describe('WHY this is the case — the reasoning, evidence, or constraint that drove it'),
331
+ problem: z.string().max(4000).optional().describe('Optional — the problem this addresses, if framing as a decision'),
332
+ severity: z.enum(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']).optional().describe('Impact if violated. Defaults to MEDIUM.'),
333
+ affectedFiles: z.array(z.string()).max(50).optional().describe('File paths this knowledge applies to (helps future retrieval target the right files)'),
334
+ tags: z.array(z.string()).max(20).optional().describe('Free-form tags for search/grouping (e.g. ["auth", "session"])'),
335
+ alternatives: z.array(z.object({
336
+ option: z.string().min(1).max(200),
337
+ reasonRejected: z.string().min(1).max(500),
338
+ })).max(10).optional().describe('Alternatives considered and why they were rejected — preserves the trade-off history'),
339
+ discoveryCostTokens: z.number().min(0).max(100000).optional().describe('How many tokens you spent to discover this. Capped at 100k; defaults to a type-based estimate.'),
340
+ },
341
+ handler: async (args, client) => {
342
+ const result = await client.captureDecision(args as unknown as CaptureDecisionInput);
343
+ const line = result.alreadyExists
344
+ ? `already-known unit: ${result.id} (cosine ${(result.similarityToExisting ?? 0).toFixed(3)} ≥ 0.95). No new row created.`
345
+ : `captured: ${result.id} (status=active, fusionPending=${result.fusionPending})`;
346
+ return buildResult(line, client);
347
+ },
348
+ },
349
+ ];
350
+
351
+ export interface RegisterToolsOptions {
352
+ /**
353
+ * Filter to tools the server's /capabilities response advertised. When
354
+ * provided, any tool whose name isn't in this list is skipped. When
355
+ * omitted, all known tools are registered (offline-safe fallback).
356
+ */
357
+ readonly enabledTools?: ReadonlySet<string>;
358
+ /**
359
+ * Per-tool description override — server's /capabilities response wins so
360
+ * the agent sees the canonical copy maintained server-side.
361
+ */
362
+ readonly descriptions?: Readonly<Record<string, string>>;
363
+ }
364
+
365
+ export function registerTools(
366
+ server: McpServer,
367
+ client: DecispherClient,
368
+ options: RegisterToolsOptions = {},
369
+ ): void {
370
+ const { enabledTools, descriptions } = options;
371
+
372
+ for (const def of TOOL_DEFINITIONS) {
373
+ if (enabledTools && !enabledTools.has(def.name)) continue;
374
+
375
+ const description = descriptions?.[def.name] ?? def.defaultDescription;
376
+
377
+ server.registerTool(
378
+ def.name,
379
+ {
380
+ description,
381
+ inputSchema: z.object(def.inputSchema),
382
+ },
383
+ async (args: Record<string, unknown>) => def.handler(args, client),
384
+ );
385
+ }
386
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['src/**/*.test.ts'],
7
+ },
8
+ });