# Files changed · PR \#8722 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/8722/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(mcp\): expose thread search](https://git.cafe/pingdotgg/t3code/pull/8722.md)

Version: 5

Head commit: 70a74a55ba4420d213fc2a56e2f627c8bd4d6b96

Base commit: 298b869dc63809571f15ec9f92eaf1740c7b53f7

Comparison base: 298b869dc63809571f15ec9f92eaf1740c7b53f7

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/mcp/McpHttpServer.ts

Status: modified; +2; −0

```diff
@@ -29,6 +29,7 @@
 import { WorktreeToolkitHandlersLive } from "./toolkits/worktree/handlers.ts";
 import { WorktreeToolkit } from "./toolkits/worktree/tools.ts";
 import * as WorktreeMcpService from "./WorktreeMcpService.ts";
+import * as ThreadSearchMcpService from "./ThreadSearchMcpService.ts";
 
 const unauthorized = HttpServerResponse.jsonUnsafe(
   {
@@ -226,6 +227,7 @@
   Layer.provide(OrchestratorToolkitHandlersLive),
   Layer.provide(OrchestratorMcpService.layer),
   Layer.provide(ThreadMetadataMcpService.layer),
+  Layer.provide(ThreadSearchMcpService.layer),
 );
 
 export const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit).pipe(
```

## apps/server/src/mcp/OrchestratorMcpService.ts

Status: modified; +18; −1

```diff
@@ -1175,6 +1175,7 @@
             batchThreadCreation: true,
             threadManagement: true,
             incrementalThreadRead: true,
+            threadSearch: true,
             scheduledTasks: true,
             maxBatchThreads: 20,
           },
@@ -1575,7 +1576,23 @@
       Effect.gen(function* () {
         const { parent, target } = yield* loadScopedThread(scope, input.threadId);
         const view = input.view ?? "messages";
+        const anchorRow =
+          input.anchor === undefined
+            ? undefined
+            : target.visibleTurnItems.find(
+                (row) =>
+                  row.sourceThreadId === input.anchor?.sourceThreadId &&
+                  (row.item.type === "user_message" || row.item.type === "assistant_message") &&
+                  row.item.messageId === input.anchor.messageId,
+              );
+        if (input.anchor !== undefined && anchorRow === undefined) {
+          return yield* failure(
+            "invalid_request",
+            `Message ${input.anchor.messageId} is not visible in thread ${input.threadId}.`,
+          );
-        const afterPosition = input.afterPosition ?? -1;
+        }
+        const afterPosition =
+          anchorRow === undefined ? (input.afterPosition ?? -1) : anchorRow.position - 1;
         const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT;
         const maxChars = input.maxCharsPerItem ?? DEFAULT_THREAD_ITEM_MAX_CHARS;
         const matching = target.visibleTurnItems
```

## apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts

Status: modified; +147; −8

