From a979cdd7157da733ef5c857d248dd9497008b038 Mon Sep 17 00:00:00 2001 From: Timo Behrendt Date: Tue, 15 Jul 2025 20:38:03 +0200 Subject: [PATCH] make runnable --- Makefile | 3 ++ src/main.ts | 63 +++++++++++++++++++++++++++++++++++++--- src/outline.ts | 28 ++++++++++++------ src/tools/collection.ts | 18 +++++++++--- src/tools/document.ts | 64 ++++++++++++++++++++++++++++++----------- 5 files changed, 142 insertions(+), 34 deletions(-) diff --git a/Makefile b/Makefile index aa0c0d4..e0cd834 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ # generates the outline api in typescript from schemas/outline.json using openapi-generator generate-outline-api: npx @openapitools/openapi-generator-cli generate -i schemas/outline.json -g typescript-fetch -o src/gen/api/outline + +start: + bun run start diff --git a/src/main.ts b/src/main.ts index 804b345..2f34804 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,69 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import pino from "pino"; +import express from "express"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import crypto from "crypto"; import { outlineMcpFactory } from "./outline"; async function main() { - const logger = pino(); - const transport = new StdioServerTransport(); - const server = outlineMcpFactory(logger, transport); + const logger = pino({ + level: "debug", + }); - server.start(); + const transports: Record = {}; + + const app = express(); + app.use(express.json()); + + app.post("/mcp", async (req, res) => { + logger.debug( + { + body: JSON.stringify(req.body), + }, + "Received MCP request" + ); + const sessionId = req.headers["mcp-session-id"] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: (sessionId) => { + transports[sessionId] = transport; + }, + }); + + transport.onclose = () => { + if (transport.sessionId) { + delete transports[transport.sessionId]; + } + }; + const outlineMcpServer = outlineMcpFactory(logger); + + await outlineMcpServer.connect(transport); + } else { + logger.error("Bad Request: No valid session ID provided"); + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session ID provided", + }, + id: null, + }); + return; + } + + await transport.handleRequest(req, res, req.body); + }); + + app.listen(3000, () => { + logger.info("Server is running on port 3000"); + }); } main(); diff --git a/src/outline.ts b/src/outline.ts index aa18c6b..6aaf857 100644 --- a/src/outline.ts +++ b/src/outline.ts @@ -5,13 +5,27 @@ import { CollectionsApi, Configuration, DocumentsApi } from "./gen/api/outline"; import { registerCollectionTools } from "./tools/collection"; import { registerDocumentTools } from "./tools/document"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { Logger } from "pino"; -const createOutlineClient = (baseUrl: string, apiKey: string) => { +const createOutlineClient = ( + baseUrl: string, + apiKey: string, + logger: Logger +) => { const config = new Configuration({ basePath: baseUrl, accessToken: () => Promise.resolve(apiKey), + middleware: [ + { + pre: async (request) => { + logger.debug({ request }, "Request"); + return request; + }, + post: async (context) => { + logger.debug({ context }, "Response"); + }, + }, + ], }); return { @@ -20,19 +34,15 @@ const createOutlineClient = (baseUrl: string, apiKey: string) => { }; }; -export const outlineMcpFactory = (logger: Logger, transport: Transport) => { +export const outlineMcpFactory = (logger: Logger) => { const server = new McpServer({ name: "outline-mcp", version: "1.0.0", }); - const client = createOutlineClient(config.baseUrl, config.apiKey); + const client = createOutlineClient(config.baseUrl, config.apiKey, logger); registerDocumentTools(server, client.documents, logger); registerCollectionTools(server, client.collections, logger); - const start = () => server.connect(transport); - - return { - start, - }; + return server; }; diff --git a/src/tools/collection.ts b/src/tools/collection.ts index 65bb792..ea1b16d 100644 --- a/src/tools/collection.ts +++ b/src/tools/collection.ts @@ -6,9 +6,13 @@ 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) => { +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", @@ -17,9 +21,15 @@ export const registerCollectionTools: ToolsFactory = (server: Mc async () => { try { const response = await client.collectionsList(); - return handleSuccess(response, logger.child({ tool: "collections.list" })); + return handleSuccess( + response, + logger.child({ tool: "collections_list" }) + ); } catch (err) { - const error = handleError(err, logger.child({ tool: "collections.list" })); + const error = handleError( + err, + logger.child({ tool: "collections_list" }) + ); logger.error(error); return error; } diff --git a/src/tools/document.ts b/src/tools/document.ts index 8c26318..de4ba1f 100644 --- a/src/tools/document.ts +++ b/src/tools/document.ts @@ -1,22 +1,45 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; - -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 => { +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"; + +export const registerDocumentTools: ToolsFactory = ( + server: McpServer, + client: DocumentsApi, + logger: Logger +): void => { server.registerTool( - "documents.info", + "documents_info", { title: "Get Document Info", - description: "Retrieve a document by its UUID, urlId, or shareId. At least one of these parameters must be provided.", + 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("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"), + 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) => { @@ -27,9 +50,12 @@ export const registerDocumentTools: ToolsFactory = (server: McpSer shareId: args?.shareId, }, }); - return handleSuccess(response, logger.child({ tool: "documents.info" })); + return handleSuccess( + response, + logger.child({ tool: "documents_info" }) + ); } catch (error) { - return handleError(error, logger.child({ tool: "documents.info" })); + return handleError(error, logger.child({ tool: "documents_info" })); } } ); @@ -47,10 +73,11 @@ export const registerDocumentTools: ToolsFactory = (server: McpSer }); server.registerTool( - "documents.list", + "documents_list", { title: "List Documents", - description: "This method will list all published documents and draft documents belonging to the current user.", + description: + "This method will list all published documents and draft documents belonging to the current user.", inputSchema: documentsListSchema.shape, }, async (args) => { @@ -59,9 +86,12 @@ export const registerDocumentTools: ToolsFactory = (server: McpSer const response = await client.documentsList({ documentsListRequest: validatedArgs, }); - return handleSuccess(response, logger.child({ tool: "documents.list" })); + return handleSuccess( + response, + logger.child({ tool: "documents_list" }) + ); } catch (error) { - return handleError(error, logger.child({ tool: "documents.list" })); + return handleError(error, logger.child({ tool: "documents_list" })); } } );