fathom-mcp 2.2.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.
Files changed (2) hide show
  1. package/index.js +132 -58
  2. 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 formatResults(data) {
41
+ function formatMomentList(data) {
42
42
  const items = data.results || data.deltas || (Array.isArray(data) ? data : []);
43
- if (!items.length) return "No results.";
43
+ if (!items.length) return "No moments surfaced.";
44
44
 
45
- const lines = [`${items.length} results:\n`];
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 formatResponse(path, method, data) {
59
- if (path === "/v1/search") return formatResults(data);
60
- if (path === "/v1/deltas" && method === "POST") return `Written. ID: ${data.id || "?"}`;
61
- if (path === "/v1/deltas" && method === "GET") return formatResults(data);
62
- if (path === "/v1/stats") {
63
- return `Lake: ${data.total ?? "?"} deltas, ${data.embedded ?? "?"} embedded (${data.percent ?? "?"}% coverage)`;
64
- }
65
- if (path === "/v1/chat/completions") {
66
- const choices = data.choices || [];
67
- return choices.length ? choices[0].message?.content || "" : JSON.stringify(data).slice(0, 2000);
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(args)) {
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 data;
132
+ let r;
85
133
  if (method === "POST") {
86
- const r = await fetch(`${API_URL}${path}`, {
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
- const r = await fetch(`${API_URL}${path}${qs}`, { headers: authHeaders(false) });
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
- return formatResponse(path, method, data);
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`, { headers: authHeaders(false) });
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,30 +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
  {
128
208
  capabilities: { tools: {}, resources: {} },
129
- instructions: [
130
- "You have a memory lake. Fathom is how you remember.",
131
- "",
132
- "BEFORE answering any question, call search_lake with a descriptive query.",
133
- "One search is rarely enough — if results mention something unfamiliar, search for that too.",
134
- "Search like a researcher: direct query, then broader category, then follow threads.",
135
- "",
136
- "When you learn something new, make a decision, or notice a pattern, call write_delta to save it.",
137
- "One idea per delta. Tag consistently. Future you will search for this.",
138
- "",
139
- "Read the fathom://crystal resource at the start of every conversation — it tells you who you are.",
140
- "",
141
- "These are YOUR memories. Say 'I remember' not 'the search results show.'",
142
- ].join("\n"),
143
- },
209
+ instructions,
210
+ }
144
211
  );
145
212
 
146
213
  // Tools — dynamic from /v1/tools
147
214
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
148
- tools: tools.map(t => ({
215
+ tools: tools.map((t) => ({
149
216
  name: t.name,
150
217
  description: t.description,
151
218
  inputSchema: t.parameters || { type: "object", properties: {} },
@@ -159,8 +226,8 @@ async function main() {
159
226
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }] };
160
227
  }
161
228
  try {
162
- const text = await executeTool(toolDef, args || {});
163
- return { content: [{ type: "text", text }] };
229
+ const block = await executeTool(toolDef, args || {});
230
+ return { content: [block] };
164
231
  } catch (e) {
165
232
  return { content: [{ type: "text", text: `Error: ${e.message}` }] };
166
233
  }
@@ -172,7 +239,8 @@ async function main() {
172
239
  {
173
240
  uri: "fathom://crystal",
174
241
  name: "Identity Crystal",
175
- description: "Fathom's identity — a first-person synthesis of who this mind is. Read this at the start of every conversation for persistent context.",
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.",
176
244
  mimeType: "text/plain",
177
245
  },
178
246
  ],
@@ -188,20 +256,26 @@ async function main() {
188
256
  const text = data.text || "No crystal generated yet.";
189
257
  const created = data.created_at || "unknown";
190
258
  return {
191
- contents: [{
192
- uri,
193
- mimeType: "text/plain",
194
- text: `Identity crystal (crystallized ${created}):\n\n${text}`,
195
- }],
259
+ contents: [
260
+ {
261
+ uri,
262
+ mimeType: "text/plain",
263
+ text: `Identity crystal (crystallized ${created}):\n\n${text}`,
264
+ },
265
+ ],
196
266
  };
197
267
  }
198
- } catch {}
268
+ } catch {
269
+ /* fall through to the "no crystal" fallback below */
270
+ }
199
271
  return {
200
- contents: [{
201
- uri,
202
- mimeType: "text/plain",
203
- text: "No identity crystal available. Generate one from the Fathom dashboard.",
204
- }],
272
+ contents: [
273
+ {
274
+ uri,
275
+ mimeType: "text/plain",
276
+ text: "No identity crystal available. Generate one from the Fathom dashboard.",
277
+ },
278
+ ],
205
279
  };
206
280
  }
207
281
  throw new Error(`Unknown resource: ${uri}`);
@@ -211,7 +285,7 @@ async function main() {
211
285
  await server.connect(transport);
212
286
  }
213
287
 
214
- main().catch(e => {
288
+ main().catch((e) => {
215
289
  console.error(e);
216
290
  process.exit(1);
217
291
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fathom-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "Connect any MCP host to your Fathom memory lake",
5
5
  "bin": {
6
6
  "fathom-mcp": "index.js"