feat(mcp): expose thread search#8722

closed2
agents/mcp-thread-search/search
agents/mcp-thread-search/tool
openedAug 30, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago
3 unresolved threads ยท Jump to file
Selected thread: packages/contracts/src/orchestratorMcp.ts:345 โ€” <!-- MURMUR_IGNORE --> ๐ŸŸ  **High** `src/orchestratorMcp.ts:345` A search result containing 1,000 astral Unicode characters is rejected by `OrchestratorMcpThreadSearchResult`, so emoji/non-BMP snippets cannot be returned successfully. `Schema.isMaxLength(1_000)` counts UTF-16 code units, while the producer counts `Array.from(...).length` code points; use the same code-point limit for `snippet` and `query` (the latter currently rejects valid 101โ€“200-character astral queries at `Schema.isMaxLength(200)`). โœ… Resolved in e5585e831fb1e8f76154c05ffcac31662ab42c99 <!-- macroscope-meta: {"kind":"code_review"} -->
0 / 13 viewed13 files changedLine totals unavailable

apps/server/src/mcp/ThreadSearchMcpService.ts ยท Earlier revision fe198b00 ยท right 81

Original diff
@@ -0,0 +1,132 @@
+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 * as Schema from "effect/Schema";
+
+import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";
+import {
+  ProjectionSnapshotQuery,
+  ProjectionThreadContentSearchInputError,
+} from "../orchestration/Services/ProjectionSnapshotQuery.ts";
+import type { McpInvocationScope } from "./McpInvocationContext.ts";
+
+const DEFAULT_LIMIT = 20;
+const DEFAULT_SNIPPET_CHARS = 240;
+const isProjectionThreadContentSearchInputError = Schema.is(
+  ProjectionThreadContentSearchInputError,
+);
+
+export interface ThreadSearchMcpServiceShape {
+  readonly search: (
+    scope: McpInvocationScope,
+    input: OrchestratorMcpThreadSearchInput,
+  ) => Effect.Effect<OrchestratorMcpThreadSearchResult, OrchestratorMcpFailure>;
+}
+
+export class ThreadSearchMcpService extends Context.Service<
+  ThreadSearchMcpService,
+  ThreadSearchMcpServiceShape
+>()("t3/mcp/ThreadSearchMcpService") {}
+
+function failure(code: OrchestratorMcpFailure["code"], message: string): OrchestratorMcpFailure {
+  return new OrchestratorMcpFailure({ code, message });
+}
+
+function errorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error);
+}
+
+const make = Effect.gen(function* () {
+  const threadManagement = yield* ThreadManagementService;
+  const projectionQuery = yield* ProjectionSnapshotQuery;
+
+  const loadShell = (threadId: McpInvocationScope["threadId"]) =>
+    threadManagement.getThreadShell(threadId).pipe(
+      Effect.mapError((error) =>
+        failure("orchestration_error", `Unable to read thread ${threadId}: ${errorMessage(error)}`),
+      ),
+      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
M
macroscopeappBotAug 30, 2026

๐ŸŸ  High mcp/ThreadSearchMcpService.ts:81

search returns no hits for newly created V2 threads, including their titles and messages, because searchThreadContent joins only the legacy projection_threads data while V2 projections are stored in orchestration_v2_projection_threads. Update the query to include the V2 projection table (or a unified source) so current MCP threads are searchable.

๐Ÿš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/mcp/ThreadSearchMcpService.ts around line 81:
2
3`search` returns no hits for newly created V2 threads, including their titles and messages, because `searchThreadContent` joins only the legacy `projection_threads` data while V2 projections are stored in `orchestration_v2_projection_threads`. Update the query to include the V2 projection table (or a unified source) so current MCP threads are searchable.
J
Julius MarmingeAug 30, 2026

Fixed by the lower query layer in 0fc6b466c87620156b8213855dd7403a83597cd6 and carried into this PR at eaff95128e6315530803cad7d6ab3d829bec338e. Search metadata is now V2-authoritative for native and imported V2 threads, including title, archive, and deletion state. Legacy metadata remains only for thread IDs with no V2 counterpart. The production MCP integration uses managed V2 writes and the real SQLite query, then reads the returned anchor.

apps/server/src/mcp/ThreadSearchMcpService.ts ยท Earlier revision 7ae406d9 ยท right 20โ€“30

Original diff
@@ -0,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,
+  ProjectionThreadContentSearchInputError,
+} from "../orchestration/Services/ProjectionSnapshotQuery.ts";
+import type { McpInvocationScope } from "./McpInvocationContext.ts";
+
+const DEFAULT_LIMIT = 20;
+const DEFAULT_SNIPPET_CHARS = 240;
+
+export interface ThreadSearchMcpServiceShape {
+  readonly search: (
+    scope: McpInvocationScope,
+    input: OrchestratorMcpThreadSearchInput,
+  ) => Effect.Effect<OrchestratorMcpThreadSearchResult, OrchestratorMcpFailure>;
+}
+
+export class ThreadSearchMcpService extends Context.Service<
+  ThreadSearchMcpService,
+  ThreadSearchMcpServiceShape
+>()("t3/mcp/ThreadSearchMcpService") {}

apps/server/src/mcp/ThreadSearchMcpService.ts ยท right 20โ€“30

M
macroscopeappBotAug 30, 2026

mcp/ThreadSearchMcpService.ts:30

Consider defining the service interface inline in the Context.Service declaration rather than keeping a standalone ThreadSearchMcpServiceShape (the sibling WorktreeMcpService does this). The inferred shape can then be referenced as ThreadSearchMcpService["Service"].

ts
1export class ThreadSearchMcpService extends Context.Service<ThreadSearchMcpService>()(
2 "t3/mcp/ThreadSearchMcpService",
3 {
4 readonly search: (
5 scope: McpInvocationScope,
6 input: OrchestratorMcpThreadSearchInput,
7 ) => Effect.Effect<OrchestratorMcpThreadSearchResult, OrchestratorMcpFailure>;
8 }
9) {}

(or keep the current two-type-argument form with the object literal inlined). The satisfies ThreadSearchMcpServiceShape on line 124 then becomes satisfies ThreadSearchMcpService["Service"], or use ThreadSearchMcpService.of({ search }).

No longer relevant as of e5585e831fb1e8f76154c05ffcac31662ab42c99

packages/contracts/src/orchestratorMcp.ts ยท Earlier revision fe198b00 ยท right 345

Original diff
@@ -301,14 +302,86 @@ export const OrchestratorMcpThreadListResult = Schema.Struct({
 });
 export type OrchestratorMcpThreadListResult = typeof OrchestratorMcpThreadListResult.Type;
 
+export const OrchestratorMcpThreadSearchInput = Schema.Struct({
+  query: TrimmedString.check(Schema.isMinLength(2), Schema.isMaxLength(200)).annotate({
+    description: "Literal text to find; %, _, 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 characters 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,
+  threadTitleTruncated: Schema.Boolean,
+  archived: Schema.Boolean,
+  source: Schema.Literals(["title", "user", "assistant"]),
+  origin: Schema.Literals(["legacy", "v2"]),
+  snippet: Schema.String.check(Schema.isMaxLength(1_000)),
M
macroscopeappBotAug 30, 2026

๐ŸŸ  High src/orchestratorMcp.ts:345

A search result containing 1,000 astral Unicode characters is rejected by OrchestratorMcpThreadSearchResult, so emoji/non-BMP snippets cannot be returned successfully. Schema.isMaxLength(1_000) counts UTF-16 code units, while the producer counts Array.from(...).length code points; use the same code-point limit for snippet and query (the latter currently rejects valid 101โ€“200-character astral queries at Schema.isMaxLength(200)).

โœ… Resolved in e5585e831fb1e8f76154c05ffcac31662ab42c99