diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md
index 002c87ff789..f5e94dbefbd 100644
--- a/docs/cli/mcp.md
+++ b/docs/cli/mcp.md
@@ -147,7 +147,7 @@ The current bridge exposes these MCP tools:
- Returns one conversation by `session_key`.
+ Returns one conversation by `session_key` using a direct Gateway session lookup.
Reads recent transcript messages for one session-backed conversation.
diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md
index 40751c78fbd..0b467135fed 100644
--- a/docs/gateway/protocol.md
+++ b/docs/gateway/protocol.md
@@ -399,6 +399,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `sessions.subscribe` and `sessions.unsubscribe` toggle session change event subscriptions for the current WS client.
- `sessions.messages.subscribe` and `sessions.messages.unsubscribe` toggle transcript/message event subscriptions for one session.
- `sessions.preview` returns bounded transcript previews for specific session keys.
+ - `sessions.describe` returns one Gateway session row for an exact session key.
- `sessions.resolve` resolves or canonicalizes a session target.
- `sessions.create` creates a new session entry.
- `sessions.send` sends a message into an existing session.
diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts
index 016c9866aa1..af96bc0f09b 100644
--- a/src/gateway/method-scopes.ts
+++ b/src/gateway/method-scopes.ts
@@ -97,6 +97,7 @@ const METHOD_SCOPE_GROUPS: Record = {
"sessions.list",
"sessions.get",
"sessions.preview",
+ "sessions.describe",
"sessions.resolve",
"sessions.compaction.list",
"sessions.compaction.get",
diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts
index 262303a832e..73bee4af7dc 100644
--- a/src/gateway/protocol/index.ts
+++ b/src/gateway/protocol/index.ts
@@ -270,6 +270,8 @@ import {
SessionsCreateParamsSchema,
type SessionsDeleteParams,
SessionsDeleteParamsSchema,
+ type SessionsDescribeParams,
+ SessionsDescribeParamsSchema,
type SessionsListParams,
SessionsListParamsSchema,
type SessionsMessagesSubscribeParams,
@@ -452,6 +454,9 @@ export const validateSessionsCleanupParams = ajv.compile(
export const validateSessionsPreviewParams = ajv.compile(
SessionsPreviewParamsSchema,
);
+export const validateSessionsDescribeParams = ajv.compile(
+ SessionsDescribeParamsSchema,
+);
export const validateSessionsResolveParams = ajv.compile(
SessionsResolveParamsSchema,
);
@@ -700,6 +705,7 @@ export {
SessionsListParamsSchema,
SessionsCleanupParamsSchema,
SessionsPreviewParamsSchema,
+ SessionsDescribeParamsSchema,
SessionsResolveParamsSchema,
SessionsCompactionListParamsSchema,
SessionsCompactionGetParamsSchema,
@@ -926,6 +932,7 @@ export type {
SessionsListParams,
SessionsCleanupParams,
SessionsPreviewParams,
+ SessionsDescribeParams,
SessionsResolveParams,
SessionsPatchParams,
SessionsPatchResult,
diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts
index 0049b1880d9..a5f6eca2400 100644
--- a/src/gateway/protocol/schema/protocol-schemas.ts
+++ b/src/gateway/protocol/schema/protocol-schemas.ts
@@ -204,6 +204,7 @@ import {
SessionsCleanupParamsSchema,
SessionsCreateParamsSchema,
SessionsDeleteParamsSchema,
+ SessionsDescribeParamsSchema,
SessionsListParamsSchema,
SessionsMessagesSubscribeParamsSchema,
SessionsMessagesUnsubscribeParamsSchema,
@@ -278,6 +279,7 @@ export const ProtocolSchemas = {
SessionsListParams: SessionsListParamsSchema,
SessionsCleanupParams: SessionsCleanupParamsSchema,
SessionsPreviewParams: SessionsPreviewParamsSchema,
+ SessionsDescribeParams: SessionsDescribeParamsSchema,
SessionsResolveParams: SessionsResolveParamsSchema,
SessionCompactionCheckpoint: SessionCompactionCheckpointSchema,
SessionsCompactionListParams: SessionsCompactionListParamsSchema,
diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts
index 20082fd09f6..8e05a0468bc 100644
--- a/src/gateway/protocol/schema/sessions.ts
+++ b/src/gateway/protocol/schema/sessions.ts
@@ -80,6 +80,15 @@ export const SessionsPreviewParamsSchema = Type.Object(
{ additionalProperties: false },
);
+export const SessionsDescribeParamsSchema = Type.Object(
+ {
+ key: NonEmptyString,
+ includeDerivedTitles: Type.Optional(Type.Boolean()),
+ includeLastMessage: Type.Optional(Type.Boolean()),
+ },
+ { additionalProperties: false },
+);
+
export const SessionsResolveParamsSchema = Type.Object(
{
key: Type.Optional(NonEmptyString),
diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts
index 540d378e9af..cddbfc3275a 100644
--- a/src/gateway/protocol/schema/types.ts
+++ b/src/gateway/protocol/schema/types.ts
@@ -46,6 +46,7 @@ export type PushTestResult = SchemaType<"PushTestResult">;
export type SessionsListParams = SchemaType<"SessionsListParams">;
export type SessionsCleanupParams = SchemaType<"SessionsCleanupParams">;
export type SessionsPreviewParams = SchemaType<"SessionsPreviewParams">;
+export type SessionsDescribeParams = SchemaType<"SessionsDescribeParams">;
export type SessionsResolveParams = SchemaType<"SessionsResolveParams">;
export type SessionCompactionCheckpoint = SchemaType<"SessionCompactionCheckpoint">;
export type SessionsCompactionListParams = SchemaType<"SessionsCompactionListParams">;
diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts
index 3952c76e8e8..2d3924f294e 100644
--- a/src/gateway/server-methods-list.ts
+++ b/src/gateway/server-methods-list.ts
@@ -96,6 +96,7 @@ const BASE_METHODS = [
"sessions.messages.subscribe",
"sessions.messages.unsubscribe",
"sessions.preview",
+ "sessions.describe",
"sessions.compaction.list",
"sessions.compaction.get",
"sessions.compaction.branch",
diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts
index e5525c2209b..6945c6c91b6 100644
--- a/src/gateway/server-methods/sessions.ts
+++ b/src/gateway/server-methods/sessions.ts
@@ -57,6 +57,7 @@ import {
validateSessionsCompactionRestoreParams,
validateSessionsCreateParams,
validateSessionsDeleteParams,
+ validateSessionsDescribeParams,
validateSessionsListParams,
validateSessionsMessagesSubscribeParams,
validateSessionsMessagesUnsubscribeParams,
@@ -76,6 +77,7 @@ import {
import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js";
import {
archiveFileOnDisk,
+ buildGatewaySessionRow,
listSessionsFromStoreAsync,
loadCombinedSessionStoreForGateway,
loadGatewaySessionRow,
@@ -824,6 +826,34 @@ export const sessionsHandlers: GatewayRequestHandlers = {
respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined);
},
+ "sessions.describe": ({ params, respond, context }) => {
+ if (!assertValidParams(params, validateSessionsDescribeParams, "sessions.describe", respond)) {
+ return;
+ }
+ const key = requireSessionKey(params.key, respond);
+ if (!key) {
+ return;
+ }
+ const cfg = context.getRuntimeConfig();
+ const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg);
+ const store = loadSessionStore(storePath);
+ const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys);
+ if (!entry) {
+ respond(true, { session: null }, undefined);
+ return;
+ }
+ const row = buildGatewaySessionRow({
+ cfg,
+ storePath,
+ store,
+ key: target.canonicalKey,
+ entry,
+ includeDerivedTitles: params.includeDerivedTitles,
+ includeLastMessage: params.includeLastMessage,
+ transcriptUsageMaxBytes: 64 * 1024,
+ });
+ respond(true, { session: row }, undefined);
+ },
"sessions.resolve": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateSessionsResolveParams, "sessions.resolve", respond)) {
return;
diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts
index 3c3b6e5c1b5..7753e09303f 100644
--- a/src/gateway/session-utils.ts
+++ b/src/gateway/session-utils.ts
@@ -1688,7 +1688,7 @@ export function loadGatewaySessionRow(
*/
const SESSIONS_LIST_YIELD_BATCH_SIZE = 10;
-function filterAndSortSessionEntries(params: {
+export function filterAndSortSessionEntries(params: {
store: Record;
opts: import("./protocol/index.js").SessionsListParams;
now: number;
diff --git a/src/gateway/sessions-resolve.test.ts b/src/gateway/sessions-resolve.test.ts
index 19fc29b2112..481af06a226 100644
--- a/src/gateway/sessions-resolve.test.ts
+++ b/src/gateway/sessions-resolve.test.ts
@@ -190,9 +190,6 @@ describe("resolveSessionKeyFromResolveParams", () => {
storePath,
store: { [deletedAgentKey]: { sessionId: "sess-orphan", updatedAt: 1 } },
});
- hoisted.listSessionsFromStoreMock.mockReturnValue({
- sessions: [{ key: deletedAgentKey, sessionId: "sess-orphan" }],
- });
hoisted.listAgentIdsMock.mockReturnValue(["main"]);
const result = await resolveSessionKeyFromResolveParams({
@@ -209,6 +206,27 @@ describe("resolveSessionKeyFromResolveParams", () => {
});
});
+ it("resolves sessionId matches from raw store metadata without hydrating session rows", async () => {
+ hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
+ storePath,
+ store: {
+ "agent:main:noisy": { sessionId: "sess-noisy", updatedAt: 2 },
+ "agent:main:target": { sessionId: "sess-target", updatedAt: 1 },
+ },
+ });
+ hoisted.listSessionsFromStoreMock.mockImplementation(() => {
+ throw new Error("session rows should not be materialized for exact sessionId lookup");
+ });
+
+ const result = await resolveSessionKeyFromResolveParams({
+ cfg: {},
+ p: { sessionId: "sess-target" },
+ });
+
+ expect(result).toEqual({ ok: true, key: "agent:main:target" });
+ expect(hoisted.listSessionsFromStoreMock).not.toHaveBeenCalled();
+ });
+
it("rejects sessions belonging to a deleted agent (label-based lookup)", async () => {
const deletedAgentKey = "agent:deleted-agent:main";
hoisted.loadCombinedSessionStoreForGatewayMock.mockReturnValue({
diff --git a/src/gateway/sessions-resolve.ts b/src/gateway/sessions-resolve.ts
index 40e39e20f03..7cb47832ede 100644
--- a/src/gateway/sessions-resolve.ts
+++ b/src/gateway/sessions-resolve.ts
@@ -1,5 +1,6 @@
-import { loadSessionStore, updateSessionStore } from "../config/sessions.js";
+import { loadSessionStore, updateSessionStore, type SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
+import { resolveSessionIdMatchSelection } from "../sessions/session-id-resolution.js";
import { parseSessionLabel } from "../sessions/session-label.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
@@ -9,6 +10,7 @@ import {
type SessionsResolveParams,
} from "./protocol/index.js";
import {
+ filterAndSortSessionEntries,
listSessionsFromStore,
loadCombinedSessionStoreForGateway,
migrateAndPruneGatewaySessionStoreKey,
@@ -70,6 +72,22 @@ function isResolvedSessionKeyVisible(params: {
}).sessions.some((session) => session.key === params.key);
}
+function findVisibleSessionIdMatches(params: {
+ store: Record;
+ p: SessionsResolveParams;
+ sessionId: string;
+}): Array<[string, SessionEntry]> {
+ const now = Date.now();
+ const entries = filterAndSortSessionEntries({
+ store: params.store,
+ now,
+ opts: resolveSessionVisibilityFilterOptions(params.p),
+ });
+ return entries.filter(
+ ([key, entry]) => entry?.sessionId === params.sessionId || key === params.sessionId,
+ );
+}
+
export async function resolveSessionKeyFromResolveParams(params: {
cfg: OpenClawConfig;
p: SessionsResolveParams;
@@ -148,29 +166,17 @@ export async function resolveSessionKeyFromResolveParams(params: {
}
if (hasSessionId) {
- const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
- const list = listSessionsFromStore({
- cfg,
- storePath,
- store,
- opts: {
- includeGlobal: p.includeGlobal === true,
- includeUnknown: p.includeUnknown === true,
- spawnedBy: p.spawnedBy,
- agentId: p.agentId,
- },
- });
- const matches = list.sessions.filter(
- (session) => session.sessionId === sessionId || session.key === sessionId,
- );
- if (matches.length === 0) {
+ const { store } = loadCombinedSessionStoreForGateway(cfg);
+ const matches = findVisibleSessionIdMatches({ store, p, sessionId });
+ const selection = resolveSessionIdMatchSelection(matches, sessionId);
+ if (selection.kind === "none") {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${sessionId}`),
};
}
- if (matches.length > 1) {
- const keys = matches.map((session) => session.key).join(", ");
+ if (selection.kind === "ambiguous") {
+ const keys = selection.sessionKeys.join(", ");
return {
ok: false,
error: errorShape(
@@ -179,11 +185,11 @@ export async function resolveSessionKeyFromResolveParams(params: {
),
};
}
- const agentCheckSessionId = validateSessionAgentExists(cfg, matches[0].key);
+ const agentCheckSessionId = validateSessionAgentExists(cfg, selection.sessionKey);
if (agentCheckSessionId) {
return agentCheckSessionId;
}
- return { ok: true, key: matches[0].key };
+ return { ok: true, key: selection.sessionKey };
}
const parsedLabel = parseSessionLabel(p.label);
diff --git a/src/mcp/channel-bridge.ts b/src/mcp/channel-bridge.ts
index 67db49e638f..3b23eae8a72 100644
--- a/src/mcp/channel-bridge.ts
+++ b/src/mcp/channel-bridge.ts
@@ -18,6 +18,7 @@ import type {
ConversationDescriptor,
PendingApproval,
QueueEvent,
+ SessionDescribeResult,
SessionListResult,
SessionMessagePayload,
WaitFilter,
@@ -206,10 +207,13 @@ export class OpenClawChannelBridge {
if (!normalizedSessionKey) {
return null;
}
- const conversations = await this.listConversations({ limit: 500, includeLastMessage: true });
- return (
- conversations.find((conversation) => conversation.sessionKey === normalizedSessionKey) ?? null
- );
+ await this.waitUntilReady();
+ const response: SessionDescribeResult = await this.requestGateway("sessions.describe", {
+ key: normalizedSessionKey,
+ includeDerivedTitles: true,
+ includeLastMessage: true,
+ });
+ return response.session ? toConversation(response.session) : null;
}
async readMessages(
diff --git a/src/mcp/channel-server.test.ts b/src/mcp/channel-server.test.ts
index dfa34800d2b..70c604ecaf8 100644
--- a/src/mcp/channel-server.test.ts
+++ b/src/mcp/channel-server.test.ts
@@ -323,6 +323,46 @@ describe("openclaw channel mcp server", () => {
);
});
+ test("gets one conversation through sessions.describe without broad listing", async () => {
+ const bridge = new OpenClawChannelBridge({} as never, {
+ claudeChannelMode: "off",
+ verbose: false,
+ });
+ const gatewayRequest = vi.fn(async (method: string) => {
+ if (method === "sessions.describe") {
+ return {
+ session: {
+ key: "agent:main:main",
+ deliveryContext: {
+ channel: "telegram",
+ to: "-100123",
+ accountId: "acct-1",
+ },
+ lastMessagePreview: "latest message",
+ },
+ };
+ }
+ throw new Error(`unexpected gateway method ${method}`);
+ });
+
+ attachReadyGateway(bridge, gatewayRequest);
+
+ await expect(bridge.getConversation("agent:main:main")).resolves.toEqual(
+ expect.objectContaining({
+ sessionKey: "agent:main:main",
+ channel: "telegram",
+ to: "-100123",
+ accountId: "acct-1",
+ lastMessagePreview: "latest message",
+ }),
+ );
+ expect(gatewayRequest).toHaveBeenCalledWith("sessions.describe", {
+ key: "agent:main:main",
+ includeDerivedTitles: true,
+ includeLastMessage: true,
+ });
+ });
+
test("lists routed sessions from deliveryContext without mirrored route fields", async () => {
const bridge = new OpenClawChannelBridge({} as never, {
claudeChannelMode: "off",
diff --git a/src/mcp/channel-shared.ts b/src/mcp/channel-shared.ts
index 5aea00122e9..8a81ae78380 100644
--- a/src/mcp/channel-shared.ts
+++ b/src/mcp/channel-shared.ts
@@ -46,6 +46,10 @@ export type SessionListResult = {
sessions?: SessionRow[];
};
+export type SessionDescribeResult = {
+ session?: SessionRow | null;
+};
+
export type ChatHistoryResult = {
messages?: Array<{ id?: string; role?: string; content?: unknown; [key: string]: unknown }>;
};