tgo-wiki 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.
Files changed (68) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +255 -0
  3. package/docs/mcp-usage.md +631 -0
  4. package/docs/v0-acceptance.md +105 -0
  5. package/docs/v0-delivery-checklist.md +57 -0
  6. package/docs/v1-acceptance.md +39 -0
  7. package/docs/v2-acceptance.md +165 -0
  8. package/package.json +69 -0
  9. package/packages/core/src/config/config-loader.ts +109 -0
  10. package/packages/core/src/config/defaults.ts +74 -0
  11. package/packages/core/src/config/workspace-resolver.ts +40 -0
  12. package/packages/core/src/documents/command-document-parser.ts +206 -0
  13. package/packages/core/src/documents/document-id.ts +26 -0
  14. package/packages/core/src/documents/document-parser-registry.ts +126 -0
  15. package/packages/core/src/documents/document-service.ts +656 -0
  16. package/packages/core/src/documents/document-store.ts +132 -0
  17. package/packages/core/src/documents/document-types.ts +33 -0
  18. package/packages/core/src/documents/pdf-text-parser.ts +35 -0
  19. package/packages/core/src/documents/text-markdown-parser.ts +50 -0
  20. package/packages/core/src/errors.ts +46 -0
  21. package/packages/core/src/git/git-service.ts +68 -0
  22. package/packages/core/src/index.ts +38 -0
  23. package/packages/core/src/markdown/markdown-scanner.ts +90 -0
  24. package/packages/core/src/permissions/permission-service.ts +50 -0
  25. package/packages/core/src/publish/publish-service.ts +142 -0
  26. package/packages/core/src/result.ts +13 -0
  27. package/packages/core/src/services/session-workflow-service.ts +493 -0
  28. package/packages/core/src/services/wiki-service.ts +119 -0
  29. package/packages/core/src/services/workspace-service.ts +223 -0
  30. package/packages/core/src/session/session-id.ts +14 -0
  31. package/packages/core/src/session/session-service.ts +77 -0
  32. package/packages/core/src/session/session-store.ts +91 -0
  33. package/packages/core/src/session/session-types.ts +17 -0
  34. package/packages/core/src/sources/source-id.ts +19 -0
  35. package/packages/core/src/sources/source-paths.ts +15 -0
  36. package/packages/core/src/sources/source-service.ts +416 -0
  37. package/packages/core/src/sources/source-types.ts +77 -0
  38. package/packages/core/src/sources/source-validator.ts +132 -0
  39. package/packages/core/src/sources/source-writer.ts +419 -0
  40. package/packages/core/src/validation/frontmatter-validator.ts +128 -0
  41. package/packages/core/src/validation/link-validator.ts +55 -0
  42. package/packages/core/src/validation/path-validator.ts +65 -0
  43. package/packages/core/src/validation/source-reference-validator.ts +191 -0
  44. package/packages/core/src/validation/validation-service.ts +106 -0
  45. package/packages/core/src/vfs/vfs-command-parser.ts +69 -0
  46. package/packages/core/src/vfs/vfs-service.ts +498 -0
  47. package/packages/core/src/web/html-to-markdown.ts +144 -0
  48. package/packages/core/src/web/static-web-fetcher.ts +537 -0
  49. package/packages/core/src/web/web-id.ts +26 -0
  50. package/packages/core/src/web/web-ingestion-service.ts +335 -0
  51. package/packages/core/src/web/web-paths.ts +6 -0
  52. package/packages/core/src/web/web-types.ts +33 -0
  53. package/packages/server/src/cli.ts +56 -0
  54. package/packages/server/src/context.ts +7 -0
  55. package/packages/server/src/index.ts +2 -0
  56. package/packages/server/src/mcp-server.ts +111 -0
  57. package/packages/server/src/schemas/documents.ts +17 -0
  58. package/packages/server/src/schemas/read.ts +16 -0
  59. package/packages/server/src/schemas/session.ts +31 -0
  60. package/packages/server/src/schemas/sources.ts +12 -0
  61. package/packages/server/src/schemas/web.ts +23 -0
  62. package/packages/server/src/tools/document-tools.ts +46 -0
  63. package/packages/server/src/tools/publish-tools.ts +33 -0
  64. package/packages/server/src/tools/read-tools.ts +52 -0
  65. package/packages/server/src/tools/response.ts +24 -0
  66. package/packages/server/src/tools/session-tools.ts +100 -0
  67. package/packages/server/src/tools/source-tools.ts +32 -0
  68. package/packages/server/src/tools/web-tools.ts +26 -0
