Skip to content

Commit b1896c2

Browse files
refactor(wiki-mcp-server): remove unnecesery barrel files, apply simple architecture
1 parent fb71afc commit b1896c2

25 files changed

Lines changed: 545 additions & 541 deletions

apps/wiki-mcp-server/README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Wiki MCP Server (`apps/wiki-mcp-server`)
2+
3+
`wiki-mcp-server` is a standalone Model Context Protocol (MCP) server that provides AI assistants with direct tool-based access to read, query, search, resolve cross-references, and create content within the wiki knowledge base over standard I/O (Stdio).
4+
5+
---
6+
7+
## 🏛️ Unified Hexagonal Architecture
8+
9+
`wiki-mcp-server` aligns with the monorepo-wide Clean Architecture principles shared across `apps/wiki-cli` and `apps/wiki-graph`:
10+
11+
```text
12+
┌────────────────────────────────────────────────────────────────────────┐
13+
│ apps/wiki-mcp-server │
14+
│ - src/index.ts (Execution Entry Point Executable) │
15+
│ - src/server.ts (MCP Server Factory & Tool Registry) │
16+
│ │
17+
│ ┌──────────────────────────────────────────────────────────────────┐ │
18+
│ │ Presentation / Driver Layer │ │
19+
│ │ - src/tools/*.ts (MCP Tool Composition Roots) │ │
20+
│ └──────────────────────────────┬───────────────────────────────────┘ │
21+
│ │ Invokes Services & Models │
22+
│ ▼ │
23+
│ ┌──────────────────────────────────────────────────────────────────┐ │
24+
│ │ Application Services Layer │ │
25+
│ │ - WikiIndexService (Directory validation & in-memory index) │ │
26+
│ │ - SearchService (Full-text matching & excerpt extraction) │ │
27+
│ └──────────────┬───────────────────────────────┬───────────────────┘ │
28+
│ │ Imports Models │ Uses Domain Logic │
29+
│ ▼ ▼ │
30+
│ ┌──────────────────────────────┐ ┌─────────────────────────────────┐ │
31+
│ │ Models Layer │ │ Domain Layer │ │
32+
│ │ - src/models/types.ts │ │ - src/domain/frontmatter.ts │ │
33+
│ │ (Interfaces & Response DTOs)│ │ - src/domain/wikilink-parser.ts │ │
34+
│ │ │ │ - src/domain/filename-gen.ts │ │
35+
│ └──────────────────────────────┘ └─────────────────────────────────┘ │
36+
└────────────────────────────────────────────────────────────────────────┘
37+
```
38+
39+
---
40+
41+
## 💡 Key Features & Functionality
42+
43+
- **Model Context Protocol Integration**
44+
- Implements official `@modelcontextprotocol/sdk` Stdio server transport.
45+
- Exposes 7 strongly typed tools with Zod validation for navigating interlinked markdown documents.
46+
47+
- **Dynamic In-Memory Wiki Indexing**
48+
- Validates folder structure (`index.md`, `entities/`, `concepts/`, `sources/`).
49+
- Scans YAML frontmatter metadata, extracts `[[WikiLink]]` cross-references, and computes incoming backlinks and tag maps.
50+
51+
- **Full-Text Content & Tag Search Engine**
52+
- Performs case-insensitive full-text search with context excerpts across page titles and body content.
53+
- Enables tag distribution queries and multi-tag filtering across entity, concept, and source documents.
54+
55+
- **Automated Page Creation & Filename Generation**
56+
- Generates standardized kebab-case filenames with date suffixes for source pages.
57+
- Automatically appends created pages to `wiki/index.md` and dynamically rebuilds the in-memory index.
58+
59+
---
60+
61+
## 📁 Module Summary
62+
63+
| File / Folder | Primary Function |
64+
| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
65+
| [`./src/index.ts`](./src/index.ts) | Pure CLI entry point binary parsing arguments and launching Stdio transport. |
66+
| [`./src/server.ts`](./src/server.ts) | MCP Server factory creating `McpServer` and registering all 7 tool schemas. |
67+
| [`./src/models/types.ts`](./src/models/types.ts) | Domain interfaces for page metadata, index state, search results, and tool outputs. |
68+
| [`./src/domain/frontmatter.ts`](./src/domain/frontmatter.ts) | Frontmatter YAML parser and schema validator using gray-matter. |
69+
| [`./src/domain/filename-gen.ts`](./src/domain/filename-gen.ts) | Kebab-case title transformer and source publication date filename generator. |
70+
| [`./src/domain/wikilink-parser.ts`](./src/domain/wikilink-parser.ts) | Regex-based `[[WikiLink]]` title extractor for alias and section formats. |
71+
| [`./src/services/wiki-index.service.ts`](./src/services/wiki-index.service.ts) | Directory structure validator and async multi-directory index builder service. |
72+
| [`./src/services/search.service.ts`](./src/services/search.service.ts) | Full-text search engine service with excerpt context extraction. |
73+
| [`./src/tools/`](./src/tools/README.md) | Dedicated tool handler composition roots for all 7 MCP tool definitions. |
74+
75+
---
76+
77+
## 🚀 Execution Commands
78+
79+
| Target | Command | Description |
80+
| ------- | ---------------------------------- | ------------------------------------------------------------- |
81+
| `build` | `npx nx run wiki-mcp-server:build` | Bundles application to `dist/apps/wiki-mcp-server/index.cjs`. |
82+
| `test` | `npx nx run wiki-mcp-server:test` | Runs Vitest unit and integration test suite. |
83+
| `debug` | `npx nx run wiki-mcp-server:debug` | Launches server inside MCP Inspector for interactive testing. |