```diff
@@ -17,6 +17,7 @@
   OrchestratorMcpThreadInterruptResult,
   OrchestratorMcpThreadListResult,
   OrchestratorMcpThreadReadResult,
+  OrchestratorMcpThreadSearchResult,
   OrchestratorMcpThreadSendResult,
   OrchestratorMcpThreadWaitResult,
   ProjectId,
@@ -44,7 +45,9 @@
 import * as Schema from "effect/Schema";
 import * as Stream from "effect/Stream";
 import { McpSchema, McpServer } from "effect/unstable/ai";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
 
+import { ServerConfig } from "../config.ts";
 import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts";
 import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
 import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts";
@@ -70,7 +73,15 @@
   decodeProviderReplayNdjson,
   materializeReplayTranscriptWorkspace,
 } from "../orchestration-v2/testkit/ReplayTranscriptNdjson.ts";
+import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
+import * as ProjectEnrichment from "../project/ProjectEnrichmentService.ts";
+import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
+import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
 import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts";
+import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts";
+import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts";
+import { OrchestrationProjectionSnapshotQueryLive } from "../orchestration/Layers/ProjectionSnapshotQuery.ts";
+import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
 import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts";
 import * as McpHttpServer from "./McpHttpServer.ts";
 import * as McpInvocationContext from "./McpInvocationContext.ts";
@@ -86,7 +97,8 @@
 const delegatedPrompt = "Inspect the delegated API boundary and return the result.";
 const delegatedResult = "Delegated API boundary inspected.";
 const cancellationPrompt = "Remain active until the parent cancels this delegated task.";
+const createdThreadPrompt = `${"😀".repeat(600)} needle newly created ordinary thread`;
+const createdThreadResponse = "Created ordinary thread completed.";
-const createdThreadPrompt = "Complete the newly created ordinary thread.";
 const queuedFollowupPrompt = "Complete the queued follow-up and return the final result.";
 const queuedFollowupResult = "Queued delegated follow-up completed.";
 
@@ -99,6 +111,7 @@
 );
 const decodeThreadListResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadListResult);
 const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult);
+const decodeThreadSearchResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadSearchResult);
 const decodeThreadSendResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadSendResult);
 const decodeThreadWaitResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadWaitResult);
 const decodeThreadUpdateResult = Schema.decodeUnknownEffect(ThreadMetadataMcpUpdateResult);
@@ -501,10 +514,13 @@
                 turn.message.text.startsWith("Delegated tasks")
                   ? deliveryTerminalGates.get(turn.threadId)
                   : parentTerminalGates.get(turn.threadId),
+              response: (turn) => {
+                if (turn.message.text === delegatedPrompt) return delegatedResult;
+                if (turn.message.text === createdThreadPrompt) {
+                  return createdThreadResponse;
+                }
-              response: (turn) =>
+                return `Claude completed: ${turn.message.text}`;
-                turn.message.text === delegatedPrompt
-                  ? delegatedResult
-                  : `Claude completed: ${turn.message.text}`,
+              },
             }),
           ]);
           // Captures parent-wake offers made when a delegated child
@@ -539,6 +555,7 @@
                 expect(yield* Ref.get(continuationOffers)).toHaveLength(count);
               }
             });
+          const databaseLayer = SqlitePersistenceMemory;
           const orchestratorLayer = makeOrchestratorV2ReplayLayerWithRegistry(
             {
               name: "orchestrator-mcp-toolkit",
@@ -553,6 +570,7 @@
               },
             },
             registryLayer,
+            { databaseLayer },
           ).pipe(Layer.provide(continuationProbeLayer));
           const orchestrationLayer = Layer.merge(
             orchestratorLayer,
@@ -613,9 +631,39 @@
               runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"),
             }),
           );
+          const projectionMetadataLayer = Layer.merge(
+            Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
+              resolve: (workspaceRoot) =>
+                Effect.succeed({
+                  canonicalKey: `test:${workspaceRoot}`,
+                  locator: {
+                    source: "git-remote" as const,
+                    remoteName: "origin",
+                    remoteUrl: "https://example.test/search.git",
+                  },
+                  rootPath: workspaceRoot,
+                }),
+            }),
+            Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
+              resolvePath: () => Effect.succeed(null),
+            }),
+          );
+          const projectionQueryLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
+            Layer.provideMerge(ThreadBackgroundLiveness.layer),
+            Layer.provideMerge(ThreadPlanProgress.layer),
+            Layer.provideMerge(ProjectEnrichment.layer),
+            Layer.provideMerge(projectionMetadataLayer),
+            Layer.provideMerge(databaseLayer),
+            Layer.provide(
+              ServerConfig.layerTest(process.cwd(), { prefix: "mcp-thread-search-test-" }),
+            ),
+            Layer.provide(NodeServices.layer),
+          );
           const testLayer = McpHttpServer.OrchestratorToolkitRegistrationLive.pipe(
             Layer.provideMerge(McpServer.McpServer.layer),
             Layer.provideMerge(orchestrationLayer),
+            Layer.provideMerge(databaseLayer),
+            Layer.provide(projectionQueryLayer),
             Layer.provide(providerRegistryLayer),
             Layer.provide(scheduledTaskStubLayer),
             Layer.provide(NodeServices.layer),
@@ -624,6 +672,18 @@
           yield* Effect.gen(function* () {
             const orchestrator = yield* OrchestratorV2;
             const server = yield* McpServer.McpServer;
+            const sql = yield* SqlClient.SqlClient;
+            // Projects remain environment-owned projection records; every
+            // thread/message/run/item searched below is written by V2.
+            yield* sql`
+              INSERT INTO projection_projects (
+                project_id, title, workspace_root, default_model_selection_json, scripts_json,
+                created_at, updated_at, deleted_at
+              ) VALUES (
+                ${projectId}, 'MCP orchestrator', ${cwd}, NULL, '[]',
+                '2026-06-17T00:00:00.000Z', '2026-06-17T00:00:00.000Z', NULL
+              )
+            `;
             yield* orchestrator.dispatch({
               type: "thread.create",
               createdBy: "user",
@@ -1939,6 +1999,78 @@
               creationSource: "mcp",
             });
             expect(promptedRead.hasMore).toBe(true);
+            const searchMessage = promptedRead.items[0]!;
+            expect(searchMessage.messageId).not.toBeNull();
+            const searchCall = yield* invoke("t3_thread_search", {
+              query: "needle",
+              threadId: promptedThread.threadId,
+              snippetChars: 1_000,
+            });
+            const searchResult = yield* decodeThreadSearchResult(searchCall.structuredContent).pipe(
+              Effect.orDie,
+            );
+            expect(searchResult).toMatchObject({
+              projectId,
+              hasMore: false,
+              nextCursor: null,
+              traversalTruncated: false,
+              consistency: "live",
+            });
+            expect(searchResult.hits[0]?.readAnchor).toEqual({
+              sourceThreadId: promptedThread.threadId,
+              messageId: searchMessage.messageId,
+            });
+            expect(Array.from(searchResult.hits[0]?.snippet ?? "").length).toBeLessThanOrEqual(
+              1_000,
+            );
+            expect((searchResult.hits[0]?.snippet ?? "").length).toBeGreaterThan(1_000);
+            const anchoredReadCall = yield* invoke("t3_thread_read", {
+              threadId: promptedThread.threadId,
+              anchor: searchResult.hits[0]?.readAnchor,
+              limit: 1,
+            });
+            const anchoredRead = yield* decodeThreadReadResult(
+              anchoredReadCall.structuredContent,
+            ).pipe(Effect.orDie);
+            expect(anchoredRead.items[0]?.messageId).toBe(searchMessage.messageId);
+            expect(anchoredRead.items[0]?.position).toBe(searchMessage.position);
+            const staleAnchorReadCall = yield* invoke("t3_thread_read", {
+              threadId: promptedThread.threadId,
+              anchor: {
+                sourceThreadId: promptedThread.threadId,
+                messageId: "message:missing-search-anchor",
+              },
+              limit: 1,
+            });
+            expect(staleAnchorReadCall.structuredContent).toMatchObject({
+              _tag: "OrchestratorMcpFailure",
+              code: "invalid_request",
+            });
+            const nulSearchCall = yield* invoke("t3_thread_search", {
+              query: "unrelated\0needle",
+              threadId: promptedThread.threadId,
+            });
+            expect(nulSearchCall.structuredContent).toMatchObject({
+              _tag: "OrchestratorMcpFailure",
+              code: "invalid_request",
+            });
+
+            yield* sql`
+              ALTER TABLE projection_projects
+              RENAME TO projection_projects_search_failure
+            `;
+            const unavailableSearchCall = yield* invoke("t3_thread_search", {
+              query: "malformed-search-boundary",
+            });
+            expect(unavailableSearchCall.structuredContent).toEqual({
+              _tag: "OrchestratorMcpFailure",
+              code: "orchestration_error",
+              message: `Unable to search thread content in project ${projectId}.`,
+            });
+            yield* sql`
+              ALTER TABLE projection_projects_search_failure
+              RENAME TO projection_projects
+            `;
             const promptedReadNextCall = yield* invoke("t3_thread_read", {
               threadId: promptedThread.threadId,
               afterPosition: promptedRead.nextPosition,
@@ -1948,9 +2080,7 @@
               promptedReadNextCall.structuredContent,
             ).pipe(Effect.orDie);
             expect(promptedReadNext.items.map((item) => item.type)).toEqual(["assistant_message"]);
+            expect(promptedReadNext.items[0]?.text).toBe(createdThreadResponse);
-            expect(promptedReadNext.items[0]?.text).toBe(
-              `Claude completed: ${createdThreadPrompt}`,
-            );
 
             const forkedThreadId = ThreadId.make("thread:mcp-orchestrator-inherited-read");
             yield* orchestrator.dispatch({
@@ -2142,6 +2272,14 @@
               _tag: "OrchestratorMcpFailure",
               code: "thread_not_found",
             });
+            const foreignSearchCall = yield* invoke("t3_thread_search", {
+              query: "foreign",
+              threadId: foreignThreadId,
+            });
+            expect(foreignSearchCall.structuredContent).toMatchObject({
+              _tag: "OrchestratorMcpFailure",
+              code: "thread_not_found",
+            });
             const listCall = yield* invoke("t3_thread_list", {
               includeSubagents: false,
               limit: 100,
@@ -2912,6 +3050,7 @@
         const testLayer = McpHttpServer.OrchestratorToolkitRegistrationLive.pipe(
           Layer.provideMerge(McpServer.McpServer.layer),
           Layer.provideMerge(orchestrationLayer),
+          Layer.provide(Layer.mock(ProjectionSnapshotQuery)({})),
           Layer.provide(providerRegistryLayer),
           Layer.provide(unusedScheduledTaskStubLayer),
           Layer.provide(NodeServices.layer),
```

