feat(mcp): expose thread search#8722
3 unresolved threads ยท Jump to file
- apps/server/src/mcp/ThreadSearchMcpService.ts:81 ยท ๐ **High** `mcp/ThreadSearchMcpService.ts:81` `search` returns no hits for newly created V2 thread
- apps/server/src/mcp/ThreadSearchMcpService.ts:20โ30 ยท <!-- MURMUR_IGNORE --> `mcp/ThreadSearchMcpService.ts:30` Consider defining the service interface i
- packages/contracts/src/orchestratorMcp.ts:345 ยท <!-- MURMUR_IGNORE --> ๐ **High** `src/orchestratorMcp.ts:345` A search result containing 1,000 as
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๐ 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:
| 1 | In 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. |
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
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"].
| 1 | export 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)),๐ 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