feat: add outline MCP server
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m56s
Monorepo Pipeline / 🧹 Lint (push) Failing after 3m24s
Monorepo Pipeline / 🏗️ Build (push) Successful in 4m11s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped

This commit is contained in:
2026-06-04 21:40:00 +02:00
parent 11babf3347
commit 04462f21e1
8 changed files with 388 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import axios from 'axios';
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
// Mock environment
process.env.OUTLINE_API_TOKEN = 'test-token';
process.env.OUTLINE_BASE_URL = 'https://outline.test';
// Mock axios
vi.mock('axios', () => {
const mockPost = vi.fn();
return {
default: {
create: vi.fn(() => ({
post: mockPost
}))
}
};
});
describe('Outline MCP Server Tools', () => {
let mockPost: any;
let requestHandler: any;
beforeEach(async () => {
vi.clearAllMocks();
// Extract the mock
const axiosInstance = axios.create();
mockPost = axiosInstance.post;
// Mock Server setRequestHandler
let capturedHandler: any;
vi.spyOn(Server.prototype, 'setRequestHandler').mockImplementation((schema: any, handler: any) => {
// The first call is usually ListToolsRequestSchema, second is CallToolRequestSchema
// We just store the last one which is CallToolRequestSchema
capturedHandler = handler;
return {} as any;
});
// Re-import to trigger registration
vi.resetModules();
await import('./index.js');
requestHandler = capturedHandler;
});
it('should search documents', async () => {
mockPost.mockResolvedValueOnce({ data: { data: [{ id: '1', title: 'Test' }] } });
const result = await requestHandler({
params: {
name: 'outline_search_documents',
arguments: { query: 'test' }
}
});
expect(mockPost).toHaveBeenCalledWith('/api/documents.search', { query: 'test', collectionId: undefined });
expect(result.content[0].text).toContain('Test');
});
it('should get a document', async () => {
mockPost.mockResolvedValueOnce({ data: { data: { id: 'doc1', title: 'Doc 1' } } });
const result = await requestHandler({
params: {
name: 'outline_get_document',
arguments: { id: 'doc1' }
}
});
expect(mockPost).toHaveBeenCalledWith('/api/documents.info', { id: 'doc1' });
expect(result.content[0].text).toContain('Doc 1');
});
it('should create a document', async () => {
mockPost.mockResolvedValueOnce({ data: { data: { id: 'new-doc' } } });
const result = await requestHandler({
params: {
name: 'outline_create_document',
arguments: { collectionId: 'col1', title: 'New Doc', text: 'Hello', publish: true }
}
});
expect(mockPost).toHaveBeenCalledWith('/api/documents.create', {
collectionId: 'col1', title: 'New Doc', text: 'Hello', publish: true
});
expect(result.content[0].text).toContain('new-doc');
});
});

View File

@@ -0,0 +1,184 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
// Environment variables
const OUTLINE_API_TOKEN = process.env.OUTLINE_API_TOKEN;
const OUTLINE_BASE_URL = process.env.OUTLINE_BASE_URL;
if (!OUTLINE_API_TOKEN || !OUTLINE_BASE_URL) {
console.error("Missing required environment variables: OUTLINE_API_TOKEN or OUTLINE_BASE_URL");
process.exit(1);
}
const axiosInstance = axios.create({
baseURL: OUTLINE_BASE_URL,
headers: {
Authorization: `Bearer ${OUTLINE_API_TOKEN}`,
"Content-Type": "application/json",
Accept: "application/json",
},
});
// Define tools
const tools: Tool[] = [
{
name: "outline_search_documents",
description: "Search for documents in Outline.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
collectionId: { type: "string", description: "Optional collection ID to scope search" },
},
required: ["query"],
},
},
{
name: "outline_get_document",
description: "Get a document by its ID.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "The ID of the document" },
},
required: ["id"],
},
},
{
name: "outline_create_document",
description: "Create a new document.",
inputSchema: {
type: "object",
properties: {
collectionId: { type: "string", description: "The ID of the collection" },
title: { type: "string", description: "Document title" },
text: { type: "string", description: "Document content in Markdown" },
publish: { type: "boolean", description: "Whether to publish the document" },
},
required: ["collectionId", "title", "text"],
},
},
{
name: "outline_update_document",
description: "Update an existing document.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "The ID of the document" },
title: { type: "string", description: "New title" },
text: { type: "string", description: "New content in Markdown" },
},
required: ["id"],
},
},
{
name: "outline_create_collection",
description: "Create a new collection.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Collection name" },
description: { type: "string", description: "Collection description" },
},
required: ["name"],
},
},
];
const server = new Server(
{
name: "at-mintel-outline-mcp",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
switch (request.params.name) {
case "outline_search_documents": {
const { query, collectionId } = request.params.arguments as any;
const res = await axiosInstance.post("/api/documents.search", {
query,
collectionId,
});
return {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
};
}
case "outline_get_document": {
const { id } = request.params.arguments as any;
const res = await axiosInstance.post("/api/documents.info", { id });
return {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
};
}
case "outline_create_document": {
const { collectionId, title, text, publish } = request.params.arguments as any;
const res = await axiosInstance.post("/api/documents.create", {
collectionId,
title,
text,
publish: publish ?? true,
});
return {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
};
}
case "outline_update_document": {
const { id, title, text } = request.params.arguments as any;
const res = await axiosInstance.post("/api/documents.update", {
id,
title,
text,
});
return {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
};
}
case "outline_create_collection": {
const { name, description } = request.params.arguments as any;
const res = await axiosInstance.post("/api/collections.create", {
name,
description,
});
return {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
};
}
default:
throw new Error(`Unknown tool: ${request.params.name}`);
}
} catch (error: any) {
const errorMsg = error.response?.data ? JSON.stringify(error.response.data) : error.message;
return {
content: [{ type: "text", text: `API Error: ${errorMsg}` }],
isError: true,
};
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Outline MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

View File

@@ -0,0 +1,13 @@
import { config } from 'dotenv';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
config({ quiet: true, path: resolve(__dirname, '../../../.env.local') });
config({ quiet: true, path: resolve(__dirname, '../../../.env') });
import('./index.js').catch(err => {
console.error('Failed to start Outline MCP Server:', err);
process.exit(1);
});