mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-28 06:24:40 +00:00
feat(mcp): add get_profile and stop bundling profile into search
search_memory now returns matching memories only. Models that need stable or recent profile context should call get_profile after search.
This commit is contained in:
parent
2415a5c796
commit
cc1167a867
13 changed files with 283 additions and 35 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ type ClientInfoResolver = (
|
|||
|
||||
const TOOL_SURFACES: Record<string, McpToolSurface> = {
|
||||
search_memory: "model_tool",
|
||||
get_profile: "model_tool",
|
||||
listDocuments: "model_tool",
|
||||
getDocument: "model_tool",
|
||||
listMemories: "model_tool",
|
||||
|
|
|
|||
88
apps/mcp/src/server/tools/get-profile.test.ts
Normal file
88
apps/mcp/src/server/tools/get-profile.test.ts
Normal file
|
|
@ -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<unknown>)
|
||||
| undefined
|
||||
|
||||
getProfile.register({
|
||||
server: {
|
||||
registerTool: (
|
||||
_name: string,
|
||||
_config: unknown,
|
||||
registered: (args: { containerTag?: string }) => Promise<unknown>,
|
||||
) => {
|
||||
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.",
|
||||
)
|
||||
})
|
||||
})
|
||||
63
apps/mcp/src/server/tools/get-profile.ts
Normal file
63
apps/mcp/src/server/tools/get-profile.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -107,6 +107,16 @@ export const searchMemoryOutputSchema = z.object({
|
|||
|
||||
export type SearchMemoryOutput = z.infer<typeof searchMemoryOutputSchema>
|
||||
|
||||
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<typeof getProfileOutputSchema>
|
||||
|
||||
export const whoAmIOutputSchema = z.object({
|
||||
userId: z.string(),
|
||||
email: z.string().optional(),
|
||||
|
|
|
|||
93
apps/mcp/src/server/tools/search-memory.test.ts
Normal file
93
apps/mcp/src/server/tools/search-memory.test.ts
Normal file
|
|
@ -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<unknown>
|
||||
}) {
|
||||
let handler:
|
||||
| ((args: { query: string; containerTag?: string }) => Promise<unknown>)
|
||||
| undefined
|
||||
|
||||
searchMemory.register({
|
||||
server: {
|
||||
registerTool: (
|
||||
_name: string,
|
||||
_config: unknown,
|
||||
registered: (args: {
|
||||
query: string
|
||||
containerTag?: string
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue