Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettin
"branchNamePrefix",
"branchNameInstructions",
],
"agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"],
"agent-behavior": [
"responseStreamingMode",
"enableAgentBrowserAccess",
"enableAgentMemoryAccess",
],
maintenance: ["continueThreadsAfterServerUpdate"],
};

Expand Down Expand Up @@ -389,6 +393,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
onValueChange={(value) => write({ enableAgentBrowserAccess: value })}
/>
</SettingsSection>
<SettingsSection title="Memory">
<SettingsSwitchRow
icon="brain"
label="Agent memory"
subtitle="Let agents remember and recall project notes across threads."
value={uniform("enableAgentMemoryAccess")}
disabled={disabledFor("enableAgentMemoryAccess")}
onValueChange={(value) => write({ enableAgentMemoryAccess: value })}
/>
</SettingsSection>
</>
) : null}

Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ describe("RPC authorization scopes", () => {
);
});

it("lets read-only clients review project memory but not delete it", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.memoryList)).toBe(AuthOrchestrationReadScope);
expect(requiredScopeForRpcMethod(WS_METHODS.memoryDelete)).toBe(AuthOrchestrationOperateScope);
});

it("separates ACP Registry discovery from provisioning", () => {
expect(requiredScopeForRpcMethod(WS_METHODS.serverSearchAcpRegistry)).toBe(
AuthOrchestrationReadScope,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,
[WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,
[WS_METHODS.memoryList]: AuthOrchestrationReadScope,
[WS_METHODS.memoryDelete]: AuthOrchestrationOperateScope,
[WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope,
[WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope,
[WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope,
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import { WorktreeToolkit } from "./toolkits/worktree/tools.ts";
import * as WorktreeMcpService from "./WorktreeMcpService.ts";
import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts";
import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
import { MemoryToolkitHandlersLive } from "./toolkits/memory/handlers.ts";
import { MemoryToolkit } from "./toolkits/memory/tools.ts";
import {
DeviceScreenshotToolkitHandlersLive,
DeviceStandardToolkitHandlersLive,
Expand Down Expand Up @@ -695,6 +697,10 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest
Layer.provide(PullRequestsToolkitHandlersLive),
);

export const MemoryToolkitRegistrationLive = McpServer.toolkit(MemoryToolkit).pipe(
Layer.provide(MemoryToolkitHandlersLive),
);

const DeviceStandardToolkitRegistrationLive = McpServer.toolkit(DeviceStandardToolkit).pipe(
Layer.provide(DeviceStandardToolkitHandlersLive),
);
Expand Down Expand Up @@ -725,5 +731,6 @@ export const layer = Layer.mergeAll(
PreviewControlsRegistrationLive,
WorktreeToolkitRegistrationLive,
PullRequestsToolkitRegistrationLive,
MemoryToolkitRegistrationLive,
DeviceToolkitRegistrationLive,
).pipe(Layer.provideMerge(McpTransportLive));
1 change: 1 addition & 0 deletions apps/server/src/mcp/McpInvocationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const ALL_MCP_CAPABILITIES = [
"worktree",
"device",
"pull-requests",
"memory",
] as const;
export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number];

Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/mcp/toolkits/memory/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as Effect from "effect/Effect";

import * as MemoryService from "../../../memory/MemoryService.ts";
import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import { MemoryThreadNotFoundError, MemoryToolkit } from "./tools.ts";

const make = Effect.gen(function* () {
const memory = yield* MemoryService.MemoryService;
const engine = yield* Orchestrator.OrchestratorV2;

/** The project always comes from the credential's thread, never from tool input. */
const authority = Effect.gen(function* () {
const scope = yield* McpInvocationContext.requireMcpCapability("memory");
const thread = yield* engine
.getThreadShell(scope.threadId)
.pipe(Effect.mapError(() => new MemoryThreadNotFoundError({ threadId: scope.threadId })));
if (thread === null || thread === undefined) {
return yield* new MemoryThreadNotFoundError({ threadId: scope.threadId });
}
return {
projectId: thread.projectId,
threadId: thread.id,
} satisfies MemoryService.MemoryAuthority;
});

return MemoryToolkit.of({
memory_remember: (input) =>
authority.pipe(Effect.flatMap((caller) => memory.remember(input, caller))),
memory_recall: (input) =>
authority.pipe(Effect.flatMap((caller) => memory.search(input, caller))),
memory_forget: (input) =>
authority.pipe(
Effect.flatMap((caller) => memory.forget(input, caller)),
Effect.map((forgotten) => ({ forgotten })),
),
});
});

export const MemoryToolkitHandlersLive = MemoryToolkit.toLayer(make);
149 changes: 149 additions & 0 deletions apps/server/src/mcp/toolkits/memory/memory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import {
EnvironmentId,
type OrchestrationV2ThreadShell,
ProjectId,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { McpSchema, McpServer } from "effect/unstable/ai";

import * as MemoryService from "../../../memory/MemoryService.ts";
import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import { SqlitePersistenceMemory } from "../../../persistence/Layers/Sqlite.ts";
import * as McpHttpServer from "../../McpHttpServer.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";

const threadA = ThreadId.make("thread-memory-a");
const threadB = ThreadId.make("thread-memory-b");
const projectOf: Record<string, ProjectId> = {
[threadA]: ProjectId.make("project-memory-a"),
[threadB]: ProjectId.make("project-memory-b"),
};

const TestLayer = McpHttpServer.MemoryToolkitRegistrationLive.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provideMerge(
MemoryService.layer.pipe(
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(NodeServices.layer),
),
),
Layer.provide(
Layer.mock(Orchestrator.OrchestratorV2)({
getThreadShell: (threadId) =>
Effect.succeed(
projectOf[threadId] === undefined
? null
: ({ id: threadId, projectId: projectOf[threadId] } as OrchestrationV2ThreadShell),
),
}),
),
);

const client = McpSchema.McpServerClient.of({
clientId: 1,
clientCapabilities: {},
clientInfo: { name: "memory-test", version: "1.0.0" },
protocolVersion: "2025-06-18",
initializePayload: {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "memory-test", version: "1.0.0" },
},
getClient: Effect.die("unused"),
});

const call = (
name: string,
args: Record<string, unknown>,
input: { readonly threadId: ThreadId; readonly memory: boolean },
) =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const result = yield* server.callTool({ name, arguments: args }).pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, {
environmentId: EnvironmentId.make("environment-memory-test"),
threadId: input.threadId,
providerSessionId: "provider-session-memory-test",
providerInstanceId: ProviderInstanceId.make("codex"),
capabilities: new Set(input.memory ? (["memory"] as const) : ([] as const)),
issuedAt: 1,
}),
Effect.provideService(McpSchema.McpServerClient, client),
);
return {
isError: result.isError === true,
structured: result.structuredContent as Record<string, unknown> | undefined,
text: result.content.map((part) => ("text" in part ? part.text : "")).join("\n"),
};
});

it.layer(TestLayer)("memory MCP tools", (it) => {
it.effect("remember, recall and forget through the registered toolkit", () =>
Effect.gen(function* () {
const remembered = yield* call(
"memory_remember",
{ category: "fact", content: "Storybook runs on port 6006 via `vp run storybook`." },
{ threadId: threadA, memory: true },
);
assert.isFalse(remembered.isError, remembered.text);
assert.strictEqual(remembered.structured?.projectId, projectOf[threadA]);
const id = remembered.structured?.id;

const recalled = yield* call(
"memory_recall",
{ query: "storybook port" },
{ threadId: threadA, memory: true },
);
assert.isFalse(recalled.isError, recalled.text);
const entries = recalled.structured?.entries as ReadonlyArray<{ readonly id: string }>;
assert.deepEqual(
entries.map((entry) => entry.id),
[id],
);

// Another project's thread cannot see or delete it, even with the id.
const otherRecall = yield* call(
"memory_recall",
{ query: "storybook port" },
{ threadId: threadB, memory: true },
);
assert.deepEqual(otherRecall.structured?.entries, []);
const otherForget = yield* call("memory_forget", { id }, { threadId: threadB, memory: true });
assert.deepEqual(otherForget.structured, { forgotten: false });

const forgotten = yield* call("memory_forget", { id }, { threadId: threadA, memory: true });
assert.deepEqual(forgotten.structured, { forgotten: true });
const empty = yield* call(
"memory_recall",
{ query: "storybook" },
{ threadId: threadA, memory: true },
);
assert.deepEqual(empty.structured?.entries, []);
}),
);

it.effect("refuses every memory tool when the credential lacks the memory capability", () =>
Effect.gen(function* () {
for (const [name, args] of [
["memory_remember", { category: "fact", content: "should not be stored" }],
["memory_recall", { query: "stored" }],
["memory_forget", { id: "mem_anything" }],
] as const) {
const result = yield* call(name, args, { threadId: threadA, memory: false });
assert.isTrue(result.isError, name);
assert.include(result.text, "memory", name);
}
const after = yield* call(
"memory_recall",
{ query: "should not be stored" },
{ threadId: threadA, memory: true },
);
assert.deepEqual(after.structured?.entries, []);
}),
);
});
82 changes: 82 additions & 0 deletions apps/server/src/mcp/toolkits/memory/tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {
McpCapabilityUnavailableError,
MemoryEntry,
MemoryForgetInput,
MemoryQueryInput,
MemoryQueryResult,
MemoryRememberInput,
MemoryStorageError,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import * as Tool from "effect/unstable/ai/Tool";
import * as Toolkit from "effect/unstable/ai/Toolkit";

import * as MemoryService from "../../../memory/MemoryService.ts";
import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";

const dependencies = [
McpInvocationContext.McpInvocationContext,
MemoryService.MemoryService,
Orchestrator.OrchestratorV2,
];

export class MemoryThreadNotFoundError extends Schema.TaggedError<MemoryThreadNotFoundError>()(
"MemoryThreadNotFoundError",
{ threadId: Schema.String },
) {
override get message(): string {
return `Thread ${this.threadId} was not found, so its project memory cannot be reached.`;
}
}

export const MemoryToolError = Schema.Union([
McpCapabilityUnavailableError,
MemoryThreadNotFoundError,
MemoryService.MemoryProjectFullError,
MemoryStorageError,
]);

const SCOPE =
"Project memory is shared by every agent and provider working in this thread's project, persists across threads, and the user can read and delete it in project settings.";

const RememberTool = Tool.make("memory_remember", {
description: `Save one durable note for future agents in this project: how to build or test, a non-obvious API behaviour, a decision and its reason, or a user preference. Do not save secrets, transient task state, or anything already in the repository's instruction files. ${SCOPE}`,
parameters: MemoryRememberInput,
success: MemoryEntry,
failure: MemoryToolError,
dependencies,
})
.annotate(Tool.Title, "Remember a project note")
.annotate(Tool.Readonly, false)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, false)
.annotate(Tool.OpenWorld, false);

const RecallTool = Tool.make("memory_recall", {
description: `Recall project notes relevant to what you are about to do. Call it at the start of a task with words describing the task. Matching is lexical: entries containing more of your words rank first. ${SCOPE}`,
parameters: MemoryQueryInput,
success: MemoryQueryResult,
failure: MemoryToolError,
dependencies,
})
.annotate(Tool.Title, "Recall project notes")
.annotate(Tool.Readonly, true)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);

