src / toolsProvider.ts
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { configSchematics, globalConfigSchematics } from "./config";
import { fredFetch, clean } from "./fredClient";
function getKey(ctl: ToolsProviderController): string {
const g = ctl.getGlobalPluginConfig(globalConfigSchematics);
const key = (g.get("fredApiKey") as string)?.trim();
if (!key) throw new Error("FRED API Key not set. Please set it in Plugin Settings > Global Config (fred-mcp-lmstudio). Get key at https://fred.stlouisfed.org/docs/api/api_key.html");
return key;
}
export async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const tools: Tool[] = [];
// ---------- fred_browse ----------
tools.push(
tool({
name: "fred_browse",
description: "Browse FRED catalog: categories, releases, sources, category_series, release_series. Access 800k+ series structure.",
parameters: {
browse_type: z.enum(["categories", "releases", "sources", "category_series", "release_series"]).describe("Type of browsing"),
category_id: z.number().int().optional().describe("Category ID (for subcategories or category_series)"),
release_id: z.number().int().optional().describe("Release ID (for release_series)"),
limit: z.number().int().min(1).max(1000).optional(),
offset: z.number().int().min(0).optional(),
order_by: z.string().optional().describe("Field to order by"),
sort_order: z.enum(["asc", "desc"]).optional(),
},
implementation: async ({ browse_type, category_id, release_id, limit, offset, order_by, sort_order }) => {
try {
const apiKey = getKey(ctl);
const perChat = ctl.getPluginConfig(configSchematics);
const defLimit = perChat.get("defaultLimit") as number;
const defSort = perChat.get("defaultSortOrder") as string;
const base = clean({ limit: limit ?? defLimit, offset, order_by, sort_order: sort_order ?? defSort });
let endpoint = "";
let params: any = { ...base };
switch (browse_type) {
case "categories":
endpoint = category_id !== undefined ? "category/children" : "category/children";
params.category_id = category_id ?? 0;
break;
case "releases":
endpoint = "releases";
break;
case "sources":
endpoint = "sources";
break;
case "category_series":
if (category_id === undefined) return "Error: category_id required for category_series";
endpoint = "category/series";
params.category_id = category_id;
break;
case "release_series":
if (release_id === undefined) return "Error: release_id required for release_series";
endpoint = "release/series";
params.release_id = release_id;
break;
}
const data = await fredFetch(endpoint, params, apiKey);
return JSON.stringify(data, null, 2).slice(0, 12000);
} catch (e: any) {
return `Error in fred_browse: ${e.message}`;
}
},
})
);
// ---------- fred_search ----------
tools.push(
tool({
name: "fred_search",
description: "Search FRED series by keywords, tags, filters. Use to discover series IDs like GDP, UNRATE, CPIAUCSL.",
parameters: {
search_text: z.string().optional().describe("Keywords in title/description"),
search_type: z.enum(["full_text", "series_id"]).optional(),
tag_names: z.string().optional().describe("Comma-separated tags to include, e.g. 'gdp,quarterly'"),
exclude_tag_names: z.string().optional().describe("Comma-separated tags to exclude"),
limit: z.number().int().min(1).max(1000).optional(),
offset: z.number().int().min(0).optional(),
order_by: z.string().optional().describe("popularity, last_updated, etc."),
sort_order: z.enum(["asc", "desc"]).optional(),
filter_variable: z.enum(["frequency", "units", "seasonal_adjustment"]).optional(),
filter_value: z.string().optional(),
},
implementation: async (p) => {
try {
const apiKey = getKey(ctl);
const perChat = ctl.getPluginConfig(configSchematics);
const params = clean({
search_text: p.search_text,
search_type: p.search_type,
tag_names: p.tag_names,
exclude_tag_names: p.exclude_tag_names,
limit: p.limit ?? (perChat.get("defaultLimit") as number),
offset: p.offset,
order_by: p.order_by,
sort_order: p.sort_order ?? (perChat.get("defaultSortOrder") as string),
filter_variable: p.filter_variable,
filter_value: p.filter_value,
});
const data = await fredFetch("series/search", params, apiKey);
// concise summary for LLM
const series = (data as any).seriess ?? (data as any).series ?? [];
const summary = series.slice(0, 20).map((s: any) => ({
id: s.id,
title: s.title,
units: s.units,
frequency: s.frequency,
seasonal_adjustment: s.seasonal_adjustment_short,
last_updated: s.last_updated,
popularity: s.popularity,
}));
return JSON.stringify({ count: (data as any).count ?? series.length, summary, raw: data }, null, 2).slice(0, 15000);
} catch (e: any) {
return `Error in fred_search: ${e.message}`;
}
},
})
);
// ---------- fred_get_series ----------
tools.push(
tool({
name: "fred_get_series",
description: "Retrieve observations for a FRED series ID (e.g., GDP, UNRATE, CPIAUCSL). Supports date range, transformations, aggregation.",
parameters: {
series_id: z.string().describe("FRED series ID, e.g. GDP"),
observation_start: z.string().optional().describe("YYYY-MM-DD start"),
observation_end: z.string().optional().describe("YYYY-MM-DD end"),
limit: z.number().int().min(1).max(100000).optional(),
offset: z.number().int().min(0).optional(),
sort_order: z.enum(["asc", "desc"]).optional(),
units: z.enum(["lin", "chg", "ch1", "pch", "pc1", "pca", "cch", "cca", "log"]).optional().describe("Transformation"),
frequency: z.enum(["d", "w", "bw", "m", "q", "sa", "a", "wef", "weth", "wew", "wetu", "wem", "wesu", "wesa", "bwew", "bwem"]).optional(),
aggregation_method: z.enum(["avg", "sum", "eop"]).optional(),
},
implementation: async (p) => {
try {
const apiKey = getKey(ctl);
const perChat = ctl.getPluginConfig(configSchematics);
const obsParams = clean({
series_id: p.series_id,
observation_start: p.observation_start,
observation_end: p.observation_end,
limit: p.limit ?? (perChat.get("defaultLimit") as number),
offset: p.offset,
sort_order: p.sort_order ?? "asc",
units: p.units,
frequency: p.frequency,
aggregation_method: p.aggregation_method,
});
// fetch metadata + observations in parallel
const [meta, obs] = await Promise.all([
fredFetch("series", { series_id: p.series_id }, apiKey).catch(() => ({})),
fredFetch("series/observations", obsParams, apiKey),
]);
const observations = (obs as any).observations ?? [];
const result = {
series: (meta as any).seriess?.[0] ?? (meta as any).series?.[0] ?? { id: p.series_id },
observations_count: observations.length,
observations: observations.slice(-200), // keep last 200 to avoid token blowup
full_range: {
start: observations[0]?.date,
end: observations[observations.length - 1]?.date,
},
};
return JSON.stringify(result, null, 2).slice(0, 20000);
} catch (e: any) {
return `Error in fred_get_series: ${e.message}`;
}
},
})
);
return tools;
}
src / toolsProvider.ts
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { configSchematics, globalConfigSchematics } from "./config";
import { fredFetch, clean } from "./fredClient";
function getKey(ctl: ToolsProviderController): string {
const g = ctl.getGlobalPluginConfig(globalConfigSchematics);
const key = (g.get("fredApiKey") as string)?.trim();
if (!key) throw new Error("FRED API Key not set. Please set it in Plugin Settings > Global Config (fred-mcp-lmstudio). Get key at https://fred.stlouisfed.org/docs/api/api_key.html");
return key;
}
export async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const tools: Tool[] = [];
// ---------- fred_browse ----------
tools.push(
tool({
name: "fred_browse",
description: "Browse FRED catalog: categories, releases, sources, category_series, release_series. Access 800k+ series structure.",
parameters: {
browse_type: z.enum(["categories", "releases", "sources", "category_series", "release_series"]).describe("Type of browsing"),
category_id: z.number().int().optional().describe("Category ID (for subcategories or category_series)"),
release_id: z.number().int().optional().describe("Release ID (for release_series)"),
limit: z.number().int().min(1).max(1000).optional(),
offset: z.number().int().min(0).optional(),
order_by: z.string().optional().describe("Field to order by"),
sort_order: z.enum(["asc", "desc"]).optional(),
},
implementation: async ({ browse_type, category_id, release_id, limit, offset, order_by, sort_order }) => {
try {
const apiKey = getKey(ctl);
const perChat = ctl.getPluginConfig(configSchematics);
const defLimit = perChat.get("defaultLimit") as number;
const defSort = perChat.get("defaultSortOrder") as string;
const base = clean({ limit: limit ?? defLimit, offset, order_by, sort_order: sort_order ?? defSort });
let endpoint = "";
let params: any = { ...base };
switch (browse_type) {
case "categories":
endpoint = category_id !== undefined ? "category/children" : "category/children";
params.category_id = category_id ?? 0;
break;
case "releases":
endpoint = "releases";
break;
case "sources":
endpoint = "sources";
break;
case "category_series":
if (category_id === undefined) return "Error: category_id required for category_series";
endpoint = "category/series";
params.category_id = category_id;
break;
case "release_series":
if (release_id === undefined) return "Error: release_id required for release_series";
endpoint = "release/series";
params.release_id = release_id;
break;
}
const data = await fredFetch(endpoint, params, apiKey);
return JSON.stringify(data, null, 2).slice(0, 12000);
} catch (e: any) {
return `Error in fred_browse: ${e.message}`;
}
},
})
);
// ---------- fred_search ----------
tools.push(
tool({
name: "fred_search",
description: "Search FRED series by keywords, tags, filters. Use to discover series IDs like GDP, UNRATE, CPIAUCSL.",
parameters: {
search_text: z.string().optional().describe("Keywords in title/description"),
search_type: z.enum(["full_text", "series_id"]).optional(),
tag_names: z.string().optional().describe("Comma-separated tags to include, e.g. 'gdp,quarterly'"),
exclude_tag_names: z.string().optional().describe("Comma-separated tags to exclude"),
limit: z.number().int().min(1).max(1000).optional(),
offset: z.number().int().min(0).optional(),
order_by: z.string().optional().describe("popularity, last_updated, etc."),
sort_order: z.enum(["asc", "desc"]).optional(),
filter_variable: z.enum(["frequency", "units", "seasonal_adjustment"]).optional(),
filter_value: z.string().optional(),
},
implementation: async (p) => {
try {
const apiKey = getKey(ctl);
const perChat = ctl.getPluginConfig(configSchematics);
const params = clean({
search_text: p.search_text,
search_type: p.search_type,
tag_names: p.tag_names,
exclude_tag_names: p.exclude_tag_names,
limit: p.limit ?? (perChat.get("defaultLimit") as number),
offset: p.offset,
order_by: p.order_by,
sort_order: p.sort_order ?? (perChat.get("defaultSortOrder") as string),
filter_variable: p.filter_variable,
filter_value: p.filter_value,
});
const data = await fredFetch("series/search", params, apiKey);
// concise summary for LLM
const series = (data as any).seriess ?? (data as any).series ?? [];
const summary = series.slice(0, 20).map((s: any) => ({
id: s.id,
title: s.title,
units: s.units,
frequency: s.frequency,
seasonal_adjustment: s.seasonal_adjustment_short,
last_updated: s.last_updated,
popularity: s.popularity,
}));
return JSON.stringify({ count: (data as any).count ?? series.length, summary, raw: data }, null, 2).slice(0, 15000);
} catch (e: any) {
return `Error in fred_search: ${e.message}`;
}
},
})
);
// ---------- fred_get_series ----------
tools.push(
tool({
name: "fred_get_series",
description: "Retrieve observations for a FRED series ID (e.g., GDP, UNRATE, CPIAUCSL). Supports date range, transformations, aggregation.",
parameters: {
series_id: z.string().describe("FRED series ID, e.g. GDP"),
observation_start: z.string().optional().describe("YYYY-MM-DD start"),
observation_end: z.string().optional().describe("YYYY-MM-DD end"),
limit: z.number().int().min(1).max(100000).optional(),
offset: z.number().int().min(0).optional(),
sort_order: z.enum(["asc", "desc"]).optional(),
units: z.enum(["lin", "chg", "ch1", "pch", "pc1", "pca", "cch", "cca", "log"]).optional().describe("Transformation"),
frequency: z.enum(["d", "w", "bw", "m", "q", "sa", "a", "wef", "weth", "wew", "wetu", "wem", "wesu", "wesa", "bwew", "bwem"]).optional(),
aggregation_method: z.enum(["avg", "sum", "eop"]).optional(),
},
implementation: async (p) => {
try {
const apiKey = getKey(ctl);
const perChat = ctl.getPluginConfig(configSchematics);
const obsParams = clean({
series_id: p.series_id,
observation_start: p.observation_start,
observation_end: p.observation_end,
limit: p.limit ?? (perChat.get("defaultLimit") as number),
offset: p.offset,
sort_order: p.sort_order ?? "asc",
units: p.units,
frequency: p.frequency,
aggregation_method: p.aggregation_method,
});
// fetch metadata + observations in parallel
const [meta, obs] = await Promise.all([
fredFetch("series", { series_id: p.series_id }, apiKey).catch(() => ({})),
fredFetch("series/observations", obsParams, apiKey),
]);
const observations = (obs as any).observations ?? [];
const result = {
series: (meta as any).seriess?.[0] ?? (meta as any).series?.[0] ?? { id: p.series_id },
observations_count: observations.length,
observations: observations.slice(-200), // keep last 200 to avoid token blowup
full_range: {
start: observations[0]?.date,
end: observations[observations.length - 1]?.date,
},
};
return JSON.stringify(result, null, 2).slice(0, 20000);
} catch (e: any) {
return `Error in fred_get_series: ${e.message}`;
}
},
})
);
return tools;
}