apps/wiki-mcp-server/src/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Wiki MCP Server Source Core (`apps/wiki-mcp-server/src`)
2+
3+
This directory houses the core application logic, domain data structures, search engines, markdown parsing utilities, and MCP tool routers for the Wiki MCP Server organized into clean architectural layers.
4+
5+
---
6+
7+
## 💡 Key Features & Functionality
8+
9+
- **Application Composition & Stdio Transport**
10+
- **`./index.ts`**: Pure execution entry point parsing CLI/environment arguments and initializing Stdio transport.
11+
- **`./server.ts`**: MCP Server factory function (`createMcpServer`) declaring Zod tool schemas and routing calls to tool handlers.
12+
13+
- **Layered Clean Architecture & Co-located Specs**
14+
- **Models Layer (`./models/`)**: Defines type contracts for `PageMeta`, `WikiIndex`, `SearchResult`, and tool response structures.
15+
- **Domain Layer (`./domain/`)**: Pure parsing utilities (`frontmatter.ts`, `wikilink-parser.ts`, `filename-gen.ts`) with co-located unit tests (`*.spec.ts`).
16+
- **Services Layer (`./services/`)**: Async stateful services (`wiki-index.service.ts`, `search.service.ts`) with co-located service tests (`*.spec.ts`).
17+
- **Tools Layer (`./tools/`)**: Driver composition roots for all 7 MCP tool handlers with co-located tool tests (`*.spec.ts`).
18+
19+
---
20+
21+
## 📁 Module Summary
22+
23+
| File / Folder | Primary Function |
24+
| --- | --- |
25+
| [`./index.ts`](./index.ts) | Pure CLI entry point binary parsing arguments and launching Stdio transport. |
26+
| [`./server.ts`](./server.ts) | MCP Server factory creating `McpServer` and registering all 7 tool schemas. |
27+
| [`./models/types.ts`](./models/types.ts) | Core TypeScript interfaces and tool result type contracts. |
28+
| [`./domain/frontmatter.ts`](./domain/frontmatter.ts) | YAML frontmatter extractor and field validator using `gray-matter`. |
29+
| [`./domain/filename-gen.ts`](./domain/filename-gen.ts) | Filename generator for kebab-case titles and source document dates. |
30+
| [`./domain/wikilink-parser.ts`](./domain/wikilink-parser.ts) | Regex-based parser for extracting deduplicated WikiLink targets. |
31+
| [`./services/wiki-index.service.ts`](./services/wiki-index.service.ts) | Directory structure validator and in-memory index scanner service. |
32+
| [`./services/search.service.ts`](./services/search.service.ts) | Search engine service providing case-insensitive matching and excerpts. |
33+
| [`./tools/`](./tools/README.md) | Handler functions executing business logic for individual MCP tool calls. |

