noverload-mcp
Version:
MCP server for Noverload - Access saved content in AI tools with advanced search, synthesis, and token management
628 lines • 27.5 kB
JavaScript
import { z } from "zod";
// Schema definitions matching your Noverload database
// Made more flexible to handle API variations
export const ContentSchema = z.object({
id: z.string(),
userId: z.string().optional().default(""), // Made optional with default
url: z.string(),
title: z.string().nullable().optional(),
description: z.string().nullable().optional(),
contentType: z.enum(["youtube", "x_twitter", "reddit", "article", "pdf"]).optional().default("article"),
status: z.enum(["pending", "processing", "completed", "failed"]).optional().default("completed"),
summary: z.any().nullable().optional(), // Can be string or object
keyInsights: z.array(z.string()).nullable().optional().default(null),
rawText: z.string().nullable().optional(), // Full content text
tokenCount: z.number().nullable().optional(), // Estimated token count for raw_text
ogImage: z.string().nullable().optional(),
processingMetadata: z.any().nullable().optional(),
tags: z.array(z.string()).optional().default([]), // Associated tags with default
createdAt: z.string().optional().default(() => new Date().toISOString()),
updatedAt: z.string().optional().default(() => new Date().toISOString()),
});
export const ActionSchema = z.object({
id: z.string(),
contentId: z.string(),
goalId: z.string().nullable(),
title: z.string(),
description: z.string().nullable(),
priority: z.enum(["high", "medium", "low"]),
completed: z.boolean(),
completedAt: z.string().nullable(),
createdAt: z.string(),
});
export const GoalSchema = z.object({
id: z.string(),
userId: z.string(),
title: z.string(),
description: z.string().nullable(),
category: z.enum(["health", "wealth", "relationships"]),
isActive: z.boolean(),
createdAt: z.string(),
});
export class NoverloadClient {
config;
headers;
constructor(config) {
this.config = config;
this.headers = {
"Authorization": `Bearer ${config.accessToken}`,
"Content-Type": "application/json",
};
}
async initialize() {
// Verify the token is valid by making a test request
const response = await this.request("/api/user");
if (!response.ok) {
if (response.status === 401) {
throw new Error("Access token is invalid or expired. Please generate a new token from Noverload.");
}
const errorText = await response.text().catch(() => "Unknown error");
throw new Error(`Invalid access token or API unavailable: ${response.status} - ${errorText}`);
}
}
async validateToken() {
try {
const response = await this.request("/api/user");
return response.ok;
}
catch {
return false;
}
}
async request(path, options = {}) {
const url = `${this.config.apiUrl}${path}`;
return fetch(url, {
...options,
headers: {
...this.headers,
...options.headers,
},
});
}
// Content methods
async listContent(filters) {
const params = new URLSearchParams();
if (filters?.status)
params.append("status", filters.status);
if (filters?.contentType)
params.append("type", filters.contentType);
if (filters?.limit)
params.append("limit", filters.limit.toString());
const response = await this.request(`/api/mcp/v2/content?${params}`);
if (!response.ok) {
let errorMessage = "Failed to fetch content list";
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
}
else if (errorData.error) {
errorMessage = errorData.error;
}
if (errorData.code) {
errorMessage = `[${errorData.code}] ${errorMessage}`;
}
}
catch {
errorMessage = `${errorMessage} (HTTP ${response.status})`;
}
throw new Error(errorMessage);
}
const data = await response.json();
// v2 returns { success, contents, pagination }
const rawContents = data.contents || data;
// Transform and validate each item with defaults for missing fields
const transformedContents = Array.isArray(rawContents) ? rawContents.map((item) => ({
id: item.id || item._id || "",
userId: item.userId || item.user_id || "",
url: item.url || "",
title: item.title || null,
description: item.description || null,
contentType: item.contentType || item.content_type || "article",
status: item.status || "completed",
summary: item.summary || null,
keyInsights: item.keyInsights || item.key_insights || null,
rawText: item.rawText || item.raw_text || null,
tokenCount: item.tokenCount || item.token_count || null,
ogImage: item.ogImage || item.og_image || null,
processingMetadata: item.processingMetadata || item.processing_metadata || null,
tags: item.tags || [],
createdAt: item.createdAt || item.created_at || new Date().toISOString(),
updatedAt: item.updatedAt || item.updated_at || new Date().toISOString(),
})) : [];
return z.array(ContentSchema).parse(transformedContents);
}
async getContent(id) {
const response = await this.request(`/api/mcp/v2/content?id=${id}`);
if (!response.ok) {
let errorMessage = `Failed to get content with ID: ${id}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
}
else if (errorData.error) {
errorMessage = errorData.error;
}
if (errorData.code) {
errorMessage = `[${errorData.code}] ${errorMessage}`;
}
}
catch {
// If JSON parsing fails, use status text
errorMessage = `${errorMessage} (HTTP ${response.status})`;
}
throw new Error(errorMessage);
}
const data = await response.json();
// v2 returns { success, content }
const rawContent = data.content || data;
// Transform with defaults for missing fields
const transformedContent = {
id: rawContent.id || rawContent._id || "",
userId: rawContent.userId || rawContent.user_id || "",
url: rawContent.url || "",
title: rawContent.title || null,
description: rawContent.description || null,
contentType: rawContent.contentType || rawContent.content_type || "article",
status: rawContent.status || "completed",
summary: rawContent.summary || null,
keyInsights: rawContent.keyInsights || rawContent.key_insights || null,
rawText: rawContent.rawText || rawContent.raw_text || null,
tokenCount: rawContent.tokenCount || rawContent.token_count || null,
ogImage: rawContent.ogImage || rawContent.og_image || null,
processingMetadata: rawContent.processingMetadata || rawContent.processing_metadata || null,
tags: rawContent.tags || [],
createdAt: rawContent.createdAt || rawContent.created_at || new Date().toISOString(),
updatedAt: rawContent.updatedAt || rawContent.updated_at || new Date().toISOString(),
};
return ContentSchema.parse(transformedContent);
}
async saveContent(url) {
if (this.config.readOnly) {
throw new Error("Cannot save content in read-only mode");
}
// v2 API doesn't support saving - use the main API endpoint
const response = await this.request("/api/mcp/content", {
method: "POST",
body: JSON.stringify({ url }),
});
if (!response.ok) {
let errorMessage = `Failed to save content from URL: ${url}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
}
else if (errorData.error) {
errorMessage = errorData.error;
}
if (errorData.code) {
errorMessage = `[${errorData.code}] ${errorMessage}`;
}
}
catch {
errorMessage = `${errorMessage} (HTTP ${response.status})`;
}
throw new Error(errorMessage);
}
const data = await response.json();
return ContentSchema.parse(data);
}
// Action methods
async listActions(filters) {
const params = new URLSearchParams();
if (filters?.contentId)
params.append("contentId", filters.contentId);
if (filters?.goalId)
params.append("goalId", filters.goalId);
if (filters?.completed !== undefined) {
params.append("completed", filters.completed.toString());
}
const response = await this.request(`/api/mcp/v2/actions?${params}`);
if (!response.ok)
throw new Error("Failed to fetch actions");
const data = await response.json();
// v2 returns { success, actions, pagination, statistics }
const result = data.actions || data;
return z.array(ActionSchema).parse(result);
}
async completeAction(id) {
if (this.config.readOnly) {
throw new Error("Cannot complete action in read-only mode");
}
const response = await this.request(`/api/mcp/v2/actions`, {
method: "POST",
body: JSON.stringify({ actionId: id, status: "completed" }),
});
if (!response.ok)
throw new Error("Failed to complete action");
const data = await response.json();
return ActionSchema.parse(data);
}
// Goal methods
async listGoals() {
// Goals endpoint stays the same for now
const response = await this.request("/api/mcp/goals");
if (!response.ok)
throw new Error("Failed to fetch goals");
const data = await response.json();
return z.array(GoalSchema).parse(data);
}
async searchContent(query, options) {
// Try v2 search first with better configuration
try {
// Determine the best search mode based on parameters
let searchMode = "smart";
// If searchMode is explicitly specified, map it appropriately
if (options?.searchMode) {
if (options.searchMode === "phrase" || options.searchMode === "all") {
searchMode = "fulltext"; // Use fulltext for exact/all matching
}
else if (options.searchMode === "any") {
searchMode = "hybrid"; // Use hybrid for broader matching
}
}
// Override to semantic if concept expansion is explicitly requested
if (options?.enableConceptExpansion === true) {
searchMode = "semantic";
}
const v2Body = {
query,
mode: searchMode, // Use appropriate mode based on request
filters: options?.contentTypes || options?.tags || options?.dateFrom ? {
contentTypes: options?.contentTypes,
dateRange: (options?.dateFrom || options?.dateTo) ? {
from: options?.dateFrom,
to: options?.dateTo,
} : undefined,
tags: options?.tags,
domains: options?.excludeDomains ? {
exclude: options.excludeDomains,
} : undefined,
} : undefined,
options: {
limit: options?.limit || 10,
includeContent: options?.includeFullContent || false,
includeMetadata: true, // Always include metadata for richer results
includeSnippets: true, // Include snippets with highlights
minRelevance: 0.25, // Lower threshold for vector search to catch more results
},
features: {
expandConcepts: options?.enableConceptExpansion !== false, // Default to true
includeRelated: false, // Don't include related by default (token heavy)
aggregateInsights: false, // Don't aggregate by default
},
};
const response = await this.request(`/api/mcp/v2/search`, {
method: "POST",
body: JSON.stringify(v2Body),
});
if (!response.ok) {
// Fall back to v1 search if v2 fails
console.warn(`v2 search failed with status ${response.status}, trying v1 fallback`);
return this.searchContentV1(query, options);
}
const data = await response.json();
// v2 returns { success, query, results, pagination, metadata }
const results = data?.results || [];
if (Array.isArray(results) && results.length > 0) {
return results.map((item) => ({
id: item.id || item._id || "",
userId: item.userId || item.user_id || "",
url: item.url || "",
title: item.title || "Untitled",
description: item.description || (typeof item.summary === 'string' ? item.summary.slice(0, 500) : ""),
contentType: item.contentType || item.content_type || "article",
status: item.status || item.metadata?.processingStatus || "completed",
summary: item.summary || null,
keyInsights: item.keyInsights || item.key_insights || [],
rawText: item.rawText || item.raw_text || item.fullContent || null,
tokenCount: item.tokenCount || item.token_count || null,
ogImage: item.ogImage || item.og_image || null,
processingMetadata: item.processingMetadata || item.processing_metadata || null,
tags: item.tags || [],
createdAt: item.createdAt || item.created_at || item.metadata?.createdAt || new Date().toISOString(),
updatedAt: item.updatedAt || item.updated_at || item.metadata?.updatedAt || new Date().toISOString(),
relevanceScore: item.relevanceScore || item.score || 0,
}));
}
// If no results, try a fallback search with looser parameters
if (results.length === 0 && !options?.includeFullContent) {
console.log("No results found, trying broader search...");
return this.searchContentV1(query, options);
}
return [];
}
catch (error) {
console.error("v2 search error:", error);
// Fall back to v1 search
return this.searchContentV1(query, options);
}
}
// Fallback v1 search method for compatibility
async searchContentV1(query, options) {
try {
const params = new URLSearchParams({
q: query,
limit: (options?.limit || 10).toString(),
});
if (options?.includeFullContent)
params.append("includeFullContent", "true");
if (options?.contentTypes)
params.append("contentTypes", options.contentTypes.join(","));
if (options?.tags)
params.append("tags", options.tags.join(","));
const response = await this.request(`/api/mcp/search?${params}`);
if (!response.ok) {
console.error(`v1 search also failed: ${response.status}`);
return [];
}
const data = await response.json();
const results = Array.isArray(data) ? data : (data.results || []);
return results.map((item) => ({
id: item.id || item._id || "",
userId: item.userId || item.user_id || "",
url: item.url || "",
title: item.title || "Untitled",
description: item.description || "",
contentType: item.contentType || item.content_type || "article",
status: item.status || "completed",
summary: item.summary || null,
keyInsights: item.keyInsights || item.key_insights || [],
rawText: item.rawText || item.raw_text || null,
tokenCount: item.tokenCount || item.token_count || null,
ogImage: item.ogImage || item.og_image || null,
processingMetadata: item.processingMetadata || item.processing_metadata || null,
tags: item.tags || [],
createdAt: item.createdAt || item.created_at || new Date().toISOString(),
updatedAt: item.updatedAt || item.updated_at || new Date().toISOString(),
}));
}
catch (error) {
console.error("v1 search error:", error);
return [];
}
}
// New v2 search method with enhanced features
async searchContentV2(params) {
// Map legacy modes to v2 modes
let searchMode = "smart";
if (params.mode) {
if (params.mode === "semantic")
searchMode = "semantic";
else if (params.mode === "hybrid" || params.mode === "any")
searchMode = "hybrid";
else if (params.mode === "fulltext" || params.mode === "all" || params.mode === "phrase")
searchMode = "fulltext";
else
searchMode = "smart";
}
const body = {
query: params.query,
mode: searchMode,
filters: params.contentTypes ? {
contentTypes: params.contentTypes,
} : undefined,
options: {
limit: params.limit || 10,
includeContent: params.includeContent || false,
includeMetadata: true,
includeSnippets: true,
minRelevance: params.minRelevance || 0.25, // Lower threshold for better recall
},
features: {
expandConcepts: searchMode === "semantic" || searchMode === "smart",
includeRelated: false,
aggregateInsights: false,
},
};
const response = await this.request(`/api/mcp/v2/search`, {
method: "POST",
body: JSON.stringify(body),
});
if (!response.ok) {
// Fall back to v1 search if v2 fails
console.warn(`v2 search failed with status ${response.status}, trying v1 fallback`);
return this.searchContentV1(params.query, {
limit: params.limit,
contentTypes: params.contentTypes,
includeFullContent: params.includeContent,
});
}
const data = await response.json();
// Map results to include aiInsights field
if (data.results) {
data.results = data.results.map((item) => ({
...item,
aiInsights: item.ai_insights || item.aiInsights || item.processingMetadata?.ai_insights || {},
}));
}
return data;
}
// New methods for enhanced endpoints
async estimateSearchTokens(query, limit = 10) {
// Use v2 search with estimateOnly flag
const response = await this.request(`/api/mcp/v2/search`, {
method: "POST",
body: JSON.stringify({
query,
options: { limit },
features: { estimateOnly: true },
}),
});
if (!response.ok) {
throw new Error("Failed to estimate search tokens");
}
const data = await response.json();
// v2 returns { estimate, warning, recommendations }
return data.estimate || data;
}
async synthesizeContent(params) {
try {
// If no content IDs provided, search for relevant content first
let sourceIds = params.contentIds;
if (!sourceIds || sourceIds.length === 0) {
// Search for content related to the query
const searchResults = await this.searchContent(params.query, {
limit: params.maxSources || 5,
enableConceptExpansion: true,
});
if (searchResults && searchResults.length > 0) {
sourceIds = searchResults.map((item) => item.id).filter((id) => id);
}
if (!sourceIds || sourceIds.length === 0) {
return {
success: false,
error: "No content found to synthesize. Please save some content first or provide specific content IDs.",
synthesis: null,
};
}
}
// Try v2 synthesis endpoint
const v2Body = {
sources: {
contentIds: sourceIds,
limit: params.maxSources || 5,
},
synthesis: {
mode: params.synthesisMode || "actionable",
depth: "standard",
},
output: {
includeContradictions: params.findContradictions || false,
includeConnections: params.findConnections !== false, // Default true
includeQuotes: true,
includeActionPlan: params.synthesisMode === "actionable",
},
};
const response = await this.request("/api/mcp/v2/synthesis", {
method: "POST",
body: JSON.stringify(v2Body),
});
if (!response.ok) {
// Try fallback v1 synthesis
console.warn(`v2 synthesis failed with status ${response.status}, trying v1 fallback`);
return this.synthesizeContentV1(params, sourceIds);
}
const data = await response.json();
return data;
}
catch (error) {
console.error("Synthesis error:", error);
// Try v1 fallback
return this.synthesizeContentV1(params, params.contentIds);
}
}
// Fallback v1 synthesis method
async synthesizeContentV1(params, contentIds) {
try {
// If we don't have content IDs, we need to search first
if (!contentIds || contentIds.length === 0) {
const searchResults = await this.searchContent(params.query, {
limit: params.maxSources || 5,
});
if (!searchResults || searchResults.length === 0) {
return {
success: false,
error: "No content found to synthesize",
synthesis: null,
};
}
contentIds = searchResults.map((item) => item.id).filter((id) => id);
}
// Try a simpler synthesis approach
const response = await this.request("/api/mcp/synthesis", {
method: "POST",
body: JSON.stringify({
query: params.query,
contentIds: contentIds,
mode: params.synthesisMode || "actionable",
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error");
return {
success: false,
error: `Synthesis failed: ${errorText}`,
synthesis: null,
};
}
return response.json();
}
catch (error) {
return {
success: false,
error: `Synthesis error: ${error}`,
synthesis: null,
};
}
}
async findSimilarContent(contentId, options) {
const params = new URLSearchParams();
if (options?.limit)
params.append("limit", options.limit.toString());
if (options?.minSimilarity)
params.append("minSimilarity", options.minSimilarity.toString());
// v2 doesn't have a direct similar endpoint yet, use v1 for now
const response = await this.request(`/api/mcp/content/${contentId}/similar?${params}`);
if (!response.ok) {
throw new Error(`Failed to find similar content for ID: ${contentId}`);
}
return response.json();
}
async batchGetContent(ids, includeFullContent = false) {
// Use v2 content endpoint with batch operation
const response = await this.request("/api/mcp/v2/content", {
method: "POST",
body: JSON.stringify({
operation: "get",
contentIds: ids,
enrich: {
includeContent: includeFullContent,
},
}),
});
if (!response.ok) {
throw new Error("Failed to batch fetch content");
}
return response.json();
}
async getEnrichedContent(ids, includeFullContent = false) {
try {
const response = await this.request("/api/mcp/v2/content", {
method: "POST",
body: JSON.stringify({
operation: "get",
contentIds: ids,
enrich: {
includeContent: includeFullContent,
includeActions: false,
includeConcepts: false,
includeRelated: false,
},
tokenOptions: {
requireConfirmation: false, // We handle confirmation in the tool
},
}),
});
if (!response.ok) {
console.error(`Get enriched content failed: ${response.status}`);
return [];
}
const data = await response.json();
// Handle confirmation requirement
if (data.requiresConfirmation) {
// For now, return empty - the tool will handle the warning
console.log("Content requires confirmation:", data.message);
return [];
}
return data.contents || [];
}
catch (error) {
console.error("Get enriched content error:", error);
return [];
}
}
}
//# sourceMappingURL=client.js.map