#!/usr/bin/env node
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';
import dotenv from 'dotenv';
dotenv.config();
const MIRO_API_BASE = 'https://api.miro.com/v2';
const MIRO_TOKEN = process.env.MIRO_ACCESS_TOKEN;
if (!MIRO_TOKEN) {
console.error('MIRO_ACCESS_TOKEN is required. Please set it in your .env file');
process.exit(1);
}
const miroClient = axios.create({
baseURL: MIRO_API_BASE,
headers: {
'Authorization': `Bearer ${MIRO_TOKEN}`,
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
class MiroMCPServer {
private server: Server;
constructor() {
this.server = new Server(
{
name: 'miro-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
this.setupHandlers();
}
private setupHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'list_boards',
description: 'List all MIRO boards accessible to the user',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Maximum number of boards to return (default: 10, max: 50)',
default: 10
}
}
}
},
{
name: 'get_board',
description: 'Get details about a specific MIRO board',
inputSchema: {
type: 'object',
properties: {
board_id: {
type: 'string',
description: 'The ID of the MIRO board'
}
},
required: ['board_id']
}
},
{
name: 'get_board_items',
description: 'Get all items (widgets) from a MIRO board',
inputSchema: {
type: 'object',
properties: {
board_id: {
type: 'string',
description: 'The ID of the MIRO board'
},
item_type: {
type: 'string',
description: 'Filter by item type (sticky_note, text, shape, card, etc.)',
enum: ['sticky_note', 'text', 'shape', 'card', 'image', 'frame', 'connector']
},
limit: {
type: 'number',
description: 'Maximum number of items to return (default: 50, max: 100)',
default: 50
}
},
required: ['board_id']
}
},
{
name: 'get_board_frames',
description: 'Get all frames from a MIRO board',
inputSchema: {
type: 'object',
properties: {
board_id: {
type: 'string',
description: 'The ID of the MIRO board'
}
},
required: ['board_id']
}
},
{
name: 'search_board_content',
description: 'Search for text content across all items in a MIRO board',
inputSchema: {
type: 'object',
properties: {
board_id: {
type: 'string',
description: 'The ID of the MIRO board'
},
query: {
type: 'string',
description: 'Text to search for in board items'
}
},
required: ['board_id', 'query']
}
}
] as Tool[]
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
if (!args) {
throw new Error('No arguments provided');
}
switch (name) {
case 'list_boards':
return await this.listBoards(args.limit as number || 10);
case 'get_board':
return await this.getBoard(args.board_id as string);
case 'get_board_items':
return await this.getBoardItems(
args.board_id as string,
args.item_type as string | undefined,
args.limit as number || 50
);
case 'get_board_frames':
return await this.getBoardFrames(args.board_id as string);
case 'search_board_content':
return await this.searchBoardContent(args.board_id as string, args.query as string);
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error: any) {
return {
content: [
{
type: 'text',
text: `Error: ${error.message}`,
},
],
};
}
});
}
private async listBoards(limit: number) {
try {
const response = await miroClient.get('/boards', {
params: { limit: Math.min(limit, 50) }
});
const boards = response.data.data.map((board: any) => ({
id: board.id,
name: board.name,
description: board.description,
viewLink: board.viewLink,
modifiedAt: board.modifiedAt,
createdAt: board.createdAt
}));
return {
content: [
{
type: 'text',
text: JSON.stringify(boards, null, 2),
},
],
};
} catch (error: any) {
throw new Error(`Failed to list boards: ${error.response?.data?.message || error.message}`);
}
}
private async getBoard(boardId: string) {
try {
const response = await miroClient.get(`/boards/${boardId}`);
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
};
} catch (error: any) {
throw new Error(`Failed to get board: ${error.response?.data?.message || error.message}`);
}
}
private async getBoardItems(boardId: string, itemType?: string, limit: number = 50) {
try {
const params: any = { limit: Math.min(limit, 100) };
if (itemType) {
params.type = itemType;
}
const response = await miroClient.get(`/boards/${boardId}/items`, { params });
const items = response.data.data.map((item: any) => {
const baseItem = {
id: item.id,
type: item.type,
position: item.position,
geometry: item.geometry
};
if (item.data?.content) {
return { ...baseItem, content: item.data.content };
}
if (item.data?.title) {
return { ...baseItem, title: item.data.title };
}
if (item.data?.text) {
return { ...baseItem, text: item.data.text };
}
return { ...baseItem, data: item.data };
});
return {
content: [
{
type: 'text',
text: JSON.stringify(items, null, 2),
},
],
};
} catch (error: any) {
throw new Error(`Failed to get board items: ${error.response?.data?.message || error.message}`);
}
}
private async getBoardFrames(boardId: string) {
try {
const response = await miroClient.get(`/boards/${boardId}/frames`);
const frames = response.data.data.map((frame: any) => ({
id: frame.id,
title: frame.data?.title || 'Untitled Frame',
type: frame.type,
geometry: frame.geometry,
childrenIds: frame.data?.childrenIds || []
}));
return {
content: [
{
type: 'text',
text: JSON.stringify(frames, null, 2),
},
],
};
} catch (error: any) {
throw new Error(`Failed to get board frames: ${error.response?.data?.message || error.message}`);
}
}
private async searchBoardContent(boardId: string, query: string) {
try {
const response = await miroClient.get(`/boards/${boardId}/items`, {
params: { limit: 100 }
});
const queryLower = query.toLowerCase();
const matchingItems = response.data.data.filter((item: any) => {
const content = item.data?.content || '';
const title = item.data?.title || '';
const text = item.data?.text || '';
return content.toLowerCase().includes(queryLower) ||
title.toLowerCase().includes(queryLower) ||
text.toLowerCase().includes(queryLower);
});
const results = matchingItems.map((item: any) => ({
id: item.id,
type: item.type,
content: item.data?.content || item.data?.title || item.data?.text || '',
position: item.position
}));
return {
content: [
{
type: 'text',
text: JSON.stringify({
query,
totalMatches: results.length,
matches: results
}, null, 2),
},
],
};
} catch (error: any) {
throw new Error(`Failed to search board content: ${error.response?.data?.message || error.message}`);
}
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('MIRO MCP server running');
}
}
const server = new MiroMCPServer();
server.run().catch(console.error);