apps/wiki-mcp-server/src/__tests__/filename-gen.unit.test.ts renamed to apps/wiki-mcp-server/src/domain/filename-gen.spec.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, afterEach } from 'vitest';
2-
import { generateFileName } from '../filename-gen';
2+
import { generateFileName } from './filename-gen';
33

44
describe('generateFileName', () => {
55
afterEach(() => {
@@ -40,18 +40,14 @@ describe('generateFileName', () => {
4040
});
4141

4242
it('truncates very long titles at a word boundary', () => {
43-
const longTitle = 'a '.repeat(60).trim(); // 60 "a" words separated by spaces
43+
const longTitle = 'a '.repeat(60).trim();
4444
const result = generateFileName(longTitle, 'entity');
45-
// Should be truncated and end with .md
4645
expect(result.endsWith('.md')).toBe(true);
47-
// The base name (without .md) should be at most 100 chars
4846
expect(result.length - 3).toBeLessThanOrEqual(100);
4947
});
5048

5149
it('handles titles with only special characters', () => {
52-
// All non-alphanumeric chars become hyphens, then get trimmed
5350
const result = generateFileName('!!!@@@###', 'entity');
54-
// After kebab conversion: empty string (all chars are non-alphanumeric, become hyphens, then trimmed)
5551
expect(result).toBe('.md');
5652
});
5753
});
@@ -104,8 +100,7 @@ describe('generateFileName', () => {
104100

105101
expect(result.startsWith('source-')).toBe(true);
106102
expect(result.endsWith('-2024-06-01.md')).toBe(true);
107-
// Total base name (without .md) should be reasonable
108-
expect(result.length - 3).toBeLessThanOrEqual(100 + 18); // kebab + source- + -date
103+
expect(result.length - 3).toBeLessThanOrEqual(100 + 18);
109104
});
110105
});
111106
});
Lines changed: 13 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,5 @@
1-
/**
2-
* Filename Generator - Generates filenames following wiki naming conventions.
3-
*
4-
* Entity/concept: kebab-case-noun.md
5-
* Source: source-title-yyyy-mm-dd.md
6-
*/
7-
81
const MAX_FILENAME_LENGTH = 100;
92

