@sourcepress/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ import type { EngineContext } from "@sourcepress/server";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createMcpServer } from "../server.js";
4
+
5
+ function createMockEngine(): EngineContext {
6
+ return {
7
+ config: {
8
+ intent: { path: "intent/" },
9
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
10
+ } as any,
11
+ github: {
12
+ getTree: vi.fn().mockResolvedValue([]),
13
+ getFile: vi.fn().mockResolvedValue(null),
14
+ createBranch: vi.fn().mockResolvedValue("abc"),
15
+ createOrUpdateFile: vi.fn().mockResolvedValue({ sha: "x", commit_sha: "y" }),
16
+ createPR: vi.fn().mockResolvedValue({ number: 1, html_url: "https://github.com/test/pr/1" }),
17
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
18
+ } as any,
19
+ knowledge: {
20
+ getGraph: vi.fn().mockReturnValue(null),
21
+ query: vi.fn().mockReturnValue(null),
22
+ findStale: vi.fn().mockReturnValue([]),
23
+ findGaps: vi.fn().mockReturnValue([]),
24
+ ingest: vi.fn().mockResolvedValue({
25
+ path: "knowledge/test.md",
26
+ type: "notes",
27
+ quality: "draft",
28
+ quality_score: 5,
29
+ entities: [],
30
+ ingested_at: "2026-04-01",
31
+ source: "manual",
32
+ body: "test",
33
+ }),
34
+ buildGraph: vi.fn().mockResolvedValue({
35
+ entities: new Map(),
36
+ relations: [],
37
+ clusters: [],
38
+ built_at: "2026-04-01",
39
+ file_count: 0,
40
+ }),
41
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
42
+ } as any,
43
+ knowledgeStore: {
44
+ list: vi.fn().mockResolvedValue([]),
45
+ retrieve: vi.fn().mockResolvedValue(null),
46
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
47
+ } as any,
48
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
49
+ cache: {} as any,
50
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
51
+ budget: {} as any,
52
+ // biome-ignore lint/suspicious/noExplicitAny: partial mock of EngineContext
53
+ provider: {} as any,
54
+ listContent: vi.fn().mockResolvedValue([]),
55
+ getContent: vi.fn().mockResolvedValue(null),
56
+ getCollectionDef: vi.fn().mockImplementation((name: string) => {
57
+ if (name === "posts") {
58
+ return { name: "Blog Posts", path: "content/posts", format: "mdx", fields: {} };
59
+ }
60
+ return null;
61
+ }),
62
+ listCollections: vi.fn().mockReturnValue(["posts"]),
63
+ };
64
+ }
65
+
66
+ describe("MCP Server", () => {
67
+ it("creates MCP server instance", () => {
68
+ const engine = createMockEngine();
69
+ const server = createMcpServer(engine);
70
+ expect(server).toBeDefined();
71
+ });
72
+
73
+ it("server has correct name and version", () => {
74
+ const engine = createMockEngine();
75
+ const server = createMcpServer(engine);
76
+ // McpServer stores server info internally
77
+ expect(server).toBeDefined();
78
+ });
79
+
80
+ it("registers sourcepress_list_content tool", () => {
81
+ const engine = createMockEngine();
82
+ const server = createMcpServer(engine);
83
+ // biome-ignore lint/suspicious/noExplicitAny: accessing private MCP SDK internals for test assertion
84
+ const tools = (server as any)._registeredTools as Record<string, unknown>;
85
+ expect(Object.keys(tools)).toContain("sourcepress_list_content");
86
+ });
87
+ });
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { createMcpServer } from "./server.js";
package/src/server.ts ADDED
@@ -0,0 +1,572 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { EngineContext } from "@sourcepress/server";
3
+ import { z } from "zod";
4
+
5
+ export function createMcpServer(engine: EngineContext): McpServer {
6
+ const server = new McpServer({
7
+ name: "sourcepress",
8
+ version: "0.1.0",
9
+ });
10
+
11
+ // --- TOOLS ---
12
+
13
+ // Content tools
14
+ server.tool(
15
+ "sourcepress_list_content",
16
+ "List content files, optionally filtered by collection",
17
+ { collection: z.string().optional().describe("Collection name to filter by") },
18
+ async ({ collection }) => {
19
+ if (collection) {
20
+ const def = engine.getCollectionDef(collection);
21
+ if (!def) {
22
+ return {
23
+ content: [{ type: "text" as const, text: `Collection "${collection}" not found` }],
24
+ isError: true,
25
+ };
26
+ }
27
+ const files = await engine.listContent(collection);
28
+ return {
29
+ content: [
30
+ {
31
+ type: "text" as const,
32
+ text: JSON.stringify(
33
+ files.map((f) => ({
34
+ collection: f.collection,
35
+ slug: f.slug,
36
+ path: f.path,
37
+ title: f.frontmatter.title ?? f.slug,
38
+ })),
39
+ null,
40
+ 2,
41
+ ),
42
+ },
43
+ ],
44
+ };
45
+ }
46
+
47
+ const collections = engine.listCollections();
48
+ const allContent = [];
49
+ for (const coll of collections) {
50
+ const files = await engine.listContent(coll);
51
+ allContent.push(
52
+ ...files.map((f) => ({
53
+ collection: f.collection,
54
+ slug: f.slug,
55
+ path: f.path,
56
+ title: f.frontmatter.title ?? f.slug,
57
+ })),
58
+ );
59
+ }
60
+ return { content: [{ type: "text" as const, text: JSON.stringify(allContent, null, 2) }] };
61
+ },
62
+ );
63
+
64
+ server.tool(
65
+ "sourcepress_get_content",
66
+ "Get a single content file by collection and slug",
67
+ {
68
+ collection: z.string().describe("Collection name"),
69
+ slug: z.string().describe("Content slug"),
70
+ },
71
+ async ({ collection, slug }) => {
72
+ const file = await engine.getContent(collection, slug);
73
+ if (!file) {
74
+ return {
75
+ content: [
76
+ { type: "text" as const, text: `Content "${slug}" not found in "${collection}"` },
77
+ ],
78
+ isError: true,
79
+ };
80
+ }
81
+ return { content: [{ type: "text" as const, text: JSON.stringify(file, null, 2) }] };
82
+ },
83
+ );
84
+
85
+ server.tool(
86
+ "sourcepress_create_content",
87
+ "Create new content in a collection (creates a GitHub PR)",
88
+ {
89
+ collection: z.string().describe("Collection name"),
90
+ slug: z.string().describe("Content slug"),
91
+ frontmatter: z.record(z.unknown()).describe("Frontmatter key-value pairs"),
92
+ body: z.string().describe("Content body in markdown"),
93
+ },
94
+ async ({ collection, slug, frontmatter, body }) => {
95
+ const def = engine.getCollectionDef(collection);
96
+ if (!def) {
97
+ return {
98
+ content: [{ type: "text" as const, text: `Collection "${collection}" not found` }],
99
+ isError: true,
100
+ };
101
+ }
102
+
103
+ const ext = def.format === "yaml" ? "yaml" : def.format === "json" ? "json" : def.format;
104
+ const filePath = `${def.path}/${slug}.${ext}`;
105
+ const frontmatterYaml = Object.entries(frontmatter)
106
+ .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
107
+ .join("\n");
108
+ const fileContent = `---\n${frontmatterYaml}\n---\n\n${body}`;
109
+
110
+ const branchName = `sourcepress/create-${collection}-${slug}-${Date.now()}`;
111
+ await engine.github.createBranch(branchName);
112
+ await engine.github.createOrUpdateFile(
113
+ filePath,
114
+ fileContent,
115
+ `feat(${collection}): create ${slug}`,
116
+ branchName,
117
+ );
118
+ const pr = await engine.github.createPR(
119
+ `Create ${collection}: ${slug}`,
120
+ "Created by SourcePress MCP.",
121
+ branchName,
122
+ );
123
+
124
+ return {
125
+ content: [
126
+ {
127
+ type: "text" as const,
128
+ text: `Created PR #${pr.number}: ${pr.html_url}\nPath: ${filePath}`,
129
+ },
130
+ ],
131
+ };
132
+ },
133
+ );
134
+
135
+ // Knowledge tools
136
+ server.tool(
137
+ "sourcepress_list_knowledge",
138
+ "List all knowledge files in the knowledge store",
139
+ {},
140
+ async () => {
141
+ const files = await engine.knowledgeStore.list();
142
+ const items = files.map((f) => ({
143
+ path: f.path,
144
+ type: f.type,
145
+ quality: f.quality,
146
+ quality_score: f.quality_score,
147
+ entities: f.entities,
148
+ source: f.source,
149
+ }));
150
+ return { content: [{ type: "text" as const, text: JSON.stringify(items, null, 2) }] };
151
+ },
152
+ );
153
+
154
+ server.tool(
155
+ "sourcepress_ingest_knowledge",
156
+ "Ingest new knowledge (classifies, extracts entities, stores)",
157
+ {
158
+ path: z.string().describe("Path for the knowledge file (e.g. knowledge/clients/acme.md)"),
159
+ body: z.string().describe("The knowledge content text"),
160
+ source: z
161
+ .enum(["manual", "url", "document", "transcript", "scrape"])
162
+ .optional()
163
+ .describe("Source type"),
164
+ source_url: z.string().optional().describe("Source URL if applicable"),
165
+ },
166
+ async ({ path, body, source, source_url }) => {
167
+ const result = await engine.knowledge.ingest(path, body, source ?? "manual", source_url);
168
+ return {
169
+ content: [
170
+ {
171
+ type: "text" as const,
172
+ text: JSON.stringify(
173
+ {
174
+ ingested: true,
175
+ path: result.path,
176
+ type: result.type,
177
+ quality: result.quality,
178
+ quality_score: result.quality_score,
179
+ entities: result.entities,
180
+ },
181
+ null,
182
+ 2,
183
+ ),
184
+ },
185
+ ],
186
+ };
187
+ },
188
+ );
189
+
190
+ server.tool(
191
+ "sourcepress_import_from_url",
192
+ "Scrape a URL, extract readable content, classify, extract entities, and store as knowledge",
193
+ {
194
+ url: z.string().url().describe("The URL to scrape and import"),
195
+ },
196
+ async ({ url }) => {
197
+ try {
198
+ const result = await engine.knowledge.importUrl(url);
199
+ return {
200
+ content: [
201
+ {
202
+ type: "text" as const,
203
+ text: JSON.stringify(
204
+ {
205
+ imported: true,
206
+ path: result.path,
207
+ type: result.type,
208
+ quality: result.quality,
209
+ quality_score: result.quality_score,
210
+ entities: result.entities,
211
+ source_url: result.source_url,
212
+ },
213
+ null,
214
+ 2,
215
+ ),
216
+ },
217
+ ],
218
+ };
219
+ } catch (error) {
220
+ return {
221
+ content: [
222
+ {
223
+ type: "text" as const,
224
+ text: `Failed to import ${url}: ${error instanceof Error ? error.message : String(error)}`,
225
+ },
226
+ ],
227
+ isError: true,
228
+ };
229
+ }
230
+ },
231
+ );
232
+
233
+ server.tool(
234
+ "sourcepress_import_batch",
235
+ "Import multiple URLs as a background job. Returns a job_id to track progress.",
236
+ {
237
+ urls: z.array(z.string().url()).min(1).describe("List of URLs to import"),
238
+ },
239
+ async ({ urls }) => {
240
+ if (!engine.jobs) {
241
+ return {
242
+ content: [{ type: "text" as const, text: "Job system not configured" }],
243
+ isError: true,
244
+ };
245
+ }
246
+ const jobId = await engine.jobs.enqueue({
247
+ type: "import-batch",
248
+ params: { urls },
249
+ });
250
+ const status = await engine.jobs.status(jobId);
251
+ return {
252
+ content: [
253
+ {
254
+ type: "text" as const,
255
+ text: JSON.stringify(
256
+ {
257
+ started: true,
258
+ job_id: jobId,
259
+ url_count: urls.length,
260
+ status: status?.status ?? "queued",
261
+ },
262
+ null,
263
+ 2,
264
+ ),
265
+ },
266
+ ],
267
+ };
268
+ },
269
+ );
270
+
271
+ server.tool(
272
+ "sourcepress_import_sitemap",
273
+ "Fetch and parse a sitemap, returning URL sections for interactive selection. Use import_sitemap_run to start the actual import after the user selects sections.",
274
+ {
275
+ url: z.string().url().describe("Sitemap URL (e.g. https://example.com/sitemap.xml)"),
276
+ },
277
+ async ({ url }) => {
278
+ try {
279
+ const result = await engine.knowledge.parseSitemap(url);
280
+ return {
281
+ content: [
282
+ {
283
+ type: "text" as const,
284
+ text: JSON.stringify(
285
+ {
286
+ sitemap_url: result.sitemap_url,
287
+ total_urls: result.total_urls,
288
+ sections: result.sections.map((s) => ({
289
+ name: s.name,
290
+ pattern: s.pattern,
291
+ count: s.count,
292
+ })),
293
+ },
294
+ null,
295
+ 2,
296
+ ),
297
+ },
298
+ ],
299
+ };
300
+ } catch (error) {
301
+ return {
302
+ content: [
303
+ {
304
+ type: "text" as const,
305
+ text: `Failed to parse sitemap: ${error instanceof Error ? error.message : String(error)}`,
306
+ },
307
+ ],
308
+ isError: true,
309
+ };
310
+ }
311
+ },
312
+ );
313
+
314
+ server.tool(
315
+ "sourcepress_import_sitemap_run",
316
+ "Run a sitemap import with include/exclude filters. Creates a background job. Use import_sitemap first to see available sections.",
317
+ {
318
+ sitemap_url: z.string().url().describe("The sitemap URL (same as used in import_sitemap)"),
319
+ include: z
320
+ .array(z.string())
321
+ .optional()
322
+ .describe('Path patterns to include (e.g. ["/services/*", "/cases/*"])'),
323
+ exclude: z
324
+ .array(z.string())
325
+ .optional()
326
+ .describe('Path patterns to exclude (e.g. ["/blog/*"])'),
327
+ },
328
+ async ({ sitemap_url, include, exclude }) => {
329
+ if (!engine.jobs) {
330
+ return {
331
+ content: [{ type: "text" as const, text: "Job system not configured" }],
332
+ isError: true,
333
+ };
334
+ }
335
+ const jobId = await engine.jobs.enqueue({
336
+ type: "import-sitemap",
337
+ params: { sitemap_url, include, exclude },
338
+ });
339
+ const status = await engine.jobs.status(jobId);
340
+ return {
341
+ content: [
342
+ {
343
+ type: "text" as const,
344
+ text: JSON.stringify(
345
+ {
346
+ started: true,
347
+ job_id: jobId,
348
+ sitemap_url,
349
+ include: include ?? [],
350
+ exclude: exclude ?? [],
351
+ status: status?.status ?? "queued",
352
+ },
353
+ null,
354
+ 2,
355
+ ),
356
+ },
357
+ ],
358
+ };
359
+ },
360
+ );
361
+
362
+ // Graph tools
363
+ server.tool(
364
+ "sourcepress_query_graph",
365
+ "Query the knowledge graph for an entity by name or alias",
366
+ { name: z.string().describe("Entity name or alias to search for") },
367
+ async ({ name }) => {
368
+ const result = engine.knowledge.query(name);
369
+ if (!result) {
370
+ return {
371
+ content: [
372
+ {
373
+ type: "text" as const,
374
+ text: `Entity "${name}" not found. Graph may need rebuilding.`,
375
+ },
376
+ ],
377
+ isError: true,
378
+ };
379
+ }
380
+ return {
381
+ content: [
382
+ {
383
+ type: "text" as const,
384
+ text: JSON.stringify(
385
+ {
386
+ entity: result.entity,
387
+ relations: result.relations,
388
+ related_entities: result.related_entities,
389
+ files: result.files,
390
+ },
391
+ null,
392
+ 2,
393
+ ),
394
+ },
395
+ ],
396
+ };
397
+ },
398
+ );
399
+
400
+ server.tool(
401
+ "sourcepress_find_gaps",
402
+ "Find knowledge gaps — entities with knowledge but no content",
403
+ {},
404
+ async () => {
405
+ const collections = engine.listCollections();
406
+ const allContent = [];
407
+ for (const coll of collections) {
408
+ const files = await engine.listContent(coll);
409
+ allContent.push(...files);
410
+ }
411
+ const gaps = engine.knowledge.findGaps(allContent);
412
+ return { content: [{ type: "text" as const, text: JSON.stringify(gaps, null, 2) }] };
413
+ },
414
+ );
415
+
416
+ server.tool(
417
+ "sourcepress_find_stale",
418
+ "Find stale content — content whose source knowledge has been updated",
419
+ {},
420
+ async () => {
421
+ const collections = engine.listCollections();
422
+ const allContent = [];
423
+ for (const coll of collections) {
424
+ const files = await engine.listContent(coll);
425
+ allContent.push(...files);
426
+ }
427
+ const knowledgeFiles = await engine.knowledgeStore.list();
428
+ const timestamps: Record<string, string> = {};
429
+ for (const f of knowledgeFiles) {
430
+ timestamps[f.path] = f.ingested_at;
431
+ }
432
+ const stale = engine.knowledge.findStale(allContent, timestamps);
433
+ return { content: [{ type: "text" as const, text: JSON.stringify(stale, null, 2) }] };
434
+ },
435
+ );
436
+
437
+ server.tool(
438
+ "sourcepress_score_content",
439
+ "Score content against intent using AI",
440
+ {
441
+ collection: z.string().describe("Collection name"),
442
+ slug: z.string().describe("Content slug"),
443
+ intent: z.string().describe("Intent text to score against"),
444
+ },
445
+ async ({ collection, slug, intent }) => {
446
+ const { score } = await import("@sourcepress/ai");
447
+ const file = await engine.getContent(collection, slug);
448
+ if (!file) {
449
+ return {
450
+ content: [
451
+ { type: "text" as const, text: `Content "${slug}" not found in "${collection}"` },
452
+ ],
453
+ isError: true,
454
+ };
455
+ }
456
+ const result = await score(
457
+ { content: file.body, intent, collection_name: collection },
458
+ engine.provider,
459
+ engine.budget,
460
+ );
461
+ return {
462
+ content: [
463
+ {
464
+ type: "text" as const,
465
+ text: JSON.stringify(
466
+ {
467
+ score: result.score,
468
+ issues: result.issues,
469
+ strengths: result.strengths,
470
+ },
471
+ null,
472
+ 2,
473
+ ),
474
+ },
475
+ ],
476
+ };
477
+ },
478
+ );
479
+
480
+ server.tool(
481
+ "sourcepress_rebuild_graph",
482
+ "Rebuild the knowledge graph from all knowledge files",
483
+ {},
484
+ async () => {
485
+ const graph = await engine.knowledge.buildGraph();
486
+ return {
487
+ content: [
488
+ {
489
+ type: "text" as const,
490
+ text: JSON.stringify(
491
+ {
492
+ rebuilt: true,
493
+ entity_count: graph.entities.size,
494
+ relation_count: graph.relations.length,
495
+ cluster_count: graph.clusters.length,
496
+ file_count: graph.file_count,
497
+ built_at: graph.built_at,
498
+ },
499
+ null,
500
+ 2,
501
+ ),
502
+ },
503
+ ],
504
+ };
505
+ },
506
+ );
507
+
508
+ // --- RESOURCES ---
509
+
510
+ server.resource(
511
+ "schema",
512
+ "sourcepress://schema",
513
+ { description: "Full SourcePress schema — all collections with fields and formats" },
514
+ async () => {
515
+ const collections = engine.listCollections();
516
+ const schema: Record<string, unknown> = {};
517
+ for (const name of collections) {
518
+ const def = engine.getCollectionDef(name);
519
+ if (def)
520
+ schema[name] = { name: def.name, path: def.path, format: def.format, fields: def.fields };
521
+ }
522
+ return {
523
+ contents: [
524
+ {
525
+ uri: "sourcepress://schema",
526
+ text: JSON.stringify(schema, null, 2),
527
+ mimeType: "application/json",
528
+ },
529
+ ],
530
+ };
531
+ },
532
+ );
533
+
534
+ server.resource(
535
+ "graph",
536
+ "sourcepress://graph",
537
+ { description: "Knowledge graph summary — entities, relations, clusters" },
538
+ async () => {
539
+ const graph = engine.knowledge.getGraph();
540
+ if (!graph) {
541
+ return {
542
+ contents: [
543
+ {
544
+ uri: "sourcepress://graph",
545
+ text: '{"status":"empty","message":"No graph built yet"}',
546
+ mimeType: "application/json",
547
+ },
548
+ ],
549
+ };
550
+ }
551
+ const summary = {
552
+ entity_count: graph.entities.size,
553
+ relation_count: graph.relations.length,
554
+ cluster_count: graph.clusters.length,
555
+ file_count: graph.file_count,
556
+ built_at: graph.built_at,
557
+ entities: Array.from(graph.entities.keys()),
558
+ };
559
+ return {
560
+ contents: [
561
+ {
562
+ uri: "sourcepress://graph",
563
+ text: JSON.stringify(summary, null, 2),
564
+ mimeType: "application/json",
565
+ },
566
+ ],
567
+ };
568
+ },
569
+ );
570
+
571
+ return server;
572
+ }
package/src/stdio.ts ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import type { SourcePressConfig } from "@sourcepress/core";
4
+ import { createEngine } from "@sourcepress/server";
5
+ import { createMcpServer } from "./server.js";
6
+
7
+ async function main() {
8
+ const githubToken = process.env.GITHUB_TOKEN;
9
+ if (!githubToken) {
10
+ console.error("GITHUB_TOKEN environment variable is required");
11
+ process.exit(1);
12
+ }
13
+
14
+ const config: SourcePressConfig = {
15
+ repository: {
16
+ owner: process.env.GITHUB_OWNER ?? "sourcepress",
17
+ repo: process.env.GITHUB_REPO ?? "demo",
18
+ branch: process.env.GITHUB_BRANCH ?? "main",
19
+ },
20
+ ai: {
21
+ provider: (process.env.AI_PROVIDER as "anthropic" | "openai" | "local") ?? "anthropic",
22
+ model: process.env.AI_MODEL ?? "claude-sonnet-4-5-20250514",
23
+ },
24
+ collections: {},
25
+ knowledge: { path: "knowledge/", graph: { backend: "local" } },
26
+ intent: { path: "intent/" },
27
+ };
28
+
29
+ const engine = await createEngine({
30
+ config,
31
+ githubToken,
32
+ aiApiKey: process.env.AI_API_KEY,
33
+ });
34
+
35
+ const mcpServer = createMcpServer(engine);
36
+ const transport = new StdioServerTransport();
37
+ await mcpServer.connect(transport);
38
+ }
39
+
40
+ main().catch((err) => {
41
+ console.error("MCP server error:", err);
42
+ process.exit(1);
43
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }