#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import axios from "axios";
// Function to extract video ID from Loom URL
function extractVideoId(url: string): string | null {
try {
// Extract the part after "share/" and before any query parameters
const regex = /\/share\/([^/?]+)/;
const match = url.match(regex);
return match ? match[1] : null;
} catch (error) {
console.error("Error extracting video ID:", error);
return null;
}
}
// Function to fetch transcript URL from Loom API
async function fetchTranscriptUrl(videoId: string): Promise<string | null> {
try {
// FIX #1: Send as object instead of array
// FIX #2: Add proper browser-like headers
const response = await axios.post(
'https://www.loom.com/graphql',
{
operationName: "FetchVideoTranscript",
variables: {
videoId: videoId,
password: null
},
query: `query FetchVideoTranscript($videoId: ID!, $password: String) {
fetchVideoTranscript(videoId: $videoId, password: $password) {
... on VideoTranscriptDetails {
id
video_id
s3_id
version
transcript_url
captions_url
processing_service
transcription_status
processing_start_time
processing_end_time
createdAt
updatedAt
source_url
captions_source_url
filler_words
filler_word_removal
__typename
}
... on GenericError {
message
__typename
}
__typename
}
}`
},
{
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
}
);
console.error("API Response:", JSON.stringify(response.data, null, 2));
// FIX #3: Access response.data.data instead of response.data[0].data
if (response.data?.data?.fetchVideoTranscript?.captions_source_url) {
return response.data.data.fetchVideoTranscript.captions_source_url;
}
return null;
} catch (error) {
console.error("Error fetching transcript URL:", error);
return null;
}
}
// Function to fetch and parse VTT content
async function fetchVttContent(url: string): Promise<string> {
try {
console.error("Fetching VTT from URL:", url);
const response = await axios.get(url);
console.error("VTT Response status:", response.status);
return parseVttToText(response.data);
} catch (error) {
console.error("Error fetching VTT content:", error);
throw new Error(
`Failed to fetch transcript: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
// Function to parse VTT file content to plain text
function parseVttToText(vttContent: string): string {
// Remove WebVTT header and metadata
const lines = vttContent.split('\n');
let transcript = '';
for (const line of lines) {
// Skip WebVTT header, timestamps, and empty lines
if (
line.includes('-->') ||
line.trim() === '' ||
line.match(/^\d+$/) ||
line.startsWith('WEBVTT')
) {
continue;
}
// Add the content line to the transcript
transcript += line.trim() + ' ';
}
return transcript.trim();
}
// Function to fetch comments from Loom API
async function fetchVideoComments(videoId: string): Promise<any | null> {
try {
// FIX #1: Send as object instead of array
// FIX #2: Add proper browser-like headers
const response = await axios.post(
'https://www.loom.com/graphql',
{
operationName: "fetchVideoComments",
variables: {
id: videoId,
password: null
},
query: `query fetchVideoComments($id: ID!, $password: String) {
video: getVideo(id: $id, password: $password) {
__typename
... on RegularUserVideo {
id
videoMeetingPlatform
video_comments(includeDeleted: true) {
...CommentPostFragment
__typename
}
__typename
}
}
}
fragment CommentPostFragment on PublicVideoComment {
id
content(withMentionMarkups: true)
plainContent: content(withMentionMarkups: false)
time_stamp
user_name
avatar {
name
thumb
isAtlassianMastered
__typename
}
edited
createdAt
isChatMessage
user_id
anon_user_id
deletedAt
children_comments {
...CommentReplyFragment
__typename
}
__typename
}
fragment CommentReplyFragment on PublicVideoComment {
id
content(withMentionMarkups: true)
plainContent: content(withMentionMarkups: false)
time_stamp
user_name
avatar {
name
thumb
isAtlassianMastered
__typename
}
edited
user_id
anon_user_id
createdAt
isChatMessage
comment_post_id
extended_reaction
__typename
}`
},
{
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
}
);
console.error("API Response:", JSON.stringify(response.data, null, 2));
// FIX #3: Access response.data.data instead of response.data[0].data
if (response.data?.data?.video?.video_comments) {
return response.data.data.video.video_comments;
}
return null;
} catch (error) {
console.error("Error fetching video comments:", error);
return null;
}
}
const server = new McpServer({
name: "loom-transcript",
version: "1.0.5"
});
// Register the tool
(server as any).tool(
"getLoomTranscript",
"Get transcript text from a Loom video URL",
{
videoUrl: z.string().describe("The Loom video URL (e.g., https://www.loom.com/share/123456)")
},
async ({ videoUrl }) => {
try {
console.error("Processing video URL:", videoUrl);
// Extract video ID from URL
const videoId = extractVideoId(videoUrl);
console.error("Extracted video ID:", videoId);
if (!videoId) {
return {
content: [
{
type: "text",
text: "Error: Could not extract video ID from the provided URL."
}
]
};
}
// Fetch transcript URL
const captionsUrl = await fetchTranscriptUrl(videoId);
console.error("Captions URL:", captionsUrl);
if (!captionsUrl) {
return {
content: [
{
type: "text",
text: "Error: Could not fetch transcript for this video."
}
]
};
}
// Fetch and parse VTT content
const transcriptText = await fetchVttContent(captionsUrl);
console.error("Transcript length:", transcriptText.length);
return {
content: [
{
type: "text",
text: transcriptText
}
]
};
} catch (error) {
console.error("Error in handler:", error);
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`
}
]
};
}
}
);
// Register the tool for fetching comments
(server as any).tool(
"getLoomComments",
"Get comments from a Loom video URL",
{
videoUrl: z.string().describe("The Loom video URL (e.g., https://www.loom.com/share/123456)")
},
async ({ videoUrl }) => {
try {
console.error("Processing video URL for comments:", videoUrl);
// Extract video ID from URL
const videoId = extractVideoId(videoUrl);
console.error("Extracted video ID:", videoId);
if (!videoId) {
return {
content: [
{
type: "text",
text: "Error: Could not extract video ID from the provided URL."
}
]
};
}
// Fetch video comments
const comments = await fetchVideoComments(videoId);
console.error("Comments fetched:", comments);
if (!comments) {
return {
content: [
{
type: "text",
text: "Error: Could not fetch comments for this video."
}
]
};
}
return {
content: [
{
type: "text",
text: JSON.stringify(comments, null, 2)
}
]
};
} catch (error) {
console.error("Error in comments handler:", error);
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`
}
]
};
}
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Loom Transcript MCP is running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});