const ForgetTool = Tool.make("memory_forget", {
description: `Delete a project note that is wrong or outdated, by the id that memory_recall returned. forgotten is false when no such note exists in this project. To correct a note, forget it and remember the corrected version. ${SCOPE}`,
parameters: MemoryForgetInput,
success: Schema.Struct({ forgotten: Schema.Boolean }),
failure: MemoryToolError,
dependencies,
})
.annotate(Tool.Title, "Forget a project note")
.annotate(Tool.Readonly, false)
.annotate(Tool.Destructive, true)
.annotate(Tool.Idempotent, false)
.annotate(Tool.OpenWorld, false);

export const MemoryToolkit = Toolkit.make(RememberTool, RecallTool, ForgetTool);
5 changes: 5 additions & 0 deletions apps/server/src/mcp/toolkits/worktree/registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { HttpBody, HttpClient, HttpRouter } from "effect/unstable/http";

import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts";
import * as GitWorkflowService from "../../../git/GitWorkflowService.ts";
import * as MemoryService from "../../../memory/MemoryService.ts";
import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdapterRegistry.ts";
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import * as ProjectService from "../../../project/ProjectService.ts";
Expand All @@ -38,6 +39,7 @@ const StubServicesLive = Layer.mergeAll(
Layer.mock(GitWorkflowService.GitWorkflowService)({}),
Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}),
Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({}),
Layer.mock(MemoryService.MemoryService)({}),
);

const ToolsListPayload = Schema.fromJsonString(
Expand Down Expand Up @@ -123,6 +125,9 @@ it.effect("production mcp layer lists worktree tools over http", () =>
// than replacing them.
expect(toolNames).toContain("preview_status");
expect(toolNames).toContain("delegate_task");
expect(toolNames).toEqual(
expect.arrayContaining(["memory_remember", "memory_recall", "memory_forget"]),
);

// The handoff tool mutates thread state, reaches the network (origin
// fetch), and runs project setup scripts, so its MCP hints must not
Expand Down
Loading
Loading