make runnable
Some checks failed
CI / Test (pull_request) Failing after 19s

This commit is contained in:
2025-07-15 20:38:03 +02:00
parent 37327b5ce4
commit a979cdd715
5 changed files with 142 additions and 34 deletions

View File

@@ -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

View File

@@ -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<string, StreamableHTTPServerTransport> = {};
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();

View File

@@ -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;
};

View File

@@ -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<CollectionsApi> = (server: McpServer, client: CollectionsApi, logger: Logger) => {
export const registerCollectionTools: ToolsFactory<CollectionsApi> = (
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<CollectionsApi> = (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;
}

View File

@@ -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<DocumentsApi> = (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<DocumentsApi> = (
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<DocumentsApi> = (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<DocumentsApi> = (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<DocumentsApi> = (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" }));
}
}
);