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:
Dhravya 2026-09-12 22:52:06 +00:00
parent 2415a5c796
commit cc1167a867
13 changed files with 283 additions and 35 deletions

View file

@ -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.

View file

@ -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 |

View file

@ -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",

View file

@ -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)

View file

@ -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(

View file

@ -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()

View file

@ -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",

View 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.",
)
})
})

View 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)
}
},
)
}

View file

@ -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)

View file

@ -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(),

View 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,
},
])
})
})

View file

@ -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,