From 5dfc45e92f47c12593d82903433646e8d6ba1e46 Mon Sep 17 00:00:00 2001 From: E Jikan <94772817+vstreame@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:01:53 +0900 Subject: [PATCH] Stream self-host MCP responses so native elicitation reaches the client The shared in-memory session store (self-host) created its transport with enableJsonResponse, which buffers a POST's answer into one JSON body. An elicitation/create issued during a tools/call had no stream to ride, so native-mode approvals never reached the client and the call died on the SDK's 60s request timeout. The local app fixed the same bug in #1556. Use SSE streaming (the spec default). Streaming answers a POST as soon as its stream opens, so the idle sweep's in-flight claim now lasts until the POST's SSE body ends rather than when handleRequest resolves; the standalone GET stream still does not count. Native elicitations also get PAUSED_APPROVAL_TIMEOUT_MS (4 min) instead of the SDK's 60s default, since a human answers them. Fixes #2140 --- .../selfhost-native-elicitation-streaming.md | 5 + .../src/integrations-mcp.test.ts | 3 +- apps/host-selfhost/src/mcp/mcp.test.ts | 11 +- apps/host-selfhost/src/testing/mcp-sse.ts | 17 +++ e2e/selfhost/mcp-native-elicitation.test.ts | 114 ++++++++++++++++++ .../mcp/src/in-memory-session-store.test.ts | 107 ++++++++++++++-- .../hosts/mcp/src/in-memory-session-store.ts | 85 +++++++++++-- packages/hosts/mcp/src/tool-server.ts | 5 +- 8 files changed, 322 insertions(+), 25 deletions(-) create mode 100644 .changeset/selfhost-native-elicitation-streaming.md create mode 100644 apps/host-selfhost/src/testing/mcp-sse.ts create mode 100644 e2e/selfhost/mcp-native-elicitation.test.ts diff --git a/.changeset/selfhost-native-elicitation-streaming.md b/.changeset/selfhost-native-elicitation-streaming.md new file mode 100644 index 0000000000..9db678dd45 --- /dev/null +++ b/.changeset/selfhost-native-elicitation-streaming.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Self-hosted MCP now delivers `elicitation_mode=native` approvals to the client. Before this fix, the server answered each `tools/call` as a single JSON body, so an `elicitation/create` sent during the call never reached the client and the call failed after 60s with `-32001 Request timed out`. Responses now stream, as they already do in the local app. Native approvals also wait up to 4 minutes for a human to answer, up from the MCP SDK's 60s default. diff --git a/apps/host-selfhost/src/integrations-mcp.test.ts b/apps/host-selfhost/src/integrations-mcp.test.ts index e29fcbbf4f..0b0531d99e 100644 --- a/apps/host-selfhost/src/integrations-mcp.test.ts +++ b/apps/host-selfhost/src/integrations-mcp.test.ts @@ -9,6 +9,7 @@ import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/ import { makeScopedExecutor } from "@executor-js/api/server"; import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; +import { readJsonRpcResponse } from "./testing/mcp-sse"; import { mintInviteCode } from "./testing/mint-invite"; import { SelfHostScopedExecutorSeams } from "./execution"; import type { SelfHostPlugins } from "./plugins"; @@ -165,5 +166,5 @@ test("a user's MCP execute sandbox can reach an org-owned connection's tools", a sessionId, ); expect(call.status).toBe(200); - expect(JSON.stringify(await call.json())).toContain("tiny"); + expect(JSON.stringify(await readJsonRpcResponse(call))).toContain("tiny"); }); diff --git a/apps/host-selfhost/src/mcp/mcp.test.ts b/apps/host-selfhost/src/mcp/mcp.test.ts index 60f07d2988..c20fc64fd7 100644 --- a/apps/host-selfhost/src/mcp/mcp.test.ts +++ b/apps/host-selfhost/src/mcp/mcp.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { afterAll, expect, test } from "@effect/vitest"; +import { readJsonRpcResponse } from "../testing/mcp-sse"; import { mintInviteCode } from "../testing/mint-invite"; process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-mcp-")); @@ -89,7 +90,7 @@ const initSession = async (token: string, browser = false): Promise => { browser, ); expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toContain("application/json"); + expect(res.headers.get("content-type")).toContain("text/event-stream"); const sessionId = res.headers.get("mcp-session-id") ?? ""; expect(sessionId).not.toBe(""); await res.text(); @@ -113,7 +114,9 @@ test("an authenticated MCP client initializes, lists tools, and executes code", const sessionId = await initSession(token); const list = await mcp(token, { jsonrpc: "2.0", id: 2, method: "tools/list" }, sessionId); - const listBody = (await list.json()) as { result: { tools: ReadonlyArray<{ name: string }> } }; + const listBody = (await readJsonRpcResponse(list)) as { + result: { tools: ReadonlyArray<{ name: string }> }; + }; expect(listBody.result.tools.map((tool) => tool.name)).toContain("execute"); const call = await mcp( @@ -127,7 +130,7 @@ test("an authenticated MCP client initializes, lists tools, and executes code", sessionId, ); expect(call.status).toBe(200); - expect(JSON.stringify(await call.json())).toContain("42"); + expect(JSON.stringify(await readJsonRpcResponse(call))).toContain("42"); }); test("an MCP session cannot be reused by another user, and unauth is rejected", async () => { @@ -297,7 +300,7 @@ test("a browser approval uses the bootstrap admin's demoted membership at the si sessionId, true, ); - const paused = (await pausedResponse.json()) as { + const paused = (await readJsonRpcResponse(pausedResponse)) as { readonly result?: { readonly structuredContent?: { readonly executionId?: string }; }; diff --git a/apps/host-selfhost/src/testing/mcp-sse.ts b/apps/host-selfhost/src/testing/mcp-sse.ts new file mode 100644 index 0000000000..6ffaeb293c --- /dev/null +++ b/apps/host-selfhost/src/testing/mcp-sse.ts @@ -0,0 +1,17 @@ +import { Schema } from "effect"; + +// Test helper: the MCP endpoint answers requests as streamable-HTTP SSE (so a +// native elicitation can ride a tool call's own stream), which puts the +// JSON-RPC response on a `data:` line instead of in a JSON body. + +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); + +/** Read the JSON-RPC response an MCP SSE answer carries. */ +export const readJsonRpcResponse = async (response: Response): Promise => { + const data = (await response.text()) + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .at(-1); + return decodeJson(data ?? ""); +}; diff --git a/e2e/selfhost/mcp-native-elicitation.test.ts b/e2e/selfhost/mcp-native-elicitation.test.ts new file mode 100644 index 0000000000..6230453d32 --- /dev/null +++ b/e2e/selfhost/mcp-native-elicitation.test.ts @@ -0,0 +1,114 @@ +// Self-host native elicitation through the real Streamable HTTP transport +// (#2140). A policy gates a built-in read tool, so the test observes both +// directions on the same tools/call stream: elicitation/create reaches the +// client, and the human's decision returns to the execution engine. The last +// call answers after the MCP SDK's 60s default request timeout, the way a human +// approving on an async surface does. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; +import type { Identity } from "../src/target"; + +const coreApi = composePluginApi([] as const); +const GATED_TOOL = "executor.coreTools.policies.list"; +const GATED_CODE = ` +const result = await tools.executor.coreTools.policies.list({}); +return JSON.stringify(result); +`; +/** Past the MCP SDK's 60s default request timeout. */ +const SLOW_HUMAN_MS = 61_000; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +scenario( + "MCP · native elicitation carries approval decisions on the tool call stream", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const api = yield* Api; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const apiClient = yield* api.client(coreApi, identity); + const policy = yield* apiClient.policies.create({ + payload: { owner: "org", pattern: GATED_TOOL, action: "require_approval" }, + }); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + + yield* Effect.gen(function* () { + let decision: "accept" | "decline" = "accept"; + let answerAfterMs = 0; + let elicitationCount = 0; + const client = yield* Effect.acquireRelease( + Effect.promise(async () => { + const connectedClient = new Client( + { name: "executor-selfhost-native-elicitation-e2e", version: "1.0.0" }, + { capabilities: { elicitation: { form: {}, url: {} } } }, + ); + connectedClient.setRequestHandler(ElicitRequestSchema, async () => { + elicitationCount += 1; + await new Promise((resolve) => setTimeout(resolve, answerAfterMs)); + return decision === "accept" + ? { action: "accept" as const, content: {} } + : { action: decision }; + }); + const url = new URL(mcp.url); + url.searchParams.set("elicitation_mode", "native"); + url.searchParams.set("artifacts", "false"); + await connectedClient.connect( + new StreamableHTTPClientTransport(url, { + requestInit: { headers: { authorization: `Bearer ${bearer}` } }, + }), + ); + return connectedClient; + }), + (connectedClient) => Effect.promise(() => connectedClient.close()), + ); + const callGated = (timeout: number) => + Effect.promise(() => + client.callTool({ name: "execute", arguments: { code: GATED_CODE } }, undefined, { + timeout, + }), + ); + + const accepted = yield* callGated(30_000); + expect(elicitationCount, "the native elicitation reached the client").toBe(1); + expect(accepted.isError, "accepting lets the gated tool complete").toBeFalsy(); + expect( + JSON.stringify(accepted.content), + "the gated tool returned its policy listing", + ).toContain(policy.id); + + decision = "decline"; + const declined = yield* callGated(30_000); + expect(elicitationCount, "the second native elicitation also reached the client").toBe(2); + expect(declined.isError, "declining blocks the gated tool").toBe(true); + expect( + JSON.stringify(declined.content), + "the engine reports the client's decline decision", + ).toContain("declined by the user"); + + decision = "accept"; + answerAfterMs = SLOW_HUMAN_MS; + const slow = yield* callGated(SLOW_HUMAN_MS + 30_000); + expect(elicitationCount, "the slow approval reached the client").toBe(3); + expect(slow.isError, "an approval answered after 60s still completes the call").toBeFalsy(); + expect( + JSON.stringify(slow.content), + "the slowly approved tool returned its policy listing", + ).toContain(policy.id); + }).pipe( + Effect.scoped, + Effect.ensuring( + apiClient.policies + .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ), + ); + }), +); diff --git a/packages/hosts/mcp/src/in-memory-session-store.test.ts b/packages/hosts/mcp/src/in-memory-session-store.test.ts index bdc51db85d..285e615b93 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.test.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, type Cause } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { Effect, Schema, type Cause } from "effect"; import type { ExecutionEngine } from "@executor-js/execution"; import { FormElicitation, ToolAddress, createExecutor } from "@executor-js/sdk"; @@ -25,6 +28,18 @@ const TEST_PRINCIPAL: Principal = { orgRoleModel: "organization", }; +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); + +/** A `tools/call` POST is answered as an SSE stream; read the JSON-RPC response it carries. */ +const readJsonRpcResponse = async (response: Response): Promise => { + const data = (await response.text()) + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .at(-1); + return decodeJson(data ?? ""); +}; + it("preserves native elicitation mode when creating an in-memory MCP session", async () => { let buildOptions: McpBuildServerOptions | undefined; const sessions = makeInMemoryMcpSessionStore((_principal, options) => { @@ -60,6 +75,72 @@ it("preserves native elicitation mode when creating an in-memory MCP session", a expect(buildOptions?.elicitationMode).toEqual({ mode: "native" }); }); +// Regression for #2140: in native mode the approval is an `elicitation/create` +// the server sends DURING the `tools/call`. It can only reach the client on +// that call's own SSE stream; a JSON-mode transport dropped it and the call +// died on the request timeout. +it("delivers a native elicitation to the client during a tools/call", async () => { + const engine: ExecutionEngine = { + ...makeIdleTestEngine(), + execute: (_code, { onElicitation }) => + onElicitation({ + address: ToolAddress.make("slack.org.main.send_message"), + args: {}, + request: FormElicitation.make({ message: "Send the message?", requestedSchema: {} }), + }).pipe(Effect.map((response) => ({ result: `decision:${response.action}` }))), + }; + const sessions = makeInMemoryMcpSessionStore((_principal, options) => + createExecutorMcpServer({ + engine, + ...(options?.elicitationMode ? { elicitationMode: options.elicitationMode } : {}), + }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine }))), + ); + const fetchThroughStore = async (url: string | URL, init?: RequestInit) => { + const request = new Request(url.toString(), init); + const result = await Effect.runPromise( + sessions.store.dispatch({ + request, + principal: TEST_PRINCIPAL, + resource: defaultMcpResource, + sessionId: request.headers.get("mcp-session-id"), + method: request.method, + }), + ); + return typeof result === "string" ? new Response(null, { status: 404 }) : result; + }; + + const elicitations: string[] = []; + const client = new Client( + { name: "native-elicitation-test", version: "1.0.0" }, + { capabilities: { elicitation: { form: {} } } }, + ); + client.setRequestHandler(ElicitRequestSchema, async (request) => { + elicitations.push(request.params.message); + return { action: "accept" as const, content: {} }; + }); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the client and the store + try { + await client.connect( + new StreamableHTTPClientTransport( + new URL("https://executor.test/mcp?elicitation_mode=native"), + { fetch: fetchThroughStore }, + ), + ); + const result = await client.callTool( + { name: "execute", arguments: { code: "send()" } }, + undefined, + { timeout: 5_000 }, + ); + expect(elicitations).toEqual(["Send the message?"]); + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([{ type: "text", text: "decision:accept" }]); + } finally { + await client.close(); + await sessions.close(); + } +}); + /** A do-nothing engine: the eviction test drives session lifetime, not tools. */ const makeIdleTestEngine = (): ExecutionEngine => ({ execute: () => Effect.succeed({ result: "unused" }), @@ -223,10 +304,10 @@ it("keeps overlapping warm-session workspace writes bound to their request roles releaseWrites(); const [memberResponse, adminResponse] = await Promise.all([memberCall, adminCall]); - const memberBody = (await memberResponse.json()) as { + const memberBody = (await readJsonRpcResponse(memberResponse)) as { result?: { isError?: boolean }; }; - const adminBody = (await adminResponse.json()) as { + const adminBody = (await readJsonRpcResponse(adminResponse)) as { result?: { isError?: boolean }; }; expect(memberBody.result?.isError).toBe(true); @@ -303,7 +384,7 @@ it("binds a paused workspace write to the resuming principal after demotion", as executionId, action: "accept", }); - const body = (await resumed.json()) as { result?: { isError?: boolean } }; + const body = (await readJsonRpcResponse(resumed)) as { result?: { isError?: boolean } }; expect(body.result?.isError).toBe(true); expect(await Effect.runPromise(executor.policies.list())).toEqual([]); @@ -374,7 +455,7 @@ it("uses the browser approver's demoted role after an admin starts waiting", asy ) as Promise; const pausedResponse = await call(2, "execute", { code: "create workspace policy" }); - const pausedBody = (await pausedResponse.json()) as { + const pausedBody = (await readJsonRpcResponse(pausedResponse)) as { result?: { structuredContent?: { executionId?: string } }; }; const pausedExecutionId = pausedBody.result?.structuredContent?.executionId; @@ -398,7 +479,7 @@ it("uses the browser approver's demoted role after an admin starts waiting", asy ); expect(approvalResponse?.status).toBe(200); - const resumeBody = (await (await firstResume).json()) as { + const resumeBody = (await readJsonRpcResponse(await firstResume)) as { result?: { isError?: boolean }; }; expect(resumeBody.result?.isError).toBe(true); @@ -522,11 +603,21 @@ it("never evicts a session while one of its requests is still in flight", async expect(sessions.sessionCount()).toBe(1); expect(latched.shutdowns()).toBe(0); - // The parked request still completes, on the transport it started on. - latched.release(); + // The POST is answered as an SSE stream the moment it opens, while the + // engine is still parked. The claim rides the stream, not the Response: the + // call is still in flight, so the session is still busy. const response = await inFlight; expect(response).toBeInstanceOf(Response); expect((response as Response).status).toBe(200); + expect(await sessions.sweepIdleSessions(startedAt + IDLE_TTL_MS)).toBe(0); + expect(sessions.sessionCount()).toBe(1); + + // The parked request still completes, on the stream it started on. + latched.release(); + expect(await readJsonRpcResponse(response as Response)).toMatchObject({ + id: 2, + result: { content: [{ type: "text", text: "released" }] }, + }); // And the reprieve is only for the duration of the call: the session is // restamped as it ends, so the next idle window still reclaims it — engine diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index 952f9bd83f..8f49cc7ef3 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -68,9 +68,8 @@ import type { BrowserApprovalStore, McpPassthroughUnavailableError } from "./too // lifetime of the process. // // The standalone SSE stream is NOT a substitute teardown signal, and it is not -// absent either. `enableJsonResponse` governs only how a POST carrying requests -// answers; a POST carrying just the `notifications/initialized` notification -// still gets a bare 202, which is exactly the cue the client SDK uses to open +// absent either. A POST carrying just the `notifications/initialized` +// notification gets a bare 202, which is exactly the cue the client SDK uses to open // the long-lived `GET /mcp` stream. So essentially every session holds an open // server-to-client stream for its whole life. That stream is silent by design // (it exists for server-initiated messages) and this transport does no max-age @@ -86,6 +85,56 @@ import type { BrowserApprovalStore, McpPassthroughUnavailableError } from "./too // gets the store's existing "not-found" (404, -32001), the client's cue to // re-initialize. The cost is bounded and visible — a connected-but-quiet client // loses its stream at the ceiling and re-initializes on its next call. +/** Run `fn` at most once; later calls are no-ops. */ +const once = (fn: () => void): (() => void) => { + let done = false; + return () => { + if (done) return; + done = true; + fn(); + }; +}; + +/** + * Hand back `response` with `release` deferred until its SSE body ends — read + * to completion, errored, or cancelled by a client that went away. Any other + * response (a JSON error, a bare 202) releases immediately. + */ +const releaseWhenStreamEnds = (response: Response, release: () => void): Response => { + const body = response.body; + if (!body || !response.headers.get("content-type")?.includes("text/event-stream")) { + release(); + return response; + } + const reader = body.getReader(); + const stream = new ReadableStream({ + pull: (controller) => + reader.read().then( + (chunk) => { + if (chunk.done) { + release(); + controller.close(); + } else { + controller.enqueue(chunk.value); + } + }, + (cause: unknown) => { + release(); + controller.error(cause); + }, + ), + cancel: (reason) => { + release(); + return reader.cancel(reason); + }, + }); + return new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +}; + /** Idle window after which an untouched session is evicted. */ const DEFAULT_SESSION_IDLE_TTL_MS = 30 * 60 * 1000; /** Floor on the sweep interval, so a small TTL cannot spin the timer. */ @@ -255,8 +304,8 @@ export const makeInMemoryMcpSessionStore = ( // Monotonic-ish last-touch stamp per live session, the first input the idle // sweep reads. Written on create and on every forwarded request. const lastSeen = new Map(); - // Requests currently inside `transport.handleRequest` for a session, the - // sweep's second input. A stamp alone cannot describe a long call: it is + // Requests currently being served for a session (a POST's SSE stream stays + // claimed until it ends), the sweep's second input. A stamp alone cannot describe a long call: it is // written BEFORE the await, so a single `execute` that outruns the TTL (a // browser approval waiting on a human, a slow upstream) would look exactly // like an abandoned session and have its transport, server, and engine closed @@ -396,13 +445,22 @@ export const makeInMemoryMcpSessionStore = ( if (!sessionOwnerMatches(owner, principal, resource)) return Effect.succeed("forbidden"); owners.set(sessionId, { principal, resource }); touch(sessionId); - // Claim before the await, release in the finalizer — `runHandleRequest` - // already recovers every failure to a 500, but `ensuring` also covers an - // interrupt, so the counter cannot be left permanently raised (which would - // make the session immortal, the opposite leak). + // Claim before the await. A POST answered as an SSE stream is still being + // served after `handleRequest` resolves — the tool call and any native + // elicitation ride that stream — so its claim is released when the stream + // ends, not when it opens. `runHandleRequest` already recovers every failure + // to a 500, but `onError` also covers an interrupt, so the counter cannot be + // left permanently raised (which would make the session immortal, the + // opposite leak). beginRequest(sessionId); + const release = once(() => endRequest(sessionId)); return runHandleRequest(transport, request, orgWriteAccessForPrincipal(principal)).pipe( - Effect.ensuring(Effect.sync(() => endRequest(sessionId))), + Effect.map((response) => { + if (request.method === "POST") return releaseWhenStreamEnds(response, release); + release(); + return response; + }), + Effect.onError(() => Effect.sync(() => release())), ); }; @@ -459,8 +517,13 @@ export const makeInMemoryMcpSessionStore = ( Effect.flatMap(({ mcpServer, engine, executor, close }) => Effect.gen(function* () { const transport = new WebStandardStreamableHTTPServerTransport({ + // SSE streaming (the spec default), NOT `enableJsonResponse: true`. + // JSON mode buffers a POST's answer into one body, so it has no open + // stream to write on: an `elicitation/create` issued DURING a + // `tools/call` is dropped and native elicitation dies on the request + // timeout (#2140; the local app's #1555). Streaming keeps the tool + // call's own stream writable, which is what native elicitation rides. sessionIdGenerator: () => crypto.randomUUID(), - enableJsonResponse: true, onsessioninitialized: (sid) => { createdSessionId = sid; transports.set(sid, transport); diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index f7c1a349f7..b51ee73ed7 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -509,9 +509,12 @@ const makeMcpElicitationHandler = clientCapabilities: server.server.getClientCapabilities() ?? null, }); + // A human answers this, often on an async surface, so the SDK's 60s + // request default is too short; give it the same window a paused + // approval gets. const response = await server.server.elicitInput( params as Parameters[0], - { relatedRequestId }, + { relatedRequestId, timeout: PAUSED_APPROVAL_TIMEOUT_MS }, ); const meta = answeredTerms(response._meta);