UNPKG

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
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