## apps/server/src/mcp/ThreadSearchMcpService.ts

Status: added; +127; −0

```diff
@@ -1,0 +1,127 @@
+import {
+  OrchestratorMcpFailure,
+  type OrchestratorMcpThreadSearchInput,
+  type OrchestratorMcpThreadSearchResult,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+
+import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";
+import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
+import type { McpInvocationScope } from "./McpInvocationContext.ts";
+
+const DEFAULT_LIMIT = 20;
+const DEFAULT_SNIPPET_CHARS = 240;
+
+export class ThreadSearchMcpService extends Context.Service<
+  ThreadSearchMcpService,
+  {
+    readonly search: (
+      scope: McpInvocationScope,
+      input: OrchestratorMcpThreadSearchInput,
+    ) => Effect.Effect<OrchestratorMcpThreadSearchResult, OrchestratorMcpFailure>;
+  }
+>()("t3/mcp/ThreadSearchMcpService") {}
+
+function failure(code: OrchestratorMcpFailure["code"], message: string): OrchestratorMcpFailure {
+  return new OrchestratorMcpFailure({ code, message });
+}
+
+const make = Effect.gen(function* () {
+  const threadManagement = yield* ThreadManagementService;
+  const projectionQuery = yield* ProjectionSnapshotQuery;
+
+  const loadShell = (threadId: McpInvocationScope["threadId"]) =>
+    threadManagement.getThreadShell(threadId).pipe(
+      Effect.mapError(() => failure("orchestration_error", `Unable to read thread ${threadId}.`)),
+      Effect.flatMap((shell) =>
+        shell === null
+          ? Effect.fail(failure("thread_not_found", `Thread ${threadId} was not found.`))
+          : Effect.succeed(shell),
+      ),
+    );
+
+  return {
+    search: (scope, input) =>
+      Effect.gen(function* () {
+        if (!scope.capabilities.has("orchestration")) {
+          return yield* failure(
+            "capability_denied",
+            "This MCP credential does not grant orchestration capabilities.",
+          );
+        }
+
+        const caller = yield* loadShell(scope.threadId);
+        if (input.threadId !== undefined) {
+          const target = yield* loadShell(input.threadId);
+          if (target.projectId !== caller.projectId) {
+            return yield* failure(
+              "thread_not_found",
+              `Thread ${input.threadId} was not found in this project.`,
+            );
+          }
+        }
+
+        const page = yield* projectionQuery
+          .searchThreadContent({
+            projectId: caller.projectId,
+            ...(input.threadId === undefined ? {} : { threadId: input.threadId }),
+            query: input.query,
+            includeArchived: input.includeArchived === true,
+            offset: input.cursor ?? 0,
+            limit: input.limit ?? DEFAULT_LIMIT,
+            snippetChars: input.snippetChars ?? DEFAULT_SNIPPET_CHARS,
+          })
+          .pipe(
+            Effect.catchTags({
+              ProjectionThreadContentSearchInputError: (error) =>
+                Effect.fail(failure("invalid_request", error.message)),
+              PersistenceSqlError: () =>
+                Effect.fail(
+                  failure(
+                    "orchestration_error",
+                    `Unable to search thread content in project ${caller.projectId}.`,
+                  ),
+                ),
+              PersistenceDecodeError: () =>
+                Effect.fail(
+                  failure(
+                    "orchestration_error",
+                    `Unable to search thread content in project ${caller.projectId}.`,
+                  ),
+                ),
+            }),
+          );
+
+        return {
+          projectId: caller.projectId,
+          hits: page.hits.map((hit) => ({
+            threadId: hit.threadId,
+            projectId: hit.projectId,
+            threadTitle: hit.threadTitle,
+            threadTitleTruncated: hit.threadTitleTruncated,
+            archived: hit.archived,
+            source: hit.source,
+            origin: hit.origin,
+            snippet: hit.snippet,
+            snippetTruncated: hit.snippetTruncated,
+            matchedAt: hit.matchedAt,
+            messageId: hit.messageId,
+            runId: hit.runId,
+            itemId: hit.itemId,
+            readAnchor:
+              hit.sourceThreadId === null || hit.messageId === null
+                ? null
+                : { sourceThreadId: hit.sourceThreadId, messageId: hit.messageId },
+          })),
+          nextCursor: page.nextOffset,
+          hasMore: page.hasMore,
+          traversalTruncated: page.hasMore && page.nextOffset === null,
+          consistency: "live",
+        } satisfies OrchestratorMcpThreadSearchResult;
+      }),
+  } satisfies ThreadSearchMcpService["Service"];
+});
+
+export const layer = Layer.effect(ThreadSearchMcpService, make);
```

