@telvok/librarian-mcp 1.0.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,143 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import matter from 'gray-matter';
4
+ import { getLibraryPath, getImportedPath, getLocalPath } from '../library/storage.js';
5
+ // ============================================================================
6
+ // Tool Definition
7
+ // ============================================================================
8
+ export const adoptTool = {
9
+ name: 'adopt',
10
+ description: `Make imported knowledge ours.
11
+
12
+ When an entry from an imported package proves useful, adopt it into our
13
+ local library. It graduates from "their knowledge" to "our knowledge" -
14
+ now we can edit and evolve it.
15
+
16
+ Examples:
17
+ - adopt({ path: "imported/stripe-patterns/webhook-idempotency" })
18
+ - adopt({ path: "imported/auth-patterns/token-refresh", title: "Our token refresh" })`,
19
+ inputSchema: {
20
+ type: 'object',
21
+ properties: {
22
+ path: {
23
+ type: 'string',
24
+ description: "Path to entry to adopt (e.g., 'imported/package-name/entry-name')",
25
+ },
26
+ title: {
27
+ type: 'string',
28
+ description: 'New title for adopted entry. Keeps original if not provided.',
29
+ },
30
+ },
31
+ required: ['path'],
32
+ },
33
+ async handler(args) {
34
+ const { path: entryPath, title: newTitle } = args;
35
+ if (!entryPath) {
36
+ throw new Error('path is required');
37
+ }
38
+ const libraryPath = getLibraryPath();
39
+ const importedPath = getImportedPath(libraryPath);
40
+ const localPath = getLocalPath(libraryPath);
41
+ // Normalize path: strip "imported/" prefix if present, add .md if missing
42
+ let normalizedPath = entryPath;
43
+ if (normalizedPath.startsWith('imported/')) {
44
+ normalizedPath = normalizedPath.slice('imported/'.length);
45
+ }
46
+ if (!normalizedPath.endsWith('.md')) {
47
+ normalizedPath += '.md';
48
+ }
49
+ const sourcePath = path.join(importedPath, normalizedPath);
50
+ // Check if source exists
51
+ try {
52
+ await fs.access(sourcePath);
53
+ }
54
+ catch {
55
+ throw new Error(`Entry not found: ${entryPath}`);
56
+ }
57
+ // Read source file
58
+ const content = await fs.readFile(sourcePath, 'utf-8');
59
+ const { data, content: body } = matter(content);
60
+ // Extract package name from path
61
+ const pathParts = normalizedPath.split('/');
62
+ const packageName = pathParts[0];
63
+ // Determine title
64
+ let title = newTitle;
65
+ if (!title) {
66
+ // Try to extract from frontmatter or H1
67
+ title = data.title;
68
+ if (!title) {
69
+ const headingMatch = body.match(/^#\s+(.+)$/m);
70
+ if (headingMatch) {
71
+ title = headingMatch[1].trim();
72
+ }
73
+ else {
74
+ title = path.basename(sourcePath, '.md').replace(/-/g, ' ');
75
+ }
76
+ }
77
+ }
78
+ // Generate slug for new filename
79
+ const slug = slugify(title);
80
+ const now = new Date().toISOString();
81
+ // Ensure local directory exists
82
+ await fs.mkdir(localPath, { recursive: true });
83
+ // Handle filename collisions
84
+ let filename = `${slug}.md`;
85
+ let destPath = path.join(localPath, filename);
86
+ let counter = 1;
87
+ while (await fileExists(destPath)) {
88
+ filename = `${slug}-${counter}.md`;
89
+ destPath = path.join(localPath, filename);
90
+ counter++;
91
+ }
92
+ // Build new frontmatter
93
+ const newFrontmatter = {
94
+ ...data,
95
+ updated: now,
96
+ source: `adopted from ${packageName}`,
97
+ };
98
+ // Update title in frontmatter if changed
99
+ if (newTitle) {
100
+ newFrontmatter.title = newTitle;
101
+ }
102
+ // Update body if title changed
103
+ let newBody = body;
104
+ if (newTitle) {
105
+ // Replace first H1 if exists
106
+ const headingMatch = body.match(/^#\s+.+$/m);
107
+ if (headingMatch) {
108
+ newBody = body.replace(/^#\s+.+$/m, `# ${newTitle}`);
109
+ }
110
+ else {
111
+ // Prepend title
112
+ newBody = `# ${newTitle}\n\n${body}`;
113
+ }
114
+ }
115
+ // Write adopted file
116
+ const fileContent = matter.stringify(newBody, newFrontmatter);
117
+ await fs.writeFile(destPath, fileContent, 'utf-8');
118
+ return {
119
+ success: true,
120
+ from: path.relative(libraryPath, sourcePath),
121
+ to: path.relative(libraryPath, destPath),
122
+ };
123
+ },
124
+ };
125
+ // ============================================================================
126
+ // Helper Functions
127
+ // ============================================================================
128
+ function slugify(text) {
129
+ return text
130
+ .toLowerCase()
131
+ .replace(/[^a-z0-9]+/g, '-')
132
+ .replace(/^-+|-+$/g, '')
133
+ .slice(0, 50);
134
+ }
135
+ async function fileExists(filePath) {
136
+ try {
137
+ await fs.access(filePath);
138
+ return true;
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
@@ -0,0 +1,34 @@
1
+ export interface BriefEntry {
2
+ title: string;
3
+ intent: string | null;
4
+ context: string | null;
5
+ preview: string;
6
+ path: string;
7
+ created: string;
8
+ }
9
+ export interface BriefResult {
10
+ entries: BriefEntry[];
11
+ total: number;
12
+ message: string;
13
+ libraryPath: string;
14
+ }
15
+ export declare const briefTool: {
16
+ name: string;
17
+ description: string;
18
+ inputSchema: {
19
+ type: "object";
20
+ properties: {
21
+ query: {
22
+ type: string;
23
+ description: string;
24
+ };
25
+ limit: {
26
+ type: string;
27
+ description: string;
28
+ default: number;
29
+ };
30
+ };
31
+ required: never[];
32
+ };
33
+ handler(args: unknown): Promise<BriefResult>;
34
+ };
@@ -0,0 +1,161 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import matter from 'gray-matter';
4
+ import { glob } from 'glob';
5
+ import { getLibraryPath, getLocalPath, getImportedPath } from '../library/storage.js';
6
+ // ============================================================================
7
+ // Tool Definition
8
+ // ============================================================================
9
+ export const briefTool = {
10
+ name: 'brief',
11
+ description: `Check what we already know before diving in.
12
+
13
+ We've solved problems before. Before thinking through a problem, making
14
+ decisions, or planning - brief yourself on what past-us figured out.
15
+ Searches intent, insight, context, and examples.
16
+
17
+ Examples:
18
+ - brief({ query: "stripe webhooks" })
19
+ - brief({ query: "auth token" })
20
+ - brief({}) → returns recent entries`,
21
+ inputSchema: {
22
+ type: 'object',
23
+ properties: {
24
+ query: {
25
+ type: 'string',
26
+ description: 'What are we working on? Searches our library. Leave empty to see recent entries.',
27
+ },
28
+ limit: {
29
+ type: 'number',
30
+ description: 'Max entries to return',
31
+ default: 5,
32
+ },
33
+ },
34
+ required: [],
35
+ },
36
+ async handler(args) {
37
+ const { query, limit = 5 } = args;
38
+ const libraryPath = getLibraryPath();
39
+ const localPath = getLocalPath(libraryPath);
40
+ const importedPath = getImportedPath(libraryPath);
41
+ let allEntries = [];
42
+ // Read local entries
43
+ try {
44
+ const localFiles = await glob(path.join(localPath, '**/*.md'), { nodir: true });
45
+ for (const filePath of localFiles) {
46
+ const entry = await readEntry(filePath, libraryPath);
47
+ if (entry) {
48
+ allEntries.push(entry);
49
+ }
50
+ }
51
+ }
52
+ catch {
53
+ // No local files yet
54
+ }
55
+ // Read imported entries
56
+ try {
57
+ const importedFiles = await glob(path.join(importedPath, '**/*.md'), { nodir: true });
58
+ for (const filePath of importedFiles) {
59
+ const entry = await readEntry(filePath, libraryPath);
60
+ if (entry) {
61
+ allEntries.push(entry);
62
+ }
63
+ }
64
+ }
65
+ catch {
66
+ // No imported files
67
+ }
68
+ // If no entries at all
69
+ if (allEntries.length === 0) {
70
+ return {
71
+ entries: [],
72
+ total: 0,
73
+ message: 'No entries yet. Start recording!',
74
+ libraryPath: localPath,
75
+ };
76
+ }
77
+ // Filter by query if provided
78
+ if (query) {
79
+ const searchTerm = query.toLowerCase();
80
+ allEntries = allEntries.filter(entry => matchesSearch(entry, searchTerm));
81
+ }
82
+ // Sort by created date (most recent first)
83
+ allEntries.sort((a, b) => {
84
+ return new Date(b.created).getTime() - new Date(a.created).getTime();
85
+ });
86
+ const total = allEntries.length;
87
+ // Apply limit
88
+ const entries = allEntries.slice(0, limit);
89
+ // Build message
90
+ let message;
91
+ if (query) {
92
+ message = total === 0
93
+ ? `No entries found for "${query}".`
94
+ : `Found ${total} ${total === 1 ? 'entry' : 'entries'} for "${query}".`;
95
+ }
96
+ else {
97
+ message = `${total} ${total === 1 ? 'entry' : 'entries'} in library.`;
98
+ }
99
+ return {
100
+ entries,
101
+ total,
102
+ message,
103
+ libraryPath: localPath,
104
+ };
105
+ },
106
+ };
107
+ // ============================================================================
108
+ // Helper Functions
109
+ // ============================================================================
110
+ async function readEntry(filePath, libraryPath) {
111
+ try {
112
+ const content = await fs.readFile(filePath, 'utf-8');
113
+ const { data, content: body } = matter(content);
114
+ // Extract title from H1 or filename
115
+ let title = data.title;
116
+ if (!title) {
117
+ const headingMatch = body.match(/^#\s+(.+)$/m);
118
+ if (headingMatch) {
119
+ title = headingMatch[1].trim();
120
+ }
121
+ else {
122
+ title = path.basename(filePath, '.md').replace(/-/g, ' ');
123
+ }
124
+ }
125
+ // Extract preview - first 100 chars of body content
126
+ const bodyText = body.trim();
127
+ const preview = bodyText.length > 100
128
+ ? bodyText.slice(0, 100) + '...'
129
+ : bodyText;
130
+ return {
131
+ title,
132
+ intent: data.intent || null,
133
+ context: data.context || null,
134
+ preview,
135
+ path: path.relative(libraryPath, filePath),
136
+ created: data.created || new Date().toISOString(),
137
+ };
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ function matchesSearch(entry, searchTerm) {
144
+ // Check title
145
+ if (entry.title.toLowerCase().includes(searchTerm)) {
146
+ return true;
147
+ }
148
+ // Check intent
149
+ if (entry.intent && entry.intent.toLowerCase().includes(searchTerm)) {
150
+ return true;
151
+ }
152
+ // Check context
153
+ if (entry.context && entry.context.toLowerCase().includes(searchTerm)) {
154
+ return true;
155
+ }
156
+ // Check preview (basic substring match - Claude does semantic filtering)
157
+ if (entry.preview.toLowerCase().includes(searchTerm)) {
158
+ return true;
159
+ }
160
+ return false;
161
+ }
@@ -0,0 +1,3 @@
1
+ export { briefTool } from './brief.js';
2
+ export { recordTool } from './record.js';
3
+ export { adoptTool } from './adopt.js';
@@ -0,0 +1,3 @@
1
+ export { briefTool } from './brief.js';
2
+ export { recordTool } from './record.js';
3
+ export { adoptTool } from './adopt.js';
@@ -0,0 +1,40 @@
1
+ export interface RecordResult {
2
+ success: boolean;
3
+ path: string;
4
+ title: string;
5
+ }
6
+ export declare const recordTool: {
7
+ name: string;
8
+ description: string;
9
+ inputSchema: {
10
+ type: "object";
11
+ properties: {
12
+ insight: {
13
+ type: string;
14
+ description: string;
15
+ };
16
+ intent: {
17
+ type: string;
18
+ description: string;
19
+ };
20
+ reasoning: {
21
+ type: string;
22
+ description: string;
23
+ };
24
+ context: {
25
+ type: string;
26
+ description: string;
27
+ };
28
+ example: {
29
+ type: string;
30
+ description: string;
31
+ };
32
+ title: {
33
+ type: string;
34
+ description: string;
35
+ };
36
+ };
37
+ required: string[];
38
+ };
39
+ handler(args: unknown): Promise<RecordResult>;
40
+ };
@@ -0,0 +1,186 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { getLibraryPath, getLocalPath } from '../library/storage.js';
4
+ // ============================================================================
5
+ // Tool Definition
6
+ // ============================================================================
7
+ export const recordTool = {
8
+ name: 'record',
9
+ description: `Capture knowledge worth keeping. We're building a library together.
10
+
11
+ Every session we learn things that evaporate by tomorrow. This catches
12
+ the good stuff - what we learned, why it matters, how it works.
13
+
14
+ Quality bar: "I wish we knew this yesterday"
15
+
16
+ Good entries:
17
+ - "Stripe retries webhooks but doesn't dedupe - always check idempotency key"
18
+ - "Clock skew between services - add 30s buffer to token validation"
19
+ - "The staging deploy must happen before prod or the migration breaks"
20
+
21
+ Not worth recording:
22
+ - Generic docs (we can search those)
23
+ - Temporary hacks
24
+ - Stuff that'll change next week
25
+
26
+ Examples:
27
+
28
+ Quick:
29
+ - record({ insight: "Stripe webhooks need idempotency checks" })
30
+
31
+ Rich:
32
+ - record({
33
+ intent: "Add Stripe webhook handler",
34
+ insight: "Stripe retries failed webhooks but doesn't dedupe. Always check idempotency key or you'll process payments twice.",
35
+ reasoning: "Their retry logic assumes failures, not slow responses",
36
+ context: "payments",
37
+ example: "if (await isDuplicate(event.id)) return;"
38
+ })
39
+
40
+ WHEN TO CALL THIS:
41
+ - The moment you think "that's useful" - capture it NOW
42
+ - After solving something tricky - what made it work?
43
+ - When you make a decision - why this over alternatives?
44
+ - Before context dies - don't let insights evaporate
45
+
46
+ Multiple calls welcome - one insight per call. Don't batch, don't wait.
47
+ Context compacts, memories disappear. If it's worth knowing tomorrow,
48
+ record it today.`,
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ insight: {
53
+ type: 'string',
54
+ description: 'What did we learn? The knowledge worth keeping.',
55
+ },
56
+ intent: {
57
+ type: 'string',
58
+ description: 'What were we trying to accomplish?',
59
+ },
60
+ reasoning: {
61
+ type: 'string',
62
+ description: 'Why does this work? Why this over alternatives?',
63
+ },
64
+ context: {
65
+ type: 'string',
66
+ description: "Topic, area, or when this applies (e.g., 'auth', 'payments', 'only on Windows')",
67
+ },
68
+ example: {
69
+ type: 'string',
70
+ description: 'Code snippet or concrete illustration',
71
+ },
72
+ title: {
73
+ type: 'string',
74
+ description: 'Entry title. Auto-generated from insight if not provided.',
75
+ },
76
+ },
77
+ required: ['insight'],
78
+ },
79
+ async handler(args) {
80
+ const { insight, intent, reasoning, context, example, title: providedTitle } = args;
81
+ if (!insight) {
82
+ throw new Error('insight is required');
83
+ }
84
+ const libraryPath = getLibraryPath();
85
+ const localPath = getLocalPath(libraryPath);
86
+ // Ensure local directory exists
87
+ await fs.mkdir(localPath, { recursive: true });
88
+ // Generate title
89
+ const title = providedTitle || generateTitle(insight, intent);
90
+ // Generate slug for filename
91
+ const slug = slugify(title);
92
+ const created = new Date().toISOString();
93
+ // Handle filename collisions
94
+ let filename = `${slug}.md`;
95
+ let filePath = path.join(localPath, filename);
96
+ let counter = 1;
97
+ while (await fileExists(filePath)) {
98
+ filename = `${slug}-${counter}.md`;
99
+ filePath = path.join(localPath, filename);
100
+ counter++;
101
+ }
102
+ // Build frontmatter
103
+ const frontmatterLines = ['---'];
104
+ if (intent) {
105
+ frontmatterLines.push(`intent: "${escapeYaml(intent)}"`);
106
+ }
107
+ if (context) {
108
+ frontmatterLines.push(`context: "${escapeYaml(context)}"`);
109
+ }
110
+ frontmatterLines.push(`created: "${created}"`);
111
+ frontmatterLines.push(`updated: "${created}"`);
112
+ frontmatterLines.push('source: "local"');
113
+ frontmatterLines.push('---');
114
+ // Build body
115
+ const bodyLines = [];
116
+ bodyLines.push(`# ${title}`);
117
+ bodyLines.push('');
118
+ bodyLines.push(insight);
119
+ if (reasoning) {
120
+ bodyLines.push('');
121
+ bodyLines.push('## Reasoning');
122
+ bodyLines.push('');
123
+ bodyLines.push(reasoning);
124
+ }
125
+ if (example) {
126
+ bodyLines.push('');
127
+ bodyLines.push('## Example');
128
+ bodyLines.push('');
129
+ // Detect if it looks like code
130
+ if (example.includes('\n') || example.includes('{') || example.includes('(')) {
131
+ bodyLines.push('```');
132
+ bodyLines.push(example);
133
+ bodyLines.push('```');
134
+ }
135
+ else {
136
+ bodyLines.push('```');
137
+ bodyLines.push(example);
138
+ bodyLines.push('```');
139
+ }
140
+ }
141
+ // Combine and write
142
+ const fileContent = frontmatterLines.join('\n') + '\n\n' + bodyLines.join('\n') + '\n';
143
+ await fs.writeFile(filePath, fileContent, 'utf-8');
144
+ const relativePath = path.relative(libraryPath, filePath);
145
+ return {
146
+ success: true,
147
+ path: relativePath,
148
+ title,
149
+ };
150
+ },
151
+ };
152
+ // ============================================================================
153
+ // Helper Functions
154
+ // ============================================================================
155
+ function generateTitle(insight, intent) {
156
+ // Try to extract from first sentence of insight
157
+ const firstSentence = insight.split(/[.!?\n]/)[0].trim();
158
+ if (firstSentence.length <= 60) {
159
+ return firstSentence;
160
+ }
161
+ // If insight is too long, try intent
162
+ if (intent && intent.length <= 60) {
163
+ return intent;
164
+ }
165
+ // Truncate insight
166
+ return firstSentence.slice(0, 57) + '...';
167
+ }
168
+ function slugify(text) {
169
+ return text
170
+ .toLowerCase()
171
+ .replace(/[^a-z0-9]+/g, '-')
172
+ .replace(/^-+|-+$/g, '')
173
+ .slice(0, 50);
174
+ }
175
+ function escapeYaml(text) {
176
+ return text.replace(/"/g, '\\"').replace(/\n/g, ' ');
177
+ }
178
+ async function fileExists(filePath) {
179
+ try {
180
+ await fs.access(filePath);
181
+ return true;
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@telvok/librarian-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Knowledge capture MCP server - remember what you learn with AI",
5
+ "type": "module",
6
+ "main": "dist/server.js",
7
+ "bin": {
8
+ "librarian-mcp": "dist/server.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsc --watch",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "keywords": [
20
+ "mcp",
21
+ "model-context-protocol",
22
+ "claude",
23
+ "ai",
24
+ "knowledge-management",
25
+ "memory"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/kogbuze/librarian.git"
30
+ },
31
+ "author": "Telvok",
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.0.0",
35
+ "glob": "^11.0.0",
36
+ "gray-matter": "^4.0.3",
37
+ "uuid": "^11.0.0",
38
+ "zod": "^3.24.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.0.0",
42
+ "@types/uuid": "^10.0.0",
43
+ "typescript": "^5.7.0"
44
+ }
45
+ }