@echoes-io/mcp-server 1.0.0 → 1.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/README.md CHANGED
@@ -15,7 +15,10 @@ Add to your MCP client configuration (e.g., `~/.config/q/mcp.json` for Amazon Q)
15
15
  "mcpServers": {
16
16
  "echoes": {
17
17
  "command": "npx",
18
- "args": ["-y", "@echoes-io/mcp-server"]
18
+ "args": ["-y", "@echoes-io/mcp-server"],
19
+ "env": {
20
+ "ECHOES_TIMELINE": "your-timeline-name"
21
+ }
19
22
  }
20
23
  }
21
24
  }
@@ -33,21 +36,57 @@ Then configure:
33
36
  {
34
37
  "mcpServers": {
35
38
  "echoes": {
36
- "command": "echoes-mcp-server"
39
+ "command": "echoes-mcp-server",
40
+ "env": {
41
+ "ECHOES_TIMELINE": "your-timeline-name"
42
+ }
37
43
  }
38
44
  }
39
45
  }
40
46
  ```
41
47
 
42
- ## Usage
43
-
44
- Once configured, the server provides tools for:
45
- - Content management (chapters, episodes, arcs)
46
- - Word counting and text statistics
47
- - Database operations via @echoes-io/tracker
48
- - Timeline synchronization
49
-
50
- Available tools will be listed by your MCP client.
48
+ **Important:** The `ECHOES_TIMELINE` environment variable must be set to specify which timeline to work with. All tools operate on this timeline.
49
+
50
+ ## Available Tools
51
+
52
+ All tools operate on the timeline specified by the `ECHOES_TIMELINE` environment variable.
53
+
54
+ ### Content Operations
55
+ - **`words-count`** - Count words and text statistics in markdown files
56
+ - Input: `file` (path to markdown file)
57
+
58
+ - **`chapter-info`** - Extract chapter metadata from database
59
+ - Input: `arc`, `episode`, `chapter`
60
+
61
+ - **`chapter-refresh`** - Refresh chapter metadata and word counts from file
62
+ - Input: `file` (path to chapter file)
63
+
64
+ - **`chapter-insert`** - Insert new chapter with automatic renumbering
65
+ - Input: `arc`, `episode`, `after`, `pov`, `title`, optional: `excerpt`, `location`, `outfit`, `kink`, `file`
66
+
67
+ - **`chapter-delete`** - Delete chapter from database and optionally from filesystem
68
+ - Input: `arc`, `episode`, `chapter`, optional: `file` (to delete from filesystem)
69
+
70
+ ### Episode Operations
71
+ - **`episode-info`** - Get episode information and list of chapters
72
+ - Input: `arc`, `episode`
73
+
74
+ - **`episode-update`** - Update episode metadata (description, title, slug)
75
+ - Input: `arc`, `episode`, optional: `description`, `title`, `slug`
76
+
77
+ ### Timeline Operations
78
+ - **`timeline-sync`** - Synchronize filesystem content with database
79
+ - Input: `contentPath` (path to content directory)
80
+
81
+ ### Statistics
82
+ - **`stats`** - Get aggregate statistics with optional filters
83
+ - Input: optional: `arc`, `episode`, `pov`
84
+ - Output: Total words/chapters, POV distribution, arc/episode breakdown, longest/shortest chapters
85
+ - Examples:
86
+ - No filters: Overall timeline statistics
87
+ - `arc: "arc1"`: Statistics for specific arc
88
+ - `arc: "arc1", episode: 1`: Statistics for specific episode
89
+ - `pov: "Alice"`: Statistics for specific POV across timeline
51
90
 
52
91
  ## Development
53
92
 
@@ -73,10 +112,24 @@ npm run lint:fix
73
112
  ### Tech Stack
74
113
 
75
114
  - **Language**: TypeScript (strict mode)
76
- - **Testing**: Vitest
115
+ - **Testing**: Vitest (97%+ coverage)
77
116
  - **Linting**: Biome
78
117
  - **Build**: TypeScript compiler
79
118
 
119
+ ### Architecture
120
+
121
+ - **MCP Protocol**: Standard Model Context Protocol implementation
122
+ - **Database**: SQLite via @echoes-io/tracker (singleton pattern)
123
+ - **Validation**: Zod schemas for type-safe inputs
124
+ - **Testing**: Comprehensive unit and integration tests
125
+ - **Environment**: Uses `ECHOES_TIMELINE` env var for timeline context
126
+
127
+ ## Roadmap
128
+
129
+ ### Planned Features
130
+ - **Book generation** - LaTeX/PDF compilation tools for creating publishable books from timeline content
131
+ - **RAG system** - Retrieval-Augmented Generation integration for AI-assisted writing and content analysis
132
+
80
133
  ## License
81
134
 
82
135
  MIT
package/lib/server.d.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { Tracker } from '@echoes-io/tracker';
1
2
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
- export declare function createServer(): Server<{
3
+ export declare function createServer(tracker: Tracker): Server<{
3
4
  method: string;
4
5
  params?: {
5
6
  [x: string]: unknown;
package/lib/server.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { Tracker } from '@echoes-io/tracker';
4
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
7
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
+ import { zodToJsonSchema } from 'zod-to-json-schema';
9
+ import { chapterDelete, chapterDeleteSchema, chapterInfo, chapterInfoSchema, chapterInsert, chapterInsertSchema, chapterRefresh, chapterRefreshSchema, episodeInfo, episodeInfoSchema, episodeUpdate, episodeUpdateSchema, stats, statsSchema, timelineSync, timelineSyncSchema, wordsCount, wordsCountSchema, } from './tools/index.js';
7
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
11
  const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
9
- export function createServer() {
12
+ export function createServer(tracker) {
10
13
  const server = new Server({
11
14
  name: pkg.name,
12
15
  version: pkg.version,
@@ -17,16 +20,89 @@ export function createServer() {
17
20
  });
18
21
  server.setRequestHandler(ListToolsRequestSchema, async () => {
19
22
  return {
20
- tools: [],
23
+ tools: [
24
+ {
25
+ name: 'words-count',
26
+ description: 'Count words and text statistics in a markdown file',
27
+ inputSchema: zodToJsonSchema(wordsCountSchema),
28
+ },
29
+ {
30
+ name: 'chapter-info',
31
+ description: 'Extract chapter metadata, content preview, and statistics',
32
+ inputSchema: zodToJsonSchema(chapterInfoSchema),
33
+ },
34
+ {
35
+ name: 'episode-info',
36
+ description: 'Get episode information and list of chapters',
37
+ inputSchema: zodToJsonSchema(episodeInfoSchema),
38
+ },
39
+ {
40
+ name: 'episode-update',
41
+ description: 'Update episode metadata (description, title, slug)',
42
+ inputSchema: zodToJsonSchema(episodeUpdateSchema),
43
+ },
44
+ {
45
+ name: 'chapter-refresh',
46
+ description: 'Refresh chapter metadata and statistics from file',
47
+ inputSchema: zodToJsonSchema(chapterRefreshSchema),
48
+ },
49
+ {
50
+ name: 'chapter-delete',
51
+ description: 'Delete chapter from database and optionally from filesystem',
52
+ inputSchema: zodToJsonSchema(chapterDeleteSchema),
53
+ },
54
+ {
55
+ name: 'chapter-insert',
56
+ description: 'Insert new chapter and automatically renumber subsequent chapters',
57
+ inputSchema: zodToJsonSchema(chapterInsertSchema),
58
+ },
59
+ {
60
+ name: 'timeline-sync',
61
+ description: 'Synchronize timeline content with database',
62
+ inputSchema: zodToJsonSchema(timelineSyncSchema),
63
+ },
64
+ {
65
+ name: 'stats',
66
+ description: 'Get statistics for timeline, arc, episode, or POV',
67
+ inputSchema: zodToJsonSchema(statsSchema),
68
+ },
69
+ ],
21
70
  };
22
71
  });
23
72
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
24
- throw new Error(`Unknown tool: ${request.params.name}`);
73
+ const { name, arguments: args } = request.params;
74
+ switch (name) {
75
+ case 'words-count':
76
+ return await wordsCount(wordsCountSchema.parse(args));
77
+ case 'chapter-info':
78
+ return await chapterInfo(chapterInfoSchema.parse(args), tracker);
79
+ case 'chapter-refresh':
80
+ return await chapterRefresh(chapterRefreshSchema.parse(args), tracker);
81
+ case 'chapter-delete':
82
+ return await chapterDelete(chapterDeleteSchema.parse(args), tracker);
83
+ case 'chapter-insert':
84
+ return await chapterInsert(chapterInsertSchema.parse(args), tracker);
85
+ case 'episode-info':
86
+ return await episodeInfo(episodeInfoSchema.parse(args), tracker);
87
+ case 'episode-update':
88
+ return await episodeUpdate(episodeUpdateSchema.parse(args), tracker);
89
+ case 'timeline-sync':
90
+ return await timelineSync(timelineSyncSchema.parse(args), tracker);
91
+ case 'stats':
92
+ return await stats(statsSchema.parse(args), tracker);
93
+ default:
94
+ throw new Error(`Unknown tool: ${name}`);
95
+ }
25
96
  });
26
97
  return server;
27
98
  }
28
99
  export async function runServer() {
29
- const server = createServer();
100
+ // Initialize tracker database in appropriate location
101
+ const dbPath = process.env.NODE_ENV === 'test' ? ':memory:' : './tracker.db';
102
+ const tracker = new Tracker(dbPath);
103
+ await tracker.init();
104
+ console.error(`Tracker database initialized: ${dbPath}`);
105
+ const server = createServer(tracker);
30
106
  const transport = new StdioServerTransport();
31
107
  await server.connect(transport);
32
108
  console.error('Echoes MCP Server running on stdio');
@@ -0,0 +1,24 @@
1
+ import type { Tracker } from '@echoes-io/tracker';
2
+ import { z } from 'zod';
3
+ export declare const chapterDeleteSchema: z.ZodObject<{
4
+ arc: z.ZodString;
5
+ episode: z.ZodNumber;
6
+ chapter: z.ZodNumber;
7
+ file: z.ZodOptional<z.ZodString>;
8
+ }, "strip", z.ZodTypeAny, {
9
+ arc: string;
10
+ episode: number;
11
+ chapter: number;
12
+ file?: string | undefined;
13
+ }, {
14
+ arc: string;
15
+ episode: number;
16
+ chapter: number;
17
+ file?: string | undefined;
18
+ }>;
19
+ export declare function chapterDelete(args: z.infer<typeof chapterDeleteSchema>, tracker: Tracker): Promise<{
20
+ content: {
21
+ type: "text";
22
+ text: string;
23
+ }[];
24
+ }>;
@@ -0,0 +1,49 @@
1
+ import { unlinkSync } from 'node:fs';
2
+ import { z } from 'zod';
3
+ import { getTimeline } from '../utils.js';
4
+ export const chapterDeleteSchema = z.object({
5
+ arc: z.string().describe('Arc name'),
6
+ episode: z.number().describe('Episode number'),
7
+ chapter: z.number().describe('Chapter number'),
8
+ file: z.string().optional().describe('Path to markdown file to delete from filesystem'),
9
+ });
10
+ export async function chapterDelete(args, tracker) {
11
+ try {
12
+ const timeline = getTimeline();
13
+ const existing = await tracker.getChapter(timeline, args.arc, args.episode, args.chapter);
14
+ if (!existing) {
15
+ throw new Error(`Chapter not found: ${timeline}/${args.arc}/ep${args.episode}/ch${args.chapter}`);
16
+ }
17
+ await tracker.deleteChapter(timeline, args.arc, args.episode, args.chapter);
18
+ let fileDeleted = false;
19
+ if (args.file) {
20
+ unlinkSync(args.file);
21
+ fileDeleted = true;
22
+ }
23
+ return {
24
+ content: [
25
+ {
26
+ type: 'text',
27
+ text: JSON.stringify({
28
+ timeline,
29
+ arc: args.arc,
30
+ episode: args.episode,
31
+ chapter: args.chapter,
32
+ deleted: {
33
+ pov: existing.pov,
34
+ title: existing.title,
35
+ words: existing.words,
36
+ },
37
+ fileDeleted,
38
+ message: fileDeleted
39
+ ? 'Chapter deleted from database and filesystem'
40
+ : 'Chapter deleted from database only',
41
+ }, null, 2),
42
+ },
43
+ ],
44
+ };
45
+ }
46
+ catch (error) {
47
+ throw new Error(`Failed to delete chapter: ${error instanceof Error ? error.message : 'Unknown error'}`);
48
+ }
49
+ }
@@ -0,0 +1,21 @@
1
+ import type { Tracker } from '@echoes-io/tracker';
2
+ import { z } from 'zod';
3
+ export declare const chapterInfoSchema: z.ZodObject<{
4
+ arc: z.ZodString;
5
+ episode: z.ZodNumber;
6
+ chapter: z.ZodNumber;
7
+ }, "strip", z.ZodTypeAny, {
8
+ arc: string;
9
+ episode: number;
10
+ chapter: number;
11
+ }, {
12
+ arc: string;
13
+ episode: number;
14
+ chapter: number;
15
+ }>;
16
+ export declare function chapterInfo(args: z.infer<typeof chapterInfoSchema>, tracker: Tracker): Promise<{
17
+ content: {
18
+ type: "text";
19
+ text: string;
20
+ }[];
21
+ }>;
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import { getTimeline } from '../utils.js';
3
+ export const chapterInfoSchema = z.object({
4
+ arc: z.string().describe('Arc name'),
5
+ episode: z.number().describe('Episode number'),
6
+ chapter: z.number().describe('Chapter number'),
7
+ });
8
+ export async function chapterInfo(args, tracker) {
9
+ try {
10
+ const timeline = getTimeline();
11
+ const chapter = await tracker.getChapter(timeline, args.arc, args.episode, args.chapter);
12
+ if (!chapter) {
13
+ throw new Error(`Chapter not found: ${timeline}/${args.arc}/ep${args.episode}/ch${args.chapter}`);
14
+ }
15
+ return {
16
+ content: [
17
+ {
18
+ type: 'text',
19
+ text: JSON.stringify({
20
+ timeline,
21
+ arc: args.arc,
22
+ episode: args.episode,
23
+ chapter: args.chapter,
24
+ metadata: {
25
+ pov: chapter.pov,
26
+ title: chapter.title,
27
+ date: chapter.date,
28
+ excerpt: chapter.excerpt,
29
+ location: chapter.location,
30
+ outfit: chapter.outfit,
31
+ kink: chapter.kink,
32
+ },
33
+ stats: {
34
+ words: chapter.words,
35
+ characters: chapter.characters,
36
+ charactersNoSpaces: chapter.charactersNoSpaces,
37
+ paragraphs: chapter.paragraphs,
38
+ sentences: chapter.sentences,
39
+ },
40
+ }, null, 2),
41
+ },
42
+ ],
43
+ };
44
+ }
45
+ catch (error) {
46
+ throw new Error(`Failed to get chapter info: ${error instanceof Error ? error.message : 'Unknown error'}`);
47
+ }
48
+ }
@@ -0,0 +1,42 @@
1
+ import type { Tracker } from '@echoes-io/tracker';
2
+ import { z } from 'zod';
3
+ export declare const chapterInsertSchema: z.ZodObject<{
4
+ arc: z.ZodString;
5
+ episode: z.ZodNumber;
6
+ after: z.ZodNumber;
7
+ pov: z.ZodString;
8
+ title: z.ZodString;
9
+ excerpt: z.ZodOptional<z.ZodString>;
10
+ location: z.ZodOptional<z.ZodString>;
11
+ outfit: z.ZodOptional<z.ZodString>;
12
+ kink: z.ZodOptional<z.ZodString>;
13
+ file: z.ZodOptional<z.ZodString>;
14
+ }, "strip", z.ZodTypeAny, {
15
+ arc: string;
16
+ episode: number;
17
+ after: number;
18
+ pov: string;
19
+ title: string;
20
+ file?: string | undefined;
21
+ excerpt?: string | undefined;
22
+ location?: string | undefined;
23
+ outfit?: string | undefined;
24
+ kink?: string | undefined;
25
+ }, {
26
+ arc: string;
27
+ episode: number;
28
+ after: number;
29
+ pov: string;
30
+ title: string;
31
+ file?: string | undefined;
32
+ excerpt?: string | undefined;
33
+ location?: string | undefined;
34
+ outfit?: string | undefined;
35
+ kink?: string | undefined;
36
+ }>;
37
+ export declare function chapterInsert(args: z.infer<typeof chapterInsertSchema>, tracker: Tracker): Promise<{
38
+ content: {
39
+ type: "text";
40
+ text: string;
41
+ }[];
42
+ }>;
@@ -0,0 +1,99 @@
1
+ import { getTextStats, parseMarkdown } from '@echoes-io/utils';
2
+ import { z } from 'zod';
3
+ import { getTimeline } from '../utils.js';
4
+ export const chapterInsertSchema = z.object({
5
+ arc: z.string().describe('Arc name'),
6
+ episode: z.number().describe('Episode number'),
7
+ after: z.number().describe('Insert after this chapter number'),
8
+ pov: z.string().describe('Point of view character'),
9
+ title: z.string().describe('Chapter title'),
10
+ excerpt: z.string().optional().describe('Chapter excerpt'),
11
+ location: z.string().optional().describe('Chapter location'),
12
+ outfit: z.string().optional().describe('Character outfit'),
13
+ kink: z.string().optional().describe('Content tags'),
14
+ file: z.string().optional().describe('Path to markdown file to read content from'),
15
+ });
16
+ export async function chapterInsert(args, tracker) {
17
+ try {
18
+ const timeline = getTimeline();
19
+ const episode = await tracker.getEpisode(timeline, args.arc, args.episode);
20
+ if (!episode) {
21
+ throw new Error(`Episode not found: ${timeline}/${args.arc}/ep${args.episode}`);
22
+ }
23
+ const existingChapters = await tracker.getChapters(timeline, args.arc, args.episode);
24
+ const newChapterNumber = args.after + 1;
25
+ const chaptersToRenumber = existingChapters.filter((ch) => ch.number >= newChapterNumber);
26
+ for (const chapter of chaptersToRenumber) {
27
+ try {
28
+ await tracker.updateChapter(timeline, args.arc, args.episode, chapter.number, {
29
+ number: chapter.number + 1,
30
+ });
31
+ }
32
+ catch (error) {
33
+ throw new Error(`Failed to renumber chapter ${chapter.number}: ${error instanceof Error ? error.message : 'Unknown error'}`);
34
+ }
35
+ }
36
+ let words = 0;
37
+ let characters = 0;
38
+ let charactersNoSpaces = 0;
39
+ let paragraphs = 0;
40
+ let sentences = 0;
41
+ if (args.file) {
42
+ const { readFileSync } = await import('node:fs');
43
+ const content = readFileSync(args.file, 'utf-8');
44
+ const { content: markdownContent } = parseMarkdown(content);
45
+ const stats = getTextStats(markdownContent);
46
+ words = stats.words;
47
+ characters = stats.characters;
48
+ charactersNoSpaces = stats.charactersNoSpaces;
49
+ paragraphs = stats.paragraphs;
50
+ sentences = stats.sentences;
51
+ }
52
+ await tracker.createChapter({
53
+ timelineName: timeline,
54
+ arcName: args.arc,
55
+ episodeNumber: args.episode,
56
+ partNumber: 1,
57
+ number: newChapterNumber,
58
+ pov: args.pov,
59
+ title: args.title,
60
+ date: new Date(),
61
+ excerpt: args.excerpt || '',
62
+ location: args.location || '',
63
+ outfit: args.outfit || '',
64
+ kink: args.kink || '',
65
+ words,
66
+ characters,
67
+ charactersNoSpaces,
68
+ paragraphs,
69
+ sentences,
70
+ readingTimeMinutes: Math.ceil(words / 200),
71
+ });
72
+ return {
73
+ content: [
74
+ {
75
+ type: 'text',
76
+ text: JSON.stringify({
77
+ timeline,
78
+ arc: args.arc,
79
+ episode: args.episode,
80
+ inserted: {
81
+ chapter: newChapterNumber,
82
+ pov: args.pov,
83
+ title: args.title,
84
+ words,
85
+ },
86
+ renumbered: chaptersToRenumber.map((ch) => ({
87
+ oldNumber: ch.number,
88
+ newNumber: ch.number + 1,
89
+ title: ch.title,
90
+ })),
91
+ }, null, 2),
92
+ },
93
+ ],
94
+ };
95
+ }
96
+ catch (error) {
97
+ throw new Error(`Failed to insert chapter: ${error instanceof Error ? error.message : 'Unknown error'}`);
98
+ }
99
+ }
@@ -0,0 +1,15 @@
1
+ import type { Tracker } from '@echoes-io/tracker';
2
+ import { z } from 'zod';
3
+ export declare const chapterRefreshSchema: z.ZodObject<{
4
+ file: z.ZodString;
5
+ }, "strip", z.ZodTypeAny, {
6
+ file: string;
7
+ }, {
8
+ file: string;
9
+ }>;
10
+ export declare function chapterRefresh(args: z.infer<typeof chapterRefreshSchema>, tracker: Tracker): Promise<{
11
+ content: {
12
+ type: "text";
13
+ text: string;
14
+ }[];
15
+ }>;
@@ -0,0 +1,79 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { getTextStats, parseMarkdown } from '@echoes-io/utils';
3
+ import { z } from 'zod';
4
+ import { getTimeline } from '../utils.js';
5
+ export const chapterRefreshSchema = z.object({
6
+ file: z.string().describe('Path to chapter markdown file'),
7
+ });
8
+ export async function chapterRefresh(args, tracker) {
9
+ try {
10
+ const content = readFileSync(args.file, 'utf-8');
11
+ const { metadata, content: markdownContent } = parseMarkdown(content);
12
+ const stats = getTextStats(markdownContent);
13
+ const timeline = getTimeline();
14
+ const arc = metadata.arc;
15
+ const episode = metadata.episode;
16
+ const chapter = metadata.chapter;
17
+ if (!arc || !episode || !chapter) {
18
+ throw new Error('Missing required metadata: arc, episode, or chapter');
19
+ }
20
+ const existing = await tracker.getChapter(timeline, arc, episode, chapter);
21
+ if (!existing) {
22
+ throw new Error(`Chapter not found in database: ${timeline}/${arc}/ep${episode}/ch${chapter}`);
23
+ }
24
+ const chapterData = {
25
+ timelineName: timeline,
26
+ arcName: arc,
27
+ episodeNumber: episode,
28
+ partNumber: metadata.part || 1,
29
+ number: chapter,
30
+ pov: metadata.pov || 'Unknown',
31
+ title: metadata.title || 'Untitled',
32
+ date: new Date(metadata.date || Date.now()),
33
+ excerpt: metadata.excerpt || '',
34
+ location: metadata.location || '',
35
+ outfit: metadata.outfit || '',
36
+ kink: metadata.kink || '',
37
+ words: stats.words,
38
+ characters: stats.characters,
39
+ charactersNoSpaces: stats.charactersNoSpaces,
40
+ paragraphs: stats.paragraphs,
41
+ sentences: stats.sentences,
42
+ readingTimeMinutes: Math.ceil(stats.words / 200),
43
+ };
44
+ await tracker.updateChapter(timeline, arc, episode, chapter, chapterData);
45
+ return {
46
+ content: [
47
+ {
48
+ type: 'text',
49
+ text: JSON.stringify({
50
+ file: args.file,
51
+ timeline,
52
+ arc,
53
+ episode,
54
+ chapter,
55
+ updated: {
56
+ metadata: {
57
+ pov: chapterData.pov,
58
+ title: chapterData.title,
59
+ date: chapterData.date,
60
+ excerpt: chapterData.excerpt,
61
+ location: chapterData.location,
62
+ },
63
+ stats: {
64
+ words: stats.words,
65
+ characters: stats.characters,
66
+ paragraphs: stats.paragraphs,
67
+ sentences: stats.sentences,
68
+ readingTimeMinutes: chapterData.readingTimeMinutes,
69
+ },
70
+ },
71
+ }, null, 2),
72
+ },
73
+ ],
74
+ };
75
+ }
76
+ catch (error) {
77
+ throw new Error(`Failed to refresh chapter: ${error instanceof Error ? error.message : 'Unknown error'}`);
78
+ }
79
+ }
@@ -0,0 +1,18 @@
1
+ import type { Tracker } from '@echoes-io/tracker';
2
+ import { z } from 'zod';
3
+ export declare const episodeInfoSchema: z.ZodObject<{
4
+ arc: z.ZodString;
5
+ episode: z.ZodNumber;
6
+ }, "strip", z.ZodTypeAny, {
7
+ arc: string;
8
+ episode: number;
9
+ }, {
10
+ arc: string;
11
+ episode: number;
12
+ }>;
13
+ export declare function episodeInfo(args: z.infer<typeof episodeInfoSchema>, tracker: Tracker): Promise<{
14
+ content: {
15
+ type: "text";
16
+ text: string;
17
+ }[];
18
+ }>;
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ import { getTimeline } from '../utils.js';
3
+ export const episodeInfoSchema = z.object({
4
+ arc: z.string().describe('Arc name'),
5
+ episode: z.number().describe('Episode number'),
6
+ });
7
+ export async function episodeInfo(args, tracker) {
8
+ try {
9
+ const timeline = getTimeline();
10
+ const episode = await tracker.getEpisode(timeline, args.arc, args.episode);
11
+ if (!episode) {
12
+ throw new Error(`Episode not found: ${timeline}/${args.arc}/ep${args.episode}`);
13
+ }
14
+ const chapters = await tracker.getChapters(timeline, args.arc, args.episode);
15
+ return {
16
+ content: [
17
+ {
18
+ type: 'text',
19
+ text: JSON.stringify({
20
+ timeline,
21
+ arc: args.arc,
22
+ episodeInfo: {
23
+ number: episode.number,
24
+ title: episode.title,
25
+ slug: episode.slug,
26
+ description: episode.description,
27
+ },
28
+ chapters: chapters.map((ch) => ({
29
+ number: ch.number,
30
+ pov: ch.pov,
31
+ title: ch.title,
32
+ words: ch.words,
33
+ })),
34
+ stats: {
35
+ totalChapters: chapters.length,
36
+ totalWords: chapters.reduce((sum, ch) => sum + ch.words, 0),
37
+ },
38
+ }, null, 2),
39
+ },
40
+ ],
41
+ };
42
+ }
43
+ catch (error) {
44
+ throw new Error(`Failed to get episode info: ${error instanceof Error ? error.message : 'Unknown error'}`);
45
+ }
46
+ }
@@ -0,0 +1,27 @@
1
+ import type { Tracker } from '@echoes-io/tracker';
2
+ import { z } from 'zod';
3
+ export declare const episodeUpdateSchema: z.ZodObject<{
4
+ arc: z.ZodString;
5
+ episode: z.ZodNumber;
6
+ description: z.ZodOptional<z.ZodString>;
7
+ title: z.ZodOptional<z.ZodString>;
8
+ slug: z.ZodOptional<z.ZodString>;
9
+ }, "strip", z.ZodTypeAny, {
10
+ arc: string;
11
+ episode: number;
12
+ title?: string | undefined;
13
+ description?: string | undefined;
14
+ slug?: string | undefined;
15
+ }, {
16
+ arc: string;
17
+ episode: number;
18
+ title?: string | undefined;
19
+ description?: string | undefined;
20
+ slug?: string | undefined;
21
+ }>;
22
+ export declare function episodeUpdate(args: z.infer<typeof episodeUpdateSchema>, tracker: Tracker): Promise<{
23
+ content: {
24
+ type: "text";
25
+ text: string;
26
+ }[];
27
+ }>;
@@ -0,0 +1,43 @@
1
+ import { z } from 'zod';
2
+ import { getTimeline } from '../utils.js';
3
+ export const episodeUpdateSchema = z.object({
4
+ arc: z.string().describe('Arc name'),
5
+ episode: z.number().describe('Episode number'),
6
+ description: z.string().optional().describe('Episode description'),
7
+ title: z.string().optional().describe('Episode title'),
8
+ slug: z.string().optional().describe('Episode slug'),
9
+ });
10
+ export async function episodeUpdate(args, tracker) {
11
+ try {
12
+ const timeline = getTimeline();
13
+ const existing = await tracker.getEpisode(timeline, args.arc, args.episode);
14
+ if (!existing) {
15
+ throw new Error(`Episode not found: ${timeline}/${args.arc}/ep${args.episode}`);
16
+ }
17
+ const updateData = {};
18
+ if (args.description !== undefined)
19
+ updateData.description = args.description;
20
+ if (args.title !== undefined)
21
+ updateData.title = args.title;
22
+ if (args.slug !== undefined)
23
+ updateData.slug = args.slug;
24
+ await tracker.updateEpisode(timeline, args.arc, args.episode, updateData);
25
+ return {
26
+ content: [
27
+ {
28
+ type: 'text',
29
+ text: JSON.stringify({
30
+ timeline,
31
+ arc: args.arc,
32
+ episode: args.episode,
33
+ updated: updateData,
34
+ message: 'Episode successfully updated',
35
+ }, null, 2),
36
+ },
37
+ ],
38
+ };
39
+ }
40
+ catch (error) {
41
+ throw new Error(`Failed to update episode: ${error instanceof Error ? error.message : 'Unknown error'}`);
42
+ }
43
+ }
@@ -0,0 +1,9 @@
1
+ export { chapterDelete, chapterDeleteSchema } from './chapter-delete.js';
2
+ export { chapterInfo, chapterInfoSchema } from './chapter-info.js';
3
+ export { chapterInsert, chapterInsertSchema } from './chapter-insert.js';
4
+ export { chapterRefresh, chapterRefreshSchema } from './chapter-refresh.js';
5
+ export { episodeInfo, episodeInfoSchema } from './episode-info.js';
6
+ export { episodeUpdate, episodeUpdateSchema } from './episode-update.js';
7
+ export { stats, statsSchema } from './stats.js';
8
+ export { timelineSync, timelineSyncSchema } from './timeline-sync.js';
9
+ export { wordsCount, wordsCountSchema } from './words-count.js';
@@ -0,0 +1,9 @@
1
+ export { chapterDelete, chapterDeleteSchema } from './chapter-delete.js';
2
+ export { chapterInfo, chapterInfoSchema } from './chapter-info.js';
3
+ export { chapterInsert, chapterInsertSchema } from './chapter-insert.js';
4
+ export { chapterRefresh, chapterRefreshSchema } from './chapter-refresh.js';
5
+ export { episodeInfo, episodeInfoSchema } from './episode-info.js';
6
+ export { episodeUpdate, episodeUpdateSchema } from './episode-update.js';
7
+ export { stats, statsSchema } from './stats.js';
8
+ export { timelineSync, timelineSyncSchema } from './timeline-sync.js';
9
+ export { wordsCount, wordsCountSchema } from './words-count.js';
@@ -0,0 +1,21 @@
1
+ import type { Tracker } from '@echoes-io/tracker';
2
+ import { z } from 'zod';
3
+ export declare const statsSchema: z.ZodObject<{
4
+ arc: z.ZodOptional<z.ZodString>;
5
+ episode: z.ZodOptional<z.ZodNumber>;
6
+ pov: z.ZodOptional<z.ZodString>;
7
+ }, "strip", z.ZodTypeAny, {
8
+ arc?: string | undefined;
9
+ episode?: number | undefined;
10
+ pov?: string | undefined;
11
+ }, {
12
+ arc?: string | undefined;
13
+ episode?: number | undefined;
14
+ pov?: string | undefined;
15
+ }>;
16
+ export declare function stats(args: z.infer<typeof statsSchema>, tracker: Tracker): Promise<{
17
+ content: {
18
+ type: "text";
19
+ text: string;
20
+ }[];
21
+ }>;
@@ -0,0 +1,133 @@
1
+ import { z } from 'zod';
2
+ import { getTimeline } from '../utils.js';
3
+ export const statsSchema = z.object({
4
+ arc: z.string().optional().describe('Filter by arc name'),
5
+ episode: z.number().optional().describe('Filter by episode number'),
6
+ pov: z.string().optional().describe('Filter by POV character'),
7
+ });
8
+ export async function stats(args, tracker) {
9
+ try {
10
+ const timeline = getTimeline();
11
+ let chapters = [];
12
+ // Get chapters based on filters
13
+ if (args.arc && args.episode) {
14
+ chapters = await tracker.getChapters(timeline, args.arc, args.episode);
15
+ }
16
+ else if (args.arc) {
17
+ const episodes = await tracker.getEpisodes(timeline, args.arc);
18
+ for (const ep of episodes) {
19
+ const epChapters = await tracker.getChapters(timeline, args.arc, ep.number);
20
+ chapters.push(...epChapters);
21
+ }
22
+ }
23
+ else {
24
+ const arcs = await tracker.getArcs(timeline);
25
+ for (const arc of arcs) {
26
+ const episodes = await tracker.getEpisodes(timeline, arc.name);
27
+ for (const ep of episodes) {
28
+ const epChapters = await tracker.getChapters(timeline, arc.name, ep.number);
29
+ chapters.push(...epChapters);
30
+ }
31
+ }
32
+ }
33
+ // Filter by POV if specified
34
+ if (args.pov) {
35
+ chapters = chapters.filter((ch) => ch.pov === args.pov);
36
+ }
37
+ // Calculate statistics
38
+ const totalWords = chapters.reduce((sum, ch) => sum + ch.words, 0);
39
+ const totalChapters = chapters.length;
40
+ // POV distribution
41
+ const povStats = {};
42
+ for (const ch of chapters) {
43
+ if (!povStats[ch.pov]) {
44
+ povStats[ch.pov] = { chapters: 0, words: 0 };
45
+ }
46
+ povStats[ch.pov].chapters++;
47
+ povStats[ch.pov].words += ch.words;
48
+ }
49
+ // Arc breakdown (if not filtered by arc)
50
+ const arcStats = {};
51
+ if (!args.arc) {
52
+ for (const ch of chapters) {
53
+ if (!arcStats[ch.arcName]) {
54
+ arcStats[ch.arcName] = { chapters: 0, words: 0, episodes: new Set() };
55
+ }
56
+ arcStats[ch.arcName].chapters++;
57
+ arcStats[ch.arcName].words += ch.words;
58
+ arcStats[ch.arcName].episodes.add(ch.episodeNumber);
59
+ }
60
+ }
61
+ // Episode breakdown (if filtered by arc but not episode)
62
+ const episodeStats = {};
63
+ if (args.arc && !args.episode) {
64
+ for (const ch of chapters) {
65
+ if (!episodeStats[ch.episodeNumber]) {
66
+ episodeStats[ch.episodeNumber] = { chapters: 0, words: 0 };
67
+ }
68
+ episodeStats[ch.episodeNumber].chapters++;
69
+ episodeStats[ch.episodeNumber].words += ch.words;
70
+ }
71
+ }
72
+ const result = {
73
+ timeline,
74
+ filters: {
75
+ arc: args.arc || null,
76
+ episode: args.episode || null,
77
+ pov: args.pov || null,
78
+ },
79
+ summary: {
80
+ totalChapters,
81
+ totalWords,
82
+ averageChapterLength: totalChapters > 0 ? Math.round(totalWords / totalChapters) : 0,
83
+ },
84
+ povDistribution: Object.entries(povStats).map(([pov, stats]) => ({
85
+ pov,
86
+ chapters: stats.chapters,
87
+ words: stats.words,
88
+ percentage: totalWords > 0 ? Math.round((stats.words / totalWords) * 100) : 0,
89
+ })),
90
+ };
91
+ if (!args.arc && Object.keys(arcStats).length > 0) {
92
+ result.arcBreakdown = Object.entries(arcStats).map(([arc, stats]) => ({
93
+ arc,
94
+ chapters: stats.chapters,
95
+ words: stats.words,
96
+ episodes: stats.episodes.size,
97
+ }));
98
+ }
99
+ if (args.arc && !args.episode && Object.keys(episodeStats).length > 0) {
100
+ result.episodeBreakdown = Object.entries(episodeStats).map(([episode, stats]) => ({
101
+ episode: Number(episode),
102
+ chapters: stats.chapters,
103
+ words: stats.words,
104
+ }));
105
+ }
106
+ if (totalChapters > 0) {
107
+ const sortedByWords = [...chapters].sort((a, b) => b.words - a.words);
108
+ result.extremes = {
109
+ longest: {
110
+ title: sortedByWords[0].title,
111
+ pov: sortedByWords[0].pov,
112
+ words: sortedByWords[0].words,
113
+ },
114
+ shortest: {
115
+ title: sortedByWords[sortedByWords.length - 1].title,
116
+ pov: sortedByWords[sortedByWords.length - 1].pov,
117
+ words: sortedByWords[sortedByWords.length - 1].words,
118
+ },
119
+ };
120
+ }
121
+ return {
122
+ content: [
123
+ {
124
+ type: 'text',
125
+ text: JSON.stringify(result, null, 2),
126
+ },
127
+ ],
128
+ };
129
+ }
130
+ catch (error) {
131
+ throw new Error(`Failed to get stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
132
+ }
133
+ }
@@ -0,0 +1,15 @@
1
+ import type { Tracker } from '@echoes-io/tracker';
2
+ import { z } from 'zod';
3
+ export declare const timelineSyncSchema: z.ZodObject<{
4
+ contentPath: z.ZodString;
5
+ }, "strip", z.ZodTypeAny, {
6
+ contentPath: string;
7
+ }, {
8
+ contentPath: string;
9
+ }>;
10
+ export declare function timelineSync(args: z.infer<typeof timelineSyncSchema>, tracker: Tracker): Promise<{
11
+ content: {
12
+ type: "text";
13
+ text: string;
14
+ }[];
15
+ }>;
@@ -0,0 +1,183 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { extname, join } from 'node:path';
3
+ import { getTextStats, parseMarkdown } from '@echoes-io/utils';
4
+ import { z } from 'zod';
5
+ import { getTimeline } from '../utils.js';
6
+ export const timelineSyncSchema = z.object({
7
+ contentPath: z.string().describe('Path to content directory'),
8
+ });
9
+ export async function timelineSync(args, tracker) {
10
+ try {
11
+ const timeline = getTimeline();
12
+ let added = 0, updated = 0, deleted = 0, errors = 0;
13
+ let timelineRecord = await tracker.getTimeline(timeline);
14
+ if (!timelineRecord) {
15
+ timelineRecord = await tracker.createTimeline({
16
+ name: timeline,
17
+ description: `Timeline ${timeline}`,
18
+ });
19
+ added++;
20
+ }
21
+ const arcs = readdirSync(args.contentPath, { withFileTypes: true })
22
+ .filter((entry) => entry.isDirectory())
23
+ .map((entry) => entry.name);
24
+ for (const arcName of arcs) {
25
+ const arcPath = join(args.contentPath, arcName);
26
+ let arc = await tracker.getArc(timeline, arcName);
27
+ if (!arc) {
28
+ arc = await tracker.createArc({
29
+ timelineName: timeline,
30
+ name: arcName,
31
+ number: 1,
32
+ description: `Arc ${arcName}`,
33
+ });
34
+ added++;
35
+ }
36
+ const episodes = readdirSync(arcPath, { withFileTypes: true })
37
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('ep'))
38
+ .map((entry) => ({
39
+ name: entry.name,
40
+ number: Number.parseInt(entry.name.match(/ep(\d+)/)?.[1] || '0', 10),
41
+ }));
42
+ for (const ep of episodes) {
43
+ const episodePath = join(arcPath, ep.name);
44
+ let episode = await tracker.getEpisode(timeline, arcName, ep.number);
45
+ if (!episode) {
46
+ episode = await tracker.createEpisode({
47
+ timelineName: timeline,
48
+ arcName: arcName,
49
+ number: ep.number,
50
+ slug: ep.name,
51
+ title: ep.name,
52
+ description: `Episode ${ep.number}`,
53
+ });
54
+ added++;
55
+ }
56
+ const chapters = readdirSync(episodePath)
57
+ .filter((file) => extname(file) === '.md')
58
+ .map((file) => {
59
+ try {
60
+ const filePath = join(episodePath, file);
61
+ const content = require('node:fs').readFileSync(filePath, 'utf-8');
62
+ const { metadata, content: markdownContent } = parseMarkdown(content);
63
+ const stats = getTextStats(markdownContent);
64
+ return {
65
+ file: filePath,
66
+ metadata,
67
+ stats,
68
+ };
69
+ }
70
+ catch (_error) {
71
+ errors++;
72
+ return null;
73
+ }
74
+ })
75
+ .filter((ch) => ch !== null);
76
+ for (const chapterData of chapters) {
77
+ if (!chapterData)
78
+ continue;
79
+ const chNumber = chapterData.metadata.chapter;
80
+ if (!chNumber)
81
+ continue;
82
+ try {
83
+ const existing = await tracker.getChapter(timeline, arcName, ep.number, chNumber);
84
+ const data = {
85
+ timelineName: timeline,
86
+ arcName: arcName,
87
+ episodeNumber: ep.number,
88
+ partNumber: chapterData.metadata.part || 1,
89
+ number: chNumber,
90
+ pov: chapterData.metadata.pov || 'Unknown',
91
+ title: chapterData.metadata.title || 'Untitled',
92
+ date: new Date(chapterData.metadata.date || Date.now()),
93
+ excerpt: chapterData.metadata.excerpt || '',
94
+ location: chapterData.metadata.location || '',
95
+ outfit: chapterData.metadata.outfit || '',
96
+ kink: chapterData.metadata.kink || '',
97
+ words: chapterData.stats.words,
98
+ characters: chapterData.stats.characters,
99
+ charactersNoSpaces: chapterData.stats.charactersNoSpaces,
100
+ paragraphs: chapterData.stats.paragraphs,
101
+ sentences: chapterData.stats.sentences,
102
+ readingTimeMinutes: Math.ceil(chapterData.stats.words / 200),
103
+ };
104
+ if (existing) {
105
+ await tracker.updateChapter(timeline, arcName, ep.number, chNumber, data);
106
+ updated++;
107
+ }
108
+ else {
109
+ await tracker.createChapter(data);
110
+ added++;
111
+ }
112
+ }
113
+ catch (_error) {
114
+ errors++;
115
+ }
116
+ }
117
+ }
118
+ }
119
+ try {
120
+ const dbArcs = await tracker.getArcs(timeline);
121
+ for (const arc of dbArcs) {
122
+ const dbEpisodes = await tracker.getEpisodes(timeline, arc.name);
123
+ for (const episode of dbEpisodes) {
124
+ const allChapters = await tracker.getChapters(timeline, arc.name, episode.number);
125
+ for (const dbChapter of allChapters) {
126
+ let fileExists = false;
127
+ try {
128
+ const arcPath = join(args.contentPath, dbChapter.arcName);
129
+ if (existsSync(arcPath)) {
130
+ const episodeDirs = readdirSync(arcPath, { withFileTypes: true }).filter((entry) => entry.isDirectory() &&
131
+ entry.name.startsWith(`ep${dbChapter.episodeNumber.toString().padStart(2, '0')}-`));
132
+ for (const episodeDir of episodeDirs) {
133
+ const episodePath = join(arcPath, episodeDir.name);
134
+ const chapterFiles = readdirSync(episodePath).filter((file) => file.includes(`ch${dbChapter.number.toString().padStart(3, '0')}`) &&
135
+ file.endsWith('.md'));
136
+ if (chapterFiles.length > 0) {
137
+ fileExists = true;
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ }
143
+ catch (_error) {
144
+ fileExists = false;
145
+ }
146
+ if (!fileExists) {
147
+ try {
148
+ await tracker.deleteChapter(dbChapter.timelineName, dbChapter.arcName, dbChapter.episodeNumber, dbChapter.number);
149
+ deleted++;
150
+ }
151
+ catch (_error) {
152
+ errors++;
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ catch (_error) {
160
+ errors++;
161
+ }
162
+ return {
163
+ content: [
164
+ {
165
+ type: 'text',
166
+ text: JSON.stringify({
167
+ timeline,
168
+ contentPath: args.contentPath,
169
+ summary: {
170
+ added,
171
+ updated,
172
+ deleted,
173
+ errors,
174
+ },
175
+ }, null, 2),
176
+ },
177
+ ],
178
+ };
179
+ }
180
+ catch (error) {
181
+ throw new Error(`Failed to sync timeline: ${error instanceof Error ? error.message : 'Unknown error'}`);
182
+ }
183
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export declare const wordsCountSchema: z.ZodObject<{
3
+ file: z.ZodString;
4
+ }, "strip", z.ZodTypeAny, {
5
+ file: string;
6
+ }, {
7
+ file: string;
8
+ }>;
9
+ export declare function wordsCount(args: z.infer<typeof wordsCountSchema>): Promise<{
10
+ content: {
11
+ type: "text";
12
+ text: string;
13
+ }[];
14
+ }>;
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { getTextStats } from '@echoes-io/utils';
3
+ import { z } from 'zod';
4
+ export const wordsCountSchema = z.object({
5
+ file: z.string().describe('Path to markdown file'),
6
+ });
7
+ export async function wordsCount(args) {
8
+ try {
9
+ const content = readFileSync(args.file, 'utf-8');
10
+ const stats = getTextStats(content);
11
+ return {
12
+ content: [
13
+ {
14
+ type: 'text',
15
+ text: JSON.stringify({
16
+ file: args.file,
17
+ words: stats.words,
18
+ characters: stats.characters,
19
+ charactersNoSpaces: stats.charactersNoSpaces,
20
+ paragraphs: stats.paragraphs,
21
+ sentences: stats.sentences,
22
+ }, null, 2),
23
+ },
24
+ ],
25
+ };
26
+ }
27
+ catch (error) {
28
+ throw new Error(`Failed to count words: ${error instanceof Error ? error.message : 'Unknown error'}`);
29
+ }
30
+ }
package/lib/utils.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function getTimeline(): string;
package/lib/utils.js ADDED
@@ -0,0 +1,7 @@
1
+ export function getTimeline() {
2
+ const timeline = process.env.ECHOES_TIMELINE;
3
+ if (!timeline) {
4
+ throw new Error('ECHOES_TIMELINE environment variable is not set');
5
+ }
6
+ return timeline;
7
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@echoes-io/mcp-server",
3
3
  "type": "module",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "description": "Model Context Protocol server for AI integration with Echoes storytelling platform",
6
6
  "scripts": {
7
7
  "dev": "tsx cli/index.ts",