## apps/server/src/mcp/toolkits/orchestrator/handlers.ts

Status: modified; +7; −0

```diff
@@ -4,6 +4,7 @@
 import { McpInvocationContext } from "../../McpInvocationContext.ts";
 import { OrchestratorMcpService } from "../../OrchestratorMcpService.ts";
 import { ThreadMetadataMcpService } from "../../ThreadMetadataMcpService.ts";
+import { ThreadSearchMcpService } from "../../ThreadSearchMcpService.ts";
 
 const handlers = {
   orchestrator_capabilities: () =>
@@ -98,6 +99,12 @@
       const service = yield* ThreadMetadataMcpService;
       return yield* service.update(scope, input);
     }),
+  t3_thread_search: (input) =>
+    Effect.gen(function* () {
+      const scope = yield* McpInvocationContext;
+      const service = yield* ThreadSearchMcpService;
+      return yield* service.search(scope, input);
+    }),
   t3_thread_send: (input) =>
     Effect.gen(function* () {
       const scope = yield* McpInvocationContext;
```

## apps/server/src/mcp/toolkits/orchestrator/tools.ts

Status: modified; +18; −0

```diff
@@ -21,6 +21,8 @@
   OrchestratorMcpThreadListResult,
   OrchestratorMcpThreadReadInput,
   OrchestratorMcpThreadReadResult,
+  OrchestratorMcpThreadSearchInput,
+  OrchestratorMcpThreadSearchResult,
   OrchestratorMcpThreadSendInput,
   OrchestratorMcpThreadSendResult,
   OrchestratorMcpThreadStartInput,
@@ -34,6 +36,7 @@
 import * as McpInvocationContext from "../../McpInvocationContext.ts";
 import { OrchestratorMcpService } from "../../OrchestratorMcpService.ts";
 import { ThreadMetadataMcpService } from "../../ThreadMetadataMcpService.ts";
+import { ThreadSearchMcpService } from "../../ThreadSearchMcpService.ts";
 
 const dependencies = [McpInvocationContext.McpInvocationContext, OrchestratorMcpService];
 const threadMetadataDependencies = [
@@ -210,6 +213,20 @@
   .annotate(Tool.Destructive, true)
   .annotate(Tool.Idempotent, false);
 
+export const ThreadSearchTool = Tool.make("t3_thread_search", {
+  description:
+    "Search durable thread titles and visible user/assistant messages in the calling project. Archived threads are excluded unless includeArchived=true; deleted threads and other projects are never searched. Results and snippets are bounded. Pagination reads live projection state, so concurrent updates can shift later pages. A non-null readAnchor can be passed to t3_thread_read.anchor to open the visible hit.",
+  parameters: OrchestratorMcpThreadSearchInput,
+  success: OrchestratorMcpThreadSearchResult,
+  failure: OrchestratorMcpFailure,
+  failureMode: "return",
+  dependencies: [McpInvocationContext.McpInvocationContext, ThreadSearchMcpService],
+})
+  .annotate(Tool.Title, "Search T3 threads")
+  .annotate(Tool.Readonly, true)
+  .annotate(Tool.Destructive, false)
+  .annotate(Tool.Idempotent, true);
+
 export const ThreadSendTool = Tool.make("t3_thread_send", {
   description:
     "Send a message to a T3 thread in the calling project. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.",
@@ -263,6 +280,7 @@
   ThreadListTool,
   ThreadReadTool,
   ThreadUpdateTool,
+  ThreadSearchTool,
   ThreadSendTool,
   ThreadWaitTool,
   ThreadInterruptTool,
```

