2 Commits

Author SHA1 Message Date
d249cb4ae9 eslint magic
Some checks failed
CI / Test (pull_request) Failing after 17s
2025-07-15 20:59:10 +02:00
ab11063884 safely parse session id 2025-07-15 20:59:00 +02:00
5 changed files with 66 additions and 79 deletions

View File

@@ -8,10 +8,19 @@ import tsPlugin from "typescript-eslint";
/** @type {import('eslint').Linter.Config[]} */ /** @type {import('eslint').Linter.Config[]} */
export default [ export default [
{
ignores: [
"node_modules/**",
"dist/**",
"build/**",
"coverage/**",
"*.min.js",
"src/gen/**", // Exclude generated API files
],
},
securityPlugin.configs.recommended, securityPlugin.configs.recommended,
{ {
files: ["**/*.ts"], files: ["src/**/*.ts"],
ignores: ["src/gen/**"],
}, },
{ {
languageOptions: { globals: globals.node }, languageOptions: { globals: globals.node },
@@ -60,16 +69,7 @@ export default [
"import/order": [ "import/order": [
"error", "error",
{ {
groups: [ groups: ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"],
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"object",
"type",
],
"newlines-between": "always", "newlines-between": "always",
alphabetize: { alphabetize: {
order: "asc", order: "asc",

View File

@@ -1,12 +1,13 @@
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 { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import crypto from "crypto"; import express from "express";
import pino from "pino";
import { z } from "zod";
import { outlineMcpFactory } from "./outline"; import { outlineMcpFactory } from "./outline";
const sessionIdSchema = z.string().uuid();
async function main() { async function main() {
const logger = pino({ const logger = pino({
level: "debug", level: "debug",
@@ -24,12 +25,40 @@ async function main() {
}, },
"Received MCP request" "Received MCP request"
); );
const sessionId = req.headers["mcp-session-id"] as string | undefined; const sessionIdHeader = req.headers["mcp-session-id"] as string | undefined;
let transport: StreamableHTTPServerTransport; let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) { if (sessionIdHeader) {
transport = transports[sessionId]; const safeSessionId = sessionIdSchema.safeParse(sessionIdHeader);
} else if (!sessionId && isInitializeRequest(req.body)) { if (!safeSessionId.success) {
logger.error("Invalid session ID format");
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Invalid session ID format",
},
id: null,
});
return;
}
const sessionId = safeSessionId.data;
if (transports[sessionId]) {
transport = transports[sessionId];
} else {
logger.error("Session not found");
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Session not found",
},
id: null,
});
return;
}
} else if (isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({ transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(), sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (sessionId) => { onsessioninitialized: (sessionId) => {
@@ -42,7 +71,9 @@ async function main() {
delete transports[transport.sessionId]; delete transports[transport.sessionId];
} }
}; };
const outlineMcpServer = outlineMcpFactory(logger); const outlineMcpServer = outlineMcpFactory(
logger.child({ sessionId: transport.sessionId })
);
await outlineMcpServer.connect(transport); await outlineMcpServer.connect(transport);
} else { } else {

View File

@@ -7,11 +7,7 @@ import { registerDocumentTools } from "./tools/document";
import type { Logger } from "pino"; import type { Logger } from "pino";
const createOutlineClient = ( const createOutlineClient = (baseUrl: string, apiKey: string, logger: Logger) => {
baseUrl: string,
apiKey: string,
logger: Logger
) => {
const config = new Configuration({ const config = new Configuration({
basePath: baseUrl, basePath: baseUrl,
accessToken: () => Promise.resolve(apiKey), accessToken: () => Promise.resolve(apiKey),

View File

@@ -6,11 +6,7 @@ import type { ToolsFactory } from "./toolsFactory";
import type { CollectionsApi } from "../gen/api/outline"; import type { CollectionsApi } from "../gen/api/outline";
import type { Logger } from "pino"; import type { Logger } from "pino";
export const registerCollectionTools: ToolsFactory<CollectionsApi> = ( export const registerCollectionTools: ToolsFactory<CollectionsApi> = (server: McpServer, client: CollectionsApi, logger: Logger) => {
server: McpServer,
client: CollectionsApi,
logger: Logger
) => {
server.registerTool( server.registerTool(
"collections_list", "collections_list",
{ {
@@ -21,15 +17,9 @@ export const registerCollectionTools: ToolsFactory<CollectionsApi> = (
async () => { async () => {
try { try {
const response = await client.collectionsList(); const response = await client.collectionsList();
return handleSuccess( return handleSuccess(response, logger.child({ tool: "collections_list" }));
response,
logger.child({ tool: "collections_list" })
);
} catch (err) { } catch (err) {
const error = handleError( const error = handleError(err, logger.child({ tool: "collections_list" }));
err,
logger.child({ tool: "collections_list" })
);
logger.error(error); logger.error(error);
return error; return error;
} }

View File

@@ -1,45 +1,22 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod"; import { z } from "zod";
import type { Logger } from "pino";
import { import { backlinkDocumentId, collectionId, direction, limit, offset, parentDocumentId, sort, userId } from "./common";
backlinkDocumentId,
collectionId,
direction,
limit,
offset,
parentDocumentId,
sort,
userId,
} from "./common";
import { handleSuccess, handleError } from "./utils"; import { handleSuccess, handleError } from "./utils";
import type { ToolsFactory } from "./toolsFactory"; import type { ToolsFactory } from "./toolsFactory";
import type { DocumentsApi } from "../gen/api/outline"; import type { DocumentsApi } from "../gen/api/outline";
import type { Logger } from "pino";
export const registerDocumentTools: ToolsFactory<DocumentsApi> = ( export const registerDocumentTools: ToolsFactory<DocumentsApi> = (server: McpServer, client: DocumentsApi, logger: Logger): void => {
server: McpServer,
client: DocumentsApi,
logger: Logger
): void => {
server.registerTool( server.registerTool(
"documents_info", "documents_info",
{ {
title: "Get Document Info", title: "Get Document Info",
description: description: "Retrieve a document by its UUID, urlId, or shareId. At least one of these parameters must be provided.",
"Retrieve a document by its UUID, urlId, or shareId. At least one of these parameters must be provided.",
inputSchema: { inputSchema: {
id: z id: z.string().optional().describe("Unique identifier for the document. Either the UUID or the urlId is acceptable."),
.string() shareId: z.string().optional().describe("Unique identifier for a document share, a shareId may be used in place of a document UUID"),
.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) => { async (args) => {
@@ -50,10 +27,7 @@ export const registerDocumentTools: ToolsFactory<DocumentsApi> = (
shareId: args?.shareId, shareId: args?.shareId,
}, },
}); });
return handleSuccess( return handleSuccess(response, logger.child({ tool: "documents_info" }));
response,
logger.child({ tool: "documents_info" })
);
} catch (error) { } catch (error) {
return handleError(error, logger.child({ tool: "documents_info" })); return handleError(error, logger.child({ tool: "documents_info" }));
} }
@@ -76,8 +50,7 @@ export const registerDocumentTools: ToolsFactory<DocumentsApi> = (
"documents_list", "documents_list",
{ {
title: "List Documents", title: "List Documents",
description: description: "This method will list all published documents and draft documents belonging to the current user.",
"This method will list all published documents and draft documents belonging to the current user.",
inputSchema: documentsListSchema.shape, inputSchema: documentsListSchema.shape,
}, },
async (args) => { async (args) => {
@@ -86,10 +59,7 @@ export const registerDocumentTools: ToolsFactory<DocumentsApi> = (
const response = await client.documentsList({ const response = await client.documentsList({
documentsListRequest: validatedArgs, documentsListRequest: validatedArgs,
}); });
return handleSuccess( return handleSuccess(response, logger.child({ tool: "documents_list" }));
response,
logger.child({ tool: "documents_list" })
);
} catch (error) { } catch (error) {
return handleError(error, logger.child({ tool: "documents_list" })); return handleError(error, logger.child({ tool: "documents_list" }));
} }