fathom-mcp 2.1.0 → 2.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +134 -44
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -36,13 +36,13 @@ function authHeaders(json = true) {
|
|
|
36
36
|
return h;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// ── Result formatting
|
|
39
|
+
// ── Result formatting (keyed by tool's response_kind) ────────────────
|
|
40
40
|
|
|
41
|
-
function
|
|
41
|
+
function formatMomentList(data) {
|
|
42
42
|
const items = data.results || data.deltas || (Array.isArray(data) ? data : []);
|
|
43
|
-
if (!items.length) return "No
|
|
43
|
+
if (!items.length) return "No moments surfaced.";
|
|
44
44
|
|
|
45
|
-
const lines = [`${items.length}
|
|
45
|
+
const lines = [`${items.length} moments:\n`];
|
|
46
46
|
for (const raw of items) {
|
|
47
47
|
const d = raw.delta || raw;
|
|
48
48
|
const ts = (d.timestamp || "").slice(0, 16);
|
|
@@ -55,62 +55,140 @@ function formatResults(data) {
|
|
|
55
55
|
return lines.join("\n");
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
58
|
+
function formatRecall(data) {
|
|
59
|
+
const total = data.total_count || 0;
|
|
60
|
+
const tree = data.tree || [];
|
|
61
|
+
if (!total || !tree.length) return "No moments surfaced.";
|
|
62
|
+
const header = `${total} moments across ${tree.length} step(s):\n`;
|
|
63
|
+
return header + "\n" + (data.as_prompt || "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatStats(data) {
|
|
67
|
+
return `Your mind: ${data.total ?? "?"} moments, ${data.embedded ?? "?"} embedded (${data.percent ?? "?"}% coverage)`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatTags(data) {
|
|
71
|
+
// /v1/tags returns {tag: count}. Top 50 keeps context bounded.
|
|
72
|
+
if (!data || typeof data !== "object") return "No tags.";
|
|
73
|
+
const entries = Object.entries(data).sort((a, b) => b[1] - a[1]);
|
|
74
|
+
if (!entries.length) return "No tags.";
|
|
75
|
+
const top = entries.slice(0, 50);
|
|
76
|
+
const lines = [`${entries.length} tags (top ${top.length}):\n`];
|
|
77
|
+
for (const [tag, count] of top) lines.push(` ${tag} (${count})`);
|
|
78
|
+
return lines.join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatByKind(kind, data) {
|
|
82
|
+
switch (kind) {
|
|
83
|
+
case "tree":
|
|
84
|
+
return formatRecall(data);
|
|
85
|
+
case "moments":
|
|
86
|
+
return formatMomentList(data);
|
|
87
|
+
case "stats":
|
|
88
|
+
return formatStats(data);
|
|
89
|
+
case "tags":
|
|
90
|
+
return formatTags(data);
|
|
91
|
+
case "write_receipt":
|
|
92
|
+
return `Written. ID: ${data.id || "?"}`;
|
|
93
|
+
default:
|
|
94
|
+
return JSON.stringify(data, null, 2).slice(0, 2000);
|
|
68
95
|
}
|
|
69
|
-
return JSON.stringify(data, null, 2).slice(0, 2000);
|
|
70
96
|
}
|
|
71
97
|
|
|
72
98
|
// ── Tool execution ───────────────────────────────
|
|
73
99
|
|
|
100
|
+
// Substitute {placeholder} segments in a URL path template using the
|
|
101
|
+
// caller's args. Consumed args are returned separately so they don't
|
|
102
|
+
// leak into the body or query string as duplicate fields.
|
|
103
|
+
function applyPathTemplate(pathTemplate, args) {
|
|
104
|
+
const names = [...pathTemplate.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
|
|
105
|
+
let path = pathTemplate;
|
|
106
|
+
const consumed = new Set();
|
|
107
|
+
for (const name of names) {
|
|
108
|
+
const val = args[name];
|
|
109
|
+
if (val == null) throw new Error(`missing path param: ${name}`);
|
|
110
|
+
path = path.replace(`{${name}}`, encodeURIComponent(String(val)));
|
|
111
|
+
consumed.add(name);
|
|
112
|
+
}
|
|
113
|
+
const remaining = {};
|
|
114
|
+
for (const [k, v] of Object.entries(args)) if (!consumed.has(k)) remaining[k] = v;
|
|
115
|
+
return { path, remaining };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Returns a single MCP content block: `{type:"text",text}` or
|
|
119
|
+
// `{type:"image",data,mimeType}`. The CallToolRequestSchema handler
|
|
120
|
+
// wraps it into `{content: [block]}`.
|
|
74
121
|
async function executeTool(toolDef, args) {
|
|
75
|
-
const { method, path } = toolDef.endpoint;
|
|
122
|
+
const { method, path: pathTemplate } = toolDef.endpoint;
|
|
76
123
|
const requestMap = toolDef.request_map || {};
|
|
124
|
+
const { path, remaining } = applyPathTemplate(pathTemplate, args || {});
|
|
77
125
|
|
|
78
126
|
const mapped = {};
|
|
79
|
-
for (const [k, v] of Object.entries(
|
|
127
|
+
for (const [k, v] of Object.entries(remaining)) {
|
|
80
128
|
if (v == null) continue;
|
|
81
129
|
mapped[requestMap[k] || k] = v;
|
|
82
130
|
}
|
|
83
131
|
|
|
84
|
-
let
|
|
132
|
+
let r;
|
|
85
133
|
if (method === "POST") {
|
|
86
|
-
|
|
134
|
+
r = await fetch(`${API_URL}${path}`, {
|
|
87
135
|
method: "POST",
|
|
88
136
|
headers: authHeaders(true),
|
|
89
137
|
body: JSON.stringify(mapped),
|
|
90
138
|
});
|
|
91
|
-
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
92
|
-
data = await r.json();
|
|
93
139
|
} else {
|
|
94
140
|
const params = {};
|
|
95
141
|
for (const [k, v] of Object.entries(mapped)) {
|
|
96
142
|
params[k] = Array.isArray(v) ? v.join(",") : String(v);
|
|
97
143
|
}
|
|
98
144
|
const qs = Object.keys(params).length ? "?" + new URLSearchParams(params) : "";
|
|
99
|
-
|
|
100
|
-
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
101
|
-
data = await r.json();
|
|
145
|
+
r = await fetch(`${API_URL}${path}${qs}`, { headers: authHeaders(false) });
|
|
102
146
|
}
|
|
147
|
+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
148
|
+
|
|
149
|
+
const kind = toolDef.response_kind || "json";
|
|
103
150
|
|
|
104
|
-
|
|
151
|
+
if (kind === "image") {
|
|
152
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
153
|
+
const mimeType = r.headers.get("content-type") || "image/webp";
|
|
154
|
+
return { type: "image", data: buf.toString("base64"), mimeType };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const data = await r.json();
|
|
158
|
+
return { type: "text", text: formatByKind(kind, data) };
|
|
105
159
|
}
|
|
106
160
|
|
|
107
161
|
// ── MCP server ───────────────────────────────────
|
|
108
162
|
|
|
163
|
+
const MCP_SURFACE = "mcp";
|
|
164
|
+
const FALLBACK_INSTRUCTIONS =
|
|
165
|
+
"You have a Fathom lake of memories. Call remember before answering anything " +
|
|
166
|
+
"about the past; write when you learn something; read the fathom://crystal " +
|
|
167
|
+
"resource at the start of every conversation.";
|
|
168
|
+
|
|
169
|
+
async function fetchInstructions() {
|
|
170
|
+
try {
|
|
171
|
+
const r = await fetch(
|
|
172
|
+
`${API_URL}/v1/agent-instructions?surface=${encodeURIComponent(MCP_SURFACE)}`,
|
|
173
|
+
{ headers: authHeaders(false) }
|
|
174
|
+
);
|
|
175
|
+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
176
|
+
const data = await r.json();
|
|
177
|
+
return (data.text || "").trim() || FALLBACK_INSTRUCTIONS;
|
|
178
|
+
} catch (e) {
|
|
179
|
+
console.error(`Could not load instructions from ${API_URL}: ${e.message}`);
|
|
180
|
+
return FALLBACK_INSTRUCTIONS;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
109
184
|
async function main() {
|
|
110
|
-
// Load tool definitions from the API
|
|
185
|
+
// Load tool definitions from the API, scoped to the MCP surface so
|
|
186
|
+
// chat-only tools (rename_session, routines, explain) never appear here.
|
|
111
187
|
let tools = [];
|
|
112
188
|
try {
|
|
113
|
-
const r = await fetch(`${API_URL}/v1/tools
|
|
189
|
+
const r = await fetch(`${API_URL}/v1/tools?surface=${encodeURIComponent(MCP_SURFACE)}`, {
|
|
190
|
+
headers: authHeaders(false),
|
|
191
|
+
});
|
|
114
192
|
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
115
193
|
const data = await r.json();
|
|
116
194
|
tools = data.tools || [];
|
|
@@ -122,14 +200,19 @@ async function main() {
|
|
|
122
200
|
const toolMap = {};
|
|
123
201
|
for (const t of tools) toolMap[t.name] = t;
|
|
124
202
|
|
|
203
|
+
const instructions = await fetchInstructions();
|
|
204
|
+
|
|
125
205
|
const server = new Server(
|
|
126
206
|
{ name: "Fathom", version: "2.1.0" },
|
|
127
|
-
{
|
|
207
|
+
{
|
|
208
|
+
capabilities: { tools: {}, resources: {} },
|
|
209
|
+
instructions,
|
|
210
|
+
}
|
|
128
211
|
);
|
|
129
212
|
|
|
130
213
|
// Tools — dynamic from /v1/tools
|
|
131
214
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
132
|
-
tools: tools.map(t => ({
|
|
215
|
+
tools: tools.map((t) => ({
|
|
133
216
|
name: t.name,
|
|
134
217
|
description: t.description,
|
|
135
218
|
inputSchema: t.parameters || { type: "object", properties: {} },
|
|
@@ -143,8 +226,8 @@ async function main() {
|
|
|
143
226
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
|
|
144
227
|
}
|
|
145
228
|
try {
|
|
146
|
-
const
|
|
147
|
-
return { content: [
|
|
229
|
+
const block = await executeTool(toolDef, args || {});
|
|
230
|
+
return { content: [block] };
|
|
148
231
|
} catch (e) {
|
|
149
232
|
return { content: [{ type: "text", text: `Error: ${e.message}` }] };
|
|
150
233
|
}
|
|
@@ -156,7 +239,8 @@ async function main() {
|
|
|
156
239
|
{
|
|
157
240
|
uri: "fathom://crystal",
|
|
158
241
|
name: "Identity Crystal",
|
|
159
|
-
description:
|
|
242
|
+
description:
|
|
243
|
+
"Fathom's identity — a first-person synthesis of who this mind is. Read this at the start of every conversation for persistent context.",
|
|
160
244
|
mimeType: "text/plain",
|
|
161
245
|
},
|
|
162
246
|
],
|
|
@@ -172,20 +256,26 @@ async function main() {
|
|
|
172
256
|
const text = data.text || "No crystal generated yet.";
|
|
173
257
|
const created = data.created_at || "unknown";
|
|
174
258
|
return {
|
|
175
|
-
contents: [
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
259
|
+
contents: [
|
|
260
|
+
{
|
|
261
|
+
uri,
|
|
262
|
+
mimeType: "text/plain",
|
|
263
|
+
text: `Identity crystal (crystallized ${created}):\n\n${text}`,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
180
266
|
};
|
|
181
267
|
}
|
|
182
|
-
} catch {
|
|
268
|
+
} catch {
|
|
269
|
+
/* fall through to the "no crystal" fallback below */
|
|
270
|
+
}
|
|
183
271
|
return {
|
|
184
|
-
contents: [
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
272
|
+
contents: [
|
|
273
|
+
{
|
|
274
|
+
uri,
|
|
275
|
+
mimeType: "text/plain",
|
|
276
|
+
text: "No identity crystal available. Generate one from the Fathom dashboard.",
|
|
277
|
+
},
|
|
278
|
+
],
|
|
189
279
|
};
|
|
190
280
|
}
|
|
191
281
|
throw new Error(`Unknown resource: ${uri}`);
|
|
@@ -195,7 +285,7 @@ async function main() {
|
|
|
195
285
|
await server.connect(transport);
|
|
196
286
|
}
|
|
197
287
|
|
|
198
|
-
main().catch(e => {
|
|
288
|
+
main().catch((e) => {
|
|
199
289
|
console.error(e);
|
|
200
290
|
process.exit(1);
|
|
201
291
|
});
|