## apps/server/src/mcp/toolkits/worktree/registration.test.ts

Status: modified; +6; −0

```diff
@@ -10,6 +10,7 @@
 import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts";
 import * as GitWorkflowService from "../../../git/GitWorkflowService.ts";
 import { ThreadManagementService } from "../../../orchestration-v2/ThreadManagementService.ts";
+import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts";
 import * as ProjectService from "../../../project/ProjectService.ts";
 import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts";
 import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts";
@@ -22,6 +23,7 @@
 
 const StubServicesLive = Layer.mergeAll(
   Layer.mock(ThreadManagementService)({}),
+  Layer.mock(ProjectionSnapshotQuery)({}),
   Layer.mock(ProviderRegistry)({}),
   Layer.mock(ScheduledTaskService)({}),
   Layer.mock(ProjectService.ProjectService)({}),
@@ -114,6 +116,10 @@
       // than replacing them.
       expect(toolNames).toContain("preview_status");
       expect(toolNames).toContain("delegate_task");
+      const search = tools.find((tool) => tool.name === "t3_thread_search");
+      expect(search?.inputSchema.type).toBe("object");
+      expect(search?.annotations?.readOnlyHint).toBe(true);
+      expect(search?.annotations?.destructiveHint).toBe(false);
 
       // The handoff tool mutates thread state, reaches the network (origin
       // fetch), and runs project setup scripts, so its MCP hints must not
```

## apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts

Status: modified; +1; −0

```diff
@@ -807,6 +807,7 @@
   "mcp__t3-code__orchestrator_capabilities",
   "mcp__t3-code__list_scheduled_tasks",
   "mcp__t3-code__t3_thread_list",
+  "mcp__t3-code__t3_thread_search",
   "mcp__t3-code__t3_thread_wait",
 ];
 
```

## docs/orchestration-v2/orchestrator-mcp-server.md

Status: modified; +31; −3

```diff
@@ -141,7 +141,7 @@
 
 ## Tool Surface
 
-The server exposes eleven orchestration tools.
+The server exposes the following orchestration tools.
 
 ### `orchestrator_capabilities`
 
@@ -151,7 +151,8 @@
 - the parent runtime and interaction modes;
 - registered provider instances and advertised models;
 - whether each provider can run a child task; and
+- feature flags for polling, cancellation, batch thread creation, incremental
-- feature flags for polling, cancellation, and batch thread creation.
+  reads, and thread search.
 
 Unavailable providers include model-visible constraints such as missing V2
 adapter support, disabled state, missing executable, or missing authentication.
@@ -276,7 +277,11 @@
 messages, and proposed plans. The `activity` view also returns summarized tool,
 reasoning, checkpoint, handoff, and runtime-request items. Large item text is
 bounded and reports whether it was truncated. `afterPosition` and
+`nextPosition` support incremental reads. A search hit with a non-null
+`readAnchor` can instead be passed as `anchor`; the read starts inclusively at
+that currently visible source message. `anchor` and `afterPosition` are
+mutually exclusive, and an anchor that is no longer visible fails rather than
-`nextPosition` support incremental reads.
+falling back to a raw stored ordinal.
 
 Thread and message results include required `createdBy` and `creationSource`
 provenance. MCP-created threads and user-role messages use `createdBy: "agent"`
@@ -297,7 +302,30 @@
 `clientRequestId` for the same action and thread replays the same command
 receipt. Thread list and read results expose the linked pull request, and thread
 detail also exposes an in-flight title regeneration.
+
+### `t3_thread_search`
 
+Searches durable thread titles and locally stored visible user and assistant
+messages in the calling thread's project. Archived threads are excluded by
+default and can be included explicitly; deleted threads, deleted projects, and
+threads from other projects are always excluded. Streaming messages,
+rolled-back runs, cancelled queued messages, and inherited-only fork content
+are not search candidates. This avoids duplicating source text under every
+fork and keeps the result aligned with canonical durable history.
+
+The literal query is 2–200 Unicode code points, display titles are bounded to
+500 code points, and `snippetChars` accepts 64–1,000 code points. Bounds never
+split a valid surrogate pair. Literal `%`, `_`, and `!` characters are escaped
+rather than treated as SQL wildcard syntax; NUL is rejected because SQLite
+cannot apply literal `LIKE` matching to it reliably. Pages and traversal
+cursors are bounded. Results report source kind, storage origin, stable V2
+identities when available, and whether the bounded display title or snippet
+was truncated. Legacy matches keep nullable anchors instead of inventing V2
+item IDs. `nextCursor` continues a live query, not a snapshot: concurrent
+projection updates can shift later pages. When the bounded traversal ceiling
+is reached, `hasMore` remains true, `traversalTruncated` is true, and no further
+cursor is returned.
+
 ### `t3_thread_send`
 
 Sends a message to an ordinary or delegated thread in the calling project:
```

## packages/contracts/src/orchestratorMcp.test.ts

Status: modified; +18; −0