@@ -0,0 +1,335 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { link, lstat, mkdir, readFile, realpath, unlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { loadConfig } from "../config/config-loader.js";
5
+ import { resolveWorkspacePaths, type WorkspacePaths } from "../config/workspace-resolver.js";
6
+ import { WikiError, toWikiError } from "../errors.js";
7
+ import { err, ok, type Result } from "../result.js";
8
+ import { SessionStore } from "../session/session-store.js";
9
+ import type { SessionMetadata } from "../session/session-types.js";
10
+ import { assertValidSourceId } from "../sources/source-id.js";
11
+ import type { WebSourceMetadata } from "../sources/source-types.js";
12
+ import { assertNoExistingSourceForCreate, publishSource } from "../sources/source-writer.js";
13
+ import { generateWebSourceId, type WebSourceIdGenerator } from "./web-id.js";
14
+ import { htmlToRawMarkdown } from "./html-to-markdown.js";
15
+ import { webHtmlBlobPath } from "./web-paths.js";
16
+ import { staticWebFetch } from "./static-web-fetcher.js";
17
+ import type { StaticWebFetchResult, WebFetchInput, WebFetchResult } from "./web-types.js";
18
+
19
+ export type WebFetcher = (url: string) => Promise<StaticWebFetchResult>;
20
+
21
+ export type WebIngestionServiceOptions = {
22
+ clock?: () => Date;
23
+ fetcher?: WebFetcher;
24
+ };
25
+
26
+ const fetcherName = "static-fetch";
27
+ const fetcherVersion = "1.0.0";
28
+
29
+ export class WebIngestionService {
30
+ private readonly paths: WorkspacePaths;
31
+ private readonly sessions: SessionStore;
32
+ private readonly clock: () => Date;
33
+ private readonly fetcher?: WebFetcher;
34
+
35
+ constructor(
36
+ workspaceRoot: string,
37
+ private readonly idGenerator: WebSourceIdGenerator = generateWebSourceId,
38
+ options: WebIngestionServiceOptions = {}
39
+ ) {
40
+ this.paths = resolveWorkspacePaths(workspaceRoot);
41
+ this.sessions = new SessionStore(this.paths);
42
+ this.clock = options.clock ?? (() => new Date());
43
+ this.fetcher = options.fetcher;
44
+ }
45
+
46
+ async fetch(input: WebFetchInput): Promise<Result<WebFetchResult>> {
47
+ try {
48
+ const session = await this.sessions.read(input.sessionId);
49
+ if (session.status !== "open") {
50
+ throw new WikiError("validation_failed", `Session is not open: ${input.sessionId}`, {
51
+ status: session.status
52
+ });
53
+ }
54
+
55
+ const worktreeRoot = await this.sessionWorktreeRoot(session);
56
+ const config = await loadConfig(this.paths);
57
+ const fetchResult = this.fetcher ? await this.fetcher(input.url) : await staticWebFetch(input.url, config.web);
58
+ const fetchedAtDate = this.clock();
59
+ const fetchedAt = fetchedAtDate.toISOString();
60
+ const sourceId = this.idGenerator({
61
+ finalUrl: fetchResult.finalUrl,
62
+ htmlBlobSha256: fetchResult.htmlBlobSha256,
63
+ fetchedAt: fetchedAtDate
64
+ });
65
+ assertValidSourceId(sourceId);
66
+ await assertNoExistingSourceForCreate(worktreeRoot, sourceId);
67
+ await this.writeHtmlBlob(fetchResult);
68
+
69
+ const converted = htmlToRawMarkdown({
70
+ html: fetchResult.htmlText,
71
+ requestedUrl: input.url,
72
+ finalUrl: fetchResult.finalUrl,
73
+ fetchedAt,
74
+ fetcherName
75
+ });
76
+
77
+ const rawMarkdownPath = sourceRelativePath(sourceId, "raw.md");
78
+ const metadataPath = sourceRelativePath(sourceId, "metadata.json");
79
+ const metadata: WebSourceMetadata = {
80
+ documentId: sourceId,
81
+ version: 1,
82
+ sourceType: "web",
83
+ url: input.url,
84
+ finalUrl: fetchResult.finalUrl,
85
+ title: converted.title,
86
+ contentType: fetchResult.contentType,
87
+ statusCode: fetchResult.statusCode,
88
+ htmlBlobSha256: fetchResult.htmlBlobSha256,
89
+ rawMarkdownPath,
90
+ fetcher: {
91
+ name: fetcherName,
92
+ version: fetcherVersion
93
+ },
94
+ fetchMetadata: {
95
+ htmlBytes: fetchResult.htmlBytes,
96
+ markdownBytes: Buffer.byteLength(converted.markdown, "utf8")
97
+ },
98
+ createdAt: fetchedAt,
99
+ updatedAt: fetchedAt,
100
+ createdBy: input.createdBy,
101
+ status: "fetched"
102
+ };
103
+
104
+ await publishSource({
105
+ worktreeRoot,
106
+ sourceId,
107
+ markdown: converted.markdown,
108
+ metadataJson: `${JSON.stringify(metadata, null, 2)}\n`,
109
+ mode: "create"
110
+ });
111
+
112
+ return ok({
113
+ source_id: sourceId,
114
+ version: metadata.version,
115
+ raw_markdown_path: rawMarkdownPath,
116
+ metadata_path: metadataPath,
117
+ html_blob_sha256: fetchResult.htmlBlobSha256,
118
+ status: "fetched",
119
+ title: converted.title,
120
+ warnings: []
121
+ });
122
+ } catch (error) {
123
+ return err(toWikiError(error, "validation_failed"));
124
+ }
125
+ }
126
+
127
+ private async writeHtmlBlob(fetchResult: StaticWebFetchResult): Promise<void> {
128
+ assertValidSha256(fetchResult.htmlBlobSha256);
129
+ const content = Buffer.from(fetchResult.htmlContent);
130
+ assertContentMatchesSha256(content, fetchResult.htmlBlobSha256);
131
+ await this.ensureWebBlobStoreRoot();
132
+ const blobPath = webHtmlBlobPath(this.paths, fetchResult.htmlBlobSha256);
133
+
134
+ try {
135
+ await writeAtomicallyNoOverwrite(this.paths.webBlobsPath, blobPath, content);
136
+ } catch (error) {
137
+ if (!isFileExists(error)) {
138
+ throw error;
139
+ }
140
+
141
+ await assertExistingBlobMatches(blobPath, fetchResult.htmlBlobSha256);
142
+ }
143
+ }
144
+
145
+ private async ensureWebBlobStoreRoot(): Promise<void> {
146
+ const realWorkspaceRoot = await realpath(this.paths.workspaceRoot);
147
+ await ensureDirectoryForWrite(this.paths.statePath, realWorkspaceRoot, "State directory");
148
+ const realStateRoot = await realpath(this.paths.statePath);
149
+
150
+ await ensureDirectoryForWrite(this.paths.webStatePath, realStateRoot, "Web state directory");
151
+ const realWebStateRoot = await realpath(this.paths.webStatePath);
152
+
153
+ const webBlobsParent = path.dirname(this.paths.webBlobsPath);
154
+ await ensureDirectoryForWrite(webBlobsParent, realWebStateRoot, "Web blobs directory");
155
+ const realWebBlobsParent = await realpath(webBlobsParent);
156
+
157
+ await ensureDirectoryForWrite(this.paths.webBlobsPath, realWebBlobsParent, "Web blob store");
158
+ const realWebBlobsRoot = await realpath(this.paths.webBlobsPath);
159
+ ensureInsideRealRoot(realWebStateRoot, realWebBlobsRoot, this.paths.webBlobsPath, "Web blob store escapes web state boundary");
160
+ }
161
+
162
+ private async sessionWorktreeRoot(metadata: SessionMetadata): Promise<string> {
163
+ const absolute = path.resolve(this.paths.workspaceRoot, metadata.worktree);
164
+ const sessionsRoot = path.resolve(this.paths.sessionsWorktreePath);
165
+
166
+ if (!absolute.startsWith(`${sessionsRoot}${path.sep}`)) {
167
+ throw new WikiError("session_metadata_invalid", `Session worktree escapes sessions root: ${metadata.sessionId}`);
168
+ }
169
+
170
+ const realWorkspaceRoot = await realpath(this.paths.workspaceRoot);
171
+ await ensureDirectoryForWrite(this.paths.worktreesPath, realWorkspaceRoot, "Worktrees boundary");
172
+ const realWorktreesRoot = await realpath(this.paths.worktreesPath);
173
+
174
+ await ensureDirectoryForWrite(this.paths.sessionsWorktreePath, realWorktreesRoot, "Sessions worktree boundary");
175
+ const realSessionsRoot = await realpath(this.paths.sessionsWorktreePath);
176
+
177
+ await ensureDescendantDirectoryForWrite(this.paths.sessionsWorktreePath, absolute, realSessionsRoot, "Session worktree root");
178
+ const realWorktreeRoot = await realpath(absolute);
179
+ ensureStrictDescendant(realWorktreesRoot, realSessionsRoot, this.paths.sessionsWorktreePath, "Sessions worktree boundary escapes worktrees boundary");
180
+ ensureStrictDescendant(realSessionsRoot, realWorktreeRoot, absolute, "Session worktree root escapes sessions boundary");
181
+ return realWorktreeRoot;
182
+ }
183
+ }
184
+
185
+ function sourceRelativePath(sourceId: string, fileName: "metadata.json" | "raw.md"): string {
186
+ return `sources/${sourceId}/${fileName}`;
187
+ }
188
+
189
+ async function ensureDescendantDirectoryForWrite(root: string, target: string, realBoundary: string, label: string): Promise<void> {
190
+ const relativePath = path.relative(root, target);
191
+ if (relativePath.length === 0 || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
192
+ throw new WikiError("invalid_path", `${label} escapes boundary: ${target}`);
193
+ }
194
+
195
+ const segments = relativePath.split(path.sep).filter(segment => segment.length > 0 && segment !== ".");
196
+ let current = root;
197
+
198
+ for (const segment of segments) {
199
+ current = path.join(current, segment);
200
+ await ensureDirectoryForWrite(current, realBoundary, label);
201
+ }
202
+ }
203
+
204
+ async function ensureDirectoryForWrite(directoryPath: string, realBoundary: string, label: string): Promise<void> {
205
+ try {
206
+ const stat = await lstat(directoryPath);
207
+
208
+ if (stat.isSymbolicLink()) {
209
+ throw new WikiError("invalid_path", `${label} is a symlink: ${directoryPath}`);
210
+ }
211
+
212
+ if (!stat.isDirectory()) {
213
+ throw new WikiError("invalid_path", `${label} is not a directory: ${directoryPath}`);
214
+ }
215
+ } catch (error) {
216
+ if (!isEnoent(error)) {
217
+ throw error;
218
+ }
219
+
220
+ await mkdir(directoryPath);
221
+ await ensureDirectoryNotSymlink(directoryPath, label);
222
+ }
223
+
224
+ const realDirectory = await realpath(directoryPath);
225
+ ensureInsideRealRoot(realBoundary, realDirectory, directoryPath, `${label} escapes boundary`);
226
+ }
227
+
228
+ async function ensureDirectoryNotSymlink(directoryPath: string, label: string): Promise<void> {
229
+ const stat = await lstat(directoryPath);
230
+
231
+ if (stat.isSymbolicLink()) {
232
+ throw new WikiError("invalid_path", `${label} is a symlink: ${directoryPath}`);
233
+ }
234
+
235
+ if (!stat.isDirectory()) {
236
+ throw new WikiError("invalid_path", `${label} is not a directory: ${directoryPath}`);
237
+ }
238
+ }
239
+
240
+ function ensureStrictDescendant(realRoot: string, target: string, originalPath: string, message: string): void {
241
+ if (!target.startsWith(`${realRoot}${path.sep}`)) {
242
+ throw new WikiError("invalid_path", `${message}: ${originalPath}`);
243
+ }
244
+ }
245
+
246
+ function ensureInsideRealRoot(realRoot: string, target: string, originalPath: string, message: string): void {
247
+ if (!target.startsWith(`${realRoot}${path.sep}`) && target !== realRoot) {
248
+ throw new WikiError("invalid_path", `${message}: ${originalPath}`);
249
+ }
250
+ }
251
+
252
+ function assertValidSha256(value: string): void {
253
+ if (!/^[0-9a-f]{64}$/.test(value)) {
254
+ throw new WikiError("validation_failed", `Invalid sha256: ${value}`);
255
+ }
256
+ }
257
+
258
+ function assertContentMatchesSha256(content: Uint8Array, expected: string): void {
259
+ const actual = createHash("sha256").update(content).digest("hex");
260
+
261
+ if (actual !== expected) {
262
+ throw new WikiError("validation_failed", `Blob content does not match sha256: ${expected}`, {
263
+ expected,
264
+ actual
265
+ });
266
+ }
267
+ }
268
+
269
+ async function writeAtomicallyNoOverwrite(directory: string, finalPath: string, content: Buffer): Promise<void> {
270
+ const tempPath = await writeUniqueTempFile(directory, path.basename(finalPath), content);
271
+
272
+ try {
273
+ await link(tempPath, finalPath);
274
+ } finally {
275
+ await unlinkIfExists(tempPath);
276
+ }
277
+ }
278
+
279
+ async function writeUniqueTempFile(directory: string, finalBaseName: string, content: Buffer): Promise<string> {
280
+ for (let attempt = 0; attempt < 10; attempt += 1) {
281
+ const tempPath = path.join(directory, `.${finalBaseName}.${randomBytes(8).toString("hex")}.tmp`);
282
+
283
+ try {
284
+ await writeFile(tempPath, content, { flag: "wx" });
285
+ return tempPath;
286
+ } catch (error) {
287
+ if (isFileExists(error)) {
288
+ continue;
289
+ }
290
+
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ throw new WikiError("validation_failed", `Could not create unique temp file for: ${finalBaseName}`);
296
+ }
297
+
298
+ async function assertExistingBlobMatches(blobPath: string, blobSha256: string): Promise<void> {
299
+ const stat = await lstat(blobPath);
300
+ if (stat.isSymbolicLink()) {
301
+ throw new WikiError("invalid_path", `Existing web blob is a symlink: ${blobPath}`);
302
+ }
303
+
304
+ if (!stat.isFile()) {
305
+ throw new WikiError("invalid_path", `Existing web blob is not a file: ${blobPath}`);
306
+ }
307
+
308
+ const existing = await readFile(blobPath);
309
+ const actual = createHash("sha256").update(existing).digest("hex");
310
+
311
+ if (actual !== blobSha256) {
312
+ throw new WikiError("validation_failed", `Existing blob content does not match sha256 file name: ${blobSha256}`, {
313
+ expected: blobSha256,
314
+ actual
315
+ });
316
+ }
317
+ }
318
+
319
+ async function unlinkIfExists(filePath: string): Promise<void> {
320
+ try {
321
+ await unlink(filePath);
322
+ } catch (error) {
323
+ if (!isEnoent(error)) {
324
+ throw error;
325
+ }
326
+ }
327
+ }
328
+
329
+ function isEnoent(error: unknown): boolean {
330
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
331
+ }
332
+
333
+ function isFileExists(error: unknown): boolean {
334
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
335
+ }
@@ -0,0 +1,6 @@
1
+ import path from "node:path";
2
+ import type { WorkspacePaths } from "../config/workspace-resolver.js";
3
+
4
+ export function webHtmlBlobPath(paths: WorkspacePaths, sha256: string): string {
5
+ return path.join(paths.webBlobsPath, sha256);
6
+ }
@@ -0,0 +1,33 @@
1
+ export type WebFetchConfig = {
2
+ requestTimeoutMs: number;
3
+ maxResponseBytes: number;
4
+ allowedPrivateHosts?: string[];
5
+ };
6
+
7
+ export type StaticWebFetchResult = {
8
+ requestedUrl: string;
9
+ finalUrl: string;
10
+ contentType: string;
11
+ statusCode: number;
12
+ htmlBytes: number;
13
+ htmlContent: Uint8Array;
14
+ htmlText: string;
15
+ htmlBlobSha256: string;
16
+ };
17
+
18
+ export type WebFetchInput = {
19
+ sessionId: string;
20
+ url: string;
21
+ createdBy?: string;
22
+ };
23
+
24
+ export type WebFetchResult = {
25
+ source_id: string;
26
+ version: number;
27
+ raw_markdown_path: string;
28
+ metadata_path: string;
29
+ html_blob_sha256: string;
30
+ status: "fetched";
31
+ title?: string;
32
+ warnings: string[];
33
+ };
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bun
2
+ import { WorkspaceService, type Role } from "../../core/src/index.js";
3
+
4
+ function getArg(name: string): string | undefined {
5
+ const index = process.argv.indexOf(name);
6
+ return index >= 0 ? process.argv[index + 1] : undefined;
7
+ }
8
+
9
+ async function main(): Promise<void> {
10
+ const command = process.argv[2];
11
+ const workspace = getArg("--workspace");
12
+
13
+ if (!workspace) {
14
+ console.error("Missing required --workspace <path>");
15
+ process.exit(1);
16
+ }
17
+
18
+ if (command === "init") {
19
+ const result = await new WorkspaceService(workspace).init();
20
+
21
+ if (!result.ok) {
22
+ console.error(JSON.stringify({ error: result.error }, null, 2));
23
+ process.exit(1);
24
+ }
25
+
26
+ console.log(JSON.stringify(result.value, null, 2));
27
+ return;
28
+ }
29
+
30
+ if (command === "status") {
31
+ const result = await new WorkspaceService(workspace).status();
32
+
33
+ if (!result.ok) {
34
+ console.error(JSON.stringify({ error: result.error }, null, 2));
35
+ process.exit(1);
36
+ }
37
+
38
+ console.log(JSON.stringify(result.value, null, 2));
39
+ return;
40
+ }
41
+
42
+ if (command === "mcp") {
43
+ const role = (getArg("--role") ?? "reader") as Role;
44
+ const { startStdioMcpServer } = await import("./mcp-server.js");
45
+ await startStdioMcpServer({ workspaceRoot: workspace, role });
46
+ return;
47
+ }
48
+
49
+ console.error("Usage: tgo-wiki <init|status|mcp> --workspace <path> [--role <role>]");
50
+ process.exit(1);
51
+ }
52
+
53
+ main().catch(error => {
54
+ console.error(error instanceof Error ? error.message : String(error));
55
+ process.exit(1);
56
+ });
@@ -0,0 +1,7 @@
1
+ import type { Role } from "../../core/src/index.js";
2
+
3
+ export type ServerContext = {
4
+ workspaceRoot: string;
5
+ role: Role;
6
+ agentId?: string;
7
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./context.js";
2
+ export * from "./mcp-server.js";
@@ -0,0 +1,111 @@
1
+ import { McpServer, StdioServerTransport } from "@modelcontextprotocol/server";
2
+ import type { ServerContext } from "./context.js";
3
+ import { documentParseSchema, documentUploadSchema } from "./schemas/documents.js";
4
+ import { vfsExecSchema, wikiChannelStatusSchema, wikiReadSchema } from "./schemas/read.js";
5
+ import {
6
+ wikiSessionCommitSchema,
7
+ wikiSessionDiffSchema,
8
+ wikiSessionPatchSchema,
9
+ wikiSessionStartSchema,
10
+ wikiSessionValidateSchema
11
+ } from "./schemas/session.js";
12
+ import { sourceListSchema, sourceReadSchema } from "./schemas/sources.js";
13
+ import { webFetchSchema } from "./schemas/web.js";
14
+ import { callDocumentParse, callDocumentUpload } from "./tools/document-tools.js";
15
+ import { callVfsExec, callWikiChannelStatus, callWikiRead } from "./tools/read-tools.js";
16
+ import { callWikiPublishSession, wikiPublishSessionSchema } from "./tools/publish-tools.js";
17
+ import { callSourceList, callSourceRead } from "./tools/source-tools.js";
18
+ import { callWebFetch } from "./tools/web-tools.js";
19
+ import {
20
+ callWikiSessionCommit,
21
+ callWikiSessionDiff,
22
+ callWikiSessionPatch,
23
+ callWikiSessionStart,
24
+ callWikiSessionValidate
25
+ } from "./tools/session-tools.js";
26
+
27
+ const instructions =
28
+ "Read stable wiki content before editing. All writes must happen in an isolated session. Uploaded documents become raw sources first. Read sources, create wiki pages in sessions, validate, commit, and publish with publisher role.";
29
+
30
+ export function createMcpServer(ctx: ServerContext): McpServer {
31
+ const server = new McpServer({ name: "tgo-wiki", version: "0.0.0" }, { instructions });
32
+
33
+ server.registerTool(
34
+ "vfs_exec",
35
+ { description: "Run a read-only VFS command against stable wiki content.", inputSchema: vfsExecSchema },
36
+ input => callVfsExec(ctx, input)
37
+ );
38
+ server.registerTool(
39
+ "wiki_read",
40
+ { description: "Read a wiki Markdown page.", inputSchema: wikiReadSchema },
41
+ input => callWikiRead(ctx, input)
42
+ );
43
+ server.registerTool(
44
+ "wiki_channel_status",
45
+ { description: "Read stable channel status.", inputSchema: wikiChannelStatusSchema },
46
+ input => callWikiChannelStatus(ctx, input)
47
+ );
48
+ server.registerTool(
49
+ "document_upload",
50
+ { description: "Upload a document into an open session as a pending raw source.", inputSchema: documentUploadSchema },
51
+ input => callDocumentUpload(ctx, input)
52
+ );
53
+ server.registerTool(
54
+ "document_parse",
55
+ { description: "Parse an uploaded document into session source files.", inputSchema: documentParseSchema },
56
+ input => callDocumentParse(ctx, input)
57
+ );
58
+ server.registerTool(
59
+ "web_fetch",
60
+ { description: "Fetch one static web page into an open session as a raw source.", inputSchema: webFetchSchema },
61
+ input => callWebFetch(ctx, input)
62
+ );
63
+ server.registerTool(
64
+ "source_list",
65
+ { description: "List parsed document sources from stable or a session ref.", inputSchema: sourceListSchema },
66
+ input => callSourceList(ctx, input)
67
+ );
68
+ server.registerTool(
69
+ "source_read",
70
+ { description: "Read parsed source metadata and raw Markdown from stable or a session ref.", inputSchema: sourceReadSchema },
71
+ input => callSourceRead(ctx, input)
72
+ );
73
+ server.registerTool(
74
+ "wiki_session_start",
75
+ { description: "Create an isolated wiki edit session.", inputSchema: wikiSessionStartSchema },
76
+ input => callWikiSessionStart(ctx, input)
77
+ );
78
+ server.registerTool(
79
+ "wiki_session_patch",
80
+ { description: "Replace a Markdown page in a session worktree.", inputSchema: wikiSessionPatchSchema },
81
+ input => callWikiSessionPatch(ctx, input)
82
+ );
83
+ server.registerTool(
84
+ "wiki_session_validate",
85
+ { description: "Validate changed Markdown pages in a session.", inputSchema: wikiSessionValidateSchema },
86
+ input => callWikiSessionValidate(ctx, input)
87
+ );
88
+ server.registerTool(
89
+ "wiki_session_diff",
90
+ { description: "Show committed and working diff for a session.", inputSchema: wikiSessionDiffSchema },
91
+ input => callWikiSessionDiff(ctx, input)
92
+ );
93
+ server.registerTool(
94
+ "wiki_session_commit",
95
+ { description: "Commit validated session changes.", inputSchema: wikiSessionCommitSchema },
96
+ input => callWikiSessionCommit(ctx, input)
97
+ );
98
+ server.registerTool(
99
+ "wiki_publish_session",
100
+ { description: "Publish a committed session to the stable channel using ff-only merge.", inputSchema: wikiPublishSessionSchema },
101
+ input => callWikiPublishSession(ctx, input)
102
+ );
103
+
104
+ return server;
105
+ }
106
+
107
+ export async function startStdioMcpServer(ctx: ServerContext): Promise<void> {
108
+ const server = createMcpServer(ctx);
109
+ const transport = new StdioServerTransport();
110
+ await server.connect(transport);
111
+ }
@@ -0,0 +1,17 @@
1
+ import * as z from "zod/v4";
2
+
3
+ const safeIdSchema = z.string().regex(/^[a-zA-Z0-9._-]+$/);
4
+
5
+ export const documentUploadSchema = z.object({
6
+ session_id: safeIdSchema,
7
+ file_name: z.string().min(1),
8
+ mime_type: z.string().min(1),
9
+ content_base64: z.string().min(1)
10
+ });
11
+
12
+ export const documentParseSchema = z.object({
13
+ session_id: safeIdSchema,
14
+ document_id: safeIdSchema,
15
+ parser: z.string().min(1).optional(),
16
+ reparse: z.boolean().optional()
17
+ });
@@ -0,0 +1,16 @@
1
+ import * as z from "zod/v4";
2
+
3
+ export const vfsExecSchema = z.object({
4
+ command: z.string().min(1),
5
+ ref: z.string().optional()
6
+ });
7
+
8
+ export const wikiReadSchema = z.object({
9
+ path: z.string().min(1),
10
+ ref: z.string().optional(),
11
+ include_frontmatter: z.boolean().optional()
12
+ });
13
+
14
+ export const wikiChannelStatusSchema = z.object({
15
+ channel: z.literal("stable").optional()
16
+ });
@@ -0,0 +1,31 @@
1
+ import * as z from "zod/v4";
2
+
3
+ export const wikiSessionStartSchema = z.object({
4
+ base_ref: z.string().optional(),
5
+ purpose: z.string().optional(),
6
+ agent_id: z.string().optional()
7
+ });
8
+
9
+ export const wikiSessionPatchSchema = z
10
+ .object({
11
+ session_id: z.string().regex(/^[a-zA-Z0-9._-]+$/),
12
+ path: z.string().min(1),
13
+ content: z.string().min(1),
14
+ reason: z.string().optional()
15
+ })
16
+ .strict();
17
+
18
+ export const wikiSessionValidateSchema = z.object({
19
+ session_id: z.string().regex(/^[a-zA-Z0-9._-]+$/),
20
+ paths: z.array(z.string()).optional()
21
+ });
22
+
23
+ export const wikiSessionDiffSchema = z.object({
24
+ session_id: z.string().regex(/^[a-zA-Z0-9._-]+$/),
25
+ paths: z.array(z.string()).optional()
26
+ });
27
+
28
+ export const wikiSessionCommitSchema = z.object({
29
+ session_id: z.string().regex(/^[a-zA-Z0-9._-]+$/),
30
+ message: z.string().min(1)
31
+ });
@@ -0,0 +1,12 @@
1
+ import * as z from "zod/v4";
2
+
3
+ const safeIdSchema = z.string().regex(/^[a-zA-Z0-9._-]+$/);
4
+
5
+ export const sourceListSchema = z.object({
6
+ ref: z.string().min(1).optional()
7
+ });
8
+
9
+ export const sourceReadSchema = z.object({
10
+ document_id: safeIdSchema,
11
+ ref: z.string().min(1).optional()
12
+ });
@@ -0,0 +1,23 @@
1
+ import * as z from "zod/v4";
2
+
3
+ const safeIdSchema = z.string().regex(/^[a-zA-Z0-9._-]+$/);
4
+
5
+ export const webFetchSchema = z.object({
6
+ session_id: safeIdSchema,
7
+ url: z.string().url().superRefine((value, ctx) => {
8
+ let protocol: string;
9
+
10
+ try {
11
+ protocol = new URL(value).protocol;
12
+ } catch {
13
+ return;
14
+ }
15
+
16
+ if (protocol !== "http:" && protocol !== "https:") {
17
+ ctx.addIssue({
18
+ code: "custom",
19
+ message: "URL must use http or https"
20
+ });
21
+ }
22
+ })
23
+ });