10-
/**
11-
* Converts a title string to kebab-case.
12-
* - Lowercases the string
13-
* - Replaces spaces and non-alphanumeric characters with hyphens
14-
* - Collapses multiple consecutive hyphens into one
15-
* - Trims leading/trailing hyphens
16-
*/
173
function toKebabCase(title: string): string {
184
return title
195
.toLowerCase()
@@ -22,15 +8,19 @@ function toKebabCase(title: string): string {
228
.replace(/^-+|-+$/g, '');
239
}
2410

25-
/**
26-
* Generates a filename for a new wiki page based on title and type.
27-
*
28-
* - Entity/concept: `kebab-case-title.md`
29-
* - Source: `source-kebab-title-yyyy-mm-dd.md` (uses current date)
30-
*
31-
* Handles edge cases: special characters, unicode, and very long titles
32-
* (truncated at 100 characters before the extension).
33-
*/
11+
function truncateAtHyphen(kebab: string, maxLength: number): string {
12+
if (kebab.length <= maxLength) {
13+
return kebab;
14+
}
15+
16+
const truncated = kebab.slice(0, maxLength);
17+
const lastHyphen = truncated.lastIndexOf('-');
18+
if (lastHyphen > 0) {
19+
return truncated.slice(0, lastHyphen);
20+
}
21+
return truncated.replace(/-+$/, '');
22+
}
23+
3424
export function generateFileName(title: string, type: 'entity' | 'concept' | 'source'): string {
3525
const kebab = toKebabCase(title);
3626

@@ -41,32 +31,12 @@ export function generateFileName(title: string, type: 'entity' | 'concept' | 'so
4131
const dd = String(now.getDate()).padStart(2, '0');
4232
const dateSuffix = `${yyyy}-${mm}-${dd}`;
4333

44-
// "source-" (7) + "-" (1) + date (10) = 18 chars reserved
4534
const maxKebabLength = MAX_FILENAME_LENGTH - 18;
4635
const truncatedKebab = truncateAtHyphen(kebab, maxKebabLength);
4736

4837
return `source-${truncatedKebab}-${dateSuffix}.md`;
4938
}
5039

51-
// Entity or concept
5240
const truncatedKebab = truncateAtHyphen(kebab, MAX_FILENAME_LENGTH);
5341
return `${truncatedKebab}.md`;
5442
}
55-
56-
/**
57-
* Truncates a kebab-case string to a maximum length, cutting at a hyphen
58-
* boundary when possible to avoid splitting words. Removes trailing hyphens.
59-
*/
60-
function truncateAtHyphen(kebab: string, maxLength: number): string {
61-
if (kebab.length <= maxLength) {
62-
return kebab;
63-
}
64-
65-
const truncated = kebab.slice(0, maxLength);
66-
// Try to cut at the last hyphen to avoid splitting a word
67-
const lastHyphen = truncated.lastIndexOf('-');
68-
if (lastHyphen > 0) {
69-
return truncated.slice(0, lastHyphen);
70-
}
71-
return truncated.replace(/-+$/, '');
72-
}

apps/wiki-mcp-server/src/__tests__/frontmatter.unit.test.ts renamed to apps/wiki-mcp-server/src/domain/frontmatter.spec.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
1-
/**
2-
* Unit tests for the frontmatter parser.
3-
*/
4-
51
import { describe, it, expect } from 'vitest';
6-
import { parseFrontmatter } from '../frontmatter';
2+
import { parseFrontmatter } from './frontmatter';
73

84
describe('parseFrontmatter', () => {
95
describe('valid frontmatter', () => {

apps/wiki-mcp-server/src/frontmatter.ts renamed to apps/wiki-mcp-server/src/domain/frontmatter.ts

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
1-
/**
2-
* Frontmatter Parser - Wraps gray-matter with validation and error handling.
3-
*/
4-
51
import matter from 'gray-matter';
6-
import { PageMeta, ParseResult } from './types';
2+
import { PageMeta, ParseResult } from '../models/types';
73

84
const VALID_TYPES = ['entity', 'concept', 'source'] as const;
95

10-
/**
11-
* Parses YAML frontmatter from a wiki page file.
12-
* Validates required fields and returns a structured result.
13-
* Returns { success: false, error } for malformed/missing frontmatter.
14-
*/
156
export function parseFrontmatter(filePath: string, rawContent: string): ParseResult {
167
let parsed: matter.GrayMatterFile<string>;
178

@@ -24,25 +15,21 @@ export function parseFrontmatter(filePath: string, rawContent: string): ParseRes
2415

2516
const data = parsed.data;
2617

27-
// Check if frontmatter is empty (no data extracted)
2818
if (!data || Object.keys(data).length === 0) {
2919
return { success: false, error: 'Frontmatter is missing or empty' };
3020
}
3121

32-
// Validate required field: title
3322
if (typeof data.title !== 'string' || data.title.trim() === '') {
3423
return { success: false, error: 'Required field "title" is missing or not a non-empty string' };
3524
}
3625

37-
// Validate required field: type
3826
if (!VALID_TYPES.includes(data.type)) {
3927
return {
4028
success: false,
4129
error: `Field "type" must be one of: ${VALID_TYPES.join(', ')}. Got: "${data.type}"`,
4230
};
4331
}
4432

45-
// Validate required field: tags
4633
if (!Array.isArray(data.tags)) {
4734
return { success: false, error: 'Field "tags" must be an array' };
4835
}
@@ -52,32 +39,27 @@ export function parseFrontmatter(filePath: string, rawContent: string): ParseRes
5239
}
5340
}
5441

55-
// Validate required field: created
5642
if (typeof data.created !== 'string' && !(data.created instanceof Date)) {
5743
return { success: false, error: 'Required field "created" is missing or not a string' };
5844
}
5945

60-
// Validate required field: updated
6146
if (typeof data.updated !== 'string' && !(data.updated instanceof Date)) {
6247
return { success: false, error: 'Required field "updated" is missing or not a string' };
6348
}
6449

65-
// Normalize date fields — gray-matter may parse dates as Date objects
6650
const created = data.created instanceof Date ? data.created.toISOString().split('T')[0] : data.created;
6751
const updated = data.updated instanceof Date ? data.updated.toISOString().split('T')[0] : data.updated;
6852

69-
// Build PageMeta with required fields
7053
const meta: PageMeta = {
7154
title: data.title,
7255
type: data.type as 'entity' | 'concept' | 'source',
7356
tags: data.tags as string[],
7457
created,
7558
updated,
7659
filePath,
77-
outgoingLinks: [], // Populated later by the index builder
60+
outgoingLinks: [],
7861
};
7962

80-
// Optional fields
8163
if (data.sources !== undefined) {
8264
if (Array.isArray(data.sources)) {
8365
meta.sources = data.sources as string[];

apps/wiki-mcp-server/src/__tests__/wikilink-parser.unit.test.ts renamed to apps/wiki-mcp-server/src/domain/wikilink-parser.spec.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { extractWikiLinks } from '../wikilink-parser';
2+
import { extractWikiLinks } from './wikilink-parser';
33

44
describe('extractWikiLinks', () => {
55
it('extracts simple [[Title]] links', () => {
@@ -65,11 +65,7 @@ describe('extractWikiLinks', () => {
6565
});
6666

6767
it('handles [[Title#Section|Display]] combined form', () => {
68-
// Pipe takes precedence — everything before pipe is the raw target
69-
// Then hash is applied to that raw target
7068
const content = '[[Page Title#Section|Display Text]]';
71-
// The pipe splits first: target = "Page Title#Section"
72-
// Then hash splits: title = "Page Title"
7369
expect(extractWikiLinks(content)).toEqual(['Page Title']);
7470
});
7571

@@ -79,11 +75,8 @@ describe('extractWikiLinks', () => {
7975
});
8076

8177
it('does not match nested brackets like [[[Title]]]', () => {
82-
// The regex should still extract the inner [[Title]] from [[[Title]]]
83-
// because the pattern matches the first valid [[ ]] pair
8478
const content = '[[[Nested]]]';
8579
const result = extractWikiLinks(content);
86-
// The outer [ is not a \, so [[Nested]] should still match
8780
expect(result).toEqual(['Nested']);
8881
});
8982
});

apps/wiki-mcp-server/src/wikilink-parser.ts renamed to apps/wiki-mcp-server/src/domain/wikilink-parser.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,3 @@
1-
/**
2-
* WikiLink Parser - Extracts [[WikiLink]] references from markdown content.
3-
*
4-
* Handles:
5-
* - [[Page Title]]
6-
* - [[Page Title|Display Text]] -> extracts "Page Title"
7-
* - [[Page Title#Section]] -> extracts "Page Title"
8-
*/
9-
10-
/**
11-
* Extracts WikiLink targets from markdown content.
12-
* Returns a deduplicated array of target titles.
13-
*/
141
export function extractWikiLinks(content: string): string[] {
152
const wikiLinkPattern = /(?<!\\)\[\[([^[\]]+?)\]\]/g;
163
const titles = new Set<string>();
@@ -19,24 +6,20 @@ export function extractWikiLinks(content: string): string[] {
196
while ((match = wikiLinkPattern.exec(content)) !== null) {
207
let target = match[1];
218

22-
// Skip empty links
239
if (!target.trim()) {
2410
continue;
2511
}
2612

27-
// Handle [[Title|Display]] — extract part before the pipe
2813
const pipeIndex = target.indexOf('|');
2914
if (pipeIndex !== -1) {
3015
target = target.substring(0, pipeIndex);
3116
}
3217

33-
// Handle [[Title#Section]] — extract part before the hash
3418
const hashIndex = target.indexOf('#');
3519
if (hashIndex !== -1) {
3620
target = target.substring(0, hashIndex);
3721
}
3822

39-
// Only add non-empty titles after extraction
4023
const trimmed = target.trim();
4124
if (trimmed) {
4225
titles.add(trimmed);

0 commit comments

Comments
 (0)