```diff
@@ -8,6 +8,7 @@
   OrchestratorMcpThreadInterruptInput,
   OrchestratorMcpThreadListInput,
   OrchestratorMcpThreadReadInput,
+  OrchestratorMcpThreadSearchInput,
   OrchestratorMcpThreadSendInput,
   OrchestratorMcpThreadStartInput,
   OrchestratorMcpThreadWaitInput,
@@ -19,6 +20,7 @@
 const decodeThreadInterruptInput = Schema.decodeUnknownSync(OrchestratorMcpThreadInterruptInput);
 const decodeThreadListInput = Schema.decodeUnknownSync(OrchestratorMcpThreadListInput);
 const decodeThreadReadInput = Schema.decodeUnknownSync(OrchestratorMcpThreadReadInput);
+const decodeThreadSearchInput = Schema.decodeUnknownSync(OrchestratorMcpThreadSearchInput);
 const decodeThreadSendInput = Schema.decodeUnknownSync(OrchestratorMcpThreadSendInput);
 const decodeThreadStartInput = Schema.decodeUnknownSync(OrchestratorMcpThreadStartInput);
 const decodeThreadWaitInput = Schema.decodeUnknownSync(OrchestratorMcpThreadWaitInput);
@@ -149,6 +151,14 @@
       }).afterPosition,
     ).toBe(10);
     expect(
+      decodeThreadSearchInput({
+        query: "  durable result  ",
+        includeArchived: true,
+        limit: 25,
+        snippetChars: 300,
+      }).query,
+    ).toBe("durable result");
+    expect(
       decodeThreadSendInput({
         threadId: "thread-loop-1",
         message: "Continue with the next iteration.",
@@ -169,5 +179,13 @@
         reason: "Loop converged.",
       }).reason,
     ).toBe("Loop converged.");
+
+    expect(() =>
+      decodeThreadReadInput({
+        threadId: "thread-loop-1",
+        afterPosition: 10,
+        anchor: { sourceThreadId: "thread-loop-1", messageId: "message-loop-1" },
+      }),
+    ).toThrow(/anchor and afterPosition/);
   });
 });
```

## packages/contracts/src/orchestratorMcp.ts

Status: modified; +88; −1

