octagon-mcp
Version:
MCP server for Octagon API. Provides specialized AI agents for investment research of public and private markets.
144 lines (143 loc) • 6.13 kB
JavaScript
import { DOCS_DEFAULT_CACHE_TTL_MS, DOCS_DEFAULT_MAX_CHARS, DOCS_MAX_LIMIT, DOCS_PRIMARY_INDEX_URL, DOCS_SITE_INDEX_URL, } from "./config.js";
import { parseDocsCatalog } from "./catalog.js";
import { contentMatchesTarget, createDirectUrlEntry, readEntry, } from "./content.js";
import { fetchDocsText } from "./fetcher.js";
import { searchDocsEntries } from "./search.js";
function clampLimit(value, defaultValue) {
const requested = value ?? defaultValue;
if (!Number.isFinite(requested)) {
return defaultValue;
}
return Math.max(1, Math.min(DOCS_MAX_LIMIT, Math.floor(requested)));
}
function sourceUrlFor(source, options) {
return source === "docs" ? options.primaryIndexUrl : options.siteIndexUrl;
}
export class OctagonDocsService {
options;
catalogs = new Map();
readCache = new Map();
constructor(options = {}) {
this.options = {
cacheTtlMs: options.cacheTtlMs ?? DOCS_DEFAULT_CACHE_TTL_MS,
primaryIndexUrl: options.primaryIndexUrl ?? DOCS_PRIMARY_INDEX_URL,
siteIndexUrl: options.siteIndexUrl ?? DOCS_SITE_INDEX_URL,
};
}
async refresh({ includeSite = false, } = {}) {
this.readCache.clear();
const sources = includeSite ? ["docs", "site"] : ["docs"];
const catalogs = [];
for (const source of sources) {
const catalog = await this.fetchCatalog(source);
catalogs.push(catalog);
}
return catalogs;
}
async getCatalog({ includeSite = false, } = {}) {
const sources = includeSite ? ["docs", "site"] : ["docs"];
const catalogs = [];
for (const source of sources) {
catalogs.push(await this.getCatalogForSource(source));
}
return catalogs;
}
async list({ section, source = "docs", limit, } = {}) {
const entries = await this.getEntries(source === "all");
const normalizedSection = section?.trim().toLowerCase();
return entries
.filter(entry => source === "all" ? true : entry.source === source)
.filter(entry => normalizedSection
? entry.section.toLowerCase().includes(normalizedSection)
: true)
.slice(0, clampLimit(limit, 25));
}
async search({ query, section, source = "docs", limit, includeSnippets = true, }) {
const entries = (await this.getEntries(source === "all")).filter(entry => source === "all" ? true : entry.source === source);
return searchDocsEntries(entries, {
query,
section,
limit: clampLimit(limit, 10),
includeSnippets,
});
}
async read({ target, maxChars, source = "docs", preferCachedContent = true, }) {
const catalogs = await this.getCatalog({ includeSite: source === "all" });
const normalizedMaxChars = Math.max(1_000, Math.min(50_000, Math.floor(maxChars ?? DOCS_DEFAULT_MAX_CHARS)));
const cacheKey = `${source}:${target}:${normalizedMaxChars}:${preferCachedContent}`;
const cached = this.readCache.get(cacheKey);
if (cached) {
return cached;
}
const entries = catalogs.flatMap(catalog => catalog.entries).filter(entry => source === "all" ? true : entry.source === source);
const matchingEntries = entries.filter(candidate => contentMatchesTarget(candidate, target));
const titleMatch = entries.find(candidate => candidate.title.toLowerCase().includes(target.trim().toLowerCase()));
const directUrlEntry = createDirectUrlEntry(target);
const entry = matchingEntries.sort((a, b) => {
const contentScore = Number(Boolean(b.content)) - Number(Boolean(a.content));
if (contentScore !== 0) {
return contentScore;
}
const titleScore = Number(b.title.toLowerCase() === target.trim().toLowerCase()) -
Number(a.title.toLowerCase() === target.trim().toLowerCase());
if (titleScore !== 0) {
return titleScore;
}
return a.title.localeCompare(b.title);
})[0] ??
titleMatch ??
directUrlEntry;
if (!entry) {
throw new Error(`No Octagon docs page matched "${target}".`);
}
const result = await readEntry(entry, {
maxChars: normalizedMaxChars,
preferCachedContent,
});
this.readCache.set(cacheKey, result);
return result;
}
status() {
return {
primaryIndexUrl: this.options.primaryIndexUrl,
siteIndexUrl: this.options.siteIndexUrl,
cacheTtlMs: this.options.cacheTtlMs,
catalogs: Array.from(this.catalogs.values()).map(({ catalog }) => ({
source: catalog.source,
sourceUrl: catalog.sourceUrl,
fetchedAt: catalog.fetchedAt,
expiresAt: catalog.expiresAt,
entries: catalog.entries.length,
sections: catalog.sections,
etag: catalog.etag,
lastModified: catalog.lastModified,
})),
cachedPages: this.readCache.size,
};
}
async getEntries(includeSite) {
const catalogs = await this.getCatalog({ includeSite });
return catalogs.flatMap(catalog => catalog.entries);
}
async getCatalogForSource(source) {
const cached = this.catalogs.get(source);
if (cached && cached.expiresAtMs > Date.now()) {
return cached.catalog;
}
return this.fetchCatalog(source);
}
async fetchCatalog(source) {
const fetched = await fetchDocsText(sourceUrlFor(source, this.options));
const catalog = parseDocsCatalog(fetched, {
source,
cacheTtlMs: this.options.cacheTtlMs,
});
this.catalogs.set(source, {
catalog,
expiresAtMs: Date.now() + this.options.cacheTtlMs,
});
this.readCache.clear();
return catalog;
}
}
export const defaultDocsService = new OctagonDocsService();