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.
Files changed (2) hide show
  1. package/index.js +134 -44
  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,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
- { capabilities: { tools: {}, resources: {} } },
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 text = await executeTool(toolDef, args || {});
147
- return { content: [{ type: "text", text }] };
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: "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.",
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
- uri,
177
- mimeType: "text/plain",
178
- text: `Identity crystal (crystallized ${created}):\n\n${text}`,
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
- uri,
186
- mimeType: "text/plain",
187
- text: "No identity crystal available. Generate one from the Fathom dashboard.",
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fathom-mcp",
3
- "version": "2.1.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"