```diff
@@ -13,6 +13,7 @@
   RunId,
   ScheduledTaskId,
   ThreadId,
+  TrimmedString,
   TrimmedNonEmptyString,
   TurnItemId,
 } from "./baseSchemas.ts";
@@ -46,6 +47,18 @@
   Schema.isMaxLength(256),
 ).annotate({ description: "Stable idempotency key to reuse when retrying this mutation." });
 
+const unicodeCodePointLength = (minimum: number, maximum: number) =>
+  Schema.makeFilter((input: string) => {
+    if (input.length > maximum * 2) {
+      return `Expected between ${minimum} and ${maximum} Unicode code points.`;
+    }
+    const length = Array.from(input).length;
+    return (
+      (length >= minimum && length <= maximum) ||
+      `Expected between ${minimum} and ${maximum} Unicode code points.`
+    );
+  });
+
 /**
  * OpenCode 1.15 has been observed serializing nested MCP union objects as JSON
  * strings. Keep the structured object as the documented form while accepting
@@ -328,14 +341,87 @@
 });
 export type OrchestratorMcpThreadListResult = typeof OrchestratorMcpThreadListResult.Type;
 
+export const OrchestratorMcpThreadSearchInput = Schema.Struct({
+  query: TrimmedString.check(unicodeCodePointLength(2, 200)).annotate({
+    description:
+      "Literal text to find, from 2 to 200 Unicode code points; %, _, and ! are ordinary characters.",
+  }),
+  threadId: Schema.optional(
+    ThreadId.annotate({ description: "Optional thread in the calling project to search." }),
+  ),
+  includeArchived: Schema.optional(
+    Schema.Boolean.annotate({
+      description: "Include archived threads. Defaults to false; deleted threads stay excluded.",
+    }),
+  ),
+  cursor: Schema.optional(
+    NonNegativeInt.check(Schema.isLessThanOrEqualTo(10_000)).annotate({
+      description: "Live-query continuation cursor returned by the previous page.",
+    }),
+  ),
+  limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(50))),
+  snippetChars: Schema.optional(
+    PositiveInt.check(Schema.isBetween({ minimum: 64, maximum: 1_000 })).annotate({
+      description: "Maximum Unicode code points in each returned snippet. Defaults to 240.",
+    }),
+  ),
+});
+export type OrchestratorMcpThreadSearchInput = typeof OrchestratorMcpThreadSearchInput.Type;
+
+export const OrchestratorMcpThreadReadAnchor = Schema.Struct({
+  sourceThreadId: ThreadId,
+  messageId: MessageId,
+});
+export type OrchestratorMcpThreadReadAnchor = typeof OrchestratorMcpThreadReadAnchor.Type;
+
+export const OrchestratorMcpThreadSearchHit = Schema.Struct({
+  threadId: ThreadId,
+  projectId: ProjectId,
+  threadTitle: Schema.String.check(unicodeCodePointLength(0, 500)),
+  threadTitleTruncated: Schema.Boolean,
+  archived: Schema.Boolean,
+  source: Schema.Literals(["title", "user", "assistant"]),
+  origin: Schema.Literals(["legacy", "v2"]),
+  snippet: Schema.String.check(unicodeCodePointLength(0, 1_000)),
+  snippetTruncated: Schema.Boolean,
+  matchedAt: IsoDateTime,
+  messageId: Schema.NullOr(MessageId),
+  runId: Schema.NullOr(RunId),
+  itemId: Schema.NullOr(TurnItemId),
+  readAnchor: Schema.NullOr(OrchestratorMcpThreadReadAnchor),
+});
+export type OrchestratorMcpThreadSearchHit = typeof OrchestratorMcpThreadSearchHit.Type;
+
+export const OrchestratorMcpThreadSearchResult = Schema.Struct({
+  projectId: ProjectId,
+  hits: Schema.Array(OrchestratorMcpThreadSearchHit),
+  nextCursor: Schema.NullOr(NonNegativeInt),
+  hasMore: Schema.Boolean,
+  traversalTruncated: Schema.Boolean,
+  consistency: Schema.Literal("live"),
+});
+export type OrchestratorMcpThreadSearchResult = typeof OrchestratorMcpThreadSearchResult.Type;
+
 export const OrchestratorMcpThreadReadInput = Schema.Struct({
   threadId: ThreadId,
   view: Schema.optional(Schema.Literals(["messages", "activity"])),
   afterPosition: Schema.optional(NonNegativeInt),
+  anchor: Schema.optional(
+    OrchestratorMcpThreadReadAnchor.annotate({
+      description:
+        "Start inclusively at a visible source message returned by t3_thread_search. Cannot be combined with afterPosition.",
+    }),
+  ),
   limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))),
   runLimit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(50))),
   maxCharsPerItem: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(50_000))),
+}).check(
+  Schema.makeFilter(
+    (input) =>
+      !(input.anchor !== undefined && input.afterPosition !== undefined) ||
+      "anchor and afterPosition cannot be specified together",
+  ),
+);
-});
 export type OrchestratorMcpThreadReadInput = typeof OrchestratorMcpThreadReadInput.Type;
 
 export const OrchestratorMcpThreadDetail = Schema.Struct({
@@ -488,6 +574,7 @@
     batchThreadCreation: Schema.Boolean,
     threadManagement: Schema.Boolean,
     incrementalThreadRead: Schema.Boolean,
+    threadSearch: Schema.optional(Schema.Boolean),
     scheduledTasks: Schema.Boolean,
     maxBatchThreads: Schema.Number,
   }),
```

## packages/shared/src/t3McpToolPresentation.test.ts

Status: modified; +4; −0

```diff
@@ -8,6 +8,10 @@
       displayName: "Read a T3 thread",
       logo: "t3-code",
     });
+    expect(resolveT3McpToolPresentation("mcp__t3-code__t3_thread_search")).toEqual({
+      displayName: "Search T3 threads",
+      logo: "t3-code",
+    });
   });
 
   it("pretty prints Codex T3 MCP tool names", () => {
```

## packages/shared/src/t3McpToolPresentation.ts

Status: modified; +1; −0

```diff
@@ -49,6 +49,7 @@
   t3_thread_list: { displayName: "List T3 threads", summaryAction: "thread-list" },
   t3_thread_read: { displayName: "Read a T3 thread", summaryAction: "thread-read" },
   t3_thread_update: { displayName: "Update T3 thread metadata" },
+  t3_thread_search: { displayName: "Search T3 threads" },
   t3_thread_send: { displayName: "Send to a T3 thread", summaryAction: "thread-send" },
   t3_thread_wait: { displayName: "Wait for a T3 thread", summaryAction: "thread-wait" },
   t3_thread_interrupt: { displayName: "Interrupt a T3 thread", summaryAction: "thread-interrupt" },
```
