diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..49f87b7 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,8 @@ +import * as envVar from "env-var"; + +const config = { + baseUrl: envVar.get("OUTLINE_BASE_URL").required().asString(), + apiKey: envVar.get("OUTLINE_API_KEY").required().asString(), +}; + +export default config; diff --git a/src/main.ts b/src/main.ts index 0e8966b..804b345 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,17 +1,14 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { server } from "./outline.js"; +import pino from "pino"; + +import { outlineMcpFactory } from "./outline"; async function main() { - // Add debugging - console.error("Current working directory:", process.cwd()); - console.error("__dirname:", __dirname); - + const logger = pino(); const transport = new StdioServerTransport(); - await server.connect(transport); - console.error("Outline MCP server is running via stdio transport"); + const server = outlineMcpFactory(logger, transport); + + server.start(); } -main().catch((error) => { - console.error("Server error:", error); - process.exit(1); -}); +main(); diff --git a/src/outline.ts b/src/outline.ts index 8211dc0..aa18c6b 100644 --- a/src/outline.ts +++ b/src/outline.ts @@ -1,13 +1,38 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { registerDocumentTools } from "./tools/document"; + +import config from "./config"; +import { CollectionsApi, Configuration, DocumentsApi } from "./gen/api/outline"; import { registerCollectionTools } from "./tools/collection"; +import { registerDocumentTools } from "./tools/document"; -// Create an MCP server -export const server = new McpServer({ - name: "outline-mcp-server", - version: "1.0.0", -}); +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { Logger } from "pino"; -// Register tools -registerDocumentTools(server); -registerCollectionTools(server); +const createOutlineClient = (baseUrl: string, apiKey: string) => { + const config = new Configuration({ + basePath: baseUrl, + accessToken: () => Promise.resolve(apiKey), + }); + + return { + documents: new DocumentsApi(config), + collections: new CollectionsApi(config), + }; +}; + +export const outlineMcpFactory = (logger: Logger, transport: Transport) => { + const server = new McpServer({ + name: "outline-mcp", + version: "1.0.0", + }); + + const client = createOutlineClient(config.baseUrl, config.apiKey); + registerDocumentTools(server, client.documents, logger); + registerCollectionTools(server, client.collections, logger); + + const start = () => server.connect(transport); + + return { + start, + }; +}; diff --git a/src/tools/collection.ts b/src/tools/collection.ts index d49aaec..65bb792 100644 --- a/src/tools/collection.ts +++ b/src/tools/collection.ts @@ -1,10 +1,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { createOutlineClient, handleSuccess, handleError } from "./utils"; -// Register collection tools -export const registerCollectionTools = (server: McpServer) => { +import { handleSuccess, handleError } from "./utils"; + +import type { ToolsFactory } from "./toolsFactory"; +import type { CollectionsApi } from "../gen/api/outline"; +import type { Logger } from "pino"; + +export const registerCollectionTools: ToolsFactory = (server: McpServer, client: CollectionsApi, logger: Logger) => { server.registerTool( - "collections_list", + "collections.list", { title: "List Collections", description: "List all collections in the Outline workspace", @@ -12,11 +16,12 @@ export const registerCollectionTools = (server: McpServer) => { }, async () => { try { - const client = createOutlineClient(); - const response = await client.collections.collectionsList(); - return handleSuccess(response); - } catch (error) { - return handleError(error); + const response = await client.collectionsList(); + return handleSuccess(response, logger.child({ tool: "collections.list" })); + } catch (err) { + const error = handleError(err, logger.child({ tool: "collections.list" })); + logger.error(error); + return error; } } ); diff --git a/src/tools/common.ts b/src/tools/common.ts new file mode 100644 index 0000000..449a897 --- /dev/null +++ b/src/tools/common.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +const positiveInteger = z.number().int().positive(); +const uuid = z.string().uuid(); +const optionalUuid = uuid.optional(); + +// Pagination +export const offset = positiveInteger.optional(); +export const limit = positiveInteger.optional().default(10); +export const sort = z.enum(["createdAt", "updatedAt", "title"]).optional(); +export const direction = z + .enum(["asc", "desc"]) + .optional() + .transform((val) => val?.toUpperCase() as "ASC" | "DESC" | undefined); + +export const collectionId = optionalUuid; +export const userId = optionalUuid; +export const backlinkDocumentId = optionalUuid; +export const parentDocumentId = optionalUuid; diff --git a/src/tools/document.ts b/src/tools/document.ts index abc30be..8c26318 100644 --- a/src/tools/document.ts +++ b/src/tools/document.ts @@ -1,49 +1,67 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { createOutlineClient, handleSuccess, handleError } from "./utils"; -// Register document tools -export const registerDocumentTools = (server: McpServer) => { - server.registerTool( - "documents_list", - { - title: "List Documents", - description: "List all documents in the Outline workspace", - inputSchema: {}, - }, - async () => { - try { - const client = createOutlineClient(); - const response = await client.documents.documentsList(); - return handleSuccess(response); - } catch (error) { - return handleError(error); - } - } - ); +import { backlinkDocumentId, collectionId, direction, limit, offset, parentDocumentId, sort, userId } from "./common"; +import { handleSuccess, handleError } from "./utils"; +import type { ToolsFactory } from "./toolsFactory"; +import type { DocumentsApi } from "../gen/api/outline"; +import type { Logger } from "pino"; + +export const registerDocumentTools: ToolsFactory = (server: McpServer, client: DocumentsApi, logger: Logger): void => { server.registerTool( - "documents_info", + "documents.info", { title: "Get Document Info", - description: "Retrieve a specific document by its ID or share ID", + description: "Retrieve a document by its UUID, urlId, or shareId. At least one of these parameters must be provided.", inputSchema: { - id: z.string().optional().describe("Document UUID or URL ID"), - shareId: z.string().optional().describe("Document share ID"), + id: z.string().optional().describe("Unique identifier for the document. Either the UUID or the urlId is acceptable."), + shareId: z.string().optional().describe("Unique identifier for a document share, a shareId may be used in place of a document UUID"), }, }, async (args) => { try { - const client = createOutlineClient(); - const response = await client.documents.documentsInfo({ + const response = await client.documentsInfo({ documentsInfoRequest: { id: args?.id, shareId: args?.shareId, }, }); - return handleSuccess(response); + return handleSuccess(response, logger.child({ tool: "documents.info" })); } catch (error) { - return handleError(error); + return handleError(error, logger.child({ tool: "documents.info" })); + } + } + ); + + const documentsListSchema = z.object({ + offset, + limit, + sort, + direction, + collectionId, + userId, + backlinkDocumentId, + parentDocumentId, + template: z.boolean().optional(), + }); + + server.registerTool( + "documents.list", + { + title: "List Documents", + description: "This method will list all published documents and draft documents belonging to the current user.", + inputSchema: documentsListSchema.shape, + }, + async (args) => { + try { + const validatedArgs = documentsListSchema.parse(args); + const response = await client.documentsList({ + documentsListRequest: validatedArgs, + }); + return handleSuccess(response, logger.child({ tool: "documents.list" })); + } catch (error) { + return handleError(error, logger.child({ tool: "documents.list" })); } } ); diff --git a/src/tools/toolsFactory.ts b/src/tools/toolsFactory.ts new file mode 100644 index 0000000..a6ba500 --- /dev/null +++ b/src/tools/toolsFactory.ts @@ -0,0 +1,5 @@ +import type { BaseAPI } from "../gen/api/outline"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Logger } from "pino"; + +export type ToolsFactory = (server: McpServer, client: T, logger: Logger) => void; diff --git a/src/tools/utils.ts b/src/tools/utils.ts index 1417eda..a7e0012 100644 --- a/src/tools/utils.ts +++ b/src/tools/utils.ts @@ -1,47 +1,28 @@ -import { Configuration } from "../gen/api/outline/runtime"; -import { DocumentsApi } from "../gen/api/outline/apis/DocumentsApi"; -import { CollectionsApi } from "../gen/api/outline/apis/CollectionsApi"; +import type { Logger } from "pino"; -// Generic Outline API client factory -export const createOutlineClient = () => { - const apiKey = process.env.OUTLINE_API_KEY; - if (!apiKey) { - throw new Error("OUTLINE_API_KEY environment variable is not set"); - } +export const handleError = (error: unknown, logger: Logger) => { + const message = `Error: ${error instanceof Error ? error.message : "Unknown error"}`; - const config = new Configuration({ - basePath: process.env.OUTLINE_BASE_URL, - accessToken: () => Promise.resolve(apiKey), - }); - - return { - documents: new DocumentsApi(config), - collections: new CollectionsApi(config), - }; -}; - -// Generic response handler -export const handleApiResponse = (response: any) => { - return JSON.stringify(response, null, 2); -}; - -// Generic error response handler -export const handleError = (error: unknown) => { + logger.error(message); return { content: [ { type: "text" as const, - text: `Error: ${ - error instanceof Error ? error.message : "Unknown error" - }`, + text: message, }, ], }; }; -// Generic success response handler -export const handleSuccess = (data: any) => { +const handleApiResponse = (response: unknown) => { + return JSON.stringify(response, null, 2); +}; + +export const handleSuccess = (data: unknown, logger: Logger) => { + const message = handleApiResponse(data); + logger.debug(message); + return { - content: [{ type: "text" as const, text: handleApiResponse(data) }], + content: [{ type: "text" as const, text: message }], }; };