import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { z } from "zod";
// ============================================================================
// Configuration
// ============================================================================
const MATRIX_HOMESERVER = process.env.MATRIX_HOMESERVER || "https://matrix.org";
const MATRIX_ACCESS_TOKEN = process.env.MATRIX_ACCESS_TOKEN || "";
// ============================================================================
// Matrix API Client
// ============================================================================
interface MatrixRoom {
room_id: string;
name?: string;
topic?: string;
num_joined_members?: number;
canonical_alias?: string;
}
interface MatrixEvent {
event_id: string;
type: string;
sender: string;
origin_server_ts: number;
content: Record<string, unknown>;
state_key?: string;
}
interface MatrixMessage extends MatrixEvent {
content: {
msgtype?: string;
body?: string;
format?: string;
formatted_body?: string;
[key: string]: unknown;
};
}
interface SyncResponse {
next_batch: string;
rooms?: {
join?: Record<string, {
timeline?: {
events?: MatrixEvent[];
prev_batch?: string;
};
state?: {
events?: MatrixEvent[];
};
}>;
invite?: Record<string, unknown>;
leave?: Record<string, unknown>;
};
}
async function matrixRequest<T>(
endpoint: string,
method: "GET" | "POST" | "PUT" = "GET",
body?: Record<string, unknown>
): Promise<T> {
if (!MATRIX_ACCESS_TOKEN) {
throw new Error(
"MATRIX_ACCESS_TOKEN environment variable is required. " +
"Get your token from Element: Settings > Help & About > Access Token"
);
}
const url = `${MATRIX_HOMESERVER}/_matrix/client/v3${endpoint}`;
const headers: Record<string, string> = {
"Authorization": `Bearer ${MATRIX_ACCESS_TOKEN}`,
"Content-Type": "application/json",
};
const options: RequestInit = {
method,
headers,
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Matrix API error (${response.status}): ${errorText}`);
}
return response.json() as Promise<T>;
}
// ============================================================================
// Helper Functions
// ============================================================================
function getRoomName(roomId: string, stateEvents: MatrixEvent[]): string {
// Try to find room name from state events
const nameEvent = stateEvents.find(e => e.type === "m.room.name");
if (nameEvent && nameEvent.content.name) {
return nameEvent.content.name as string;
}
// Try canonical alias
const aliasEvent = stateEvents.find(e => e.type === "m.room.canonical_alias");
if (aliasEvent && aliasEvent.content.alias) {
return aliasEvent.content.alias as string;
}
return roomId;
}
function formatTimestamp(ts: number): string {
return new Date(ts).toISOString();
}
function formatMessage(event: MatrixMessage): string {
const time = formatTimestamp(event.origin_server_ts);
const sender = event.sender;
const body = event.content.body || "[no text content]";
return `[${time}] ${sender}: ${body}`;
}
// ============================================================================
// MCP Server Setup
// ============================================================================
const server = new McpServer({
name: "matrix-mcp-server",
version: "1.0.0",
});
// ============================================================================
// Tool: List Joined Rooms
// ============================================================================
const ListRoomsInputSchema = z.object({
limit: z.number()
.int()
.min(1)
.max(100)
.default(20)
.describe("Maximum number of rooms to return"),
}).strict();
server.registerTool(
"matrix_list_rooms",
{
title: "List Matrix Rooms",
description: `List all Matrix rooms that the user has joined.
Returns room IDs, names, and basic information about each room.
Use this to discover which rooms are available before fetching messages.
Returns:
- List of rooms with room_id, name, topic, and member count
- Use room_id with matrix_get_messages to read messages`,
inputSchema: ListRoomsInputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params) => {
try {
// Get list of joined rooms
const joinedRooms = await matrixRequest<{ joined_rooms: string[] }>(
"/joined_rooms"
);
const rooms: Array<{
room_id: string;
name: string;
topic?: string;
member_count?: number;
}> = [];
// Get details for each room (up to limit)
const roomIds = joinedRooms.joined_rooms.slice(0, params.limit);
for (const roomId of roomIds) {
try {
// Get room state to find name and topic
const state = await matrixRequest<MatrixEvent[]>(
`/rooms/${encodeURIComponent(roomId)}/state`
);
const name = getRoomName(roomId, state);
const topicEvent = state.find(e => e.type === "m.room.topic");
const topic = topicEvent?.content.topic as string | undefined;
const membersEvent = state.find(e => e.type === "m.room.member");
rooms.push({
room_id: roomId,
name,
topic,
});
} catch (err) {
// If we can't get room details, still include the room ID
rooms.push({
room_id: roomId,
name: roomId,
});
}
}
const output = {
total: joinedRooms.joined_rooms.length,
count: rooms.length,
rooms,
};
// Format as markdown
let text = `# Matrix Rooms (${rooms.length} of ${joinedRooms.joined_rooms.length})\n\n`;
for (const room of rooms) {
text += `## ${room.name}\n`;
text += `- **Room ID:** \`${room.room_id}\`\n`;
if (room.topic) {
text += `- **Topic:** ${room.topic}\n`;
}
text += "\n";
}
return {
content: [{ type: "text", text }],
structuredContent: output,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
isError: true,
content: [{
type: "text",
text: `Error listing rooms: ${message}`,
}],
};
}
}
);
// ============================================================================
// Tool: Get Messages from a Room
// ============================================================================
const GetMessagesInputSchema = z.object({
room_id: z.string()
.min(1)
.describe("The Matrix room ID (e.g., !abc123:matrix.org)"),
limit: z.number()
.int()
.min(1)
.max(100)
.default(50)
.describe("Maximum number of messages to return"),
from: z.string()
.optional()
.describe("Pagination token from a previous request (use 'end' value)"),
direction: z.enum(["b", "f"])
.default("b")
.describe("Direction to paginate: 'b' for backwards (older), 'f' for forwards (newer)"),
}).strict();
server.registerTool(
"matrix_get_messages",
{
title: "Get Matrix Messages",
description: `Retrieve messages from a specific Matrix room.
Use matrix_list_rooms first to get available room IDs.
Args:
- room_id: The room ID to fetch messages from
- limit: Maximum messages to return (1-100, default 50)
- from: Pagination token for fetching more messages
- direction: 'b' for older messages, 'f' for newer
Returns:
- List of messages with sender, timestamp, and content
- Pagination tokens for fetching more messages`,
inputSchema: GetMessagesInputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params) => {
try {
// Build query parameters
const queryParams = new URLSearchParams({
limit: params.limit.toString(),
dir: params.direction,
});
if (params.from) {
queryParams.set("from", params.from);
}
// If no 'from' token, we need to get the room's current state first
let fromToken = params.from;
if (!fromToken) {
// Do a sync to get a token
const sync = await matrixRequest<SyncResponse>(
`/sync?filter={"room":{"timeline":{"limit":1}}}&timeout=0`
);
const roomData = sync.rooms?.join?.[params.room_id];
if (roomData?.timeline?.prev_batch) {
fromToken = roomData.timeline.prev_batch;
queryParams.set("from", fromToken);
}
}
const response = await matrixRequest<{
start: string;
end: string;
chunk: MatrixEvent[];
state?: MatrixEvent[];
}>(
`/rooms/${encodeURIComponent(params.room_id)}/messages?${queryParams.toString()}`
);
// Filter to only message events
const messages = response.chunk.filter(
(e): e is MatrixMessage => e.type === "m.room.message"
);
const output = {
room_id: params.room_id,
count: messages.length,
start: response.start,
end: response.end,
has_more: messages.length === params.limit,
messages: messages.map(m => ({
event_id: m.event_id,
sender: m.sender,
timestamp: formatTimestamp(m.origin_server_ts),
msgtype: m.content.msgtype,
body: m.content.body,
})),
};
// Format as markdown
let text = `# Messages from ${params.room_id}\n\n`;
text += `Showing ${messages.length} messages\n\n`;
for (const msg of messages) {
text += formatMessage(msg) + "\n";
}
if (output.has_more) {
text += `\n---\nMore messages available. Use \`from: "${response.end}"\` to fetch older messages.`;
}
return {
content: [{ type: "text", text }],
structuredContent: output,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
isError: true,
content: [{
type: "text",
text: `Error fetching messages: ${message}. Make sure the room_id is correct and you have access to the room.`,
}],
};
}
}
);
// ============================================================================
// Tool: Search Messages
// ============================================================================
const SearchMessagesInputSchema = z.object({
query: z.string()
.min(1)
.describe("Search query to find in messages"),
room_id: z.string()
.optional()
.describe("Optional: limit search to a specific room"),
limit: z.number()
.int()
.min(1)
.max(50)
.default(10)
.describe("Maximum number of results to return"),
}).strict();
server.registerTool(
"matrix_search_messages",
{
title: "Search Matrix Messages",
description: `Search for messages across Matrix rooms.
Search for specific content within messages. Can search all rooms or a specific room.
Args:
- query: Text to search for in messages
- room_id: Optional room ID to limit search scope
- limit: Maximum results (1-50, default 10)
Returns:
- Matching messages with room, sender, and content`,
inputSchema: SearchMessagesInputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params) => {
try {
const searchBody: Record<string, unknown> = {
search_categories: {
room_events: {
search_term: params.query,
order_by: "recent",
keys: ["content.body"],
},
},
};
if (params.room_id) {
(searchBody.search_categories as Record<string, unknown>).room_events = {
...((searchBody.search_categories as Record<string, unknown>).room_events as Record<string, unknown>),
filter: {
rooms: [params.room_id],
},
};
}
const response = await matrixRequest<{
search_categories: {
room_events?: {
count?: number;
results?: Array<{
rank: number;
result: MatrixMessage;
}>;
};
};
}>("/search", "POST", searchBody);
const results = response.search_categories.room_events?.results || [];
const limitedResults = results.slice(0, params.limit);
const output = {
query: params.query,
total_found: response.search_categories.room_events?.count || 0,
count: limitedResults.length,
results: limitedResults.map(r => ({
rank: r.rank,
room_id: r.result.event_id.split(":")[1] || "unknown",
event_id: r.result.event_id,
sender: r.result.sender,
timestamp: formatTimestamp(r.result.origin_server_ts),
body: r.result.content.body,
})),
};
// Format as markdown
let text = `# Search Results for "${params.query}"\n\n`;
text += `Found ${output.total_found} results, showing ${output.count}\n\n`;
for (const result of limitedResults) {
text += `---\n`;
text += formatMessage(result.result) + "\n";
}
return {
content: [{ type: "text", text }],
structuredContent: output,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
isError: true,
content: [{
type: "text",
text: `Error searching messages: ${message}. Note: Search requires homeserver support.`,
}],
};
}
}
);
// ============================================================================
// Tool: Get Room Info
// ============================================================================
const GetRoomInfoInputSchema = z.object({
room_id: z.string()
.min(1)
.describe("The Matrix room ID"),
}).strict();
server.registerTool(
"matrix_get_room_info",
{
title: "Get Matrix Room Info",
description: `Get detailed information about a specific Matrix room.
Returns room name, topic, members, and other state information.
Args:
- room_id: The room ID to get info for
Returns:
- Room name, topic, creation info, and member list`,
inputSchema: GetRoomInfoInputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params) => {
try {
const state = await matrixRequest<MatrixEvent[]>(
`/rooms/${encodeURIComponent(params.room_id)}/state`
);
const name = getRoomName(params.room_id, state);
const topicEvent = state.find(e => e.type === "m.room.topic");
const topic = topicEvent?.content.topic as string | undefined;
const createEvent = state.find(e => e.type === "m.room.create");
const creator = createEvent?.sender;
const createdAt = createEvent ? formatTimestamp(createEvent.origin_server_ts) : undefined;
// Get members
const memberEvents = state.filter(e => e.type === "m.room.member");
const members = memberEvents
.filter(e => e.content.membership === "join")
.map(e => ({
user_id: e.state_key,
display_name: e.content.displayname as string | undefined,
}));
const output = {
room_id: params.room_id,
name,
topic,
creator,
created_at: createdAt,
member_count: members.length,
members: members.slice(0, 50), // Limit members in output
};
// Format as markdown
let text = `# Room: ${name}\n\n`;
text += `**Room ID:** \`${params.room_id}\`\n`;
if (topic) text += `**Topic:** ${topic}\n`;
if (creator) text += `**Created by:** ${creator}\n`;
if (createdAt) text += `**Created at:** ${createdAt}\n`;
text += `**Members:** ${members.length}\n\n`;
text += `## Member List\n`;
for (const member of members.slice(0, 50)) {
const displayName = member.display_name || member.user_id;
text += `- ${displayName} (${member.user_id})\n`;
}
if (members.length > 50) {
text += `\n... and ${members.length - 50} more members`;
}
return {
content: [{ type: "text", text }],
structuredContent: output,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
isError: true,
content: [{
type: "text",
text: `Error getting room info: ${message}`,
}],
};
}
}
);
// ============================================================================
// Server Transport
// ============================================================================
async function runStdio(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Matrix MCP server running on stdio");
}
async function runHTTP(): Promise<void> {
const app = express();
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ status: "ok", server: "matrix-mcp-server" });
});
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
const port = parseInt(process.env.PORT || "3000");
app.listen(port, () => {
console.error(`Matrix MCP server running on http://localhost:${port}/mcp`);
});
}
// Choose transport based on environment
const transport = process.env.TRANSPORT || "stdio";
if (transport === "http") {
runHTTP().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
} else {
runStdio().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
}