diff --git a/apps/docs/supermemory-mcp/mcp.mdx b/apps/docs/supermemory-mcp/mcp.mdx index f38845af..3a5eded1 100644 --- a/apps/docs/supermemory-mcp/mcp.mdx +++ b/apps/docs/supermemory-mcp/mcp.mdx @@ -46,7 +46,8 @@ Your assistant chooses these tools automatically. Use this table when you need t | Tool | Use it for | Inputs | Result | | --- | --- | --- | --- | -| `search_memory` | Semantic recall from one space, with optional profile context | `query` (required), `includeProfile`, `containerTag` | Profile context and matching memories | +| `search_memory` | Semantic recall from one space | `query` (required), `containerTag` | Matching memories | +| `get_profile` | Stable and recent profile context for one space | `containerTag` | Profile facts | | `add_memory` | Save information or forget outdated information | `content` (required), `action` (`save` or `forget`), `containerTag` | Save or forget confirmation | | `listDocuments` | Browse stored source documents and their summaries | `page`, `limit`, `containerTag` | Document IDs, titles, types, status, dates, and summaries | | `getDocument` | Read the available content of one document | `documentId` (required) | Document metadata, summary, and available content | @@ -56,11 +57,12 @@ Your assistant chooses these tools automatically. Use this table when you need t ### Search -`search_memory` accepts a natural-language query and returns semantically relevant memories. By default, it also includes stable and recent profile context from the same space. Set `includeProfile` to `false` when only matching memories are needed. +`search_memory` accepts a natural-language query and returns semantically relevant memories. It does not include the space profile. Use `get_profile` when the assistant needs stable facts or recent context that search did not return. Use the retrieval tools for different questions: - Use `search_memory` to answer a question from remembered context. +- Use `get_profile` for who-the-user-is, preferences, and recent context. - Use `listDocuments` to discover stored sources, then `getDocument` to read one source in full. - Use `listMemories` to inspect the extracted memory entries themselves, including their IDs and source document IDs. diff --git a/apps/mcp/README.md b/apps/mcp/README.md index 1cf39bc1..55ff4b1d 100644 --- a/apps/mcp/README.md +++ b/apps/mcp/README.md @@ -48,7 +48,8 @@ The client discovers the OAuth authorization server through | Tool | Purpose | | --- | --- | -| `search_memory` | Search memories and optionally include profile context | +| `search_memory` | Search memories in a space | +| `get_profile` | Get stable and recent profile context for a space | | `listDocuments` | List document metadata and summaries in a space | | `getDocument` | Read one document's available content by ID | | `listMemories` | List extracted memory entries and their source document IDs | diff --git a/apps/mcp/e2e/discovery.test.ts b/apps/mcp/e2e/discovery.test.ts index 3da201c2..be9ccbb0 100644 --- a/apps/mcp/e2e/discovery.test.ts +++ b/apps/mcp/e2e/discovery.test.ts @@ -11,6 +11,7 @@ const EXPECTED_TOOLS = [ "add_memory", "fetch-graph-data", "getDocument", + "get_profile", "guided-save", "listDocuments", "listMemories", @@ -28,6 +29,7 @@ const describeWithAuth = describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE) const READ_ONLY_TOOL_NAMES = [ "search_memory", + "get_profile", "listDocuments", "listMemories", "getDocument", diff --git a/apps/mcp/e2e/helpers.ts b/apps/mcp/e2e/helpers.ts index 2d8a379d..0118106d 100644 --- a/apps/mcp/e2e/helpers.ts +++ b/apps/mcp/e2e/helpers.ts @@ -225,7 +225,6 @@ export async function recallUntil( for (let i = 0; i < tries; i++) { const res = await callTool(client, "search_memory", { query, - includeProfile: false, ...(containerTag ? { containerTag } : {}), }) const txt = textOf(res) diff --git a/apps/mcp/e2e/memory.test.ts b/apps/mcp/e2e/memory.test.ts index 7ff5664a..0cd64ff2 100644 --- a/apps/mcp/e2e/memory.test.ts +++ b/apps/mcp/e2e/memory.test.ts @@ -43,21 +43,29 @@ describe.skipIf(!OAUTH_CREDENTIALS_AVAILABLE)("MCP — memory behaviors", () => expect(found, `recall never returned marker ${marker}`).not.toBeNull() }, 120_000) - it("recall includeProfile=true returns profile + memories sections", async () => { + it("search_memory does not include profile sections", async () => { const res = await callTool(s.client, "search_memory", { query: "dragonfruit", - includeProfile: true, }) expect(res.isError).toBeFalsy() const txt = textOf(res) - expect(txt).toMatch(/## (Profile|Recent context|Matching memories)/) + expect(txt).not.toMatch(/## (Profile|Stable Context|Recent [Cc]ontext)/) + expect(txt).toMatch(/## Matching memories|No matching memories found/i) + }, 30_000) + + it("get_profile returns profile sections", async () => { + const res = await callTool(s.client, "get_profile", {}) + expect(res.isError).toBeFalsy() + const txt = textOf(res) + expect(txt).toMatch( + /## (Stable Context|Recent Context)|No profile facts are available/i, + ) }, 30_000) // Hybrid search returns nearest matches even for unrelated queries — assert it responds gracefully, not empty. it("recall responds gracefully for an unmatched query", async () => { const res = await callTool(s.client, "search_memory", { query: `zzz-no-such-memory-${randomUUID()}`, - includeProfile: false, }) expect(res.isError).toBeFalsy() expect(textOf(res)).toMatch( diff --git a/apps/mcp/e2e/space-scope.test.ts b/apps/mcp/e2e/space-scope.test.ts index 1dab7267..3b5c013b 100644 --- a/apps/mcp/e2e/space-scope.test.ts +++ b/apps/mcp/e2e/space-scope.test.ts @@ -25,11 +25,13 @@ describeWithAuth("MCP - space scoping", () => { expect(propsOf(plainTools, "search_memory")).toHaveProperty( "containerTag", ) + expect(propsOf(plainTools, "get_profile")).toHaveProperty("containerTag") expect(propsOf(scopedTools, "add_memory")).toHaveProperty("containerTag") expect(propsOf(scopedTools, "search_memory")).toHaveProperty( "containerTag", ) + expect(propsOf(scopedTools, "get_profile")).toHaveProperty("containerTag") } finally { await scoped.close() await plain.close() diff --git a/apps/mcp/src/server/analytics.ts b/apps/mcp/src/server/analytics.ts index aac932d1..49e9b1e2 100644 --- a/apps/mcp/src/server/analytics.ts +++ b/apps/mcp/src/server/analytics.ts @@ -34,6 +34,7 @@ type ClientInfoResolver = ( const TOOL_SURFACES: Record = { search_memory: "model_tool", + get_profile: "model_tool", listDocuments: "model_tool", getDocument: "model_tool", listMemories: "model_tool", diff --git a/apps/mcp/src/server/tools/get-profile.test.ts b/apps/mcp/src/server/tools/get-profile.test.ts new file mode 100644 index 00000000..7c7a92f5 --- /dev/null +++ b/apps/mcp/src/server/tools/get-profile.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest" +import * as getProfile from "./get-profile" +import type { ToolDeps } from "./types" + +function registerProfile(client: { + getProfile: () => Promise<{ + profile: { static: string[]; dynamic: string[] } + }> +}) { + let handler: + | ((args: { containerTag?: string }) => Promise) + | undefined + + getProfile.register({ + server: { + registerTool: ( + _name: string, + _config: unknown, + registered: (args: { containerTag?: string }) => Promise, + ) => { + handler = registered + }, + }, + resolveContainerTag: async (tag?: string) => tag ?? "sm_project_default", + getClient: () => client, + errorResult: (error: unknown) => ({ + content: [ + { + type: "text" as const, + text: error instanceof Error ? error.message : "error", + }, + ], + isError: true, + }), + } as unknown as ToolDeps) + + if (!handler) throw new Error("get_profile was not registered") + return handler +} + +describe("get_profile", () => { + it("returns stable and recent profile facts for the resolved space", async () => { + const handler = registerProfile({ + getProfile: async () => ({ + profile: { + static: ["User is a software engineer"], + dynamic: ["User is debugging auth"], + }, + }), + }) + + const result = (await handler({ containerTag: "team_eng" })) as { + content: Array<{ text: string }> + structuredContent: { + containerTag: string + profile: { static: string[]; dynamic: string[] } + } + } + + expect(result.structuredContent).toEqual({ + containerTag: "team_eng", + profile: { + static: ["User is a software engineer"], + dynamic: ["User is debugging auth"], + }, + }) + expect(result.content[0]?.text).toContain("## Stable Context") + expect(result.content[0]?.text).toContain("User is a software engineer") + expect(result.content[0]?.text).toContain("## Recent Context") + expect(result.content[0]?.text).toContain("User is debugging auth") + }) + + it("says when the space has no profile facts yet", async () => { + const handler = registerProfile({ + getProfile: async () => ({ + profile: { static: [], dynamic: [] }, + }), + }) + + const result = (await handler({})) as { + content: Array<{ text: string }> + } + + expect(result.content[0]?.text).toBe( + "No profile facts are available for this space yet.", + ) + }) +}) diff --git a/apps/mcp/src/server/tools/get-profile.ts b/apps/mcp/src/server/tools/get-profile.ts new file mode 100644 index 00000000..c84b0a46 --- /dev/null +++ b/apps/mcp/src/server/tools/get-profile.ts @@ -0,0 +1,63 @@ +import { z } from "zod" +import { optionalContainerTagSchema } from "../container-tag" +import { formatFactSection } from "../space-presentation" +import { READ_ONLY_TOOL_ANNOTATIONS } from "./annotations" +import { getProfileOutputSchema, type GetProfileOutput } from "./output-schemas" +import { textContent, type ToolDeps } from "./types" + +export function register(deps: ToolDeps) { + const inputSchema = z.object({ + containerTag: optionalContainerTagSchema, + }) + + deps.server.registerTool( + "get_profile", + { + title: "Get Profile", + description: + "Get the stable and recent profile for one space — long-lived facts plus recent context. search_memory does not include this. After searching, call this if matching memories are not enough and you need who-the-user-is, preferences, or recent context. When the user names a space, resolve it with listSpaces and pass containerTag; otherwise use the active space. Use whoAmI for account identity and access, not profile facts.", + inputSchema, + outputSchema: getProfileOutputSchema, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + }, + async (args) => { + try { + const effectiveTag = await deps.resolveContainerTag(args.containerTag) + const profileResult = await deps.getClient(effectiveTag).getProfile() + const profile = { + static: profileResult.profile.static, + dynamic: profileResult.profile.dynamic, + } + + const parts = [ + ...formatFactSection( + "Stable Context", + profile.static, + profile.static.length, + ), + ...formatFactSection( + "Recent Context", + profile.dynamic, + profile.dynamic.length, + ), + ] + + if (parts.length === 0) { + parts.push("No profile facts are available for this space yet.") + } + + const structuredContent: GetProfileOutput = { + containerTag: effectiveTag, + profile, + } + + return { + content: [textContent(parts.join("\n"))], + structuredContent, + } + } catch (error) { + return deps.errorResult(error) + } + }, + ) +} diff --git a/apps/mcp/src/server/tools/index.ts b/apps/mcp/src/server/tools/index.ts index 6effe28d..232c3932 100644 --- a/apps/mcp/src/server/tools/index.ts +++ b/apps/mcp/src/server/tools/index.ts @@ -1,6 +1,7 @@ import * as addMemory from "./add-memory" import * as fetchGraphData from "./fetch-graph-data" import * as getDocument from "./get-document" +import * as getProfile from "./get-profile" import * as guidedSave from "./guided-save" import * as listContainerTags from "./list-container-tags" import * as listDocuments from "./list-documents" @@ -17,6 +18,7 @@ import * as whoAmI from "./who-am-i" export function registerAllTools(deps: ToolDeps) { searchMemory.register(deps) + getProfile.register(deps) listDocuments.register(deps) getDocument.register(deps) listMemories.register(deps) diff --git a/apps/mcp/src/server/tools/output-schemas.ts b/apps/mcp/src/server/tools/output-schemas.ts index 2ea8bb3b..8f10d65a 100644 --- a/apps/mcp/src/server/tools/output-schemas.ts +++ b/apps/mcp/src/server/tools/output-schemas.ts @@ -107,6 +107,16 @@ export const searchMemoryOutputSchema = z.object({ export type SearchMemoryOutput = z.infer +export const getProfileOutputSchema = z.object({ + containerTag: z.string(), + profile: z.object({ + static: z.array(z.string()), + dynamic: z.array(z.string()), + }), +}) + +export type GetProfileOutput = z.infer + export const whoAmIOutputSchema = z.object({ userId: z.string(), email: z.string().optional(), diff --git a/apps/mcp/src/server/tools/search-memory.test.ts b/apps/mcp/src/server/tools/search-memory.test.ts new file mode 100644 index 00000000..ed65a770 --- /dev/null +++ b/apps/mcp/src/server/tools/search-memory.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest" +import * as searchMemory from "./search-memory" +import type { ToolDeps } from "./types" + +function registerSearch(client: { + search: (query: string) => Promise<{ + results: Array<{ + id: string + memory?: string + chunk?: string + similarity: number + title?: string + }> + total: number + timing: number + }> + getProfile?: () => Promise +}) { + let handler: + | ((args: { query: string; containerTag?: string }) => Promise) + | undefined + + searchMemory.register({ + server: { + registerTool: ( + _name: string, + _config: unknown, + registered: (args: { + query: string + containerTag?: string + }) => Promise, + ) => { + handler = registered + }, + }, + resolveContainerTag: async (tag?: string) => tag ?? "sm_project_default", + getClient: () => client, + errorResult: (error: unknown) => ({ + content: [ + { + type: "text" as const, + text: error instanceof Error ? error.message : "error", + }, + ], + isError: true, + }), + } as unknown as ToolDeps) + + if (!handler) throw new Error("search_memory was not registered") + return handler +} + +describe("search_memory", () => { + it("returns matching memories without fetching or including profile", async () => { + const getProfile = vi.fn() + const handler = registerSearch({ + getProfile, + search: async () => ({ + results: [ + { + id: "mem_1", + memory: "User prefers dark mode", + similarity: 0.91, + }, + ], + total: 1, + timing: 12, + }), + }) + + const result = (await handler({ query: "theme preference" })) as { + content: Array<{ text: string }> + structuredContent: { + profile?: unknown + results: Array<{ text: string }> + } + } + + expect(getProfile).not.toHaveBeenCalled() + expect(result.content[0]?.text).toContain("## Matching memories") + expect(result.content[0]?.text).toContain("User prefers dark mode") + expect(result.content[0]?.text).not.toContain("## Profile") + expect(result.content[0]?.text).not.toContain("## Recent context") + expect(result.structuredContent.profile).toBeUndefined() + expect(result.structuredContent.results).toEqual([ + { + id: "mem_1", + text: "User prefers dark mode", + similarity: 0.91, + }, + ]) + }) +}) diff --git a/apps/mcp/src/server/tools/search-memory.ts b/apps/mcp/src/server/tools/search-memory.ts index f4bfbc63..67fb5d17 100644 --- a/apps/mcp/src/server/tools/search-memory.ts +++ b/apps/mcp/src/server/tools/search-memory.ts @@ -14,7 +14,6 @@ export function register(deps: ToolDeps) { .string() .max(1000, "Query exceeds maximum length") .describe("The search query to find relevant memories"), - includeProfile: z.boolean().optional().default(true), containerTag: optionalContainerTagSchema, }) @@ -22,7 +21,7 @@ export function register(deps: ToolDeps) { "search_memory", { description: - "Search memories in one space with a natural-language query. Returns relevant memories plus that space's profile summary. When the user names a space, resolve it with listSpaces and pass containerTag; otherwise use the active space.", + "Search memories in one space with a natural-language query. Returns matching memories only — not the space profile. If you need who-the-user-is, preferences, or recent context after searching, call get_profile. When the user names a space, resolve it with listSpaces and pass containerTag; otherwise use the active space.", inputSchema, outputSchema: searchMemoryOutputSchema, annotations: READ_ONLY_TOOL_ANNOTATIONS, @@ -32,28 +31,6 @@ export function register(deps: ToolDeps) { const effectiveTag = await deps.resolveContainerTag(args.containerTag) const client = deps.getClient(effectiveTag) - const parts: string[] = [] - let profile: SearchMemoryOutput["profile"] - - if (args.includeProfile !== false) { - const profileResult = await client.getProfile(args.query) - profile = profileResult.profile - - if (profileResult.profile.static.length > 0) { - parts.push("## Profile") - for (const fact of profileResult.profile.static) { - parts.push(`- ${fact}`) - } - } - - if (profileResult.profile.dynamic.length > 0) { - parts.push("\n## Recent context") - for (const fact of profileResult.profile.dynamic) { - parts.push(`- ${fact}`) - } - } - } - const searchResult = await client.search(args.query) const results = searchResult.results.map((result) => ({ id: result.id, @@ -62,21 +39,21 @@ export function register(deps: ToolDeps) { ...(result.title ? { title: result.title } : {}), })) + const parts: string[] = [] if (searchResult.results.length > 0) { - parts.push("\n## Matching memories") + parts.push("## Matching memories") for (const result of searchResult.results) { const text = getMemoryText(result) const similarity = (result.similarity * 100).toFixed(0) parts.push(`- [${similarity}%] ${text}`) } } else { - parts.push("\nNo matching memories found.") + parts.push("No matching memories found.") } const structuredContent: SearchMemoryOutput = { query: args.query, containerTag: effectiveTag, - ...(profile ? { profile } : {}), results, total: searchResult.total, timing: searchResult.timing,