From b419590b0242d4d2eea72560b5097fca9edd4dc3 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 15 Jun 2026 22:13:45 +0200 Subject: [PATCH 01/16] feat(ensapi): add MCP server for Omnigraph queries and update documentation - Introduced a new MCP server for handling Omnigraph queries at the `/api/mcp` endpoint. - Updated OpenAPI document to exclude the new MCP endpoint from generated documentation. - Added documentation for integrating with the Omnigraph MCP, including usage instructions for clients like Cursor and Claude. - Enhanced existing documentation with a new link card for the Omnigraph MCP. --- .gitignore | 4 + apps/ensapi/src/handlers/api/mcp/mcp-api.ts | 107 +++++++++++++ apps/ensapi/src/openapi-document.ts | 2 + .../content/docs/docs/integrate/ai-llm.mdx | 6 + .../integration-options/omnigraph-mcp.mdx | 144 ++++++++++++++++++ 5 files changed, 263 insertions(+) create mode 100644 apps/ensapi/src/handlers/api/mcp/mcp-api.ts create mode 100644 docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-mcp.mdx diff --git a/.gitignore b/.gitignore index 898696d363..4d0a20abde 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ apps/ensrainbow/test-* apps/fallback-ensapi/dist + +# Agent skills from npm packages (managed by skills-npm) +**/skills/npm-* +data-* diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts new file mode 100644 index 0000000000..9918693162 --- /dev/null +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts @@ -0,0 +1,107 @@ +import packageJson from "@/../package.json" with { type: "json" }; + +import { StreamableHTTPTransport } from "@hono/mcp"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Hono } from "hono"; +import { z } from "zod/v4"; + +const OmnigraphQueryInputSchema = z.object({ + query: z.string().describe("The GraphQL query document to execute."), + variables: z + .record(z.string(), z.unknown()) + .optional() + .describe("Optional GraphQL variables, keyed by variable name."), +}); + +/** GraphQL-over-HTTP request body; always includes `variables` (`null` when omitted). */ +function buildGraphQLRequestBody(query: string, variables?: Record): string { + return JSON.stringify({ query, variables: variables ?? null }); +} + +/** + * Builds the ENSNode Omnigraph MCP server and registers its tools. + * + * Exported so tests can drive the server over an in-memory transport without the HTTP layer. + */ +export function createOmnigraphMcpServer(): McpServer { + const server = new McpServer({ + name: "ensnode-omnigraph", + version: packageJson.version, + }); + + server.registerTool( + "omnigraph_query", + { + title: "Run an ENS Omnigraph GraphQL query", + description: + "Execute a read-only GraphQL query against this ENSNode instance's ENS Omnigraph API - the " + + "unified ENSv1 + ENSv2 data model. Returns the raw GraphQL JSON response (`{ data, errors }`). " + + "Use the `omnigraph` agent skill and the GraphiQL playground at `/api/omnigraph` to discover " + + "the schema and author queries.", + inputSchema: OmnigraphQueryInputSchema, + }, + async ({ query, variables }) => { + // Defer importing yoga to runtime (matching @/handlers/api/omnigraph/omnigraph-api) so the + // module's Namechain datasource requirement is only triggered at request time. + const { yoga } = await import("@/omnigraph-api/yoga"); + + // Execute against the in-process Yoga instance rather than looping back over HTTP. This reuses + // the Pothos schema, per-request context creation, and Yoga's error masking. + // + // NOTE: this bypasses the indexing-status middleware on the `/api/omnigraph` HTTP route, so + // queries run against whatever the index currently holds. `canAccelerate` only affects the + // Resolution API, so `false` is correct for generic Omnigraph queries. + const response = await yoga.fetch( + new Request("http://ensapi.internal/api/omnigraph", { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: buildGraphQLRequestBody(query, variables), + }), + { canAccelerate: false }, + ); + + return { + content: [{ type: "text", text: await response.text() }], + }; + }, + ); + + return server; +} + +/** + * The single, stateless MCP server + transport for this ENSApi process. + * + * Stateless mode (no `sessionIdGenerator`) lets one server and one transport be shared across all + * requests, which is appropriate for read-only query tools. + */ +const mcpServer = createOmnigraphMcpServer(); +const transport = new StreamableHTTPTransport(); + +/** In-flight `connect()` shared across concurrent cold-start requests. */ +let mcpConnectPromise: Promise | undefined; + +function ensureMcpConnected(): Promise { + if (mcpServer.isConnected()) { + return Promise.resolve(); + } + + mcpConnectPromise ??= mcpServer.connect(transport).catch((error) => { + mcpConnectPromise = undefined; + throw error; + }); + + return mcpConnectPromise; +} + +const app = new Hono(); + +app.all("/", async (c) => { + await ensureMcpConnected(); + + // `handleRequest` may return undefined for messages that have no HTTP body (e.g. notifications). + const response = await transport.handleRequest(c); + return response ?? c.body(null, 202); +}); + +export default app; diff --git a/apps/ensapi/src/openapi-document.ts b/apps/ensapi/src/openapi-document.ts index 2e7053f147..fe1618033d 100644 --- a/apps/ensapi/src/openapi-document.ts +++ b/apps/ensapi/src/openapi-document.ts @@ -6,6 +6,8 @@ import { openapiMeta } from "@/openapi-meta"; * Deprecated endpoints to exclude from the generated OpenAPI document. */ const HIDE_OPENAPI_ENDPOINTS: string[] = [ + // MCP (Model Context Protocol) over streamable HTTP — not a REST/OpenAPI surface. + "/api/mcp", // TODO: remove /amirealtime once the legacy endpoint is deleted. "/amirealtime", // TODO: remove all other endpoints from this list once the legacy endpoints are deleted. diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/ai-llm.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/ai-llm.mdx index 89d36c78c1..e066af855d 100644 --- a/docs/ensnode.io/src/content/docs/docs/integrate/ai-llm.mdx +++ b/docs/ensnode.io/src/content/docs/docs/integrate/ai-llm.mdx @@ -61,6 +61,12 @@ and how many other domains do they own? href="/docs/integrate/integration-options/enscli" /> + + + +## The tool + +The server exposes a single, read-only tool: + +- **`omnigraph_query`** — accepts a GraphQL `query` (and optional `variables`) and returns the raw `{ data, errors }` JSON, exactly as the [HTTP endpoint](/docs/integrate/integration-options/omnigraph-graphql-api#1-the-endpoint) does. Because it's the full Omnigraph behind one tool, an agent can answer any question the Omnigraph can — resolve records, search Domains, list a user's Domains, and much more — without a fixed, hand-written tool per use case. + +## Cursor + +Cursor supports streamable-HTTP MCP servers natively via a `url` in `mcp.json`. + +**Project-scoped** (recommended when working in one repo) — create `.cursor/mcp.json` in the project root: + +```json title=".cursor/mcp.json" +{ + "mcpServers": { + "ensnode-omnigraph": { + "url": "https://api.v2-sepolia.ensnode.io/api/mcp" + } + } +} +``` + +**User-scoped** (all projects) — use `~/.cursor/mcp.json` with the same shape. + +For a local ENSApi instance: + +```json title=".cursor/mcp.json" +{ + "mcpServers": { + "ensnode-omnigraph": { + "url": "http://localhost:4334/api/mcp" + } + } +} +``` + +Then: + +1. Open **Cursor Settings → Tools & MCP** (or **Features → MCP**). +2. Confirm `ensnode-omnigraph` is listed with a green status and the `omnigraph_query` tool. +3. In chat, ask ENS questions in plain language — e.g. _"Who owns vitalik.eth?"_ or _"List domains owned by 0x…"_ — and let the agent call `omnigraph_query`. + + + +## Claude Desktop + +Claude Desktop configures MCP servers in a JSON file (restart Claude after editing): + +| OS | Config path | +| ------- | ----------------------------------------------------------------- | +| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | +| Windows | `%APPDATA%\Claude\claude_desktop_config.json` | + +Many Claude Desktop builds expect **stdio** MCP servers rather than a remote URL. Bridge to ENSNode's streamable-HTTP endpoint with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote): + +```json title="claude_desktop_config.json" +{ + "mcpServers": { + "ensnode-omnigraph": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://api.v2-sepolia.ensnode.io/api/mcp" + ] + } + } +} +``` + +For local development, swap the URL for `http://localhost:4334/api/mcp`. + +After restart, start a new conversation and ask ENS questions — Claude should invoke `omnigraph_query` when it needs indexed ENS data. + + + +## Verify the connection + +To smoke-test without an editor, use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): + +```bash +npx @modelcontextprotocol/inspector +``` + +Set **Transport** to **Streamable HTTP**, **URL** to your `/api/mcp` endpoint, connect, then **List Tools** — you should see `omnigraph_query`. Call it with: + +```json +{ + "query": "{ __typename }" +} +``` + +## Authoring queries + +The MCP endpoint runs whatever GraphQL you hand it — it does not author queries for you. To discover the schema and write correct queries: + +- Explore interactively in the **GraphiQL playground** at `/api/omnigraph`. +- Browse the [Omnigraph Schema Reference](/docs/integrate/omnigraph/schema-reference) and [Omnigraph examples](/docs/integrate/omnigraph/examples). +- Give your agent the [`ensskills`](/docs/integrate/integration-options/ensskills) **`omnigraph`** skill, which teaches the unified ENSv1 + ENSv2 datamodel, resolution, pagination, and vetted example queries — so it writes queries that work the first time. + + + +## Where to go next + +- Want to call the same API over plain HTTP, `curl`, or your own GraphQL client? See the [ENS Omnigraph API (GraphQL)](/docs/integrate/integration-options/omnigraph-graphql-api) guide. +- Building integration code rather than driving an agent? Use [`enssdk`](/docs/integrate/integration-options/enssdk) (typed client) or [`enskit`](/docs/integrate/integration-options/enskit) (React). + + + + + + From ed71208628e1d12a5e0c317b644ad09865a7f700 Mon Sep 17 00:00:00 2001 From: djstrong Date: Mon, 15 Jun 2026 22:45:19 +0200 Subject: [PATCH 02/16] feat(ensapi): integrate MCP API and add tests for Omnigraph queries --- .../api/mcp/mcp-api.integration.test.ts | 83 +++++++++++++++++ .../src/handlers/api/mcp/mcp-api.test.ts | 91 +++++++++++++++++++ apps/ensapi/src/handlers/api/router.ts | 2 + .../starlight/sidebar-topics/integrate.ts | 4 + .../integration-options/omnigraph-mcp.mdx | 2 +- 5 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts create mode 100644 apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts new file mode 100644 index 0000000000..f7706de60b --- /dev/null +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts @@ -0,0 +1,83 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const MCP_URL = new URL("/api/mcp", process.env.ENSNODE_URL!); + +type TextContent = { type: "text"; text: string }; + +/** Connects an MCP client to the live ENSApi `/api/mcp` endpoint over Streamable HTTP. */ +async function connect() { + const client = new Client({ name: "ensapi-integration-test", version: "0.0.0" }); + const transport = new StreamableHTTPClientTransport(MCP_URL); + await client.connect(transport); + return client; +} + +/** Calls `omnigraph_query` and parses the single text content block as GraphQL JSON. */ +async function omnigraphQuery( + client: Client, + query: string, + variables?: Record, +): Promise<{ data?: T; errors?: Array<{ message: string }> }> { + const result = await client.callTool({ + name: "omnigraph_query", + arguments: { query, variables }, + }); + + const content = result.content as TextContent[]; + expect(content).toHaveLength(1); + expect(content[0].type).toBe("text"); + return JSON.parse(content[0].text); +} + +describe("Omnigraph MCP API (/api/mcp)", () => { + let client: Client; + + beforeEach(async () => { + client = await connect(); + }); + + afterEach(async () => { + await client.close(); + }); + + it("advertises the omnigraph_query tool", async () => { + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain("omnigraph_query"); + }); + + it("executes a trivial query end-to-end through Yoga", async () => { + const { data, errors } = await omnigraphQuery<{ __typename: string }>(client, "{ __typename }"); + + expect(errors).toBeUndefined(); + expect(data).toEqual({ __typename: "Query" }); + }); + + it("resolves a seeded devnet domain via Query.domain", async () => { + const { data, errors } = await omnigraphQuery<{ + domain: { canonical: { name: { interpreted: string } } } | null; + }>( + client, + /* GraphQL */ ` + query DomainByName($name: InterpretedName!) { + domain(by: { name: $name }) { + canonical { name { interpreted } } + } + } + `, + { name: "test.eth" }, + ); + + expect(errors).toBeUndefined(); + expect(data).toMatchObject({ + domain: { canonical: { name: { interpreted: "test.eth" } } }, + }); + }); + + it("surfaces GraphQL errors for invalid queries in the response payload", async () => { + const { errors } = await omnigraphQuery(client, "{ thisFieldDoesNotExist }"); + expect(errors).toBeDefined(); + expect(errors?.length ?? 0).toBeGreaterThan(0); + }); +}); diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts new file mode 100644 index 0000000000..d95a185bd6 --- /dev/null +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts @@ -0,0 +1,91 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the in-process Yoga instance so the tool can be exercised without a database. The tool +// imports it dynamically, so the mock is applied at call time. +const fetchMock = vi.fn<(request: Request, context: unknown) => Promise>(); +vi.mock("@/omnigraph-api/yoga", () => ({ yoga: { fetch: fetchMock } })); + +import { createOmnigraphMcpServer } from "./mcp-api"; + +/** Wires an MCP `Client` directly to a fresh server over an in-memory transport pair. */ +async function connectClient() { + const server = createOmnigraphMcpServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "0.0.0" }); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + return { client, server }; +} + +describe("Omnigraph MCP server", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("advertises the omnigraph_query tool", async () => { + const { client } = await connectClient(); + + const { tools } = await client.listTools(); + + expect(tools.map((t) => t.name)).toContain("omnigraph_query"); + }); + + it("executes omnigraph_query via yoga.fetch and returns the raw GraphQL JSON", async () => { + const graphqlResponse = { data: { __typename: "Query" } }; + fetchMock.mockResolvedValue( + new Response(JSON.stringify(graphqlResponse), { + headers: { "content-type": "application/json" }, + }), + ); + + const { client } = await connectClient(); + + const result = await client.callTool({ + name: "omnigraph_query", + arguments: { query: "{ __typename }" }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [request, context] = fetchMock.mock.calls[0]; + expect(request.method).toBe("POST"); + expect(new URL(request.url).pathname).toBe("/api/omnigraph"); + expect(context).toEqual({ canAccelerate: false }); + await expect(request.clone().json()).resolves.toEqual({ + query: "{ __typename }", + variables: null, + }); + + const content = result.content as Array<{ type: string; text: string }>; + expect(content).toHaveLength(1); + expect(content[0].type).toBe("text"); + expect(JSON.parse(content[0].text)).toEqual(graphqlResponse); + }); + + it("forwards GraphQL variables to yoga.fetch", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: null }), { + headers: { "content-type": "application/json" }, + }), + ); + + const { client } = await connectClient(); + + await client.callTool({ + name: "omnigraph_query", + arguments: { + query: "query($id: ID!) { node(id: $id) { __typename } }", + variables: { id: "0x1234" }, + }, + }); + + const [request] = fetchMock.mock.calls[0]; + await expect(request.clone().json()).resolves.toMatchObject({ + variables: { id: "0x1234" }, + }); + }); +}); diff --git a/apps/ensapi/src/handlers/api/router.ts b/apps/ensapi/src/handlers/api/router.ts index 1b15e11f1c..ac796be8ed 100644 --- a/apps/ensapi/src/handlers/api/router.ts +++ b/apps/ensapi/src/handlers/api/router.ts @@ -2,6 +2,7 @@ import { createApp } from "@/lib/hono-factory"; import nameTokensApi from "./explore/name-tokens-api"; import registrarActionsApi from "./explore/registrar-actions-api"; +import mcpApi from "./mcp/mcp-api"; import realtimeApi from "./meta/realtime-api"; import statusApi from "./meta/status-api"; import omnigraphApi from "./omnigraph/omnigraph-api"; @@ -15,5 +16,6 @@ app.route("/resolve", resolutionApi); app.route("/name-tokens", nameTokensApi); app.route("/registrar-actions", registrarActionsApi); app.route("/omnigraph", omnigraphApi); +app.route("/mcp", mcpApi); export default app; diff --git a/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts b/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts index 9d084e8e08..55755ff8d8 100644 --- a/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts +++ b/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts @@ -167,6 +167,10 @@ export const integrateSidebarTopic = { label: "ENS Omnigraph (GraphQL)", link: "/docs/integrate/integration-options/omnigraph-graphql-api", }, + { + label: "ENS Omnigraph MCP (AI agents)", + link: "/docs/integrate/integration-options/omnigraph-mcp", + }, { label: "ENSDb (SQL)", link: "/docs/integrate/integration-options/ensdb", diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-mcp.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-mcp.mdx index a78deb01c6..5e1b7b9d3c 100644 --- a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-mcp.mdx +++ b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-mcp.mdx @@ -55,7 +55,7 @@ Then: 3. In chat, ask ENS questions in plain language — e.g. _"Who owns vitalik.eth?"_ or _"List domains owned by 0x…"_ — and let the agent call `omnigraph_query`. ## Claude Desktop From fbade93e5b6a5bd3544a29abe8c20d44aa0c878e Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 16 Jun 2026 14:51:45 +0200 Subject: [PATCH 03/16] feat(ensapi): update MCP API with new Omnigraph features and tests --- apps/ensapi/package.json | 2 + .../api/mcp/mcp-api.integration.test.ts | 25 +- .../src/handlers/api/mcp/mcp-api.test.ts | 161 +++++- apps/ensapi/src/handlers/api/mcp/mcp-api.ts | 397 ++++++++++--- .../api/mcp/omnigraph-mcp-support.test.ts | 10 + .../handlers/api/mcp/omnigraph-mcp-support.ts | 130 +++++ .../data/omnigraph-examples/config.test.ts | 4 +- .../src/data/omnigraph-examples/config.ts | 49 +- .../src/data/omnigraph-examples/examples.ts | 5 +- .../src/commands/ensnode/omnigraph-schema.ts | 162 ++---- packages/ensnode-sdk/package.json | 4 +- packages/ensnode-sdk/src/internal.ts | 1 + .../src/omnigraph-api/example-queries.ts | 81 ++- .../omnigraph-api/schema-reference.test.ts | 26 + .../src/omnigraph-api/schema-reference.ts | 237 ++++++++ packages/ensskills/scripts/generate.ts | 103 +--- packages/ensskills/skills/omnigraph/SKILL.md | 92 ++- pnpm-lock.yaml | 533 ++++++++++++++++++ 18 files changed, 1662 insertions(+), 360 deletions(-) create mode 100644 apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts create mode 100644 apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts create mode 100644 packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts create mode 100644 packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index 4a9b71ed75..b6810916c0 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -28,9 +28,11 @@ "@ensnode/ensdb-sdk": "workspace:*", "@ensnode/ensnode-sdk": "workspace:*", "@ensnode/ponder-subgraph": "workspace:*", + "@hono/mcp": "^0.3.0", "@hono/node-server": "catalog:", "@hono/otel": "^0.2.2", "@hono/zod-openapi": "^1.4.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@namehash/ens-referrals": "workspace:*", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.7.1", diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts index f7706de60b..b9b3771d5e 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts @@ -44,7 +44,9 @@ describe("Omnigraph MCP API (/api/mcp)", () => { it("advertises the omnigraph_query tool", async () => { const { tools } = await client.listTools(); - expect(tools.map((t) => t.name)).toContain("omnigraph_query"); + expect(tools.map((t) => t.name)).toEqual( + expect.arrayContaining(["omnigraph_query", "omnigraph_schema"]), + ); }); it("executes a trivial query end-to-end through Yoga", async () => { @@ -80,4 +82,25 @@ describe("Omnigraph MCP API (/api/mcp)", () => { expect(errors).toBeDefined(); expect(errors?.length ?? 0).toBeGreaterThan(0); }); + + it("runs a vetted example query by exampleId", async () => { + const result = await client.callTool({ + name: "omnigraph_query", + arguments: { + exampleId: "domain-by-name", + variables: { name: "test.eth" }, + }, + }); + + const content = result.content as TextContent[]; + const { data, errors } = JSON.parse(content[0].text) as { + data?: { domain: { canonical: { name: { interpreted: string } } } | null }; + errors?: unknown[]; + }; + + expect(errors).toBeUndefined(); + expect(data).toMatchObject({ + domain: { canonical: { name: { interpreted: "test.eth" } } }, + }); + }); }); diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts index d95a185bd6..2b96176ffb 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const fetchMock = vi.fn<(request: Request, context: unknown) => Promise>(); vi.mock("@/omnigraph-api/yoga", () => ({ yoga: { fetch: fetchMock } })); -import { createOmnigraphMcpServer } from "./mcp-api"; +import mcpApi, { createOmnigraphMcpServer } from "./mcp-api"; /** Wires an MCP `Client` directly to a fresh server over an in-memory transport pair. */ async function connectClient() { @@ -27,12 +27,48 @@ describe("Omnigraph MCP server", () => { vi.clearAllMocks(); }); - it("advertises the omnigraph_query tool", async () => { + it("advertises omnigraph tools", async () => { const { client } = await connectClient(); const { tools } = await client.listTools(); - expect(tools.map((t) => t.name)).toContain("omnigraph_query"); + expect(tools.map((t) => t.name)).toEqual( + expect.arrayContaining(["omnigraph_query", "omnigraph_schema"]), + ); + }); + + it("advertises schema and example resources", async () => { + const { client } = await connectClient(); + + const { resources } = await client.listResources(); + + expect(resources.map((resource) => resource.uri)).toEqual( + expect.arrayContaining(["omnigraph://schema/condensed", "omnigraph://examples/index"]), + ); + }); + + it("reads the condensed schema resource", async () => { + const { client } = await connectClient(); + + const { contents } = await client.readResource({ uri: "omnigraph://schema/condensed" }); + + expect(contents).toHaveLength(1); + expect(contents[0]).toMatchObject({ mimeType: "text/markdown" }); + if (!("text" in contents[0])) throw new Error("expected text resource"); + expect(contents[0].text).toContain("account(by: AccountByInput!)"); + expect(contents[0].text).toContain("resolve(accelerate: Boolean): ReverseResolve!"); + }); + + it("reads an example resource by id", async () => { + const { client } = await connectClient(); + + const { contents } = await client.readResource({ uri: "omnigraph://examples/hello-world" }); + + expect(contents).toHaveLength(1); + if (!("text" in contents[0])) throw new Error("expected text resource"); + const payload = JSON.parse(contents[0].text) as { id: string; query: string }; + expect(payload.id).toBe("hello-world"); + expect(payload.query).toContain("primaryName(by: { chainName: ETHEREUM })"); }); it("executes omnigraph_query via yoga.fetch and returns the raw GraphQL JSON", async () => { @@ -66,6 +102,32 @@ describe("Omnigraph MCP server", () => { expect(JSON.parse(content[0].text)).toEqual(graphqlResponse); }); + it("executes omnigraph_query by exampleId", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: { account: null } }), { + headers: { "content-type": "application/json" }, + }), + ); + + const { client } = await connectClient(); + + await client.callTool({ + name: "omnigraph_query", + arguments: { + exampleId: "account-migrated-names", + variables: { address: "0x0000000000000000000000000000000000000001" }, + }, + }); + + const [request] = fetchMock.mock.calls[0]; + const body = (await request.clone().json()) as { + query: string; + variables: Record; + }; + expect(body.query).toContain("v1DomainsCount"); + expect(body.variables).toEqual({ address: "0x0000000000000000000000000000000000000001" }); + }); + it("forwards GraphQL variables to yoga.fetch", async () => { fetchMock.mockResolvedValue( new Response(JSON.stringify({ data: null }), { @@ -88,4 +150,97 @@ describe("Omnigraph MCP server", () => { variables: { id: "0x1234" }, }); }); + + it("returns omnigraph_schema lookup for Account", async () => { + const { client } = await connectClient(); + + const result = await client.callTool({ + name: "omnigraph_schema", + arguments: { type: "Account" }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text) as { name: string; fields: Array<{ name: string }> }; + expect(parsed.name).toBe("Account"); + expect(parsed.fields.map((field) => field.name)).toContain("resolve"); + }); + + it("appends validation hints for common GraphQL mistakes", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + errors: [{ message: 'Unknown argument "id" on field "Query.account".' }], + }), + { headers: { "content-type": "application/json" } }, + ), + ); + + const { client } = await connectClient(); + + const result = await client.callTool({ + name: "omnigraph_query", + arguments: { query: '{ account(id: "0x1") { address } }' }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text) as { hints: string[] }; + expect(parsed.hints).toContain( + "Use account(by: { address: $address }) — not account(id: ...).", + ); + }); + + it("advertises account-profile prompt", async () => { + const { client } = await connectClient(); + + const { prompts } = await client.listPrompts(); + expect(prompts.map((prompt) => prompt.name)).toContain("account-profile"); + + const { messages } = await client.getPrompt({ + name: "account-profile", + arguments: { address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" }, + }); + const firstMessage = messages[0].content; + if (firstMessage.type !== "text") throw new Error("expected text prompt"); + expect(firstMessage.text).toContain("hello-world"); + }); + + it("supports POST initialize followed by GET SSE for the same session", async () => { + const init = await mcpApi.fetch( + new Request("http://ensapi.internal/", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "0.0.0" }, + }, + }), + }), + ); + + expect(init.status).toBe(200); + const sessionId = init.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + const sse = await mcpApi.fetch( + new Request("http://ensapi.internal/", { + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-protocol-version": "2025-03-26", + "mcp-session-id": sessionId!, + }, + }), + ); + + expect(sse.status).toBe(200); + expect(sse.headers.get("content-type")).toContain("text/event-stream"); + }); }); diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts index 9918693162..1e8e8084d2 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts @@ -1,107 +1,376 @@ import packageJson from "@/../package.json" with { type: "json" }; import { StreamableHTTPTransport } from "@hono/mcp"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { Hono } from "hono"; import { z } from "zod/v4"; -const OmnigraphQueryInputSchema = z.object({ - query: z.string().describe("The GraphQL query document to execute."), - variables: z - .record(z.string(), z.unknown()) +import { + buildCondensedSchemaReference, + lookupOmnigraphSchema, +} from "@ensnode/ensnode-sdk/internal"; + +import { + buildOmnigraphExamplesIndex, + executeOmnigraphQuery, + listOmnigraphExampleIds, + OMNIGRAPH_MCP_INSTRUCTIONS, + resolveOmnigraphExample, +} from "./omnigraph-mcp-support"; + +const OmnigraphQueryInputSchema = z + .object({ + query: z + .string() + .optional() + .describe("GraphQL query document. Mutually exclusive with `exampleId`."), + exampleId: z + .string() + .optional() + .describe( + "Run a vetted example query by id (see omnigraph://examples/index). Mutually exclusive with `query`.", + ), + variables: z + .record(z.string(), z.unknown()) + .optional() + .describe("GraphQL variables. With `exampleId`, overrides the example defaults."), + }) + .superRefine((value, ctx) => { + const hasQuery = value.query !== undefined && value.query.length > 0; + const hasExample = value.exampleId !== undefined && value.exampleId.length > 0; + if (hasQuery === hasExample) { + ctx.addIssue({ + code: "custom", + message: "Provide exactly one of `query` or `exampleId`.", + }); + } + }); + +const OmnigraphSchemaInputSchema = z.object({ + type: z + .string() + .optional() + .describe('Type name (e.g. "Account") or "Type.field" (e.g. "Account.resolve").'), + search: z + .string() .optional() - .describe("Optional GraphQL variables, keyed by variable name."), + .describe("Keyword search over type and field names. Mutually exclusive with `type`."), }); -/** GraphQL-over-HTTP request body; always includes `variables` (`null` when omitted). */ -function buildGraphQLRequestBody(query: string, variables?: Record): string { - return JSON.stringify({ query, variables: variables ?? null }); -} - /** * Builds the ENSNode Omnigraph MCP server and registers its tools. * * Exported so tests can drive the server over an in-memory transport without the HTTP layer. */ export function createOmnigraphMcpServer(): McpServer { - const server = new McpServer({ - name: "ensnode-omnigraph", - version: packageJson.version, - }); + const server = new McpServer( + { + name: "ensnode-omnigraph", + version: packageJson.version, + }, + { + instructions: OMNIGRAPH_MCP_INSTRUCTIONS, + }, + ); + + server.registerResource( + "omnigraph-schema-condensed", + "omnigraph://schema/condensed", + { + title: "Omnigraph condensed schema", + description: "Core Omnigraph entry points and types for query authoring.", + mimeType: "text/markdown", + }, + async (uri) => ({ + contents: [ + { uri: uri.href, mimeType: "text/markdown", text: buildCondensedSchemaReference() }, + ], + }), + ); + + server.registerResource( + "omnigraph-examples-index", + "omnigraph://examples/index", + { + title: "Omnigraph example query index", + description: + "Vetted example query ids; read omnigraph://examples/{exampleId} for the payload.", + mimeType: "application/json", + }, + async (uri) => ({ + contents: [ + { uri: uri.href, mimeType: "application/json", text: buildOmnigraphExamplesIndex() }, + ], + }), + ); + + server.registerResource( + "omnigraph-example", + new ResourceTemplate("omnigraph://examples/{exampleId}", { + list: undefined, + complete: { + exampleId: async () => listOmnigraphExampleIds(), + }, + }), + { + title: "Omnigraph example query", + description: "A vetted GraphQL query and namespace-aware default variables.", + mimeType: "application/json", + }, + async (uri, { exampleId }) => { + const id = String(exampleId); + const { query, variables } = resolveOmnigraphExample(id); + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify({ id, query, variables }, null, 2), + }, + ], + }; + }, + ); + + server.registerTool( + "omnigraph_schema", + { + title: "Look up ENS Omnigraph schema", + description: + "Discover Omnigraph types and fields from the bundled schema (no network). Omit arguments for " + + "root query fields and type names; pass `type` for a type or Type.field; pass `search` to find matches.", + inputSchema: OmnigraphSchemaInputSchema, + }, + async ({ type, search }) => { + if (search && type) { + throw new Error("Provide either `type` or `search`, not both."); + } + const result = lookupOmnigraphSchema({ type, search }); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); server.registerTool( "omnigraph_query", { title: "Run an ENS Omnigraph GraphQL query", description: - "Execute a read-only GraphQL query against this ENSNode instance's ENS Omnigraph API - the " + - "unified ENSv1 + ENSv2 data model. Returns the raw GraphQL JSON response (`{ data, errors }`). " + - "Use the `omnigraph` agent skill and the GraphiQL playground at `/api/omnigraph` to discover " + - "the schema and author queries.", + "Execute a read-only GraphQL query against this ENSNode instance's ENS Omnigraph API. Returns " + + "`{ data, errors }` JSON (with optional `hints` on common validation mistakes). Prefer `exampleId` " + + "from omnigraph://examples/index; use omnigraph_schema or omnigraph://schema/condensed before writing custom queries.", inputSchema: OmnigraphQueryInputSchema, }, - async ({ query, variables }) => { - // Defer importing yoga to runtime (matching @/handlers/api/omnigraph/omnigraph-api) so the - // module's Namechain datasource requirement is only triggered at request time. - const { yoga } = await import("@/omnigraph-api/yoga"); - - // Execute against the in-process Yoga instance rather than looping back over HTTP. This reuses - // the Pothos schema, per-request context creation, and Yoga's error masking. - // - // NOTE: this bypasses the indexing-status middleware on the `/api/omnigraph` HTTP route, so - // queries run against whatever the index currently holds. `canAccelerate` only affects the - // Resolution API, so `false` is correct for generic Omnigraph queries. - const response = await yoga.fetch( - new Request("http://ensapi.internal/api/omnigraph", { - method: "POST", - headers: { "content-type": "application/json", accept: "application/json" }, - body: buildGraphQLRequestBody(query, variables), - }), - { canAccelerate: false }, - ); - + async ({ query, exampleId, variables }) => { + const resolved = exampleId + ? resolveOmnigraphExample(exampleId, variables) + : { query: query ?? "", variables }; return { - content: [{ type: "text", text: await response.text() }], + content: [ + { type: "text", text: await executeOmnigraphQuery(resolved.query, resolved.variables) }, + ], }; }, ); + server.registerPrompt( + "account-profile", + { + title: "Account primary name and profile", + description: + "Resolve an address's Ethereum primary name plus avatar, description, and socials, with ENSv1/v2 domain counts.", + argsSchema: { + address: z.string().describe("EVM address (0x…)."), + }, + }, + ({ address }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `Look up ENS data for address ${address}.`, + "Call omnigraph_query with exampleId hello-world and variables { address }.", + "Summarize: primary name, profile (avatar, description, socials), and ENSv1 vs ENSv2 domain counts.", + ].join("\n"), + }, + }, + ], + }), + ); + + server.registerPrompt( + "account-domains-by-version", + { + title: "Account ENSv1 vs ENSv2 domain counts", + description: "Count domains owned by an address, split by ENS version.", + argsSchema: { + address: z.string().describe("EVM address (0x…)."), + }, + }, + ({ address }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `How many ENSv1 vs ENSv2 domains does ${address} own?`, + "Call omnigraph_query with exampleId account-migrated-names and variables { address }.", + ].join("\n"), + }, + }, + ], + }), + ); + + server.registerPrompt( + "domain-profile", + { + title: "Domain profile", + description: "Resolve avatar, description, addresses, and socials for an ENS name.", + argsSchema: { + name: z.string().describe("ENS name (e.g. vitalik.eth)."), + }, + }, + ({ name }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `What is the ENS profile for ${name}?`, + "Call omnigraph_query with exampleId domain-profile and variables { name }.", + ].join("\n"), + }, + }, + ], + }), + ); + return server; } -/** - * The single, stateless MCP server + transport for this ENSApi process. - * - * Stateless mode (no `sessionIdGenerator`) lets one server and one transport be shared across all - * requests, which is appropriate for read-only query tools. - */ -const mcpServer = createOmnigraphMcpServer(); -const transport = new StreamableHTTPTransport(); +type McpSession = { + server: McpServer; + transport: StreamableHTTPTransport; +}; -/** In-flight `connect()` shared across concurrent cold-start requests. */ -let mcpConnectPromise: Promise | undefined; +type McpRequestContext = Parameters[0]; -function ensureMcpConnected(): Promise { - if (mcpServer.isConnected()) { - return Promise.resolve(); - } +/** Active MCP sessions keyed by `mcp-session-id` (one server + transport pair per client). */ +const sessions = new Map(); - mcpConnectPromise ??= mcpServer.connect(transport).catch((error) => { - mcpConnectPromise = undefined; - throw error; - }); +function getSessionId(ctx: { + req: { header: (name: string) => string | undefined }; +}): string | undefined { + const sessionId = ctx.req.header("mcp-session-id"); + return sessionId && sessionId.length > 0 ? sessionId : undefined; +} - return mcpConnectPromise; +function invalidSessionResponse(c: { json: (data: unknown, status: number) => Response }) { + return c.json( + { + jsonrpc: "2.0", + error: { code: -32_000, message: "Invalid or missing session ID" }, + id: null, + }, + 400, + ); +} + +async function closeSession(sessionId: string): Promise { + const session = sessions.get(sessionId); + if (!session) return; + + sessions.delete(sessionId); + await session.transport.close(); + await session.server.close(); } -const app = new Hono(); +const app = new Hono(); app.all("/", async (c) => { - await ensureMcpConnected(); + const sessionId = getSessionId(c); + const mcpContext = c as unknown as McpRequestContext; + + if (c.req.method === "GET") { + const session = sessionId ? sessions.get(sessionId) : undefined; + if (!session) return invalidSessionResponse(c); + const response = await session.transport.handleRequest(mcpContext); + return response ?? c.body(null, 202); + } + + if (c.req.method === "DELETE") { + if (!sessionId) return invalidSessionResponse(c); + const session = sessions.get(sessionId); + if (!session) return invalidSessionResponse(c); + + const response = await session.transport.handleRequest(mcpContext); + await closeSession(sessionId); + return response ?? c.body(null, 202); + } - // `handleRequest` may return undefined for messages that have no HTTP body (e.g. notifications). - const response = await transport.handleRequest(c); - return response ?? c.body(null, 202); + if (c.req.method === "POST") { + if (sessionId) { + const session = sessions.get(sessionId); + if (!session) { + return c.json( + { + jsonrpc: "2.0", + error: { code: -32_000, message: "Session not found" }, + id: null, + }, + 404, + ); + } + const response = await session.transport.handleRequest(mcpContext); + return response ?? c.body(null, 202); + } + + const body = await c.req.json(); + if (!isInitializeRequest(body)) { + return c.json( + { + jsonrpc: "2.0", + error: { code: -32_000, message: "Bad Request: No valid session ID provided" }, + id: null, + }, + 400, + ); + } + + const server = createOmnigraphMcpServer(); + const session: McpSession = { + server, + transport: new StreamableHTTPTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, session); + }, + onsessionclosed: (id) => { + void closeSession(id); + }, + }), + }; + + await server.connect(session.transport); + const response = await session.transport.handleRequest(mcpContext, body); + return response ?? c.body(null, 202); + } + + return c.json( + { + jsonrpc: "2.0", + error: { code: -32_000, message: "Method not allowed." }, + id: null, + }, + 405, + ); }); export default app; diff --git a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts new file mode 100644 index 0000000000..5a5aa77a50 --- /dev/null +++ b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import { OMNIGRAPH_MCP_INSTRUCTIONS } from "./omnigraph-mcp-support"; + +describe("omnigraph MCP support", () => { + it("documents anti-patterns in server instructions", () => { + expect(OMNIGRAPH_MCP_INSTRUCTIONS).toContain("account(id:"); + expect(OMNIGRAPH_MCP_INSTRUCTIONS).toContain("exampleId account-migrated-names"); + }); +}); diff --git a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts new file mode 100644 index 0000000000..011e0a0058 --- /dev/null +++ b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts @@ -0,0 +1,130 @@ +import { + getGraphqlApiExampleQueryById, + listGraphqlApiExampleQueryIds, + resolveGraphqlApiExampleQuery, +} from "@ensnode/ensnode-sdk/internal"; + +import di from "@/di"; + +/** MCP resource URI for a given example query id. */ +export function omnigraphExampleUri(exampleId: string): string { + return `omnigraph://examples/${exampleId}`; +} + +const VALIDATION_HINTS: Array<{ match: RegExp; hint: string }> = [ + { + match: /Unknown argument "id" on field "Query\.account"/, + hint: "Use account(by: { address: $address }) — not account(id: ...).", + }, + { + match: /Cannot query field "primaryName" on type "Account"/, + hint: "Primary names live under account.resolve.primaryName(by: { chainName: ETHEREUM }).", + }, + { + match: /Cannot query field "items"/, + hint: "Relay connections use edges { node }, pageInfo, and totalCount — not items.", + }, + { + match: /Field "account" argument "by" .* is required/, + hint: "Pass account(by: { address: $address }) or account(by: { id: $address }).", + }, +]; + +function getMcpNamespace() { + try { + return di.context.namespace; + } catch { + return undefined; + } +} + +export function listOmnigraphExampleIds(): string[] { + return listGraphqlApiExampleQueryIds(); +} + +export function buildOmnigraphExamplesIndex(): string { + return JSON.stringify( + { + examples: listGraphqlApiExampleQueryIds().map((id) => { + const { title, description } = getGraphqlApiExampleQueryById(id); + return { id, uri: omnigraphExampleUri(id), title, description }; + }), + }, + null, + 2, + ); +} + +export function resolveOmnigraphExample( + exampleId: string, + variablesOverride?: Record, +): { query: string; variables: Record } { + return resolveGraphqlApiExampleQuery(exampleId, { + namespace: getMcpNamespace(), + variables: variablesOverride, + }); +} + +function appendValidationHints(responseText: string): string { + let parsed: { data?: unknown; errors?: Array<{ message: string }> }; + try { + parsed = JSON.parse(responseText) as { data?: unknown; errors?: Array<{ message: string }> }; + } catch { + return responseText; + } + + if (!parsed.errors?.length) return responseText; + + const hints = [ + ...new Set( + parsed.errors.flatMap((error) => + VALIDATION_HINTS.filter(({ match }) => match.test(error.message)).map(({ hint }) => hint), + ), + ), + ]; + if (hints.length === 0) return responseText; + + return JSON.stringify({ ...parsed, hints }, null, 2); +} + +/** Executes a read-only Omnigraph query against the in-process Yoga instance. */ +export async function executeOmnigraphQuery( + query: string, + variables?: Record, +): Promise { + const { yoga } = await import("@/omnigraph-api/yoga"); + const response = await yoga.fetch( + new Request("http://ensapi.internal/api/omnigraph", { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ query, variables: variables ?? null }), + }), + { canAccelerate: false }, + ); + return appendValidationHints(await response.text()); +} + +export const OMNIGRAPH_MCP_INSTRUCTIONS = [ + "ENS Omnigraph MCP — read-only GraphQL over indexed ENSv1 + ENSv2 state.", + "", + "Before writing custom GraphQL:", + "1. Read omnigraph://schema/condensed or call omnigraph_schema.", + "2. Prefer omnigraph_query with exampleId (vetted queries) over guessing field names.", + "3. Read omnigraph://examples/index for available exampleId values.", + "", + "Entry points:", + "- account(by: { address }) — address-owned domains, permissions, reverse resolution", + "- domain(by: { name }) — a single name", + "- domains(where: { … }, first: N) — search canonical domains", + "", + "Common patterns:", + "- Primary name: account.resolve.primaryName(by: { chainName: ETHEREUM })", + "- Profile: domain.resolve.profile or primaryName.resolve.profile", + "- ENSv1 vs ENSv2 counts: exampleId account-migrated-names", + "- Pagination: edges { node }, pageInfo { hasNextPage endCursor }, totalCount", + "", + "Anti-patterns (will fail validation):", + "- account(id: …), Account.primaryName, connection.items", + "", + "Interactive schema browser: /api/omnigraph (GraphiQL).", +].join("\n"); diff --git a/docs/ensnode.io/src/data/omnigraph-examples/config.test.ts b/docs/ensnode.io/src/data/omnigraph-examples/config.test.ts index a80a34f900..f3ea6e4be2 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/config.test.ts +++ b/docs/ensnode.io/src/data/omnigraph-examples/config.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; import { DOCS_OMNIGRAPH_NAMESPACE_CONFIG } from "@lib/examples/omnigraph/constants"; +import { getGraphqlApiExampleQueryById } from "@ensnode/ensnode-sdk/example-queries"; + import { getOmnigraphExamplePageHref, OMNIGRAPH_EXAMPLES_CONFIG, @@ -56,7 +58,7 @@ describe("OMNIGRAPH_EXAMPLES_CONFIG", () => { }); expect(OMNIGRAPH_EXAMPLES_SIDEBAR_ITEMS.slice(1)).toEqual( pageConfigs.map((config) => ({ - label: config.title, + label: getGraphqlApiExampleQueryById(config.id).title, link: getOmnigraphExamplePageHref(config), })), ); diff --git a/docs/ensnode.io/src/data/omnigraph-examples/config.ts b/docs/ensnode.io/src/data/omnigraph-examples/config.ts index 11fea63f76..4805091a7c 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/config.ts +++ b/docs/ensnode.io/src/data/omnigraph-examples/config.ts @@ -1,10 +1,9 @@ import type { DocsOmnigraphExampleNamespace } from "@lib/examples/omnigraph/constants"; import { ENSNamespaceIds } from "@ensnode/ensnode-sdk"; +import { getGraphqlApiExampleQueryById } from "@ensnode/ensnode-sdk/example-queries"; export type OmnigraphExampleConfig = { id: string; - title: string; - description: string; category: string; namespace: DocsOmnigraphExampleNamespace; hostSeparatePage: boolean; @@ -14,164 +13,120 @@ export type OmnigraphExampleConfig = { export const OMNIGRAPH_EXAMPLES_CONFIG: OmnigraphExampleConfig[] = [ { id: "hello-world", - title: "Hello World", - description: - "From a wallet address: Ethereum primary name and interpreted profile, plus ENSv1 and ENSv2 ownership counts.", category: "Introduction", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: false, }, { id: "domain-profile", - title: "Domain Profile", - description: "Load a domain's high-level profile (avatar, socials, addresses, and more).", category: "Resolution", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: true, }, { id: "domain-records", - title: "Domain Records", - description: "For given name resolve raw records like `addresses`, `texts`, `contenthash` etc.", category: "Resolution", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: true, }, { id: "domain-by-name", - title: "Domain By Name", - description: "Load a domain by interpreted name, including profile information.", category: "Resolution", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: true, }, { id: "find-domains", - title: "Find Domains", - description: "List domains matching a name prefix with ordering and registration metadata.", category: "Search", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: true, }, { id: "domain-subdomains", - title: "Domain Subdomains", - description: "Paginate direct child names under a parent domain.", category: "Resolution", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: true, }, { id: "domain-subdomains-recently-registered", - title: "Recently Registered Subdomains", - description: "List a parent domain's subdomains ordered by most recent registration first.", category: "Resolution", namespace: ENSNamespaceIds.SepoliaV2, hostSeparatePage: true, }, { id: "domain-events", - title: "Domain Events", - description: "Raw contract events associated with a domain's registry records.", category: "History", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: true, }, { id: "domains-by-address", - title: "Account Domains", - description: "Load domains owned by an address via the Omnigraph `account` root field.", category: "Accounts", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: true, }, { id: "account-primary-name", - title: "Account Primary Name", - description: "Load a primary name for an account on Ethereum, including profile information.", category: "Accounts", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: true, }, { id: "account-events", - title: "Account Events", - description: "Events touching an account across indexed ENS contracts.", category: "History", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: true, }, { id: "registry-domains", - title: "Registry Domains", - description: "Enumerate domains under a specific v2 ETH registry contract.", category: "Registry", namespace: ENSNamespaceIds.SepoliaV2, hostSeparatePage: true, }, { id: "permissions-by-contract", - title: "Permissions By Contract", - description: "Roles and users granted on resources for a registrar or registry contract.", category: "Permissions", namespace: ENSNamespaceIds.SepoliaV2, hostSeparatePage: true, }, { id: "permissions-by-user", - title: "Permissions By User", - description: "Resources and roles for an address in the permissions graph.", category: "Permissions", namespace: ENSNamespaceIds.SepoliaV2, hostSeparatePage: true, }, { id: "account-resolver-permissions", - title: "Account Resolver Permissions", - description: "Resolver contracts where an account has been granted resolver ACLs.", category: "Permissions", namespace: ENSNamespaceIds.SepoliaV2, hostSeparatePage: true, }, { id: "domain-resolver", - title: "Domain Resolver", - description: "Assigned resolver contract address and recent resolver events.", category: "Resolution", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: true, }, { id: "namegraph", - title: "Namegraph", - description: - "Walk a domain's registry, parent, subregistry, and direct subdomains (as in Core Concepts).", category: "Exploration", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: true, }, { id: "account-migrated-names", - title: "Account Migration Counts", - description: "Count an account's ENSv1 vs ENSv2 domains to gauge its migration progress.", category: "Migration", namespace: ENSNamespaceIds.SepoliaV2, hostSeparatePage: true, }, { id: "eth-by-version", - title: "ETH TLD By Version", - description: - "Load the .eth TLD across protocol versions: one Domain per version, discriminated by `__typename` (ENSv1Domain / ENSv2Domain).", category: "Migration", namespace: ENSNamespaceIds.SepoliaV2, hostSeparatePage: true, }, { id: "accelerate-resolve", - title: "Resolve primary name and records, and track protocol acceleration", - description: - "Resolve primary name and records, and track protocol acceleration with `trace` and `accelerate` arguments.", category: "Resolution", namespace: ENSNamespaceIds.Mainnet, hostSeparatePage: false, @@ -199,7 +154,7 @@ export function getOmnigraphExampleConfigById(id: string): OmnigraphExampleConfi export const OMNIGRAPH_EXAMPLES_SIDEBAR_ITEMS: { label: string; link: string }[] = [ { label: "Overview", link: OMNIGRAPH_EXAMPLES_INDEX_PATH }, ...OMNIGRAPH_EXAMPLES_CONFIG.filter((config) => config.hostSeparatePage).map((config) => ({ - label: config.title, + label: getGraphqlApiExampleQueryById(config.id).title, link: getOmnigraphExamplePageHref(config)!, })), ]; diff --git a/docs/ensnode.io/src/data/omnigraph-examples/examples.ts b/docs/ensnode.io/src/data/omnigraph-examples/examples.ts index 38046e13f6..384cef4af7 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/examples.ts +++ b/docs/ensnode.io/src/data/omnigraph-examples/examples.ts @@ -5,6 +5,7 @@ import { } from "@lib/examples/omnigraph/example-query"; import { getOmnigraphExamplePageHref, OMNIGRAPH_EXAMPLES_CONFIG } from "./config"; +import { getGraphqlApiExampleQueryById } from "@ensnode/ensnode-sdk/example-queries"; import exampleSnapshots from "./examples.json"; import responses from "./responses.json"; import type { SnapshotExample } from "./types"; @@ -33,8 +34,8 @@ export const omnigraphExamples: OmnigraphExampleQuery[] = OMNIGRAPH_EXAMPLES_CON const href = getOmnigraphExamplePageHref(config); return OmnigraphExampleQuerySchema.parse({ id: config.id, - title: config.title, - description: config.description, + title: getGraphqlApiExampleQueryById(config.id).title, + description: getGraphqlApiExampleQueryById(config.id).description, category: config.category, namespace: config.namespace, query: example.query.trim(), diff --git a/packages/enscli/src/commands/ensnode/omnigraph-schema.ts b/packages/enscli/src/commands/ensnode/omnigraph-schema.ts index cb4dd4f7d4..0e93bea7e7 100644 --- a/packages/enscli/src/commands/ensnode/omnigraph-schema.ts +++ b/packages/enscli/src/commands/ensnode/omnigraph-schema.ts @@ -1,25 +1,7 @@ -import schemaSDL from "enssdk/omnigraph/schema.graphql"; -import { - buildSchema, - type GraphQLArgument, - type GraphQLField, - type GraphQLInputField, - type GraphQLNamedType, - type GraphQLSchema, - isEnumType, - isInputObjectType, - isInterfaceType, - isObjectType, - isUnionType, -} from "graphql"; +import { getOmnigraphSchema, lookupOmnigraphSchema } from "@ensnode/ensnode-sdk/internal"; import { printResult } from "../../lib/output"; -/** Builds the Omnigraph schema from the SDL bundled with enssdk (no network, always matches the SDK). */ -function loadSchema(): GraphQLSchema { - return buildSchema(schemaSDL); -} - interface ArgInfo { name: string; type: string; @@ -33,98 +15,6 @@ interface FieldInfo { args?: ArgInfo[]; } -function argInfo(arg: GraphQLArgument): ArgInfo { - return { name: arg.name, type: arg.type.toString(), description: arg.description ?? null }; -} - -function fieldInfo(field: GraphQLField | GraphQLInputField): FieldInfo { - const args = "args" in field && field.args.length > 0 ? field.args.map(argInfo) : undefined; - return { - name: field.name, - type: field.type.toString(), - description: field.description ?? null, - ...(args ? { args } : {}), - }; -} - -/** Field/value listing for a single named type, abstracting over object/interface/input/enum/union. */ -function describeType(type: GraphQLNamedType) { - const base = { name: type.name, description: type.description ?? null }; - if (isObjectType(type) || isInterfaceType(type)) { - return { - ...base, - kind: isObjectType(type) ? "object" : "interface", - fields: Object.values(type.getFields()).map(fieldInfo), - }; - } - if (isInputObjectType(type)) { - return { ...base, kind: "input", fields: Object.values(type.getFields()).map(fieldInfo) }; - } - if (isEnumType(type)) { - return { - ...base, - kind: "enum", - values: type.getValues().map((value) => ({ - name: value.name, - description: value.description ?? null, - })), - }; - } - if (isUnionType(type)) { - return { ...base, kind: "union", types: type.getTypes().map((member) => member.name) }; - } - return { ...base, kind: "scalar" }; -} - -/** Root listing: query entrypoints plus the major (non-connection) types, abstracting Relay plumbing. */ -function describeRoot(schema: GraphQLSchema) { - const queryType = schema.getQueryType(); - const queryFields = queryType ? Object.values(queryType.getFields()).map(fieldInfo) : []; - const types = Object.values(schema.getTypeMap()) - .filter((type) => isObjectType(type) && !type.name.startsWith("__")) - .map((type) => type.name) - .filter( - (name) => - name !== queryType?.name && - !name.endsWith("Connection") && - !name.endsWith("ConnectionEdge") && - !name.endsWith("Edge") && - !name.endsWith("Payload"), - ) - .sort(); - return { query: queryFields, types }; -} - -function describeFieldPath(schema: GraphQLSchema, typeName: string, fieldName: string) { - const type = schema.getType(typeName); - if (!type || !(isObjectType(type) || isInterfaceType(type) || isInputObjectType(type))) { - throw new Error( - `Type "${typeName}" has no fields. Run "enscli ensnode omnigraph schema" to list types.`, - ); - } - const field = type.getFields()[fieldName]; - if (!field) { - throw new Error(`Type "${typeName}" has no field "${fieldName}".`); - } - return { parent: typeName, ...fieldInfo(field) }; -} - -function searchSchema(schema: GraphQLSchema, keyword: string) { - const query = keyword.toLowerCase(); - const types: string[] = []; - const fields: string[] = []; - for (const type of Object.values(schema.getTypeMap())) { - if (type.name.startsWith("__")) continue; - if (type.name.toLowerCase().includes(query)) types.push(type.name); - if (isObjectType(type) || isInterfaceType(type) || isInputObjectType(type)) { - for (const field of Object.values(type.getFields())) { - if (field.name.toLowerCase().includes(query)) fields.push(`${type.name}.${field.name}`); - } - } - } - return { types: types.sort(), fields: fields.sort() }; -} - function renderField(field: FieldInfo, indent: string): string { const args = field.args ? `(${field.args.map((a) => `${a.name}: ${a.type}`).join(", ")})` : ""; const description = field.description @@ -133,18 +23,25 @@ function renderField(field: FieldInfo, indent: string): string { return `${indent}${field.name}${args}: ${field.type}${description}`; } -function renderTypePretty(type: ReturnType): string { +function renderTypePretty(type: { + description: string | null; + kind: string; + name: string; + fields?: FieldInfo[]; + values?: Array<{ name: string }>; + types?: string[]; +}): string { const lines: string[] = []; if (type.description) lines.push(`# ${type.description.replace(/\s+/g, " ").trim()}`); lines.push(`${type.kind} ${type.name} {`); - if ("fields" in type) for (const field of type.fields) lines.push(renderField(field, " ")); - else if ("values" in type) for (const value of type.values) lines.push(` ${value.name}`); - else if ("types" in type) lines.push(` ${type.types.join(" | ")}`); + if (type.fields) for (const field of type.fields) lines.push(renderField(field, " ")); + else if (type.values) for (const value of type.values) lines.push(` ${value.name}`); + else if (type.types) lines.push(` ${type.types.join(" | ")}`); lines.push("}"); return lines.join("\n"); } -function renderRootPretty(root: ReturnType): string { +function renderRootPretty(root: { query: FieldInfo[]; types: string[] }): string { return [ "# Root query fields", ...root.query.map((field) => renderField(field, " ")), @@ -154,11 +51,11 @@ function renderRootPretty(root: ReturnType): string { ].join("\n"); } -function renderFieldPathPretty(field: ReturnType): string { +function renderFieldPathPretty(field: FieldInfo & { parent: string }): string { return `${field.parent}.${renderField(field, "")}`; } -function renderSearchPretty(result: ReturnType): string { +function renderSearchPretty(result: { types: string[]; fields: string[] }): string { return [ "# Matching types", ...result.types.map((name) => ` ${name}`), @@ -176,15 +73,18 @@ export function runOmnigraphSchema( args: Record, target: string | undefined, ): void { - const omnigraphSchema = loadSchema(); - if (typeof args.search === "string") { - printResult(searchSchema(omnigraphSchema, args.search), args, renderSearchPretty); + const result = lookupOmnigraphSchema({ search: args.search }) as { + types: string[]; + fields: string[]; + }; + printResult(result, args, renderSearchPretty); return; } if (!target) { - printResult(describeRoot(omnigraphSchema), args, renderRootPretty); + const result = lookupOmnigraphSchema({}) as { query: FieldInfo[]; types: string[] }; + printResult(result, args, renderRootPretty); return; } @@ -195,20 +95,30 @@ export function runOmnigraphSchema( `Invalid target "${target}". Expected "Type" or "Type.field" (e.g. Domain or Domain.canonical).`, ); } - const [typeName, fieldName] = segments; printResult( - describeFieldPath(omnigraphSchema, typeName, fieldName), + lookupOmnigraphSchema({ type: target }) as FieldInfo & { parent: string }, args, renderFieldPathPretty, ); return; } - const type = omnigraphSchema.getType(target); + const type = getOmnigraphSchema().getType(target); if (!type) { throw new Error( `Unknown type "${target}". Run "enscli ensnode omnigraph schema" to list types, or use --search.`, ); } - printResult(describeType(type), args, renderTypePretty); + printResult( + lookupOmnigraphSchema({ type: target }) as { + description: string | null; + kind: string; + name: string; + fields?: FieldInfo[]; + values?: Array<{ name: string }>; + types?: string[]; + }, + args, + renderTypePretty, + ); } diff --git a/packages/ensnode-sdk/package.json b/packages/ensnode-sdk/package.json index 98ff74b150..05a9885ddd 100644 --- a/packages/ensnode-sdk/package.json +++ b/packages/ensnode-sdk/package.json @@ -19,7 +19,8 @@ ], "exports": { ".": "./src/index.ts", - "./internal": "./src/internal.ts" + "./internal": "./src/internal.ts", + "./example-queries": "./src/omnigraph-api/example-queries.ts" }, "sideEffects": false, "publishConfig": { @@ -75,6 +76,7 @@ "caip": "catalog:", "date-fns": "catalog:", "enssdk": "workspace:*", + "graphql": "^16.11.0", "zod": "catalog:" } } diff --git a/packages/ensnode-sdk/src/internal.ts b/packages/ensnode-sdk/src/internal.ts index 7d553054ae..455b68f9a0 100644 --- a/packages/ensnode-sdk/src/internal.ts +++ b/packages/ensnode-sdk/src/internal.ts @@ -26,6 +26,7 @@ export * from "./ensnode/api/shared/errors/examples"; export * from "./ensnode/api/shared/errors/zod-schemas"; export * from "./ensnode/api/shared/pagination/zod-schemas"; export * from "./omnigraph-api/example-queries"; +export * from "./omnigraph-api/schema-reference"; export * from "./registrars/zod-schemas"; export * from "./rpc"; export * from "./shared/config/build-rpc-urls"; diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index 072c3e20a6..646a42c651 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -1,10 +1,13 @@ import { asInterpretedName, toNormalizedAddress } from "enssdk"; -import { DatasourceNames, ENSNamespaceIds } from "@ensnode/datasources"; +import { DatasourceNames, type ENSNamespaceId, ENSNamespaceIds } from "@ensnode/datasources"; import { accounts } from "@ensnode/integration-test-env/devnet"; import { getDatasourceContract } from "../shared/datasource-contract"; -import type { NamespaceSpecificValue } from "../shared/namespace-specific-value"; +import { + getNamespaceSpecificValue, + type NamespaceSpecificValue, +} from "../shared/namespace-specific-value"; const SEPOLIA_V2_V2_ETH_REGISTRY = getDatasourceContract( ENSNamespaceIds.SepoliaV2, @@ -58,6 +61,8 @@ const SEPOLIA_V2_RESOLVER_WITH_RECORDS = { export type GraphqlApiExampleQuery = { id: string; + title: string; + description: string; query: string; variables: NamespaceSpecificValue>; }; @@ -70,12 +75,35 @@ export function getGraphqlApiExampleQueryById(id: string): GraphqlApiExampleQuer return found; } +export function listGraphqlApiExampleQueryIds(): string[] { + return GRAPHQL_API_EXAMPLE_QUERIES.map((example) => example.id); +} + +export function resolveGraphqlApiExampleQuery( + id: string, + options?: { namespace?: ENSNamespaceId; variables?: Record }, +): { query: string; variables: Record } { + const example = getGraphqlApiExampleQueryById(id); + const { namespace, variables: variablesOverride } = options ?? {}; + return { + query: example.query, + variables: + variablesOverride ?? + (namespace + ? getNamespaceSpecificValue(namespace, example.variables) + : example.variables.default), + }; +} + export const GRAPHQL_API_EXAMPLE_QUERIES: GraphqlApiExampleQuery[] = [ //////////////// // Hello World //////////////// { id: "hello-world", + title: "Hello World", + description: + "From a wallet address: Ethereum primary name and interpreted profile, plus ENSv1 and ENSv2 ownership counts.", query: `query HelloWorld($address: Address!) { # Lookup an Account by address. account(by: { address: $address }) { @@ -121,6 +149,8 @@ export const GRAPHQL_API_EXAMPLE_QUERIES: GraphqlApiExampleQuery[] = [ ///////////////// { id: "find-domains", + title: "Find Domains", + description: "List domains matching a name prefix with ordering and registration metadata.", query: ` query FindDomains( $name: DomainsNameFilter! @@ -158,6 +188,8 @@ query FindDomains( { id: "domain-by-name", + title: "Domain By Name", + description: "Load a domain by interpreted name, including profile information.", query: ` query DomainByName($name: InterpretedName!) { domain(by: { name: $name }) { @@ -185,6 +217,8 @@ query DomainByName($name: InterpretedName!) { //////////////////////////////// { id: "domain-by-name-type-condition", + title: "Domain By Name Type Condition", + description: "Load a domain by interpreted name with type condition.", query: ` query DomainByName($name: InterpretedName!) { domain(by: {name: $name}) { @@ -211,6 +245,8 @@ query DomainByName($name: InterpretedName!) { /////////////////////// { id: "domain-registration", + title: "Domain Registration", + description: "Load a domain registration details.", query: ` query DomainRegistration($name: InterpretedName!) { domain(by: { name: $name }) { @@ -257,6 +293,8 @@ query DomainRegistration($name: InterpretedName!) { //////////////////// { id: "domain-records", + title: "Domain Records", + description: "For given name resolve raw records like `addresses`, `texts`, `contenthash` etc.", query: ` query DomainRecords($name: InterpretedName!) { domain(by: {name: $name}) { @@ -293,6 +331,8 @@ query DomainRecords($name: InterpretedName!) { { id: "domain-profile", + title: "Domain Profile", + description: "Load a domain's high-level profile (avatar, socials, addresses, and more).", query: ` query DomainProfile($name: InterpretedName!) { domain(by: {name: $name}) { @@ -337,6 +377,8 @@ query DomainProfile($name: InterpretedName!) { ////////////////////// { id: "domain-subdomains", + title: "Domain Subdomains", + description: "Paginate direct child names under a parent domain.", query: ` query DomainSubdomains($name: InterpretedName!) { domain(by: {name: $name}) { @@ -365,6 +407,8 @@ query DomainSubdomains($name: InterpretedName!) { //////////////////////////////////// { id: "domain-subdomains-recently-registered", + title: "Recently Registered Subdomains", + description: "List a parent domain's subdomains ordered by most recent registration first.", query: ` query RecentlyRegisteredSubdomains($name: InterpretedName!) { domain(by: {name: $name}) { @@ -386,6 +430,8 @@ query RecentlyRegisteredSubdomains($name: InterpretedName!) { //////////////////////// { id: "subdomains-pagination", + title: "Subdomains Pagination", + description: "Paginate through all subdomains of a parent domain.", query: ` query SubdomainsPagination($first: Int!, $after: String) { domain(by: { name: "eth" }) { @@ -412,6 +458,8 @@ query SubdomainsPagination($first: Int!, $after: String) { ///////////////// { id: "domain-events", + title: "Domain Events", + description: "Raw contract events associated with a domain's registry records.", query: ` query DomainEvents($name: InterpretedName!) { domain(by: {name: $name}) { @@ -441,6 +489,8 @@ query DomainEvents($name: InterpretedName!) { //////////////////// { id: "domains-by-address", + title: "Account Domains", + description: "Load domains owned by an address via the Omnigraph `account` root field.", query: ` query AccountDomains( $address: Address! @@ -468,6 +518,8 @@ query AccountDomains( ///////////////////////// { id: "account-primary-name", + title: "Account Primary Name", + description: "Load a primary name for an account on Ethereum, including profile information.", query: ` query AccountPrimaryName($address: Address!) { account(by: { address: $address }) { @@ -500,6 +552,8 @@ query AccountPrimaryName($address: Address!) { //////////////////// { id: "account-events", + title: "Account Events", + description: "Events touching an account across indexed ENS contracts.", query: ` query AccountEvents( $address: Address! @@ -520,6 +574,8 @@ query AccountEvents( ///////////////////// { id: "registry-domains", + title: "Registry Domains", + description: "Enumerate domains under a specific v2 ETH registry contract.", query: ` query RegistryDomains( $registry: AccountIdInput! @@ -547,6 +603,8 @@ query RegistryDomains( //////////////////////////// { id: "permissions-by-contract", + title: "Permissions By Contract", + description: "Roles and users granted on resources for a registrar or registry contract.", query: ` query PermissionsByContract( $contract: AccountIdInput! @@ -584,6 +642,8 @@ query PermissionsByContract( //////////////////////// { id: "permissions-by-user", + title: "Permissions By User", + description: "Resources and roles for an address in the permissions graph.", query: ` query PermissionsByUser($address: Address!) { account(by: { address: $address }) { @@ -608,6 +668,8 @@ query PermissionsByUser($address: Address!) { ////////////////////////////////// { id: "account-resolver-permissions", + title: "Account Resolver Permissions", + description: "Resolver contracts where an account has been granted resolver ACLs.", query: ` query AccountResolverPermissions($address: Address!) { account(by: { address: $address }) { @@ -635,6 +697,8 @@ query AccountResolverPermissions($address: Address!) { ////////////////////////////// { id: "domain-resolver", + title: "Domain Resolver", + description: "Assigned resolver contract address and recent resolver events.", query: ` query DomainResolver($name: InterpretedName!) { domain(by: { name: $name }) { @@ -662,6 +726,8 @@ query DomainResolver($name: InterpretedName!) { //////////////////////// { id: "resolver-by-address", + title: "Resolver By Address", + description: "Load a resolver by its contract address.", query: ` query ResolverByAddress($contract: AccountIdInput!) { resolver(by: { contract: $contract }) { @@ -695,6 +761,9 @@ query ResolverByAddress($contract: AccountIdInput!) { ////////////// { id: "namegraph", + title: "Namegraph", + description: + "Walk a domain's registry, parent, subregistry, and direct subdomains (as in Core Concepts).", query: ` query Namegraph { domain(by: { name: "eth" }) { @@ -720,6 +789,8 @@ query Namegraph { ///////////////////////////// { id: "account-migrated-names", + title: "Account Migration Counts", + description: "Count an account's ENSv1 vs ENSv2 domains to gauge its migration progress.", query: ` query AccountMigratedNames($address: Address!) { account(by: { address: $address }) { @@ -734,6 +805,9 @@ query AccountMigratedNames($address: Address!) { }, { id: "eth-by-version", + title: "ETH TLD By Version", + description: + "Load the .eth TLD across protocol versions: one Domain per version, discriminated by `__typename` (ENSv1Domain / ENSv2Domain).", query: ` query GetEthDomains { domains(where: { name: { eq: "eth" } }) { @@ -749,6 +823,9 @@ query GetEthDomains { }, { id: "accelerate-resolve", + title: "Resolve primary name and records, and track protocol acceleration", + description: + "Resolve primary name and records, and track protocol acceleration with `trace` and `accelerate` arguments.", query: ` query AccelerateResolve($address: Address!) { account(by: { address: $address }) { diff --git a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts new file mode 100644 index 0000000000..073bafcbd0 --- /dev/null +++ b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { buildCondensedSchemaReference, lookupOmnigraphSchema } from "./schema-reference"; + +describe("omnigraph schema reference", () => { + it("builds a condensed schema reference with core entry points", () => { + const reference = buildCondensedSchemaReference(); + expect(reference).toContain("account(by: AccountByInput!)"); + expect(reference).toContain("#### Account"); + expect(reference).toContain("resolve(accelerate: Boolean): ReverseResolve!"); + }); + + it("searches schema fields by keyword", () => { + const result = lookupOmnigraphSchema({ search: "primaryName" }) as { fields: string[] }; + expect(result.fields).toContain("ReverseResolve.primaryName"); + }); + + it("describes a type by name", () => { + const result = lookupOmnigraphSchema({ type: "Account" }) as { + name: string; + fields: unknown[]; + }; + expect(result.name).toBe("Account"); + expect(result.fields.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts new file mode 100644 index 0000000000..b021ff840f --- /dev/null +++ b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts @@ -0,0 +1,237 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; + +import { + buildSchema, + type GraphQLArgument, + type GraphQLField, + type GraphQLInputField, + type GraphQLNamedType, + type GraphQLSchema, + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, + isUnionType, +} from "graphql"; + +const require = createRequire(import.meta.url); + +/** Types rendered with full field listings in the condensed schema reference. */ +export const OMNIGRAPH_CORE_TYPES = [ + "Domain", + "DomainCanonical", + "Account", + "Resolver", + "DomainResolver", + "Registry", + "Permissions", + "ReverseResolve", + "ForwardResolve", + "ResolvedRecords", + "PrimaryNameRecord", +] as const; + +let cachedSchema: GraphQLSchema | undefined; + +/** Loads the Omnigraph schema from the SDL bundled with enssdk (no network). */ +export function getOmnigraphSchema(): GraphQLSchema { + cachedSchema ??= buildSchema( + readFileSync(require.resolve("enssdk/omnigraph/schema.graphql"), "utf8"), + ); + return cachedSchema; +} + +interface ArgInfo { + name: string; + type: string; + description: string | null; +} + +interface FieldInfo { + name: string; + type: string; + description: string | null; + args?: ArgInfo[]; +} + +function oneLine(description: string | null | undefined): string { + return description ? description.replace(/\s+/g, " ").trim() : ""; +} + +function argInfo(arg: GraphQLArgument): ArgInfo { + return { name: arg.name, type: arg.type.toString(), description: arg.description ?? null }; +} + +function fieldInfo(field: GraphQLField | GraphQLInputField): FieldInfo { + const args = "args" in field && field.args.length > 0 ? field.args.map(argInfo) : undefined; + return { + name: field.name, + type: field.type.toString(), + description: field.description ?? null, + ...(args ? { args } : {}), + }; +} + +function renderFieldLine(field: GraphQLField): string { + const args = + field.args.length > 0 ? `(${field.args.map((a) => `${a.name}: ${a.type}`).join(", ")})` : ""; + const description = oneLine(field.description); + return `- ${field.name}${args}: ${field.type}${description ? ` — ${description}` : ""}`; +} + +function renderTypeMarkdown(type: GraphQLNamedType): string { + if (!isObjectType(type) && !isInterfaceType(type)) return ""; + const lines = [`#### ${type.name}`]; + if (type.description) lines.push(`_${oneLine(type.description)}_`); + for (const field of Object.values(type.getFields())) lines.push(renderFieldLine(field)); + return lines.join("\n"); +} + +function describeType(type: GraphQLNamedType) { + const base = { name: type.name, description: type.description ?? null }; + if (isObjectType(type) || isInterfaceType(type)) { + return { + ...base, + kind: isObjectType(type) ? "object" : "interface", + fields: Object.values(type.getFields()).map(fieldInfo), + }; + } + if (isInputObjectType(type)) { + return { ...base, kind: "input", fields: Object.values(type.getFields()).map(fieldInfo) }; + } + if (isEnumType(type)) { + return { + ...base, + kind: "enum", + values: type.getValues().map((value) => ({ + name: value.name, + description: value.description ?? null, + })), + }; + } + if (isUnionType(type)) { + return { ...base, kind: "union", types: type.getTypes().map((member) => member.name) }; + } + return { ...base, kind: "scalar" }; +} + +function describeRoot(schema: GraphQLSchema) { + const queryType = schema.getQueryType(); + const queryFields = queryType ? Object.values(queryType.getFields()).map(fieldInfo) : []; + const types = listMajorTypeNames(schema); + return { query: queryFields, types }; +} + +function listMajorTypeNames(schema: GraphQLSchema): string[] { + const queryType = schema.getQueryType(); + return Object.values(schema.getTypeMap()) + .filter((type) => isObjectType(type) && !type.name.startsWith("__")) + .map((type) => type.name) + .filter( + (name) => + name !== queryType?.name && + !name.endsWith("Connection") && + !name.endsWith("ConnectionEdge") && + !name.endsWith("Edge") && + !name.endsWith("Payload"), + ) + .sort(); +} + +function describeFieldPath(schema: GraphQLSchema, typeName: string, fieldName: string) { + const type = schema.getType(typeName); + if (!type || !(isObjectType(type) || isInterfaceType(type) || isInputObjectType(type))) { + throw new Error(`Type "${typeName}" has no fields.`); + } + const field = type.getFields()[fieldName]; + if (!field) { + throw new Error(`Type "${typeName}" has no field "${fieldName}".`); + } + return { parent: typeName, ...fieldInfo(field) }; +} + +function searchSchema(schema: GraphQLSchema, keyword: string) { + const query = keyword.toLowerCase(); + const types: string[] = []; + const fields: string[] = []; + for (const type of Object.values(schema.getTypeMap())) { + if (type.name.startsWith("__")) continue; + if (type.name.toLowerCase().includes(query)) types.push(type.name); + if (isObjectType(type) || isInterfaceType(type) || isInputObjectType(type)) { + for (const field of Object.values(type.getFields())) { + if (field.name.toLowerCase().includes(query)) fields.push(`${type.name}.${field.name}`); + } + } + } + return { search: keyword, types: types.sort(), fields: fields.sort() }; +} + +export type OmnigraphSchemaLookupInput = { + type?: string; + search?: string; +}; + +/** Looks up Omnigraph schema fields locally. Returns JSON-serializable data. */ +export function lookupOmnigraphSchema(input: OmnigraphSchemaLookupInput): unknown { + const schema = getOmnigraphSchema(); + + if (input.search) { + return searchSchema(schema, input.search); + } + + if (input.type?.includes(".")) { + const [typeName, fieldName] = input.type.split(".", 2); + if (!typeName || !fieldName) { + throw new Error('Invalid `type` — expected "Type.field" (e.g. Account.resolve).'); + } + return describeFieldPath(schema, typeName, fieldName); + } + + if (input.type) { + const type = schema.getType(input.type); + if (!type) { + throw new Error(`Unknown type "${input.type}".`); + } + return describeType(type); + } + + return describeRoot(schema); +} + +/** Condensed Omnigraph schema reference for agent skills and MCP resources. */ +export function buildCondensedSchemaReference( + schema: GraphQLSchema = getOmnigraphSchema(), +): string { + const queryType = schema.getQueryType(); + const sections: string[] = []; + + if (queryType) { + const queryFields = Object.values(queryType.getFields()).map(renderFieldLine); + sections.push(["### Query (entry points)", ...queryFields].join("\n")); + } + + const coreSections: string[] = []; + for (const name of OMNIGRAPH_CORE_TYPES) { + const type = schema.getType(name); + if (type) { + const rendered = renderTypeMarkdown(type); + if (rendered) coreSections.push(rendered); + } + } + sections.push(["### Core types", coreSections.join("\n\n")].join("\n\n")); + + const otherTypes = listMajorTypeNames(schema).filter( + (name) => !OMNIGRAPH_CORE_TYPES.includes(name as (typeof OMNIGRAPH_CORE_TYPES)[number]), + ); + sections.push( + [ + "### Other types", + "Run `npx enscli ensnode omnigraph schema ` for fields of:", + "", + otherTypes.map((name) => `\`${name}\``).join(", "), + ].join("\n"), + ); + + return sections.join("\n\n"); +} diff --git a/packages/ensskills/scripts/generate.ts b/packages/ensskills/scripts/generate.ts index 076b480d50..0bdce2f209 100644 --- a/packages/ensskills/scripts/generate.ts +++ b/packages/ensskills/scripts/generate.ts @@ -14,18 +14,11 @@ import { readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { - buildSchema, - type GraphQLField, - type GraphQLNamedType, - type GraphQLSchema, - isInterfaceType, - isObjectType, -} from "graphql"; import prettier from "prettier"; +import { buildCondensedSchemaReference } from "@ensnode/ensnode-sdk/internal"; + const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); -const SDL_PATH = resolve(SCRIPT_DIR, "../../enssdk/src/omnigraph/generated/schema.graphql"); const SKILL_PATH = resolve(SCRIPT_DIR, "../skills/omnigraph/SKILL.md"); const EXAMPLE_QUERIES_PATH = resolve( SCRIPT_DIR, @@ -36,6 +29,8 @@ const ENSCLI_EXAMPLE_COMMANDS_PATH = resolve(SCRIPT_DIR, "../../enscli/src/examp interface ExampleQuery { id: string; + title: string; + description: string; query: string; variables: { default: Record }; } @@ -47,84 +42,6 @@ interface EnscliExample { group: string; } -/** Types rendered with full field listings; everything else is listed by name only. */ -const CORE_TYPES = [ - "Domain", - "DomainCanonical", - "Account", - "Resolver", - "DomainResolver", - "Registry", - "Permissions", - "ReverseResolve", - "ForwardResolve", - "ResolvedRecords", - "PrimaryNameRecord", -]; - -function oneLine(description: string | null | undefined): string { - return description ? description.replace(/\s+/g, " ").trim() : ""; -} - -function renderFieldLine(field: GraphQLField): string { - const args = - field.args.length > 0 ? `(${field.args.map((a) => `${a.name}: ${a.type}`).join(", ")})` : ""; - const description = oneLine(field.description); - return `- ${field.name}${args}: ${field.type}${description ? ` — ${description}` : ""}`; -} - -function renderType(type: GraphQLNamedType): string { - if (!isObjectType(type) && !isInterfaceType(type)) return ""; - const lines = [`#### ${type.name}`]; - if (type.description) lines.push(`_${oneLine(type.description)}_`); - for (const field of Object.values(type.getFields())) lines.push(renderFieldLine(field)); - return lines.join("\n"); -} - -function buildSchemaReference(schema: GraphQLSchema): string { - const queryType = schema.getQueryType(); - const sections: string[] = []; - - if (queryType) { - const queryFields = Object.values(queryType.getFields()).map(renderFieldLine); - sections.push(["### Query (entry points)", ...queryFields].join("\n")); - } - - const coreSections: string[] = []; - for (const name of CORE_TYPES) { - const type = schema.getType(name); - if (type) { - const rendered = renderType(type); - if (rendered) coreSections.push(rendered); - } - } - sections.push(["### Core types", coreSections.join("\n\n")].join("\n\n")); - - const otherTypes = Object.values(schema.getTypeMap()) - .filter((type) => isObjectType(type) && !type.name.startsWith("__")) - .map((type) => type.name) - .filter( - (name) => - name !== queryType?.name && - !CORE_TYPES.includes(name) && - !name.endsWith("Connection") && - !name.endsWith("ConnectionEdge") && - !name.endsWith("Edge") && - !name.endsWith("Payload"), - ) - .sort(); - sections.push( - [ - "### Other types", - "Run `npx enscli ensnode omnigraph schema ` for fields of:", - "", - otherTypes.map((name) => `\`${name}\``).join(", "), - ].join("\n"), - ); - - return sections.join("\n\n"); -} - async function buildExamples(): Promise { // load dynamically to avoid tsconfig root error const { GRAPHQL_API_EXAMPLE_QUERIES } = (await import(EXAMPLE_QUERIES_PATH)) as { @@ -133,10 +50,17 @@ async function buildExamples(): Promise { // Skip "hello-world": it's the playground welcome blurb, not a reusable query pattern. return GRAPHQL_API_EXAMPLE_QUERIES.filter((example) => example.id !== "hello-world") .map((example) => { + if (!example.title?.trim()) { + throw new Error(`GraphQL API example "${example.id}" is missing a title`); + } + if (!example.description?.trim()) { + throw new Error(`GraphQL API example "${example.id}" is missing a description`); + } const query = example.query.trim(); const variables = JSON.stringify(example.variables.default, null, 2); return [ - `### ${example.id}`, + `### ${example.title} (${example.id})`, + example.description, "```graphql", query, "```", @@ -200,9 +124,8 @@ async function writeFormatted(path: string, content: string): Promise { } async function main(): Promise { - const schema = buildSchema(readFileSync(SDL_PATH, "utf8")); let content = readFileSync(SKILL_PATH, "utf8"); - content = replaceRegion(content, "SCHEMA", buildSchemaReference(schema), SKILL_PATH); + content = replaceRegion(content, "SCHEMA", buildCondensedSchemaReference(), SKILL_PATH); content = replaceRegion(content, "EXAMPLES", await buildExamples(), SKILL_PATH); await writeFormatted(SKILL_PATH, content); console.log(`Updated ${SKILL_PATH}`); diff --git a/packages/ensskills/skills/omnigraph/SKILL.md b/packages/ensskills/skills/omnigraph/SKILL.md index 881a926893..ad085e75d9 100644 --- a/packages/ensskills/skills/omnigraph/SKILL.md +++ b/packages/ensskills/skills/omnigraph/SKILL.md @@ -218,7 +218,9 @@ These are vetted, copy-pasteable patterns. Adapt the selection set to your needs -### find-domains +### Find Domains (find-domains) + +List domains matching a name prefix with ordering and registration metadata. ```graphql query FindDomains($name: DomainsNameFilter!, $order: DomainsOrderInput) { @@ -264,7 +266,9 @@ Variables: } ``` -### domain-by-name +### Domain By Name (domain-by-name) + +Load a domain by interpreted name, including profile information. ```graphql query DomainByName($name: InterpretedName!) { @@ -297,7 +301,9 @@ Variables: } ``` -### domain-by-name-type-condition +### Domain By Name Type Condition (domain-by-name-type-condition) + +Load a domain by interpreted name with type condition. ```graphql query DomainByName($name: InterpretedName!) { @@ -344,7 +350,9 @@ Variables: } ``` -### domain-registration +### Domain Registration (domain-registration) + +Load a domain registration details. ```graphql query DomainRegistration($name: InterpretedName!) { @@ -410,7 +418,9 @@ Variables: } ``` -### domain-records +### Domain Records (domain-records) + +For given name resolve raw records like `addresses`, `texts`, `contenthash` etc. ```graphql query DomainRecords($name: InterpretedName!) { @@ -445,7 +455,9 @@ Variables: } ``` -### domain-profile +### Domain Profile (domain-profile) + +Load a domain's high-level profile (avatar, socials, addresses, and more). ```graphql query DomainProfile($name: InterpretedName!) { @@ -493,7 +505,9 @@ Variables: } ``` -### domain-subdomains +### Domain Subdomains (domain-subdomains) + +Paginate direct child names under a parent domain. ```graphql query DomainSubdomains($name: InterpretedName!) { @@ -528,7 +542,9 @@ Variables: } ``` -### domain-subdomains-recently-registered +### Recently Registered Subdomains (domain-subdomains-recently-registered) + +List a parent domain's subdomains ordered by most recent registration first. ```graphql query RecentlyRegisteredSubdomains($name: InterpretedName!) { @@ -563,7 +579,9 @@ Variables: } ``` -### subdomains-pagination +### Subdomains Pagination (subdomains-pagination) + +Paginate through all subdomains of a parent domain. ```graphql query SubdomainsPagination($first: Int!, $after: String) { @@ -605,7 +623,9 @@ Variables: } ``` -### domain-events +### Domain Events (domain-events) + +Raw contract events associated with a domain's registry records. ```graphql query DomainEvents($name: InterpretedName!) { @@ -635,7 +655,9 @@ Variables: } ``` -### domains-by-address +### Account Domains (domains-by-address) + +Load domains owned by an address via the Omnigraph `account` root field. ```graphql query AccountDomains($address: Address!) { @@ -667,7 +689,9 @@ Variables: } ``` -### account-primary-name +### Account Primary Name (account-primary-name) + +Load a primary name for an account on Ethereum, including profile information. ```graphql query AccountPrimaryName($address: Address!) { @@ -703,7 +727,9 @@ Variables: } ``` -### account-events +### Account Events (account-events) + +Events touching an account across indexed ENS contracts. ```graphql query AccountEvents($address: Address!) { @@ -730,7 +756,9 @@ Variables: } ``` -### registry-domains +### Registry Domains (registry-domains) + +Enumerate domains under a specific v2 ETH registry contract. ```graphql query RegistryDomains($registry: AccountIdInput!) { @@ -765,7 +793,9 @@ Variables: } ``` -### permissions-by-contract +### Permissions By Contract (permissions-by-contract) + +Roles and users granted on resources for a registrar or registry contract. ```graphql query PermissionsByContract($contract: AccountIdInput!) { @@ -813,7 +843,9 @@ Variables: } ``` -### permissions-by-user +### Permissions By User (permissions-by-user) + +Resources and roles for an address in the permissions graph. ```graphql query PermissionsByUser($address: Address!) { @@ -838,7 +870,9 @@ Variables: } ``` -### account-resolver-permissions +### Account Resolver Permissions (account-resolver-permissions) + +Resolver contracts where an account has been granted resolver ACLs. ```graphql query AccountResolverPermissions($address: Address!) { @@ -866,7 +900,9 @@ Variables: } ``` -### domain-resolver +### Domain Resolver (domain-resolver) + +Assigned resolver contract address and recent resolver events. ```graphql query DomainResolver($name: InterpretedName!) { @@ -899,7 +935,9 @@ Variables: } ``` -### resolver-by-address +### Resolver By Address (resolver-by-address) + +Load a resolver by its contract address. ```graphql query ResolverByAddress($contract: AccountIdInput!) { @@ -948,7 +986,9 @@ Variables: } ``` -### namegraph +### Namegraph (namegraph) + +Walk a domain's registry, parent, subregistry, and direct subdomains (as in Core Concepts). ```graphql query Namegraph { @@ -997,7 +1037,9 @@ Variables: {} ``` -### account-migrated-names +### Account Migration Counts (account-migrated-names) + +Count an account's ENSv1 vs ENSv2 domains to gauge its migration progress. ```graphql query AccountMigratedNames($address: Address!) { @@ -1020,7 +1062,9 @@ Variables: } ``` -### eth-by-version +### ETH TLD By Version (eth-by-version) + +Load the .eth TLD across protocol versions: one Domain per version, discriminated by `__typename` (ENSv1Domain / ENSv2Domain). ```graphql query GetEthDomains { @@ -1041,7 +1085,9 @@ Variables: {} ``` -### accelerate-resolve +### Resolve primary name and records, and track protocol acceleration (accelerate-resolve) + +Resolve primary name and records, and track protocol acceleration with `trace` and `accelerate` arguments. ```graphql query AccelerateResolve($address: Address!) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3dbf3298e..9a7d3665a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -373,6 +373,9 @@ importers: '@ensnode/ponder-subgraph': specifier: workspace:* version: link:../../packages/ponder-subgraph + '@hono/mcp': + specifier: ^0.3.0 + version: 0.3.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(hono-rate-limiter@0.5.3(hono@4.12.23)(unstorage@1.17.5))(hono@4.12.23)(zod@4.3.6) '@hono/node-server': specifier: 'catalog:' version: 1.19.14(hono@4.12.23) @@ -382,6 +385,9 @@ importers: '@hono/zod-openapi': specifier: ^1.4.0 version: 1.4.0(hono@4.12.23)(zod@4.3.6) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) '@namehash/ens-referrals': specifier: workspace:* version: link:../../packages/ens-referrals @@ -1147,6 +1153,9 @@ importers: enssdk: specifier: workspace:* version: link:../enssdk + graphql: + specifier: ^16.11.0 + version: 16.11.0 zod: specifier: 'catalog:' version: 4.3.6 @@ -3034,6 +3043,14 @@ packages: peerDependencies: react: '>= 16 || ^19.0.0-rc' + '@hono/mcp@0.3.0': + resolution: {integrity: sha512-UhSWFbZwRxHBdptOn2r1HyB8Vt6gU+BL3AbTis2t8TBW69ZhG4soJQ09yEL/t/ymxszJnWp5Rq6tOXXOjuK1qg==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.1 + hono: '*' + hono-rate-limiter: ^0.5.3 + zod: ^3.25.0 || ^4.0.0 + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -3438,6 +3455,16 @@ packages: '@mermaid-js/parser@1.1.1': resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@n1ru4l/push-pull-async-iterable-iterator@3.2.0': resolution: {integrity: sha512-3fkKj25kEjsfObL6IlKPAlHYPq/oYwUkkQ03zsTTiDjD7vg/RxjdiLeCydqtxHZP0JgsXL3D/X5oAkMGzuUp/Q==} engines: {node: '>=12'} @@ -5649,6 +5676,10 @@ packages: resolution: {integrity: sha512-eUP/6pbXBkMbXFdx4IH2fVgvB7M0JvR7/lIL33zcs0IBcwjdzSSl31TOJsaCzmKSSDF9h8QYSOJux4Nd4YJqFg==} engines: {node: '>=12'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -5695,6 +5726,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: '>=8.18.0' + peerDependenciesMeta: + ajv: + optional: true + ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -5938,6 +5977,10 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -6023,6 +6066,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase@7.0.1: resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} engines: {node: '>=14.16'} @@ -6231,6 +6278,18 @@ packages: resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-hrtime@5.0.0: resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} engines: {node: '>=12'} @@ -6241,6 +6300,14 @@ packages: cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -6252,6 +6319,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -6561,6 +6632,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dependency-graph@1.0.0: resolution: {integrity: sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==} engines: {node: '>=4'} @@ -6759,6 +6834,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.243: resolution: {integrity: sha512-ZCphxFW3Q1TVhcgS9blfut1PX8lusVi2SvXQgmEEnK4TCmE1JhH2JkjJN+DNt0pJJwfBri5AROBnz2b/C+YU9g==} @@ -6771,6 +6849,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -6879,6 +6961,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -6912,6 +6997,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -6949,6 +7038,16 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + expressive-code@0.42.0: resolution: {integrity: sha512-V5DtJLEKuj4wf9O6IRtPtRObkMVy2ggR+S0MdjrTw6m58krZnDioyhW1si3Y04c5YPeooP4nd85Yq9NwEVHS4g==} @@ -7033,6 +7132,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -7074,6 +7177,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + framer-motion@12.23.24: resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==} peerDependencies: @@ -7088,6 +7195,10 @@ packages: react-dom: optional: true + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -7365,6 +7476,15 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + hono-rate-limiter@0.5.3: + resolution: {integrity: sha512-M0DxbVMpPELEzLi0AJg1XyBHLGJXz7GySjsPoK+gc5YeeBsdGDGe+2RvVuCAv8ydINiwlbxqYMNxUEyYfRji/A==} + peerDependencies: + hono: ^4.10.8 + unstorage: ^1.17.3 + peerDependenciesMeta: + unstorage: + optional: true + hono@4.12.23: resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} engines: {node: '>=16.9.0'} @@ -7394,6 +7514,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -7434,6 +7558,10 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + identifier-regex@1.0.1: resolution: {integrity: sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw==} engines: {node: '>=18'} @@ -7471,6 +7599,14 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -7572,6 +7708,9 @@ packages: resolution: {integrity: sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w==} engines: {node: '>=0.10.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regexp@3.1.0: resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} engines: {node: '>=12'} @@ -7630,6 +7769,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -7668,6 +7810,9 @@ packages: json-schema-typed@8.0.1: resolution: {integrity: sha512-XQmWYj2Sm4kn4WeTYvmpKEbyPsL7nBsb647c7pMe6l02/yx2+Jfc4dT6UZkEXnIUb5LhD55r2HPsJ1milQ4rDg==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -8079,6 +8224,14 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -8233,6 +8386,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -8360,6 +8517,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neotraverse@0.6.18: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} @@ -8443,6 +8604,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} @@ -8459,6 +8624,10 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + on-headers@1.1.0: resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} engines: {node: '>= 0.8'} @@ -8594,6 +8763,10 @@ packages: parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -8626,6 +8799,9 @@ packages: path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -8739,6 +8915,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -8918,6 +9098,10 @@ packages: resolution: {integrity: sha512-JpJpFaR7yKNb6WqKvJJ1MLbiuIQWQnbUUb06nDtf2/i8YWYYLEfP6xf9BwSJoJQg1wAy61EQB8dssQg64oX4aA==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -8933,6 +9117,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -8960,6 +9148,14 @@ packages: resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} engines: {node: '>= 0.6'} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -9227,6 +9423,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -9293,9 +9493,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serve-handler@6.1.6: resolution: {integrity: sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + serve@14.2.5: resolution: {integrity: sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA==} engines: {node: '>= 14'} @@ -9311,6 +9519,9 @@ packages: resolution: {integrity: sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw==} engines: {node: '>=11.0'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shallow-equal@1.2.1: resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} @@ -9338,6 +9549,22 @@ packages: resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} engines: {node: '>=20'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -9468,6 +9695,10 @@ packages: peerDependencies: '@astrojs/starlight': '>=0.38.0' + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -9743,6 +9974,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@6.0.0: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} @@ -9857,6 +10092,10 @@ packages: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typesafe-path@0.2.2: resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} @@ -9964,6 +10203,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unplugin-utils@0.3.1: resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} engines: {node: '>=20.19.0'} @@ -10546,6 +10789,11 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -12509,6 +12757,14 @@ snapshots: dependencies: react: 19.2.1 + '@hono/mcp@0.3.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(hono-rate-limiter@0.5.3(hono@4.12.23)(unstorage@1.17.5))(hono@4.12.23)(zod@4.3.6)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) + hono: 4.12.23 + hono-rate-limiter: 0.5.3(hono@4.12.23)(unstorage@1.17.5) + pkce-challenge: 5.0.1 + zod: 4.3.6 + '@hono/node-server@1.19.14(hono@4.12.23)': dependencies: hono: 4.12.23 @@ -12912,6 +13168,28 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.23) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.23 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@n1ru4l/push-pull-async-iterable-iterator@3.2.0': {} '@next/env@16.2.6': {} @@ -15565,6 +15843,11 @@ snapshots: module-error: 1.0.2 queue-microtask: 1.2.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -15595,6 +15878,10 @@ snapshots: optionalDependencies: ajv: 8.18.0 + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -16015,6 +16302,20 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} boring-avatars@1.11.2: {} @@ -16094,6 +16395,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase@7.0.1: {} caniuse-lite@1.0.30001751: {} @@ -16304,12 +16610,22 @@ snapshots: content-disposition@0.5.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-hrtime@5.0.0: {} convert-source-map@2.0.0: {} cookie-es@1.2.2: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} copy-anything@4.0.5: @@ -16318,6 +16634,11 @@ snapshots: core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -16643,6 +16964,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dependency-graph@1.0.0: {} deprecation@2.3.1: {} @@ -16760,6 +17083,8 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + electron-to-chromium@1.5.243: {} emmet@2.4.11: @@ -16771,6 +17096,8 @@ snapshots: emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -16980,6 +17307,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@5.0.0: {} esprima@4.0.1: {} @@ -17019,6 +17348,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + event-target-shim@5.0.1: {} eventemitter3@5.0.1: {} @@ -17068,6 +17399,44 @@ snapshots: expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + expressive-code@0.42.0: dependencies: '@expressive-code/core': 0.42.0 @@ -17155,6 +17524,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -17197,6 +17577,8 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + framer-motion@12.23.24(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: motion-dom: 12.23.23 @@ -17206,6 +17588,8 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs-extra@11.3.3: @@ -17667,6 +18051,12 @@ snapshots: highlight.js@11.11.1: {} + hono-rate-limiter@0.5.3(hono@4.12.23)(unstorage@1.17.5): + dependencies: + hono: 4.12.23 + optionalDependencies: + unstorage: 1.17.5 + hono@4.12.23: {} hookable@5.5.3: {} @@ -17692,6 +18082,14 @@ snapshots: http-cache-semantics@4.2.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -17731,6 +18129,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + identifier-regex@1.0.1: dependencies: reserved-identifiers: 1.2.0 @@ -17761,6 +18163,10 @@ snapshots: internmap@2.0.3: {} + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + iron-webcrypto@1.2.1: {} is-absolute-url@4.0.1: {} @@ -17827,6 +18233,8 @@ snapshots: is-primitive@3.0.1: {} + is-promise@4.0.0: {} + is-regexp@3.1.0: {} is-stream@2.0.1: {} @@ -17869,6 +18277,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.3: {} + joycon@3.1.1: {} js-base64@3.7.8: {} @@ -17918,6 +18328,8 @@ snapshots: json-schema-typed@8.0.1: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json5@2.2.3: {} @@ -18370,6 +18782,10 @@ snapshots: mdurl@2.0.0: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -18699,6 +19115,10 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -18813,6 +19233,8 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + neotraverse@0.6.18: {} neverpanic@0.0.7(typescript@5.9.3): @@ -18884,6 +19306,8 @@ snapshots: object-assign@4.1.1: {} + object-inspect@1.13.4: {} + obuf@1.1.2: {} obug@2.1.1: {} @@ -18898,6 +19322,10 @@ snapshots: on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + on-headers@1.1.0: {} once@1.4.0: @@ -19052,6 +19480,8 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + path-browserify@1.0.1: {} path-data-parser@0.1.0: {} @@ -19073,6 +19503,8 @@ snapshots: path-to-regexp@3.3.0: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} pathe@1.1.2: {} @@ -19199,6 +19631,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -19451,6 +19885,11 @@ snapshots: dependencies: long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} pump@3.0.3: @@ -19462,6 +19901,10 @@ snapshots: punycode@2.3.1: {} + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + quansync@0.2.11: {} quansync@1.0.0: {} @@ -19495,6 +19938,15 @@ snapshots: range-parser@1.2.0: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.0 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -19886,6 +20338,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + rrweb-cssom@0.8.0: {} run-parallel@1.2.0: @@ -19933,6 +20395,22 @@ snapshots: semver@7.8.0: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serve-handler@6.1.6: dependencies: bytes: 3.0.0 @@ -19943,6 +20421,15 @@ snapshots: path-to-regexp: 3.3.0 range-parser: 1.2.0 + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + serve@14.2.5: dependencies: '@zeit/schemas': 2.36.0 @@ -19968,6 +20455,8 @@ snapshots: is-plain-object: 2.0.4 is-primitive: 3.0.1 + setprototypeof@1.2.0: {} + shallow-equal@1.2.1: {} sharp@0.33.5: @@ -20047,6 +20536,34 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -20188,6 +20705,8 @@ snapshots: '@astrojs/starlight': 0.39.2(astro@6.3.3(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.59.0)(tsx@4.21.0)(yaml@2.8.3))(typescript@5.9.3) picomatch: 4.0.4 + statuses@2.0.2: {} + std-env@4.1.0: {} stream-replace-string@2.0.0: {} @@ -20502,6 +21021,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tough-cookie@6.0.0: dependencies: tldts: 7.0.17 @@ -20606,6 +21127,12 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typesafe-path@0.2.2: {} typescript-auto-import-cache@0.3.6: @@ -20729,6 +21256,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + unplugin-utils@0.3.1: dependencies: pathe: 2.0.3 @@ -21298,6 +21827,10 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@4.3.6: {} zustand@4.5.7(@types/react@19.2.7)(immer@9.0.21)(react@19.2.1): From 1d67e55394730aa3cd5cb8837b8558133e4c1599 Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 16 Jun 2026 15:14:45 +0200 Subject: [PATCH 04/16] feat(ensapi): add offline Omnigraph schema lookup helpers and enhance MCP API tests --- .changeset/omnigraph-schema-reference.md | 5 ++ .../api/mcp/mcp-api.integration.test.ts | 9 +++- .../src/handlers/api/mcp/mcp-api.test.ts | 49 ++++++++++++++++++- apps/ensapi/src/handlers/api/mcp/mcp-api.ts | 49 +++++++++++++++++-- .../data/omnigraph-examples/config.test.ts | 2 +- .../src/data/omnigraph-examples/config.ts | 2 +- .../src/data/omnigraph-examples/examples.ts | 2 +- packages/ensnode-sdk/package.json | 3 +- .../omnigraph-api/schema-reference.test.ts | 18 +++++++ .../src/omnigraph-api/schema-reference.ts | 45 ++++++++++++----- 10 files changed, 160 insertions(+), 24 deletions(-) create mode 100644 .changeset/omnigraph-schema-reference.md diff --git a/.changeset/omnigraph-schema-reference.md b/.changeset/omnigraph-schema-reference.md new file mode 100644 index 0000000000..d6f47e73af --- /dev/null +++ b/.changeset/omnigraph-schema-reference.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": patch +--- + +Add offline Omnigraph schema lookup helpers (`lookupOmnigraphSchema`, `buildCondensedSchemaReference`) on `@ensnode/ensnode-sdk/internal` for enscli, ENSApi MCP, and ensskills. diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts index b9b3771d5e..68b22a0093 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.integration.test.ts @@ -2,7 +2,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -const MCP_URL = new URL("/api/mcp", process.env.ENSNODE_URL!); +const ensnodeUrl = process.env.ENSNODE_URL; +if (!ensnodeUrl) { + throw new Error( + "ENSNODE_URL environment variable must be configured for Omnigraph MCP integration tests to run.", + ); +} + +const MCP_URL = new URL("/api/mcp", ensnodeUrl); type TextContent = { type: "text"; text: string }; diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts index 2b96176ffb..29d443eeef 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts @@ -1,5 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock the in-process Yoga instance so the tool can be exercised without a database. The tool @@ -9,12 +10,16 @@ vi.mock("@/omnigraph-api/yoga", () => ({ yoga: { fetch: fetchMock } })); import mcpApi, { createOmnigraphMcpServer } from "./mcp-api"; +const activeConnections: Array<{ client: Client; server: McpServer }> = []; +const activeHttpSessionIds: string[] = []; + /** Wires an MCP `Client` directly to a fresh server over an in-memory transport pair. */ async function connectClient() { const server = createOmnigraphMcpServer(); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "test-client", version: "0.0.0" }); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + activeConnections.push({ client, server }); return { client, server }; } @@ -23,7 +28,23 @@ describe("Omnigraph MCP server", () => { fetchMock.mockReset(); }); - afterEach(() => { + afterEach(async () => { + for (const { client, server } of activeConnections) { + await client.close(); + await server.close(); + } + activeConnections.length = 0; + + for (const sessionId of activeHttpSessionIds) { + await mcpApi.fetch( + new Request("http://ensapi.internal/", { + method: "DELETE", + headers: { "mcp-session-id": sessionId }, + }), + ); + } + activeHttpSessionIds.length = 0; + vi.clearAllMocks(); }); @@ -204,6 +225,31 @@ describe("Omnigraph MCP server", () => { expect(firstMessage.text).toContain("hello-world"); }); + it("returns a JSON-RPC parse error for invalid JSON on initialize", async () => { + const response = await mcpApi.fetch( + new Request("http://ensapi.internal/", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: "{not-json", + }), + ); + + expect(response.status).toBe(400); + const payload = (await response.json()) as { + jsonrpc: string; + error: { code: number; message: string }; + id: null; + }; + expect(payload).toMatchObject({ + jsonrpc: "2.0", + error: { code: -32_700, message: "Parse error" }, + id: null, + }); + }); + it("supports POST initialize followed by GET SSE for the same session", async () => { const init = await mcpApi.fetch( new Request("http://ensapi.internal/", { @@ -228,6 +274,7 @@ describe("Omnigraph MCP server", () => { expect(init.status).toBe(200); const sessionId = init.headers.get("mcp-session-id"); expect(sessionId).toBeTruthy(); + activeHttpSessionIds.push(sessionId!); const sse = await mcpApi.fetch( new Request("http://ensapi.internal/", { diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts index 1e8e8084d2..73ff4348ec 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts @@ -265,6 +265,28 @@ type McpRequestContext = Parameters[0] /** Active MCP sessions keyed by `mcp-session-id` (one server + transport pair per client). */ const sessions = new Map(); +/** Cap stored sessions to limit memory growth from repeated initialize requests. */ +const MAX_MCP_SESSIONS = 200; + +function jsonRpcErrorResponse( + c: { json: (data: unknown, status: number) => Response }, + code: number, + message: string, + status: number, +) { + return c.json({ jsonrpc: "2.0", error: { code, message }, id: null }, status); +} + +function storeSession(id: string, session: McpSession) { + sessions.set(id, session); + if (sessions.size > MAX_MCP_SESSIONS) { + const oldestId = sessions.keys().next().value; + if (typeof oldestId === "string" && oldestId !== id) { + void closeSession(oldestId); + } + } +} + function getSessionId(ctx: { req: { header: (name: string) => string | undefined }; }): string | undefined { @@ -332,7 +354,12 @@ app.all("/", async (c) => { return response ?? c.body(null, 202); } - const body = await c.req.json(); + let body: unknown; + try { + body = await c.req.json(); + } catch { + return jsonRpcErrorResponse(c, -32_700, "Parse error", 400); + } if (!isInitializeRequest(body)) { return c.json( { @@ -345,12 +372,14 @@ app.all("/", async (c) => { } const server = createOmnigraphMcpServer(); + let initializedSessionId: string | undefined; const session: McpSession = { server, transport: new StreamableHTTPTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (id) => { - sessions.set(id, session); + initializedSessionId = id; + storeSession(id, session); }, onsessionclosed: (id) => { void closeSession(id); @@ -358,9 +387,19 @@ app.all("/", async (c) => { }), }; - await server.connect(session.transport); - const response = await session.transport.handleRequest(mcpContext, body); - return response ?? c.body(null, 202); + try { + await server.connect(session.transport); + const response = await session.transport.handleRequest(mcpContext, body); + return response ?? c.body(null, 202); + } catch (error) { + if (initializedSessionId) { + await closeSession(initializedSessionId); + } else { + await session.transport.close(); + await session.server.close(); + } + throw error; + } } return c.json( diff --git a/docs/ensnode.io/src/data/omnigraph-examples/config.test.ts b/docs/ensnode.io/src/data/omnigraph-examples/config.test.ts index f3ea6e4be2..ae4590e5b0 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/config.test.ts +++ b/docs/ensnode.io/src/data/omnigraph-examples/config.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { DOCS_OMNIGRAPH_NAMESPACE_CONFIG } from "@lib/examples/omnigraph/constants"; -import { getGraphqlApiExampleQueryById } from "@ensnode/ensnode-sdk/example-queries"; +import { getGraphqlApiExampleQueryById } from "@ensnode/ensnode-sdk/internal"; import { getOmnigraphExamplePageHref, diff --git a/docs/ensnode.io/src/data/omnigraph-examples/config.ts b/docs/ensnode.io/src/data/omnigraph-examples/config.ts index 4805091a7c..37480adc0d 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/config.ts +++ b/docs/ensnode.io/src/data/omnigraph-examples/config.ts @@ -1,6 +1,6 @@ import type { DocsOmnigraphExampleNamespace } from "@lib/examples/omnigraph/constants"; import { ENSNamespaceIds } from "@ensnode/ensnode-sdk"; -import { getGraphqlApiExampleQueryById } from "@ensnode/ensnode-sdk/example-queries"; +import { getGraphqlApiExampleQueryById } from "@ensnode/ensnode-sdk/internal"; export type OmnigraphExampleConfig = { id: string; diff --git a/docs/ensnode.io/src/data/omnigraph-examples/examples.ts b/docs/ensnode.io/src/data/omnigraph-examples/examples.ts index 384cef4af7..cdf47edd4a 100644 --- a/docs/ensnode.io/src/data/omnigraph-examples/examples.ts +++ b/docs/ensnode.io/src/data/omnigraph-examples/examples.ts @@ -5,7 +5,7 @@ import { } from "@lib/examples/omnigraph/example-query"; import { getOmnigraphExamplePageHref, OMNIGRAPH_EXAMPLES_CONFIG } from "./config"; -import { getGraphqlApiExampleQueryById } from "@ensnode/ensnode-sdk/example-queries"; +import { getGraphqlApiExampleQueryById } from "@ensnode/ensnode-sdk/internal"; import exampleSnapshots from "./examples.json"; import responses from "./responses.json"; import type { SnapshotExample } from "./types"; diff --git a/packages/ensnode-sdk/package.json b/packages/ensnode-sdk/package.json index 05a9885ddd..8363d252b4 100644 --- a/packages/ensnode-sdk/package.json +++ b/packages/ensnode-sdk/package.json @@ -19,8 +19,7 @@ ], "exports": { ".": "./src/index.ts", - "./internal": "./src/internal.ts", - "./example-queries": "./src/omnigraph-api/example-queries.ts" + "./internal": "./src/internal.ts" }, "sideEffects": false, "publishConfig": { diff --git a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts index 073bafcbd0..d76186c6b3 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts @@ -23,4 +23,22 @@ describe("omnigraph schema reference", () => { expect(result.name).toBe("Account"); expect(result.fields.length).toBeGreaterThan(0); }); + + it("reports unknown types for Type.field lookups", () => { + expect(() => lookupOmnigraphSchema({ type: "NotAType.field" })).toThrow( + /Unknown type "NotAType"/, + ); + }); + + it("reports missing fields with a schema lookup hint", () => { + expect(() => lookupOmnigraphSchema({ type: "Account.notAField" })).toThrow( + /has no field "notAField"/, + ); + }); + + it("rejects Type.field lookups with extra dot-separated segments", () => { + expect(() => lookupOmnigraphSchema({ type: "Account.resolve.extra" })).toThrow( + /Invalid `type`/, + ); + }); }); diff --git a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts index b021ff840f..e89621e596 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts @@ -14,6 +14,7 @@ import { isObjectType, isUnionType, } from "graphql"; +import { z } from "zod/v4"; const require = createRequire(import.meta.url); @@ -141,12 +142,19 @@ function listMajorTypeNames(schema: GraphQLSchema): string[] { function describeFieldPath(schema: GraphQLSchema, typeName: string, fieldName: string) { const type = schema.getType(typeName); - if (!type || !(isObjectType(type) || isInterfaceType(type) || isInputObjectType(type))) { + if (!type) { + throw new Error( + `Unknown type "${typeName}". Run enscli ensnode omnigraph schema (or omnigraph_schema with no args) to list valid types.`, + ); + } + if (!(isObjectType(type) || isInterfaceType(type) || isInputObjectType(type))) { throw new Error(`Type "${typeName}" has no fields.`); } const field = type.getFields()[fieldName]; if (!field) { - throw new Error(`Type "${typeName}" has no field "${fieldName}".`); + throw new Error( + `Type "${typeName}" has no field "${fieldName}". Run enscli ensnode omnigraph schema ${typeName} (or omnigraph_schema with type "${typeName}") to list its fields.`, + ); } return { parent: typeName, ...fieldInfo(field) }; } @@ -172,26 +180,39 @@ export type OmnigraphSchemaLookupInput = { search?: string; }; +const OmnigraphSchemaLookupInputSchema = z.object({ + type: z + .string() + .optional() + .refine( + (value) => { + if (!value?.includes(".")) return true; + const parts = value.split("."); + return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0; + }, + { message: 'Invalid `type` — expected "Type.field" (e.g. Account.resolve).' }, + ), + search: z.string().optional(), +}); + /** Looks up Omnigraph schema fields locally. Returns JSON-serializable data. */ export function lookupOmnigraphSchema(input: OmnigraphSchemaLookupInput): unknown { + const parsed = OmnigraphSchemaLookupInputSchema.parse(input); const schema = getOmnigraphSchema(); - if (input.search) { - return searchSchema(schema, input.search); + if (parsed.search) { + return searchSchema(schema, parsed.search); } - if (input.type?.includes(".")) { - const [typeName, fieldName] = input.type.split(".", 2); - if (!typeName || !fieldName) { - throw new Error('Invalid `type` — expected "Type.field" (e.g. Account.resolve).'); - } + if (parsed.type?.includes(".")) { + const [typeName, fieldName] = parsed.type.split(".", 2) as [string, string]; return describeFieldPath(schema, typeName, fieldName); } - if (input.type) { - const type = schema.getType(input.type); + if (parsed.type) { + const type = schema.getType(parsed.type); if (!type) { - throw new Error(`Unknown type "${input.type}".`); + throw new Error(`Unknown type "${parsed.type}".`); } return describeType(type); } From d7f01d07d9a8dc3afdb13675fd2d573842b4cb00 Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 16 Jun 2026 15:37:30 +0200 Subject: [PATCH 05/16] feat(ensapi): refine Omnigraph schema input validation and enhance error handling --- .changeset/omnigraph-schema-reference.md | 3 +- apps/ensapi/src/handlers/api/mcp/mcp-api.ts | 53 ++--------------- .../src/commands/ensnode/omnigraph-schema.ts | 42 +++++-------- .../omnigraph-api/schema-reference.test.ts | 6 ++ .../src/omnigraph-api/schema-reference.ts | 59 ++++++++++--------- packages/enssdk/package.json | 11 ++-- packages/enssdk/scripts/inline-schema-sdl.mjs | 14 +++++ 7 files changed, 82 insertions(+), 106 deletions(-) create mode 100644 packages/enssdk/scripts/inline-schema-sdl.mjs diff --git a/.changeset/omnigraph-schema-reference.md b/.changeset/omnigraph-schema-reference.md index d6f47e73af..7d77ec0887 100644 --- a/.changeset/omnigraph-schema-reference.md +++ b/.changeset/omnigraph-schema-reference.md @@ -1,5 +1,6 @@ --- "@ensnode/ensnode-sdk": patch +"enssdk": patch --- -Add offline Omnigraph schema lookup helpers (`lookupOmnigraphSchema`, `buildCondensedSchemaReference`) on `@ensnode/ensnode-sdk/internal` for enscli, ENSApi MCP, and ensskills. +Add offline Omnigraph schema lookup helpers (`lookupOmnigraphSchema`, `buildCondensedSchemaReference`) on `@ensnode/ensnode-sdk/internal` for enscli, ENSApi MCP, and ensskills. Export bundled SDL via `enssdk/omnigraph/schema-sdl` so schema loading works in Vite/Rollup builds without Node `createRequire`. diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts index 73ff4348ec..fb8ff9436f 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts @@ -9,6 +9,7 @@ import { z } from "zod/v4"; import { buildCondensedSchemaReference, lookupOmnigraphSchema, + OmnigraphSchemaLookupInputSchema, } from "@ensnode/ensnode-sdk/internal"; import { @@ -47,17 +48,6 @@ const OmnigraphQueryInputSchema = z } }); -const OmnigraphSchemaInputSchema = z.object({ - type: z - .string() - .optional() - .describe('Type name (e.g. "Account") or "Type.field" (e.g. "Account.resolve").'), - search: z - .string() - .optional() - .describe("Keyword search over type and field names. Mutually exclusive with `type`."), -}); - /** * Builds the ENSNode Omnigraph MCP server and registers its tools. * @@ -140,12 +130,9 @@ export function createOmnigraphMcpServer(): McpServer { description: "Discover Omnigraph types and fields from the bundled schema (no network). Omit arguments for " + "root query fields and type names; pass `type` for a type or Type.field; pass `search` to find matches.", - inputSchema: OmnigraphSchemaInputSchema, + inputSchema: OmnigraphSchemaLookupInputSchema, }, async ({ type, search }) => { - if (search && type) { - throw new Error("Provide either `type` or `search`, not both."); - } const result = lookupOmnigraphSchema({ type, search }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], @@ -295,14 +282,7 @@ function getSessionId(ctx: { } function invalidSessionResponse(c: { json: (data: unknown, status: number) => Response }) { - return c.json( - { - jsonrpc: "2.0", - error: { code: -32_000, message: "Invalid or missing session ID" }, - id: null, - }, - 400, - ); + return jsonRpcErrorResponse(c, -32_000, "Invalid or missing session ID", 400); } async function closeSession(sessionId: string): Promise { @@ -341,14 +321,7 @@ app.all("/", async (c) => { if (sessionId) { const session = sessions.get(sessionId); if (!session) { - return c.json( - { - jsonrpc: "2.0", - error: { code: -32_000, message: "Session not found" }, - id: null, - }, - 404, - ); + return jsonRpcErrorResponse(c, -32_000, "Session not found", 404); } const response = await session.transport.handleRequest(mcpContext); return response ?? c.body(null, 202); @@ -361,14 +334,7 @@ app.all("/", async (c) => { return jsonRpcErrorResponse(c, -32_700, "Parse error", 400); } if (!isInitializeRequest(body)) { - return c.json( - { - jsonrpc: "2.0", - error: { code: -32_000, message: "Bad Request: No valid session ID provided" }, - id: null, - }, - 400, - ); + return jsonRpcErrorResponse(c, -32_000, "Bad Request: No valid session ID provided", 400); } const server = createOmnigraphMcpServer(); @@ -402,14 +368,7 @@ app.all("/", async (c) => { } } - return c.json( - { - jsonrpc: "2.0", - error: { code: -32_000, message: "Method not allowed." }, - id: null, - }, - 405, - ); + return jsonRpcErrorResponse(c, -32_000, "Method not allowed.", 405); }); export default app; diff --git a/packages/enscli/src/commands/ensnode/omnigraph-schema.ts b/packages/enscli/src/commands/ensnode/omnigraph-schema.ts index 0e93bea7e7..099c53b489 100644 --- a/packages/enscli/src/commands/ensnode/omnigraph-schema.ts +++ b/packages/enscli/src/commands/ensnode/omnigraph-schema.ts @@ -1,21 +1,12 @@ -import { getOmnigraphSchema, lookupOmnigraphSchema } from "@ensnode/ensnode-sdk/internal"; +import { + getOmnigraphSchema, + lookupOmnigraphSchema, + type OmnigraphSchemaFieldInfo, +} from "@ensnode/ensnode-sdk/internal"; import { printResult } from "../../lib/output"; -interface ArgInfo { - name: string; - type: string; - description: string | null; -} - -interface FieldInfo { - name: string; - type: string; - description: string | null; - args?: ArgInfo[]; -} - -function renderField(field: FieldInfo, indent: string): string { +function renderField(field: OmnigraphSchemaFieldInfo, indent: string): string { const args = field.args ? `(${field.args.map((a) => `${a.name}: ${a.type}`).join(", ")})` : ""; const description = field.description ? `\n${indent} # ${field.description.replace(/\s+/g, " ").trim()}` @@ -27,7 +18,7 @@ function renderTypePretty(type: { description: string | null; kind: string; name: string; - fields?: FieldInfo[]; + fields?: OmnigraphSchemaFieldInfo[]; values?: Array<{ name: string }>; types?: string[]; }): string { @@ -41,7 +32,7 @@ function renderTypePretty(type: { return lines.join("\n"); } -function renderRootPretty(root: { query: FieldInfo[]; types: string[] }): string { +function renderRootPretty(root: { query: OmnigraphSchemaFieldInfo[]; types: string[] }): string { return [ "# Root query fields", ...root.query.map((field) => renderField(field, " ")), @@ -51,7 +42,7 @@ function renderRootPretty(root: { query: FieldInfo[]; types: string[] }): string ].join("\n"); } -function renderFieldPathPretty(field: FieldInfo & { parent: string }): string { +function renderFieldPathPretty(field: OmnigraphSchemaFieldInfo & { parent: string }): string { return `${field.parent}.${renderField(field, "")}`; } @@ -83,20 +74,17 @@ export function runOmnigraphSchema( } if (!target) { - const result = lookupOmnigraphSchema({}) as { query: FieldInfo[]; types: string[] }; + const result = lookupOmnigraphSchema({}) as { + query: OmnigraphSchemaFieldInfo[]; + types: string[]; + }; printResult(result, args, renderRootPretty); return; } if (target.includes(".")) { - const segments = target.split("."); - if (segments.length !== 2 || segments.some((segment) => segment.length === 0)) { - throw new Error( - `Invalid target "${target}". Expected "Type" or "Type.field" (e.g. Domain or Domain.canonical).`, - ); - } printResult( - lookupOmnigraphSchema({ type: target }) as FieldInfo & { parent: string }, + lookupOmnigraphSchema({ type: target }) as OmnigraphSchemaFieldInfo & { parent: string }, args, renderFieldPathPretty, ); @@ -114,7 +102,7 @@ export function runOmnigraphSchema( description: string | null; kind: string; name: string; - fields?: FieldInfo[]; + fields?: OmnigraphSchemaFieldInfo[]; values?: Array<{ name: string }>; types?: string[]; }, diff --git a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts index d76186c6b3..9ab61b6167 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts @@ -41,4 +41,10 @@ describe("omnigraph schema reference", () => { /Invalid `type`/, ); }); + + it("rejects type and search together", () => { + expect(() => lookupOmnigraphSchema({ type: "Account", search: "resolve" })).toThrow( + /Provide either `type` or `search`/, + ); + }); }); diff --git a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts index e89621e596..1bc266b197 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts @@ -1,6 +1,4 @@ -import { readFileSync } from "node:fs"; -import { createRequire } from "node:module"; - +import { omnigraphSchemaSdl } from "enssdk/omnigraph/schema-sdl"; import { buildSchema, type GraphQLArgument, @@ -16,8 +14,6 @@ import { } from "graphql"; import { z } from "zod/v4"; -const require = createRequire(import.meta.url); - /** Types rendered with full field listings in the condensed schema reference. */ export const OMNIGRAPH_CORE_TYPES = [ "Domain", @@ -37,34 +33,34 @@ let cachedSchema: GraphQLSchema | undefined; /** Loads the Omnigraph schema from the SDL bundled with enssdk (no network). */ export function getOmnigraphSchema(): GraphQLSchema { - cachedSchema ??= buildSchema( - readFileSync(require.resolve("enssdk/omnigraph/schema.graphql"), "utf8"), - ); + cachedSchema ??= buildSchema(omnigraphSchemaSdl); return cachedSchema; } -interface ArgInfo { +export interface OmnigraphSchemaArgInfo { name: string; type: string; description: string | null; } -interface FieldInfo { +export interface OmnigraphSchemaFieldInfo { name: string; type: string; description: string | null; - args?: ArgInfo[]; + args?: OmnigraphSchemaArgInfo[]; } function oneLine(description: string | null | undefined): string { return description ? description.replace(/\s+/g, " ").trim() : ""; } -function argInfo(arg: GraphQLArgument): ArgInfo { +function argInfo(arg: GraphQLArgument): OmnigraphSchemaArgInfo { return { name: arg.name, type: arg.type.toString(), description: arg.description ?? null }; } -function fieldInfo(field: GraphQLField | GraphQLInputField): FieldInfo { +function fieldInfo( + field: GraphQLField | GraphQLInputField, +): OmnigraphSchemaFieldInfo { const args = "args" in field && field.args.length > 0 ? field.args.map(argInfo) : undefined; return { name: field.name, @@ -180,20 +176,29 @@ export type OmnigraphSchemaLookupInput = { search?: string; }; -const OmnigraphSchemaLookupInputSchema = z.object({ - type: z - .string() - .optional() - .refine( - (value) => { - if (!value?.includes(".")) return true; - const parts = value.split("."); - return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0; - }, - { message: 'Invalid `type` — expected "Type.field" (e.g. Account.resolve).' }, - ), - search: z.string().optional(), -}); +export const OmnigraphSchemaLookupInputSchema = z + .object({ + type: z + .string() + .optional() + .refine( + (value) => { + if (!value?.includes(".")) return true; + const parts = value.split("."); + return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0; + }, + { message: 'Invalid `type` — expected "Type.field" (e.g. Account.resolve).' }, + ), + search: z.string().optional(), + }) + .superRefine((value, ctx) => { + if (value.search && value.type) { + ctx.addIssue({ + code: "custom", + message: "Provide either `type` or `search`, not both.", + }); + } + }); /** Looks up Omnigraph schema fields locally. Returns JSON-serializable data. */ export function lookupOmnigraphSchema(input: OmnigraphSchemaLookupInput): unknown { diff --git a/packages/enssdk/package.json b/packages/enssdk/package.json index a3f0827f7c..dc9cd8c7a6 100644 --- a/packages/enssdk/package.json +++ b/packages/enssdk/package.json @@ -17,13 +17,15 @@ ], "files": [ "dist", - "src/omnigraph/generated/schema.graphql" + "src/omnigraph/generated/schema.graphql", + "src/omnigraph/generated/schema-sdl.ts" ], "exports": { ".": "./src/index.ts", "./core": "./src/core/index.ts", "./omnigraph": "./src/omnigraph/index.ts", - "./omnigraph/schema.graphql": "./src/omnigraph/generated/schema.graphql" + "./omnigraph/schema.graphql": "./src/omnigraph/generated/schema.graphql", + "./omnigraph/schema-sdl": "./src/omnigraph/generated/schema-sdl.ts" }, "sideEffects": false, "publishConfig": { @@ -41,7 +43,8 @@ "types": "./dist/omnigraph/index.d.ts", "default": "./dist/omnigraph/index.js" }, - "./omnigraph/schema.graphql": "./src/omnigraph/generated/schema.graphql" + "./omnigraph/schema.graphql": "./src/omnigraph/generated/schema.graphql", + "./omnigraph/schema-sdl": "./src/omnigraph/generated/schema-sdl.ts" } }, "scripts": { @@ -50,7 +53,7 @@ "lint:ci": "biome ci", "test": "vitest", "typecheck": "tsgo --noEmit", - "generate:gqlschema": "gql.tada generate-output" + "generate:gqlschema": "gql.tada generate-output && node scripts/inline-schema-sdl.mjs" }, "dependencies": { "@adraffy/ens-normalize": "catalog:", diff --git a/packages/enssdk/scripts/inline-schema-sdl.mjs b/packages/enssdk/scripts/inline-schema-sdl.mjs new file mode 100644 index 0000000000..195f8d9a93 --- /dev/null +++ b/packages/enssdk/scripts/inline-schema-sdl.mjs @@ -0,0 +1,14 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const SCHEMA_PATH = resolve(SCRIPT_DIR, "../src/omnigraph/generated/schema.graphql"); +const OUT_PATH = resolve(SCRIPT_DIR, "../src/omnigraph/generated/schema-sdl.ts"); + +const sdl = readFileSync(SCHEMA_PATH, "utf8"); +const content = `/** Auto-generated from schema.graphql — run \`pnpm generate:gqlschema\` to refresh. */ +export const omnigraphSchemaSdl = ${JSON.stringify(sdl)}; +`; + +writeFileSync(OUT_PATH, content); From d397758e77f41206184d99bac390bdb66a774478 Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 16 Jun 2026 15:58:13 +0200 Subject: [PATCH 06/16] feat(ensapi): add auto-generated Omnigraph schema SDL file for enhanced type definitions --- packages/enssdk/src/omnigraph/generated/schema-sdl.ts | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 packages/enssdk/src/omnigraph/generated/schema-sdl.ts diff --git a/packages/enssdk/src/omnigraph/generated/schema-sdl.ts b/packages/enssdk/src/omnigraph/generated/schema-sdl.ts new file mode 100644 index 0000000000..468012f9fc --- /dev/null +++ b/packages/enssdk/src/omnigraph/generated/schema-sdl.ts @@ -0,0 +1,2 @@ +/** Auto-generated from schema.graphql — run `pnpm generate:gqlschema` to refresh. */ +export const omnigraphSchemaSdl = "\"\"\"Execution status metadata for a resolver strategy.\"\"\"\ntype AccelerationStatus {\n \"\"\"Whether protocol acceleration was attempted at runtime.\"\"\"\n attempted: Boolean!\n\n \"\"\"Whether protocol acceleration was requested by the caller.\"\"\"\n requested: Boolean!\n}\n\n\"\"\"Represents an individual Account, keyed by its Address.\"\"\"\ntype Account {\n \"\"\"An EVM Address that uniquely identifies this Account on-chain.\"\"\"\n address: Address!\n\n \"\"\"\n The Domains that are owned by the Account. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: AccountDomainsWhereInput): AccountDomainsConnection\n\n \"\"\"\n All Events for which this Account is the HCA-aware `sender` (i.e. `Event.sender`).\n \"\"\"\n events(after: String, before: String, first: Int, last: Int, where: AccountEventsWhereInput): AccountEventsConnection\n\n \"\"\"A unique reference to this Account.\"\"\"\n id: Address!\n\n \"\"\"\n The Permissions granted to this Account, optionally filtered to Permissions in a specific contract.\n \"\"\"\n permissions(after: String, before: String, first: Int, last: Int, where: AccountPermissionsWhereInput): AccountPermissionsConnection\n\n \"\"\"The Permissions on Registries granted to this Account.\"\"\"\n registryPermissions(after: String, before: String, first: Int, last: Int): AccountRegistryPermissionsConnection\n\n \"\"\"Resolve primary names for this Account.\"\"\"\n resolve(\n \"\"\"\n When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled.\n @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration\n \"\"\"\n accelerate: Boolean = true\n ): ReverseResolve!\n\n \"\"\"The Permissions on Resolvers granted to this Account.\"\"\"\n resolverPermissions(after: String, before: String, first: Int, last: Int): AccountResolverPermissionsConnection\n}\n\n\"\"\"Address an Account by ID or Address.\"\"\"\ninput AccountByInput @oneOf {\n address: Address\n id: Address\n}\n\ntype AccountDomainsConnection {\n edges: [AccountDomainsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype AccountDomainsConnectionEdge {\n cursor: String!\n node: Domain!\n}\n\n\"\"\"Filter for Account.domains query.\"\"\"\ninput AccountDomainsWhereInput {\n \"\"\"\n If set, filters the set of Domains by canonicality (i.e. reachability by ENS Forward Resolution): `true` for Canonical only, `false` for non-Canonical only. If omitted, returns all Domains owned by the Account regardless of canonicality.\n \"\"\"\n canonical: Boolean\n\n \"\"\"If set, filters the set of Domains by name.\"\"\"\n name: DomainsNameFilter\n\n \"\"\"\n If set, filters the set of Domains to only those of the specified ENS protocol version.\n \"\"\"\n version: ENSProtocolVersion\n}\n\ntype AccountEventsConnection {\n edges: [AccountEventsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype AccountEventsConnectionEdge {\n cursor: String!\n node: Event!\n}\n\n\"\"\"\nFilter conditions for Account.events (where `sender` is implied by the Account).\n\"\"\"\ninput AccountEventsWhereInput {\n \"\"\"\n Filter to events whose `tx.from` matches the provided filter. Not HCA-aware — the Account's HCA-aware filter is applied via `sender = Account.id`.\n \"\"\"\n from: EventsFromFilter\n\n \"\"\"\n Filter to events whose selector (event signature) matches the provided filter.\n \"\"\"\n selector: EventsSelectorFilter\n\n \"\"\"Filter to events whose UnixTimestamp falls within the provided range.\"\"\"\n timestamp: EventsTimestampFilter\n}\n\n\"\"\"A CAIP-10 Account ID including chainId and address.\"\"\"\ntype AccountId {\n address: Address!\n chainId: ChainId!\n}\n\n\"\"\"A CAIP-10 Account ID including chainId and address.\"\"\"\ninput AccountIdInput {\n address: Address!\n chainId: ChainId!\n}\n\ntype AccountPermissionsConnection {\n edges: [AccountPermissionsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype AccountPermissionsConnectionEdge {\n cursor: String!\n node: PermissionsUser!\n}\n\n\"\"\"Filter for Account.permissions.\"\"\"\ninput AccountPermissionsWhereInput {\n \"\"\"\n If set, filters this Account's Permissions to those granted in this contract.\n \"\"\"\n contract: AccountIdInput\n}\n\ntype AccountRegistryPermissionsConnection {\n edges: [AccountRegistryPermissionsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype AccountRegistryPermissionsConnectionEdge {\n cursor: String!\n node: RegistryPermissionsUser!\n}\n\ntype AccountResolverPermissionsConnection {\n edges: [AccountResolverPermissionsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype AccountResolverPermissionsConnectionEdge {\n cursor: String!\n node: ResolverPermissionsUser!\n}\n\n\"\"\"Address represents an EVM Address in all lowercase.\"\"\"\nscalar Address\n\n\"\"\"\nA BaseRegistrarRegistration represents a Registration within an ENSv1 BaseRegistrar contract, including those deployed by Basenames and Lineanames.\n\"\"\"\ntype BaseRegistrarRegistration implements Registration {\n \"\"\"The `baseCost` for registering this Domain, in wei.\"\"\"\n baseCost: BigInt\n\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"\n Whether this Registration is in the Grace Period (90 days) and can be renewed by the current owner.\n \"\"\"\n isInGracePeriod: Boolean!\n\n \"\"\"The `premium` for registering this Domain, in wei.\"\"\"\n premium: BigInt\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The TokenId for this Domain in the BaseRegistrar. This is the bigint encoding of the Domain's LabelHash.\n \"\"\"\n tokenId: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n\n \"\"\"\n Additional metadata if this BaseRegistrarRegistration is wrapped by the NameWrapper (i.e. in the case of wrapped .eth names).\n \"\"\"\n wrapped: WrappedBaseRegistrarRegistration\n}\n\n\"\"\"\nBeautifiedLabel represents an enssdk#BeautifiedLabel: an InterpretedLabel beautified per ENSIP-15 (https://docs.ens.domains/ensip/15) for display. It is display-only and MUST NOT be used as a lookup key.\n\"\"\"\nscalar BeautifiedLabel\n\n\"\"\"\nBeautifiedName represents an enssdk#BeautifiedName: an InterpretedName whose normalized labels have been beautified per ENSIP-15 (https://docs.ens.domains/ensip/15) for display. It is display-only and MUST NOT be used as a navigation target or lookup key.\n\"\"\"\nscalar BeautifiedName\n\n\"\"\"BigInt represents non-fractional signed whole numeric values.\"\"\"\nscalar BigInt\n\n\"\"\"\nBinanceAddress represents a Bech32-encoded Binance Chain (BNB) address (coin type 714).\n\"\"\"\nscalar BinanceAddress\n\n\"\"\"\nBitcoinAddress represents a Base58Check-encoded Bitcoin address (coin type 0).\n\"\"\"\nscalar BitcoinAddress\n\n\"\"\"\nBitcoinCashAddress represents a CashAddr-encoded Bitcoin Cash address (coin type 145).\n\"\"\"\nscalar BitcoinCashAddress\n\n\"\"\"A Canonical Name, exposed in each representation we support.\"\"\"\ntype CanonicalName {\n \"\"\"\n The Canonical Name as a BeautifiedName: the InterpretedName with its normalized labels beautified per ENSIP-15 (https://docs.ens.domains/ensip/15) for display. Encoded LabelHash labels are preserved verbatim. Display-only; use `interpreted` for navigation targets and lookup keys.\n \"\"\"\n beautified: BeautifiedName!\n\n \"\"\"\n The Canonical Name as an InterpretedName: each label is either a normalized literal Label or an Encoded LabelHash.\n \"\"\"\n interpreted: InterpretedName!\n}\n\n\"\"\"ChainId represents an enssdk#ChainId.\"\"\"\nscalar ChainId\n\n\"\"\"\nThe names of chains that the Omnigraph API supports identifying by name as a syntactic convenience. The Omnigraph API supports identification of additional chains beyond this list, but those chains must be identified through other mechanisms such as `coinType` or `chainId`.\n\"\"\"\nenum ChainName {\n ARBITRUM_ONE\n BASE\n ETHEREUM\n LINEA\n OPTIMISM\n SCROLL\n}\n\n\"\"\"CoinType represents an enssdk#CoinType.\"\"\"\nscalar CoinType\n\n\"\"\"\nDogecoinAddress represents a Base58Check-encoded Dogecoin address (coin type 3).\n\"\"\"\nscalar DogecoinAddress\n\n\"\"\"\nRepresents a Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain.\n\"\"\"\ninterface Domain {\n \"\"\"\n Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree.\n \"\"\"\n canonical: DomainCanonical\n\n \"\"\"All Events associated with this Domain.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection\n\n \"\"\"A unique and stable reference to this Domain.\"\"\"\n id: DomainId!\n\n \"\"\"The Label associated with this Domain in the ENS Namegraph.\"\"\"\n label: Label!\n\n \"\"\"\n If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).\n \"\"\"\n owner: Account\n\n \"\"\"\n The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`.\n \"\"\"\n parent: Domain\n\n \"\"\"The latest Registration for this Domain, if exists.\"\"\"\n registration: Registration\n\n \"\"\"All Registrations for a Domain, including the latest Registration.\"\"\"\n registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection\n\n \"\"\"\n The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`).\n \"\"\"\n registry: Registry!\n\n \"\"\"Resolve protocol-level data for this Domain.\"\"\"\n resolve(\n \"\"\"\n When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled.\n @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration\n \"\"\"\n accelerate: Boolean = true\n ): ForwardResolve!\n\n \"\"\"Resolver relationship metadata for this Domain.\"\"\"\n resolver: DomainResolver!\n\n \"\"\"\n All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection\n\n \"\"\"The Registry this Domain declares as its Subregistry, if exists.\"\"\"\n subregistry: Registry\n}\n\n\"\"\"\nCanonicality metadata for a Domain, including its name, depth, path, and node (namehash).\n\"\"\"\ntype DomainCanonical {\n \"\"\"\n The depth of this Domain, i.e. the number of labels in this Domain's Canonical Name (e.g. 2 for `vitalik.eth`).\n \"\"\"\n depth: Int!\n\n \"\"\"The Canonical Name for this Domain.\"\"\"\n name: CanonicalName!\n\n \"\"\"\n The namehash of this Domain's Canonical Name. Note that this is NOT a stable reference to this Domain; use `Domain.id`.\n \"\"\"\n node: Node!\n\n \"\"\"\n The Canonical Path from this Domain to the ENS Root, root→leaf inclusive of this Domain.\n \"\"\"\n path: [Domain!]!\n}\n\ntype DomainEventsConnection {\n edges: [DomainEventsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype DomainEventsConnectionEdge {\n cursor: String!\n node: Event!\n}\n\n\"\"\"DomainId represents an enssdk#DomainId.\"\"\"\nscalar DomainId\n\n\"\"\"Reference a specific Domain.\"\"\"\ninput DomainIdInput @oneOf {\n id: DomainId\n name: InterpretedName\n}\n\n\"\"\"\nFilter Permissions by user address. Exactly one of `eq` or `in` must be provided.\n\"\"\"\ninput DomainPermissionsUserFilter @oneOf {\n \"\"\"Exact user address match.\"\"\"\n eq: Address\n\n \"\"\"\n User address matches any value in the set. Max 10 items. An empty set matches nothing.\n \"\"\"\n in: [Address!]\n}\n\n\"\"\"Filter Permissions over this Domain by user.\"\"\"\ninput DomainPermissionsWhereInput {\n \"\"\"Filter Permissions to those whose user matches the provided filter.\"\"\"\n user: DomainPermissionsUserFilter\n}\n\n\"\"\"The interpreted profile of an ENS name.\"\"\"\ntype DomainProfile {\n \"\"\"The interpreted addresses on the profile of an ENS name.\"\"\"\n addresses: ProfileAddresses\n\n \"\"\"\n Interpreted avatar metadata. Returns null when the raw avatar record is unset or empty.\n \"\"\"\n avatar: ProfileAvatar\n\n \"\"\"\n The interpreted description on the profile of an ENS name, or null when unset.\n \"\"\"\n description: String\n\n \"\"\"\n The interpreted email address on the profile of an ENS name, or null when unset or invalid.\n \"\"\"\n email: Email\n\n \"\"\"\n Interpreted header metadata. Returns null when the raw header record is unset or empty.\n \"\"\"\n header: ProfileHeader\n\n \"\"\"The interpreted social accounts on the profile of an ENS name.\"\"\"\n socials: ProfileSocials\n\n \"\"\"The interpreted website on the profile of an ENS name.\"\"\"\n website: ProfileWebsite\n}\n\ntype DomainRegistrationsConnection {\n edges: [DomainRegistrationsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype DomainRegistrationsConnectionEdge {\n cursor: String!\n node: Registration!\n}\n\n\"\"\"Metadata describing this Domain's relationship to its Resolver(s).\"\"\"\ntype DomainResolver {\n \"\"\"\n The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution.\n \"\"\"\n assigned: Resolver\n\n \"\"\"\n The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver. Null when no active Resolver exists or the Domain is not in the Canonical Nametree.\n \"\"\"\n effective: Resolver\n}\n\ntype DomainSubdomainsConnection {\n edges: [DomainSubdomainsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype DomainSubdomainsConnectionEdge {\n cursor: String!\n node: Domain!\n}\n\n\"\"\"\nFilter Domains by name. Exactly one of `starts_with`, `eq`, or `in` must be provided.\n\"\"\"\ninput DomainsNameFilter @oneOf {\n \"\"\"\n Exact InterpretedName match. Sugar for `in: [eq]`. Combine with `version` to disambiguate across ENS protocol versions.\n \"\"\"\n eq: InterpretedName\n\n \"\"\"\n Exact InterpretedName match against any name in the set. Max 100 items.\n \"\"\"\n in: [InterpretedName!]\n\n \"\"\"\n Prefix-match on Interpreted Name for typeahead. ex: 'vit', 'vitalik.et'. Case-insensitive (InterpretedName labels are normalized). Matched against the first 64 code points of the name; prefixes longer than 64 code points never match.\n \"\"\"\n starts_with: String\n}\n\n\"\"\"Fields by which domains can be ordered.\"\"\"\nenum DomainsOrderBy {\n \"\"\"\n Order by Canonical Name depth (number of labels); e.g. `eth` < `vitalik.eth`.\n \"\"\"\n DEPTH\n\n \"\"\"Order by the Domain's Canonical Name, alphabetically.\"\"\"\n NAME\n\n \"\"\"\n Order by the expiry of the Domain's latest Registration. A Domain that never expires (or has no Registration) is treated as +∞: it sorts last when `dir: ASC` (“expiring soonest first”) and first when `dir: DESC` (“expiring latest first”).\n \"\"\"\n REGISTRATION_EXPIRY\n\n \"\"\"\n Order by the start time of the Domain's latest Registration. A Domain with no Registration has no timestamp and sorts last when `dir: ASC` (“oldest registered first”) and first when `dir: DESC` (“most recently registered first”).\n \"\"\"\n REGISTRATION_TIMESTAMP\n}\n\n\"\"\"\nOrdering options for domains query. If no order is provided, the default is ASC.\n\"\"\"\ninput DomainsOrderInput {\n by: DomainsOrderBy!\n dir: OrderDirection = ASC\n}\n\n\"\"\"Filter for the top-level domains query.\"\"\"\ninput DomainsWhereInput {\n \"\"\"Filter the set of Domains by name.\"\"\"\n name: DomainsNameFilter!\n\n \"\"\"\n If set, filters the set of Domains to only those of the specified ENS protocol version.\n \"\"\"\n version: ENSProtocolVersion\n}\n\n\"\"\"An ENS protocol version.\"\"\"\nenum ENSProtocolVersion {\n ENSv1\n ENSv2\n}\n\n\"\"\"An ENSv1Domain represents an ENSv1 Domain.\"\"\"\ntype ENSv1Domain implements Domain {\n \"\"\"\n Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree.\n \"\"\"\n canonical: DomainCanonical\n\n \"\"\"All Events associated with this Domain.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection\n\n \"\"\"A unique and stable reference to this Domain.\"\"\"\n id: DomainId!\n\n \"\"\"The Label associated with this Domain in the ENS Namegraph.\"\"\"\n label: Label!\n\n \"\"\"The namehash of this ENSv1 Domain.\"\"\"\n node: Node!\n\n \"\"\"\n If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).\n \"\"\"\n owner: Account\n\n \"\"\"\n The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`.\n \"\"\"\n parent: Domain\n\n \"\"\"The latest Registration for this Domain, if exists.\"\"\"\n registration: Registration\n\n \"\"\"All Registrations for a Domain, including the latest Registration.\"\"\"\n registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection\n\n \"\"\"\n The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`).\n \"\"\"\n registry: Registry!\n\n \"\"\"Resolve protocol-level data for this Domain.\"\"\"\n resolve(\n \"\"\"\n When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled.\n @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration\n \"\"\"\n accelerate: Boolean = true\n ): ForwardResolve!\n\n \"\"\"Resolver relationship metadata for this Domain.\"\"\"\n resolver: DomainResolver!\n\n \"\"\"\n The rootRegistryOwner of this Domain, i.e. the owner() of this Domain within the ENSv1 Registry.\n \"\"\"\n rootRegistryOwner: Account\n\n \"\"\"\n All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection\n\n \"\"\"The Registry this Domain declares as its Subregistry, if exists.\"\"\"\n subregistry: Registry\n}\n\n\"\"\"\nAn ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, or the Lineanames shadow Registry).\n\"\"\"\ntype ENSv1Registry implements Registry {\n \"\"\"Whether the Registry is Canonical.\"\"\"\n canonical: Boolean!\n\n \"\"\"\n Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists.\n \"\"\"\n contract: AccountId!\n\n \"\"\"\n The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection\n\n \"\"\"A unique reference to this Registry.\"\"\"\n id: RegistryId!\n\n \"\"\"The Domains for which this Registry is a Subregistry.\"\"\"\n parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection\n\n \"\"\"The Permissions managed by this Registry.\"\"\"\n permissions: Permissions\n}\n\n\"\"\"\nAn ENSv1VirtualRegistry is the virtual Registry managed by an ENSv1 Domain that has children. It is keyed by `(chainId, address, node)` where `(chainId, address)` identify the concrete Registry that houses the parent Domain, and `node` is the parent Domain's namehash.\n\"\"\"\ntype ENSv1VirtualRegistry implements Registry {\n \"\"\"Whether the Registry is Canonical.\"\"\"\n canonical: Boolean!\n\n \"\"\"\n Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists.\n \"\"\"\n contract: AccountId!\n\n \"\"\"\n The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection\n\n \"\"\"A unique reference to this Registry.\"\"\"\n id: RegistryId!\n\n \"\"\"\n The namehash of the parent ENSv1 Domain that owns this virtual Registry.\n \"\"\"\n node: Node!\n\n \"\"\"The Domains for which this Registry is a Subregistry.\"\"\"\n parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection\n\n \"\"\"The Permissions managed by this Registry.\"\"\"\n permissions: Permissions\n}\n\n\"\"\"An ENSv2Domain represents an ENSv2 Domain.\"\"\"\ntype ENSv2Domain implements Domain {\n \"\"\"\n Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree.\n \"\"\"\n canonical: DomainCanonical\n\n \"\"\"All Events associated with this Domain.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection\n\n \"\"\"A unique and stable reference to this Domain.\"\"\"\n id: DomainId!\n\n \"\"\"The Label associated with this Domain in the ENS Namegraph.\"\"\"\n label: Label!\n\n \"\"\"\n If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).\n \"\"\"\n owner: Account\n\n \"\"\"\n The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`.\n \"\"\"\n parent: Domain\n\n \"\"\"\n Permissions for this Domain within its Registry, representing the roles granted to users for this Domain's token.\n \"\"\"\n permissions(after: String, before: String, first: Int, last: Int, where: DomainPermissionsWhereInput): ENSv2DomainPermissionsConnection\n\n \"\"\"The latest Registration for this Domain, if exists.\"\"\"\n registration: Registration\n\n \"\"\"All Registrations for a Domain, including the latest Registration.\"\"\"\n registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection\n\n \"\"\"\n The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`).\n \"\"\"\n registry: Registry!\n\n \"\"\"Resolve protocol-level data for this Domain.\"\"\"\n resolve(\n \"\"\"\n When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled.\n @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration\n \"\"\"\n accelerate: Boolean = true\n ): ForwardResolve!\n\n \"\"\"Resolver relationship metadata for this Domain.\"\"\"\n resolver: DomainResolver!\n\n \"\"\"\n All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection\n\n \"\"\"The Registry this Domain declares as its Subregistry, if exists.\"\"\"\n subregistry: Registry\n\n \"\"\"The ENSv2Domain's current Token Id.\"\"\"\n tokenId: BigInt!\n}\n\ntype ENSv2DomainPermissionsConnection {\n edges: [ENSv2DomainPermissionsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype ENSv2DomainPermissionsConnectionEdge {\n cursor: String!\n node: PermissionsUser!\n}\n\n\"\"\"An ENSv2Registry represents an ENSv2 Registry contract.\"\"\"\ntype ENSv2Registry implements Registry {\n \"\"\"Whether the Registry is Canonical.\"\"\"\n canonical: Boolean!\n\n \"\"\"\n Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists.\n \"\"\"\n contract: AccountId!\n\n \"\"\"\n The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection\n\n \"\"\"A unique reference to this Registry.\"\"\"\n id: RegistryId!\n\n \"\"\"The Domains for which this Registry is a Subregistry.\"\"\"\n parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection\n\n \"\"\"The Permissions managed by this Registry.\"\"\"\n permissions: Permissions\n}\n\n\"\"\"\nENSv2RegistryRegistration represents a Registration within an ENSv2 Registry.\n\"\"\"\ntype ENSv2RegistryRegistration implements Registration {\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n}\n\n\"\"\"\nENSv2RegistryReservation represents a Reservation within an ENSv2 Registry.\n\"\"\"\ntype ENSv2RegistryReservation implements Registration {\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n}\n\n\"\"\"Email represents a validated contact email address.\"\"\"\nscalar Email\n\n\"\"\"\nAn Event represents a discrete Log Event that was emitted on an EVM chain, including associated metadata.\n\"\"\"\ntype Event {\n \"\"\"Identifies the contract by which this Event was emitted.\"\"\"\n address: Address!\n\n \"\"\"Identifies the Block within which this Event was emitted.\"\"\"\n blockHash: Hex!\n\n \"\"\"The block number within which this Event was emitted.\"\"\"\n blockNumber: BigInt!\n\n \"\"\"The ChainId upon which this Event was emitted.\"\"\"\n chainId: ChainId!\n\n \"\"\"The non-indexed data of this Event's log.\"\"\"\n data: Hex!\n\n \"\"\"\n Identifies the sender of the Transaction within which this Event was emitted (`tx.from`). Never HCA-aware — always the EOA/relayer that submitted the transaction. Use `Event.sender` for the HCA-aware actor.\n \"\"\"\n from: Address!\n\n \"\"\"A unique reference to this Event.\"\"\"\n id: ID!\n\n \"\"\"The index of this Event's log within the Block.\"\"\"\n logIndex: Int!\n\n \"\"\"The HCA account address if used, otherwise Transaction.from.\"\"\"\n sender: Address!\n\n \"\"\"\n The UnixTimestamp indicating the moment in which this Event was emitted.\n \"\"\"\n timestamp: BigInt!\n\n \"\"\"\n Identifies the recipient of the Transaction within which this Event was emitted. Null if the transaction deployed a contract.\n \"\"\"\n to: Address\n\n \"\"\"The indexed topics of this Event's log.\"\"\"\n topics: [Hex!]!\n\n \"\"\"Identifies the Transaction within which this Event was emitted.\"\"\"\n transactionHash: Hex!\n\n \"\"\"The index of the Transaction within the Block.\"\"\"\n transactionIndex: Int!\n}\n\n\"\"\"\nFilter Events by `tx.from`. Not HCA-aware — use `sender` to filter by the HCA-aware actor. Exactly one of `eq` or `in` must be provided.\n\"\"\"\ninput EventsFromFilter @oneOf {\n \"\"\"Exact `tx.from` match.\"\"\"\n eq: Address\n\n \"\"\"\n `tx.from` matches any address in the set. Max 10 items. An empty set matches nothing.\n \"\"\"\n in: [Address!]\n}\n\n\"\"\"\nFilter Events by selector (event signature). Exactly one of `eq` or `in` must be provided.\n\"\"\"\ninput EventsSelectorFilter @oneOf {\n \"\"\"Exact selector match.\"\"\"\n eq: Hex\n\n \"\"\"\n Selector matches any value in the set. Max 10 items. An empty set matches nothing.\n \"\"\"\n in: [Hex!]\n}\n\n\"\"\"\nFilter Events by HCA-aware `sender` (the HCA account address if used, otherwise Transaction.from). Exactly one of `eq` or `in` must be provided.\n\"\"\"\ninput EventsSenderFilter @oneOf {\n \"\"\"Exact `sender` match.\"\"\"\n eq: Address\n\n \"\"\"\n `sender` matches any address in the set. Max 10 items. An empty set matches nothing.\n \"\"\"\n in: [Address!]\n}\n\n\"\"\"\nFilter Events by timestamp range. At least one bound must be provided. `gt`/`gte` are mutually exclusive; `lt`/`lte` are mutually exclusive.\n\"\"\"\ninput EventsTimestampFilter {\n \"\"\"Filter to events strictly after this UnixTimestamp.\"\"\"\n gt: BigInt\n\n \"\"\"Filter to events at or after this UnixTimestamp.\"\"\"\n gte: BigInt\n\n \"\"\"Filter to events strictly before this UnixTimestamp.\"\"\"\n lt: BigInt\n\n \"\"\"Filter to events at or before this UnixTimestamp.\"\"\"\n lte: BigInt\n}\n\n\"\"\"Filter conditions for an events connection.\"\"\"\ninput EventsWhereInput {\n \"\"\"\n Filter to events whose `tx.from` matches the provided filter. Not HCA-aware — use `sender` to filter by the HCA-aware actor.\n \"\"\"\n from: EventsFromFilter\n\n \"\"\"\n Filter to events whose selector (event signature) matches the provided filter.\n \"\"\"\n selector: EventsSelectorFilter\n\n \"\"\"\n Filter to events whose HCA-aware `sender` matches the provided filter (the HCA account address if used, otherwise Transaction.from).\n \"\"\"\n sender: EventsSenderFilter\n\n \"\"\"Filter to events whose UnixTimestamp falls within the provided range.\"\"\"\n timestamp: EventsTimestampFilter\n}\n\n\"\"\"\nNested domain resolution container exposing resolved data for the domain.\n\"\"\"\ntype ForwardResolve {\n \"\"\"\n Whether protocol acceleration was requested and attempted for this resolution.\n \"\"\"\n acceleration: AccelerationStatus!\n\n \"\"\"\n The interpreted profile of an ENS name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile records were selected).\n \"\"\"\n profile: DomainProfile\n\n \"\"\"\n Resolved ENS records via the ENS protocol. Null when the name is not resolvable (non-canonical, unnormalized, or no records field was selected).\n \"\"\"\n records: ResolvedRecords\n\n \"\"\"\n Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. This data model should be expected to experience breaking changes.\n \"\"\"\n trace: JSON\n}\n\n\"\"\"Hex represents viem#Hex.\"\"\"\nscalar Hex\n\n\"\"\"InterfaceId represents an ERC-165 interface id (4-byte hex selector).\"\"\"\nscalar InterfaceId\n\n\"\"\"InterpretedLabel represents an enssdk#InterpretedLabel.\"\"\"\nscalar InterpretedLabel\n\n\"\"\"InterpretedName represents an enssdk#InterpretedName.\"\"\"\nscalar InterpretedName\n\n\"\"\"JSON represents arbitrary JSON-serializable data.\"\"\"\nscalar JSON\n\n\"\"\"\nRepresents a Label within ENS, providing its hash and interpreted representation.\n\"\"\"\ntype Label {\n \"\"\"\n The Label as a BeautifiedLabel: the Interpreted Label beautified per ENSIP-15 (https://docs.ens.domains/ensip/15) for display. An Encoded LabelHash is preserved verbatim. Display-only; use `interpreted` for lookup keys. \n (@see https://ensnode.io/docs/reference/terminology#interpreted-label)\n \"\"\"\n beautified: BeautifiedLabel!\n\n \"\"\"\n The Label's LabelHash\n (@see https://ensnode.io/docs/reference/terminology#labels-labelhashes-labelhash-function)\n \"\"\"\n hash: Hex!\n\n \"\"\"\n The Label represented as an Interpreted Label. This is either a normalized Literal Label or an Encoded LabelHash. \n (@see https://ensnode.io/docs/reference/terminology#interpreted-label)\n \"\"\"\n interpreted: InterpretedLabel!\n}\n\n\"\"\"\nLitecoinAddress represents a Base58Check-encoded Litecoin address (coin type 2).\n\"\"\"\nscalar LitecoinAddress\n\n\"\"\"\nMonacoinAddress represents a Base58Check-encoded Monacoin address (coin type 22).\n\"\"\"\nscalar MonacoinAddress\n\n\"\"\"Constructs a reference to a specific Node via one of `name` or `node`.\"\"\"\ninput NameOrNodeInput @oneOf {\n name: InterpretedName\n node: Node\n}\n\n\"\"\"\nA NameWrapperRegistration represents a Registration initiated by the ENSv1 NameWrapper.\n\"\"\"\ntype NameWrapperRegistration implements Registration {\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"The Fuses for this Registration's Domain in the NameWrapper.\"\"\"\n fuses: Int!\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n}\n\n\"\"\"Node represents an enssdk#Node.\"\"\"\nscalar Node\n\n\"\"\"Sort direction\"\"\"\nenum OrderDirection {\n ASC\n DESC\n}\n\ntype PageInfo {\n endCursor: String\n hasNextPage: Boolean!\n hasPreviousPage: Boolean!\n startCursor: String\n}\n\n\"\"\"Permissions\"\"\"\ntype Permissions {\n \"\"\"The contract within which these Permissions are granted.\"\"\"\n contract: AccountId!\n\n \"\"\"All Events associated with these Permissions.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): PermissionsEventsConnection\n\n \"\"\"A unique reference to this Permission.\"\"\"\n id: PermissionsId!\n\n \"\"\"All PermissionResources managed by this contract.\"\"\"\n resources(after: String, before: String, first: Int, last: Int): PermissionsResourcesConnection\n\n \"\"\"The Root Resource.\"\"\"\n root: PermissionsResource!\n}\n\ntype PermissionsEventsConnection {\n edges: [PermissionsEventsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype PermissionsEventsConnectionEdge {\n cursor: String!\n node: Event!\n}\n\n\"\"\"PermissionsId represents an enssdk#PermissionsId.\"\"\"\nscalar PermissionsId\n\n\"\"\"Address Permissions by ID or AccountId.\"\"\"\ninput PermissionsIdInput @oneOf {\n contract: AccountIdInput\n id: PermissionsId\n}\n\n\"\"\"PermissionsResource\"\"\"\ntype PermissionsResource {\n \"\"\"The contract within which these Permissions are granted.\"\"\"\n contract: AccountId!\n\n \"\"\"A unique reference to this PermissionsResource.\"\"\"\n id: PermissionsResourceId!\n\n \"\"\"The Permissions within which this Resource is managed.\"\"\"\n permissions: Permissions!\n\n \"\"\"Identifies the Resource that this PermissionsResource represents.\"\"\"\n resource: BigInt!\n\n \"\"\"The PermissionUsers who have Roles within this Resource.\"\"\"\n users(after: String, before: String, first: Int, last: Int): PermissionsResourceUsersConnection\n}\n\n\"\"\"PermissionsResourceId represents an enssdk#PermissionsResourceId.\"\"\"\nscalar PermissionsResourceId\n\ntype PermissionsResourceUsersConnection {\n edges: [PermissionsResourceUsersConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype PermissionsResourceUsersConnectionEdge {\n cursor: String!\n node: PermissionsUser!\n}\n\ntype PermissionsResourcesConnection {\n edges: [PermissionsResourcesConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype PermissionsResourcesConnectionEdge {\n cursor: String!\n node: PermissionsResource!\n}\n\n\"\"\"PermissionsUser\"\"\"\ntype PermissionsUser {\n \"\"\"The contract within which these Permissions are granted.\"\"\"\n contract: AccountId!\n\n \"\"\"All Events associated with this PermissionsUser.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): PermissionsUserEventsConnection\n\n \"\"\"A unique reference to this PermissionsUser.\"\"\"\n id: PermissionsUserId!\n\n \"\"\"The Resource that this user has Roles within.\"\"\"\n resource: BigInt!\n\n \"\"\"The Roles this User has been granted within this Resource.\"\"\"\n roles: BigInt!\n\n \"\"\"\n The user/grantee address this Permission is granted to (the HCA account address if used).\n \"\"\"\n user: Account!\n}\n\ntype PermissionsUserEventsConnection {\n edges: [PermissionsUserEventsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype PermissionsUserEventsConnectionEdge {\n cursor: String!\n node: Event!\n}\n\n\"\"\"PermissionsUserId represents an enssdk#PermissionsUserId.\"\"\"\nscalar PermissionsUserId\n\n\"\"\"\nSelect a primary name lookup target. Exactly one of `coinType` or `chainName` must be provided.\n\"\"\"\ninput PrimaryNameByInput @oneOf {\n \"\"\"A `ChainName` to resolve the primary name for.\"\"\"\n chainName: ChainName\n\n \"\"\"The ENSIP-9 coin type to resolve the primary name for.\"\"\"\n coinType: CoinType\n}\n\n\"\"\"An ENSIP-19 primary name for an Account on a specific coin type.\"\"\"\ntype PrimaryNameRecord {\n \"\"\"\n The chain corresponding to `coinType`, or null when `coinType` is not represented in `ChainName`.\n \"\"\"\n chainName: ChainName\n\n \"\"\"The canonical ENSIP-9 coin type for this primary name lookup.\"\"\"\n coinType: CoinType!\n\n \"\"\"\n The validated primary name for this Account on this coin type, or null if none is set.\n \"\"\"\n name: CanonicalName\n\n \"\"\"Forward resolve data for this primary name.\"\"\"\n resolve: ForwardResolve!\n}\n\n\"\"\"\nFilter primary name lookups. Exactly one of `coinTypes` or `chainNames` must be provided.\n\"\"\"\ninput PrimaryNamesWhereInput @oneOf {\n \"\"\"`ChainName` values to resolve primary names for.\"\"\"\n chainNames: [ChainName!]\n\n \"\"\"Coin types to resolve primary names for.\"\"\"\n coinTypes: [CoinType!]\n}\n\n\"\"\"The interpreted addresses on the profile of an ENS name.\"\"\"\ntype ProfileAddresses {\n \"\"\"\n The interpreted Base address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n base: Address\n\n \"\"\"\n The interpreted Binance Chain (BNB) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n binance: BinanceAddress\n\n \"\"\"\n The interpreted Bitcoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n bitcoin: BitcoinAddress\n\n \"\"\"\n The interpreted Bitcoin Cash address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n bitcoincash: BitcoinCashAddress\n\n \"\"\"\n The interpreted Dogecoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n dogecoin: DogecoinAddress\n\n \"\"\"\n The interpreted Ethereum address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n ethereum: Address\n\n \"\"\"\n The interpreted Litecoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n litecoin: LitecoinAddress\n\n \"\"\"\n The interpreted Monacoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n monacoin: MonacoinAddress\n\n \"\"\"\n The interpreted Ripple (XRP) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n ripple: RippleAddress\n\n \"\"\"\n The interpreted Rootstock (RBTC) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n rootstock: RootstockAddress\n\n \"\"\"\n The interpreted Solana address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n solana: SolanaAddress\n}\n\n\"\"\"The interpreted avatar image on the profile of an ENS name.\"\"\"\ntype ProfileAvatar {\n \"\"\"\n HTTP-compatible URL for fetching the avatar image in web browsers. Abstraction over the raw avatar record (IPFS, CAIP NFT references, etc.). Returns null when the raw value is not a direct http(s) URL and no fallback URL can be derived (including when the ENS Metadata Service is unavailable for this namespace). See https://docs.ens.domains/ensip/12.\n \"\"\"\n httpUrl: String\n}\n\n\"\"\"The interpreted header image on the profile of an ENS name.\"\"\"\ntype ProfileHeader {\n \"\"\"\n HTTP-compatible URL for fetching the header image in web browsers. Abstraction over the raw header record (IPFS, CAIP NFT references, etc.). Returns null when the raw value is not a direct http(s) URL and no fallback URL can be derived (including when the ENS Metadata Service is unavailable for this namespace). See https://docs.ens.domains/ensip/12.\n \"\"\"\n httpUrl: String\n}\n\n\"\"\"An interpreted social account on the profile of an ENS name.\"\"\"\ntype ProfileSocialAccount {\n \"\"\"The handle of the social account.\"\"\"\n handle: String!\n\n \"\"\"The HTTP-compatible url to the social account.\"\"\"\n httpUrl: String!\n}\n\n\"\"\"The interpreted social accounts on the profile of an ENS name.\"\"\"\ntype ProfileSocials {\n \"\"\"\n The interpreted GitHub account. Returns null when the raw record is unset, empty, or cannot be parsed as a GitHub handle or profile URL.\n \"\"\"\n github: ProfileSocialAccount\n\n \"\"\"\n The interpreted Keybase account. Returns null when the raw record is unset, empty, or cannot be parsed as a Keybase handle or profile URL.\n \"\"\"\n keybase: ProfileSocialAccount\n\n \"\"\"\n The interpreted LinkedIn account. Returns null when the raw record is unset, empty, or cannot be parsed as a LinkedIn handle or profile URL.\n \"\"\"\n linkedin: ProfileSocialAccount\n\n \"\"\"\n The interpreted Telegram account. Returns null when the raw record is unset, empty, or cannot be parsed as a Telegram handle or profile URL.\n \"\"\"\n telegram: ProfileSocialAccount\n\n \"\"\"\n The interpreted X (Twitter) account. Returns null when the raw record is unset, empty, or cannot be parsed as a X (Twitter) handle or profile URL.\n \"\"\"\n twitter: ProfileSocialAccount\n}\n\n\"\"\"The interpreted website on the profile of an ENS name.\"\"\"\ntype ProfileWebsite {\n \"\"\"\n The HTTP-compatible website URL. Returns null when the raw url record is unset, empty, not an http(s) URL, or cannot be parsed as a valid URL.\n \"\"\"\n httpUrl: String\n}\n\ntype Query {\n \"\"\"Identify an Account by ID or Address.\"\"\"\n account(by: AccountByInput!): Account\n\n \"\"\"Identify a Domain by Name or DomainId\"\"\"\n domain(by: DomainIdInput!): Domain\n\n \"\"\"\n Find Canonical Domains by Name. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: DomainsWhereInput!): QueryDomainsConnection\n\n \"\"\"Identify Permissions by ID or AccountId.\"\"\"\n permissions(by: PermissionsIdInput!): Permissions\n\n \"\"\"\n Identify a Registry by ID or AccountId. If querying by `contract`, only concrete Registries will be returned.\n \"\"\"\n registry(by: RegistryIdInput!): Registry\n\n \"\"\"Identify a Resolver by ID or AccountId.\"\"\"\n resolver(by: ResolverIdInput!): Resolver\n\n \"\"\"\n The Root Registry for this namespace. It will be the ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry.\n \"\"\"\n root: Registry!\n}\n\ntype QueryDomainsConnection {\n edges: [QueryDomainsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype QueryDomainsConnectionEdge {\n cursor: String!\n node: Domain!\n}\n\n\"\"\"\nA Registration represents a Domain's registration status within the various registries.\n\"\"\"\ninterface Registration {\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n}\n\n\"\"\"RegistrationId represents an enssdk#RegistrationId.\"\"\"\nscalar RegistrationId\n\ntype RegistrationRenewalsConnection {\n edges: [RegistrationRenewalsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype RegistrationRenewalsConnectionEdge {\n cursor: String!\n node: Renewal!\n}\n\n\"\"\"\nA Registry represents a Registry contract in the ENS namegraph. It may be an ENSv1Registry (a concrete ENSv1 Registry contract), an ENSv1VirtualRegistry (the virtual Registry managed by an ENSv1 domain that has children), or an ENSv2Registry.\n\"\"\"\ninterface Registry {\n \"\"\"Whether the Registry is Canonical.\"\"\"\n canonical: Boolean!\n\n \"\"\"\n Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists.\n \"\"\"\n contract: AccountId!\n\n \"\"\"\n The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection\n\n \"\"\"A unique reference to this Registry.\"\"\"\n id: RegistryId!\n\n \"\"\"The Domains for which this Registry is a Subregistry.\"\"\"\n parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection\n\n \"\"\"The Permissions managed by this Registry.\"\"\"\n permissions: Permissions\n}\n\ntype RegistryDomainsConnection {\n edges: [RegistryDomainsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype RegistryDomainsConnectionEdge {\n cursor: String!\n node: Domain!\n}\n\n\"\"\"Filter for Registry.domains query.\"\"\"\ninput RegistryDomainsWhereInput {\n \"\"\"If set, filters the set of Domains in this Registry by name.\"\"\"\n name: DomainsNameFilter\n}\n\n\"\"\"RegistryId represents an enssdk#RegistryId.\"\"\"\nscalar RegistryId\n\n\"\"\"Address a Registry by ID or AccountId.\"\"\"\ninput RegistryIdInput @oneOf {\n contract: AccountIdInput\n id: RegistryId\n}\n\ntype RegistryParentsConnection {\n edges: [RegistryParentsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype RegistryParentsConnectionEdge {\n cursor: String!\n node: Domain!\n}\n\ntype RegistryPermissionsUser {\n \"\"\"A unique reference to this RegistryPermissionsUser.\"\"\"\n id: PermissionsUserId!\n\n \"\"\"The Registry in which this Permission is granted.\"\"\"\n registry: Registry!\n\n \"\"\"The Resource for which this Permission is granted.\"\"\"\n resource: BigInt!\n\n \"\"\"The Roles that this Permission grants.\"\"\"\n roles: BigInt!\n\n \"\"\"\n The user/grantee address this Permission is granted to (the HCA account address if used).\n \"\"\"\n user: Account!\n}\n\n\"\"\"A Renewal represents an extension of a Registration's expiry.\"\"\"\ntype Renewal {\n \"\"\"The `base` cost of a Renewal, in wei, if exists.\"\"\"\n base: BigInt\n\n \"\"\"The duration for which a Registration was extended.\"\"\"\n duration: BigInt!\n\n \"\"\"The Event for which this Renewal was created.\"\"\"\n event: Event!\n\n \"\"\"A unique reference to this Renewal.\"\"\"\n id: RenewalId!\n\n \"\"\"The `premium` cost of a Renewal, in wei, if exists.\"\"\"\n premium: BigInt\n\n \"\"\"The extra `referrer` data provided with a Renewal, if exists.\"\"\"\n referrer: Hex\n}\n\n\"\"\"RenewalId represents an enssdk#RenewalId.\"\"\"\nscalar RenewalId\n\n\"\"\"A resolved ABI record for an ENS name.\"\"\"\ntype ResolvedAbiRecord {\n contentType: BigInt!\n data: Hex!\n}\n\n\"\"\"A resolved address record for an ENS name.\"\"\"\ntype ResolvedAddressRecord {\n \"\"\"\n The \"raw\" resolved address record as hex, or null if not set, empty (\"0x\"), or zeroAddress. Decode with ENSIP-9 (https://docs.ens.domains/ensip/9) and address-encoder (https://github.com/ensdomains/address-encoder) for the associated coin type. Guaranteed to be at least one byte of hex data. There is no guarantee that an EVM CoinType returns an address value of any particular byte length.\n \"\"\"\n address: Hex\n\n \"\"\"The coin type for this address record.\"\"\"\n coinType: CoinType!\n}\n\n\"\"\"A resolved ERC-165 interface implementer record for an ENS name.\"\"\"\ntype ResolvedInterfaceRecord {\n implementer: Address\n interfaceId: InterfaceId!\n}\n\n\"\"\"A resolved PubkeyResolver (x, y) pair for an ENS name.\"\"\"\ntype ResolvedPubkeyRecord {\n x: Hex!\n y: Hex!\n}\n\n\"\"\"\nA resolved 'raw' text record for an ENS name. Value is any possible string and may require additional validation or preprocessing before use.\n\"\"\"\ntype ResolvedRawTextRecord {\n \"\"\"The text record key.\"\"\"\n key: String!\n\n \"\"\"\n The 'raw' text record value, or null if not set. Value is any possible string and may require additional validation or preprocessing before use.\n \"\"\"\n value: String\n}\n\n\"\"\"Records resolved for a specific ENS name via the ENS protocol.\"\"\"\ntype ResolvedRecords {\n \"\"\"\n The first stored ABI matching the requested content-type bitmask, or null if not set.\n \"\"\"\n abi(\n \"\"\"\n Content-type bitmask; the resolver returns the first stored ABI whose bit is set (lowest bit first).\n \"\"\"\n contentTypeMask: BigInt!\n ): ResolvedAbiRecord\n\n \"\"\"Resolved address records for the requested coin types.\"\"\"\n addresses(\n \"\"\"Coin types to resolve (e.g. `60` for ETH).\"\"\"\n coinTypes: [CoinType!]!\n ): [ResolvedAddressRecord!]!\n\n \"\"\"The ENSIP-7 contenthash record raw bytes, or null if not set.\"\"\"\n contenthash: Hex\n\n \"\"\"The IDNSZoneResolver zonehash raw bytes, or null if not set.\"\"\"\n dnszonehash: Hex\n\n \"\"\"Resolved ERC-165 interface implementer records for the requested ids.\"\"\"\n interfaces(\n \"\"\"ERC-165 interface ids to resolve (4-byte hex selectors).\"\"\"\n ids: [InterfaceId!]!\n ): [ResolvedInterfaceRecord!]!\n\n \"\"\"The PubkeyResolver (x, y) pair, or null if not set.\"\"\"\n pubkey: ResolvedPubkeyRecord\n\n \"\"\"\n The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. To reduce a common point of developer confusion the Omnigraph API represents this as the `reverseName` rather than the `name` record which is what this field actually resolves to onchain.\n \"\"\"\n reverseName: String\n\n \"\"\"Resolved text records for the requested keys.\"\"\"\n texts(\n \"\"\"Text record keys to resolve (e.g. `avatar`, `description`).\"\"\"\n keys: [String!]!\n ): [ResolvedRawTextRecord!]!\n\n \"\"\"The IVersionableResolver version, or null if not set or unavailable.\"\"\"\n version: BigInt\n}\n\n\"\"\"A Resolver represents a Resolver contract on-chain.\"\"\"\ntype Resolver {\n \"\"\"\n If Resolver is a Bridged Resolver, the Registry to which it Bridges resolution.\n \"\"\"\n bridged: Registry\n\n \"\"\"Contract metadata for this Resolver.\"\"\"\n contract: AccountId!\n\n \"\"\"All Events associated with this Resolver.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): ResolverEventsConnection\n\n \"\"\"\n Whether this Resolver implements ENSIP-10 wildcard resolution (`IExtendedResolver`, interfaceId `0x9061b923`), determined via a single cached `supportsInterface` RPC the first time the Resolver is observed.\n \"\"\"\n extended: Boolean!\n\n \"\"\"A unique reference to this Resolver.\"\"\"\n id: ResolverId!\n\n \"\"\"Permissions granted by this Resolver.\"\"\"\n permissions: Permissions\n\n \"\"\"ResolverRecords issued by this Resolver.\"\"\"\n records(after: String, before: String, first: Int, last: Int): ResolverRecordsConnection\n\n \"\"\"Identify a ResolverRecord by `name` or `node`.\"\"\"\n records_(by: NameOrNodeInput!): ResolverRecords\n}\n\ntype ResolverEventsConnection {\n edges: [ResolverEventsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype ResolverEventsConnectionEdge {\n cursor: String!\n node: Event!\n}\n\n\"\"\"ResolverId represents an enssdk#ResolverId.\"\"\"\nscalar ResolverId\n\n\"\"\"Address a Resolver by ID or AccountId.\"\"\"\ninput ResolverIdInput @oneOf {\n contract: AccountIdInput\n id: ResolverId\n}\n\ntype ResolverPermissionsUser {\n \"\"\"A unique reference to this ResolverPermissionsUser.\"\"\"\n id: PermissionsUserId!\n\n \"\"\"The Resolver in which this Permission is granted.\"\"\"\n resolver: Resolver!\n\n \"\"\"The Resource for which this Permission is granted.\"\"\"\n resource: BigInt!\n\n \"\"\"The Roles that this Permission grants.\"\"\"\n roles: BigInt!\n\n \"\"\"\n The user/grantee address this Permission is granted to (the HCA account address if used).\n \"\"\"\n user: Account!\n}\n\n\"\"\"ResolverRecords represents the _indexed_ records within a Resolver.\"\"\"\ntype ResolverRecords {\n \"\"\"Unique CoinTypes of `addr` records for this `node`.\"\"\"\n coinTypes: [CoinType!]!\n\n \"\"\"A unique reference to these ResolverRecords.\"\"\"\n id: ResolverRecordsId!\n\n \"\"\"Unique keys of `text` records for this `node`.\"\"\"\n keys: [String!]!\n\n \"\"\"The `name` record for this `node`, if any.\"\"\"\n name: String\n\n \"\"\"The Node for which these ResolverRecords are issued.\"\"\"\n node: Node!\n}\n\ntype ResolverRecordsConnection {\n edges: [ResolverRecordsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype ResolverRecordsConnectionEdge {\n cursor: String!\n node: ResolverRecords!\n}\n\n\"\"\"ResolverRecordsId represents an enssdk#ResolverRecordsId.\"\"\"\nscalar ResolverRecordsId\n\n\"\"\"Nested account resolution container exposing primary name resolution.\"\"\"\ntype ReverseResolve {\n \"\"\"\n Whether protocol acceleration was requested and attempted for this reverse resolution.\n \"\"\"\n acceleration: AccelerationStatus!\n\n \"\"\"\n The primary name for this Account on a specific coin type or chain name.\n \"\"\"\n primaryName(\n \"\"\"Select a coin type or chain name to resolve a primary name for.\"\"\"\n by: PrimaryNameByInput!\n ): PrimaryNameRecord!\n\n \"\"\"\n Primary names for this Account on the requested coin types or chain names.\n \"\"\"\n primaryNames(\n \"\"\"Select coin types or chain names to resolve primary names for.\"\"\"\n where: PrimaryNamesWhereInput!\n ): [PrimaryNameRecord!]!\n\n \"\"\"\n Protocol trace tree emitted by reverse resolution, represented as JSON for schema stability. This data model should be expected to experience breaking changes.\n \"\"\"\n trace: JSON!\n}\n\n\"\"\"\nRippleAddress represents a Base58Check-encoded Ripple (XRP) address (coin type 144).\n\"\"\"\nscalar RippleAddress\n\n\"\"\"\nRootstockAddress represents an EIP-55 checksummed Rootstock (RBTC) address (coin type 137).\n\"\"\"\nscalar RootstockAddress\n\n\"\"\"\nSolanaAddress represents a Base58-encoded Solana address (coin type 501).\n\"\"\"\nscalar SolanaAddress\n\n\"\"\"Filter for Domain.subdomains query.\"\"\"\ninput SubdomainsWhereInput {\n \"\"\"If set, filters the set of subdomains by name.\"\"\"\n name: DomainsNameFilter\n}\n\n\"\"\"ThreeDNSRegistration represents a Registration within ThreeDNSToken.\"\"\"\ntype ThreeDNSRegistration implements Registration {\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n}\n\n\"\"\"\nA resolvable-but-unindexed Domain: not present in the index, but resolvable because an ancestor in its namegraph path has an ENSIP-10 wildcard Resolver (e.g. off-chain / CCIP-Read names, unindexed 3DNS names, wildcard subnames).\n\"\"\"\ntype UnindexedDomain implements Domain {\n \"\"\"\n Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree.\n \"\"\"\n canonical: DomainCanonical\n\n \"\"\"All Events associated with this Domain.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection\n\n \"\"\"A unique and stable reference to this Domain.\"\"\"\n id: DomainId!\n\n \"\"\"The Label associated with this Domain in the ENS Namegraph.\"\"\"\n label: Label!\n\n \"\"\"\n If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).\n \"\"\"\n owner: Account\n\n \"\"\"\n The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`.\n \"\"\"\n parent: Domain\n\n \"\"\"The latest Registration for this Domain, if exists.\"\"\"\n registration: Registration\n\n \"\"\"All Registrations for a Domain, including the latest Registration.\"\"\"\n registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection\n\n \"\"\"\n The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`).\n \"\"\"\n registry: Registry!\n\n \"\"\"Resolve protocol-level data for this Domain.\"\"\"\n resolve(\n \"\"\"\n When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled.\n @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration\n \"\"\"\n accelerate: Boolean = true\n ): ForwardResolve!\n\n \"\"\"Resolver relationship metadata for this Domain.\"\"\"\n resolver: DomainResolver!\n\n \"\"\"\n All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection\n\n \"\"\"The Registry this Domain declares as its Subregistry, if exists.\"\"\"\n subregistry: Registry\n}\n\n\"\"\"\nAdditional metadata for BaseRegistrar Registrations wrapped by the NameWrapper (i.e. in the case of a wrapped .eth name)\n\"\"\"\ntype WrappedBaseRegistrarRegistration {\n \"\"\"The Fuses for this Registration's Domain in the NameWrapper.\"\"\"\n fuses: Int!\n\n \"\"\"\n The TokenId for this Domain in the NameWrapper. This is the bigint encoding of the Domain's Node.\n \"\"\"\n tokenId: BigInt!\n}"; From 9bddeb2abc2c1ff609c1226a9478074682585038 Mon Sep 17 00:00:00 2001 From: djstrong Date: Tue, 16 Jun 2026 18:21:12 +0200 Subject: [PATCH 07/16] fix(ensapi): improve error handling in MCP API and update schema SDL file configuration --- apps/ensapi/src/handlers/api/mcp/mcp-api.ts | 4 ++-- docs/ensnode.io/package.json | 1 + packages/enssdk/package.json | 8 +++++--- packages/enssdk/tsup.config.ts | 1 + 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts index fb8ff9436f..640412dec9 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts @@ -357,14 +357,14 @@ app.all("/", async (c) => { await server.connect(session.transport); const response = await session.transport.handleRequest(mcpContext, body); return response ?? c.body(null, 202); - } catch (error) { + } catch { if (initializedSessionId) { await closeSession(initializedSessionId); } else { await session.transport.close(); await session.server.close(); } - throw error; + return jsonRpcErrorResponse(c, -32_603, "Internal error", 500); } } diff --git a/docs/ensnode.io/package.json b/docs/ensnode.io/package.json index cf63195c77..a40f3e0311 100644 --- a/docs/ensnode.io/package.json +++ b/docs/ensnode.io/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "astro dev", "start": "astro dev", + "prebuild": "node ../../packages/enssdk/scripts/inline-schema-sdl.mjs", "build": "astro build", "preview": "astro preview", "astro": "astro", diff --git a/packages/enssdk/package.json b/packages/enssdk/package.json index dc9cd8c7a6..18006a6784 100644 --- a/packages/enssdk/package.json +++ b/packages/enssdk/package.json @@ -17,8 +17,7 @@ ], "files": [ "dist", - "src/omnigraph/generated/schema.graphql", - "src/omnigraph/generated/schema-sdl.ts" + "src/omnigraph/generated/schema.graphql" ], "exports": { ".": "./src/index.ts", @@ -44,7 +43,10 @@ "default": "./dist/omnigraph/index.js" }, "./omnigraph/schema.graphql": "./src/omnigraph/generated/schema.graphql", - "./omnigraph/schema-sdl": "./src/omnigraph/generated/schema-sdl.ts" + "./omnigraph/schema-sdl": { + "types": "./dist/omnigraph/generated/schema-sdl.d.ts", + "default": "./dist/omnigraph/generated/schema-sdl.js" + } } }, "scripts": { diff --git a/packages/enssdk/tsup.config.ts b/packages/enssdk/tsup.config.ts index 8792705715..80bdeff07d 100644 --- a/packages/enssdk/tsup.config.ts +++ b/packages/enssdk/tsup.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ index: "src/index.ts", "core/index": "src/core/index.ts", "omnigraph/index": "src/omnigraph/index.ts", + "omnigraph/generated/schema-sdl": "src/omnigraph/generated/schema-sdl.ts", }, platform: "neutral", format: ["esm"], From 605d0152cc3bbfa12e36c231f573a734c1a64c78 Mon Sep 17 00:00:00 2001 From: djstrong Date: Wed, 17 Jun 2026 19:31:09 +0200 Subject: [PATCH 08/16] refactor(ensapi): update Omnigraph schema handling and CI configuration --- .changeset/omnigraph-schema-reference.md | 2 +- .github/workflows/test_ci.yml | 4 ++-- .gitignore | 1 + docs/ensnode.io/package.json | 1 - packages/ensnode-sdk/package.json | 3 ++- .../src/omnigraph-api}/generated/schema-sdl.ts | 2 +- .../src/omnigraph-api/schema-reference.ts | 5 +++-- packages/enssdk/package.json | 11 +++-------- packages/enssdk/scripts/inline-schema-sdl.mjs | 14 -------------- packages/enssdk/tsup.config.ts | 1 - 10 files changed, 13 insertions(+), 31 deletions(-) rename packages/{enssdk/src/omnigraph => ensnode-sdk/src/omnigraph-api}/generated/schema-sdl.ts (99%) delete mode 100644 packages/enssdk/scripts/inline-schema-sdl.mjs diff --git a/.changeset/omnigraph-schema-reference.md b/.changeset/omnigraph-schema-reference.md index 7d77ec0887..2ac0b59dcd 100644 --- a/.changeset/omnigraph-schema-reference.md +++ b/.changeset/omnigraph-schema-reference.md @@ -3,4 +3,4 @@ "enssdk": patch --- -Add offline Omnigraph schema lookup helpers (`lookupOmnigraphSchema`, `buildCondensedSchemaReference`) on `@ensnode/ensnode-sdk/internal` for enscli, ENSApi MCP, and ensskills. Export bundled SDL via `enssdk/omnigraph/schema-sdl` so schema loading works in Vite/Rollup builds without Node `createRequire`. +Add offline Omnigraph schema lookup helpers (`lookupOmnigraphSchema`, `buildCondensedSchemaReference`) on `@ensnode/ensnode-sdk/internal` for enscli, ENSApi MCP, and ensskills. Bundle Omnigraph SDL in ensnode-sdk (generated from `enssdk/omnigraph/schema.graphql`) so schema loading works in Vite/Rollup builds without Node `createRequire`. diff --git a/.github/workflows/test_ci.yml b/.github/workflows/test_ci.yml index b9671aab30..7c13675337 100644 --- a/.github/workflows/test_ci.yml +++ b/.github/workflows/test_ci.yml @@ -91,11 +91,11 @@ jobs: - name: Verify generated files are committed run: | - if ! git diff --quiet packages/enssdk/src/omnigraph/generated/; then + if ! git diff --quiet packages/enssdk/src/omnigraph/generated/ packages/ensnode-sdk/src/omnigraph-api/generated/; then echo "Error: Generated files are out of sync" echo "" echo "The following generated files differ from what is committed:" - git diff --name-status packages/enssdk/src/omnigraph/generated/ + git diff --name-status packages/enssdk/src/omnigraph/generated/ packages/ensnode-sdk/src/omnigraph-api/generated/ echo "" echo "To fix, run:" echo " pnpm generate:gqlschema" diff --git a/.gitignore b/.gitignore index 4d0a20abde..929c4ae5a4 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ dist # Ponder generated !packages/enssdk/src/omnigraph/generated/ +!packages/ensnode-sdk/src/omnigraph-api/generated/ .ponder #jetbrains elements diff --git a/docs/ensnode.io/package.json b/docs/ensnode.io/package.json index a40f3e0311..cf63195c77 100644 --- a/docs/ensnode.io/package.json +++ b/docs/ensnode.io/package.json @@ -8,7 +8,6 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "prebuild": "node ../../packages/enssdk/scripts/inline-schema-sdl.mjs", "build": "astro build", "preview": "astro preview", "astro": "astro", diff --git a/packages/ensnode-sdk/package.json b/packages/ensnode-sdk/package.json index 8363d252b4..42d3d8e655 100644 --- a/packages/ensnode-sdk/package.json +++ b/packages/ensnode-sdk/package.json @@ -55,7 +55,8 @@ "lint": "biome check --write .", "lint:ci": "biome ci", "test": "vitest", - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "generate:gqlschema": "node scripts/inline-schema-sdl.mjs" }, "peerDependencies": { "viem": "catalog:" diff --git a/packages/enssdk/src/omnigraph/generated/schema-sdl.ts b/packages/ensnode-sdk/src/omnigraph-api/generated/schema-sdl.ts similarity index 99% rename from packages/enssdk/src/omnigraph/generated/schema-sdl.ts rename to packages/ensnode-sdk/src/omnigraph-api/generated/schema-sdl.ts index 468012f9fc..73046cf82d 100644 --- a/packages/enssdk/src/omnigraph/generated/schema-sdl.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/generated/schema-sdl.ts @@ -1,2 +1,2 @@ -/** Auto-generated from schema.graphql — run `pnpm generate:gqlschema` to refresh. */ +/** Auto-generated from enssdk schema.graphql — run `pnpm generate:gqlschema` to refresh. */ export const omnigraphSchemaSdl = "\"\"\"Execution status metadata for a resolver strategy.\"\"\"\ntype AccelerationStatus {\n \"\"\"Whether protocol acceleration was attempted at runtime.\"\"\"\n attempted: Boolean!\n\n \"\"\"Whether protocol acceleration was requested by the caller.\"\"\"\n requested: Boolean!\n}\n\n\"\"\"Represents an individual Account, keyed by its Address.\"\"\"\ntype Account {\n \"\"\"An EVM Address that uniquely identifies this Account on-chain.\"\"\"\n address: Address!\n\n \"\"\"\n The Domains that are owned by the Account. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: AccountDomainsWhereInput): AccountDomainsConnection\n\n \"\"\"\n All Events for which this Account is the HCA-aware `sender` (i.e. `Event.sender`).\n \"\"\"\n events(after: String, before: String, first: Int, last: Int, where: AccountEventsWhereInput): AccountEventsConnection\n\n \"\"\"A unique reference to this Account.\"\"\"\n id: Address!\n\n \"\"\"\n The Permissions granted to this Account, optionally filtered to Permissions in a specific contract.\n \"\"\"\n permissions(after: String, before: String, first: Int, last: Int, where: AccountPermissionsWhereInput): AccountPermissionsConnection\n\n \"\"\"The Permissions on Registries granted to this Account.\"\"\"\n registryPermissions(after: String, before: String, first: Int, last: Int): AccountRegistryPermissionsConnection\n\n \"\"\"Resolve primary names for this Account.\"\"\"\n resolve(\n \"\"\"\n When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled.\n @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration\n \"\"\"\n accelerate: Boolean = true\n ): ReverseResolve!\n\n \"\"\"The Permissions on Resolvers granted to this Account.\"\"\"\n resolverPermissions(after: String, before: String, first: Int, last: Int): AccountResolverPermissionsConnection\n}\n\n\"\"\"Address an Account by ID or Address.\"\"\"\ninput AccountByInput @oneOf {\n address: Address\n id: Address\n}\n\ntype AccountDomainsConnection {\n edges: [AccountDomainsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype AccountDomainsConnectionEdge {\n cursor: String!\n node: Domain!\n}\n\n\"\"\"Filter for Account.domains query.\"\"\"\ninput AccountDomainsWhereInput {\n \"\"\"\n If set, filters the set of Domains by canonicality (i.e. reachability by ENS Forward Resolution): `true` for Canonical only, `false` for non-Canonical only. If omitted, returns all Domains owned by the Account regardless of canonicality.\n \"\"\"\n canonical: Boolean\n\n \"\"\"If set, filters the set of Domains by name.\"\"\"\n name: DomainsNameFilter\n\n \"\"\"\n If set, filters the set of Domains to only those of the specified ENS protocol version.\n \"\"\"\n version: ENSProtocolVersion\n}\n\ntype AccountEventsConnection {\n edges: [AccountEventsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype AccountEventsConnectionEdge {\n cursor: String!\n node: Event!\n}\n\n\"\"\"\nFilter conditions for Account.events (where `sender` is implied by the Account).\n\"\"\"\ninput AccountEventsWhereInput {\n \"\"\"\n Filter to events whose `tx.from` matches the provided filter. Not HCA-aware — the Account's HCA-aware filter is applied via `sender = Account.id`.\n \"\"\"\n from: EventsFromFilter\n\n \"\"\"\n Filter to events whose selector (event signature) matches the provided filter.\n \"\"\"\n selector: EventsSelectorFilter\n\n \"\"\"Filter to events whose UnixTimestamp falls within the provided range.\"\"\"\n timestamp: EventsTimestampFilter\n}\n\n\"\"\"A CAIP-10 Account ID including chainId and address.\"\"\"\ntype AccountId {\n address: Address!\n chainId: ChainId!\n}\n\n\"\"\"A CAIP-10 Account ID including chainId and address.\"\"\"\ninput AccountIdInput {\n address: Address!\n chainId: ChainId!\n}\n\ntype AccountPermissionsConnection {\n edges: [AccountPermissionsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype AccountPermissionsConnectionEdge {\n cursor: String!\n node: PermissionsUser!\n}\n\n\"\"\"Filter for Account.permissions.\"\"\"\ninput AccountPermissionsWhereInput {\n \"\"\"\n If set, filters this Account's Permissions to those granted in this contract.\n \"\"\"\n contract: AccountIdInput\n}\n\ntype AccountRegistryPermissionsConnection {\n edges: [AccountRegistryPermissionsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype AccountRegistryPermissionsConnectionEdge {\n cursor: String!\n node: RegistryPermissionsUser!\n}\n\ntype AccountResolverPermissionsConnection {\n edges: [AccountResolverPermissionsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype AccountResolverPermissionsConnectionEdge {\n cursor: String!\n node: ResolverPermissionsUser!\n}\n\n\"\"\"Address represents an EVM Address in all lowercase.\"\"\"\nscalar Address\n\n\"\"\"\nA BaseRegistrarRegistration represents a Registration within an ENSv1 BaseRegistrar contract, including those deployed by Basenames and Lineanames.\n\"\"\"\ntype BaseRegistrarRegistration implements Registration {\n \"\"\"The `baseCost` for registering this Domain, in wei.\"\"\"\n baseCost: BigInt\n\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"\n Whether this Registration is in the Grace Period (90 days) and can be renewed by the current owner.\n \"\"\"\n isInGracePeriod: Boolean!\n\n \"\"\"The `premium` for registering this Domain, in wei.\"\"\"\n premium: BigInt\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The TokenId for this Domain in the BaseRegistrar. This is the bigint encoding of the Domain's LabelHash.\n \"\"\"\n tokenId: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n\n \"\"\"\n Additional metadata if this BaseRegistrarRegistration is wrapped by the NameWrapper (i.e. in the case of wrapped .eth names).\n \"\"\"\n wrapped: WrappedBaseRegistrarRegistration\n}\n\n\"\"\"\nBeautifiedLabel represents an enssdk#BeautifiedLabel: an InterpretedLabel beautified per ENSIP-15 (https://docs.ens.domains/ensip/15) for display. It is display-only and MUST NOT be used as a lookup key.\n\"\"\"\nscalar BeautifiedLabel\n\n\"\"\"\nBeautifiedName represents an enssdk#BeautifiedName: an InterpretedName whose normalized labels have been beautified per ENSIP-15 (https://docs.ens.domains/ensip/15) for display. It is display-only and MUST NOT be used as a navigation target or lookup key.\n\"\"\"\nscalar BeautifiedName\n\n\"\"\"BigInt represents non-fractional signed whole numeric values.\"\"\"\nscalar BigInt\n\n\"\"\"\nBinanceAddress represents a Bech32-encoded Binance Chain (BNB) address (coin type 714).\n\"\"\"\nscalar BinanceAddress\n\n\"\"\"\nBitcoinAddress represents a Base58Check-encoded Bitcoin address (coin type 0).\n\"\"\"\nscalar BitcoinAddress\n\n\"\"\"\nBitcoinCashAddress represents a CashAddr-encoded Bitcoin Cash address (coin type 145).\n\"\"\"\nscalar BitcoinCashAddress\n\n\"\"\"A Canonical Name, exposed in each representation we support.\"\"\"\ntype CanonicalName {\n \"\"\"\n The Canonical Name as a BeautifiedName: the InterpretedName with its normalized labels beautified per ENSIP-15 (https://docs.ens.domains/ensip/15) for display. Encoded LabelHash labels are preserved verbatim. Display-only; use `interpreted` for navigation targets and lookup keys.\n \"\"\"\n beautified: BeautifiedName!\n\n \"\"\"\n The Canonical Name as an InterpretedName: each label is either a normalized literal Label or an Encoded LabelHash.\n \"\"\"\n interpreted: InterpretedName!\n}\n\n\"\"\"ChainId represents an enssdk#ChainId.\"\"\"\nscalar ChainId\n\n\"\"\"\nThe names of chains that the Omnigraph API supports identifying by name as a syntactic convenience. The Omnigraph API supports identification of additional chains beyond this list, but those chains must be identified through other mechanisms such as `coinType` or `chainId`.\n\"\"\"\nenum ChainName {\n ARBITRUM_ONE\n BASE\n ETHEREUM\n LINEA\n OPTIMISM\n SCROLL\n}\n\n\"\"\"CoinType represents an enssdk#CoinType.\"\"\"\nscalar CoinType\n\n\"\"\"\nDogecoinAddress represents a Base58Check-encoded Dogecoin address (coin type 3).\n\"\"\"\nscalar DogecoinAddress\n\n\"\"\"\nRepresents a Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain.\n\"\"\"\ninterface Domain {\n \"\"\"\n Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree.\n \"\"\"\n canonical: DomainCanonical\n\n \"\"\"All Events associated with this Domain.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection\n\n \"\"\"A unique and stable reference to this Domain.\"\"\"\n id: DomainId!\n\n \"\"\"The Label associated with this Domain in the ENS Namegraph.\"\"\"\n label: Label!\n\n \"\"\"\n If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).\n \"\"\"\n owner: Account\n\n \"\"\"\n The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`.\n \"\"\"\n parent: Domain\n\n \"\"\"The latest Registration for this Domain, if exists.\"\"\"\n registration: Registration\n\n \"\"\"All Registrations for a Domain, including the latest Registration.\"\"\"\n registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection\n\n \"\"\"\n The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`).\n \"\"\"\n registry: Registry!\n\n \"\"\"Resolve protocol-level data for this Domain.\"\"\"\n resolve(\n \"\"\"\n When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled.\n @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration\n \"\"\"\n accelerate: Boolean = true\n ): ForwardResolve!\n\n \"\"\"Resolver relationship metadata for this Domain.\"\"\"\n resolver: DomainResolver!\n\n \"\"\"\n All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection\n\n \"\"\"The Registry this Domain declares as its Subregistry, if exists.\"\"\"\n subregistry: Registry\n}\n\n\"\"\"\nCanonicality metadata for a Domain, including its name, depth, path, and node (namehash).\n\"\"\"\ntype DomainCanonical {\n \"\"\"\n The depth of this Domain, i.e. the number of labels in this Domain's Canonical Name (e.g. 2 for `vitalik.eth`).\n \"\"\"\n depth: Int!\n\n \"\"\"The Canonical Name for this Domain.\"\"\"\n name: CanonicalName!\n\n \"\"\"\n The namehash of this Domain's Canonical Name. Note that this is NOT a stable reference to this Domain; use `Domain.id`.\n \"\"\"\n node: Node!\n\n \"\"\"\n The Canonical Path from this Domain to the ENS Root, root→leaf inclusive of this Domain.\n \"\"\"\n path: [Domain!]!\n}\n\ntype DomainEventsConnection {\n edges: [DomainEventsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype DomainEventsConnectionEdge {\n cursor: String!\n node: Event!\n}\n\n\"\"\"DomainId represents an enssdk#DomainId.\"\"\"\nscalar DomainId\n\n\"\"\"Reference a specific Domain.\"\"\"\ninput DomainIdInput @oneOf {\n id: DomainId\n name: InterpretedName\n}\n\n\"\"\"\nFilter Permissions by user address. Exactly one of `eq` or `in` must be provided.\n\"\"\"\ninput DomainPermissionsUserFilter @oneOf {\n \"\"\"Exact user address match.\"\"\"\n eq: Address\n\n \"\"\"\n User address matches any value in the set. Max 10 items. An empty set matches nothing.\n \"\"\"\n in: [Address!]\n}\n\n\"\"\"Filter Permissions over this Domain by user.\"\"\"\ninput DomainPermissionsWhereInput {\n \"\"\"Filter Permissions to those whose user matches the provided filter.\"\"\"\n user: DomainPermissionsUserFilter\n}\n\n\"\"\"The interpreted profile of an ENS name.\"\"\"\ntype DomainProfile {\n \"\"\"The interpreted addresses on the profile of an ENS name.\"\"\"\n addresses: ProfileAddresses\n\n \"\"\"\n Interpreted avatar metadata. Returns null when the raw avatar record is unset or empty.\n \"\"\"\n avatar: ProfileAvatar\n\n \"\"\"\n The interpreted description on the profile of an ENS name, or null when unset.\n \"\"\"\n description: String\n\n \"\"\"\n The interpreted email address on the profile of an ENS name, or null when unset or invalid.\n \"\"\"\n email: Email\n\n \"\"\"\n Interpreted header metadata. Returns null when the raw header record is unset or empty.\n \"\"\"\n header: ProfileHeader\n\n \"\"\"The interpreted social accounts on the profile of an ENS name.\"\"\"\n socials: ProfileSocials\n\n \"\"\"The interpreted website on the profile of an ENS name.\"\"\"\n website: ProfileWebsite\n}\n\ntype DomainRegistrationsConnection {\n edges: [DomainRegistrationsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype DomainRegistrationsConnectionEdge {\n cursor: String!\n node: Registration!\n}\n\n\"\"\"Metadata describing this Domain's relationship to its Resolver(s).\"\"\"\ntype DomainResolver {\n \"\"\"\n The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution.\n \"\"\"\n assigned: Resolver\n\n \"\"\"\n The Resolver that ENS Forward Resolution (ENSIP-10) lands on for this Domain — i.e. its _effective_ Resolver. Null when no active Resolver exists or the Domain is not in the Canonical Nametree.\n \"\"\"\n effective: Resolver\n}\n\ntype DomainSubdomainsConnection {\n edges: [DomainSubdomainsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype DomainSubdomainsConnectionEdge {\n cursor: String!\n node: Domain!\n}\n\n\"\"\"\nFilter Domains by name. Exactly one of `starts_with`, `eq`, or `in` must be provided.\n\"\"\"\ninput DomainsNameFilter @oneOf {\n \"\"\"\n Exact InterpretedName match. Sugar for `in: [eq]`. Combine with `version` to disambiguate across ENS protocol versions.\n \"\"\"\n eq: InterpretedName\n\n \"\"\"\n Exact InterpretedName match against any name in the set. Max 100 items.\n \"\"\"\n in: [InterpretedName!]\n\n \"\"\"\n Prefix-match on Interpreted Name for typeahead. ex: 'vit', 'vitalik.et'. Case-insensitive (InterpretedName labels are normalized). Matched against the first 64 code points of the name; prefixes longer than 64 code points never match.\n \"\"\"\n starts_with: String\n}\n\n\"\"\"Fields by which domains can be ordered.\"\"\"\nenum DomainsOrderBy {\n \"\"\"\n Order by Canonical Name depth (number of labels); e.g. `eth` < `vitalik.eth`.\n \"\"\"\n DEPTH\n\n \"\"\"Order by the Domain's Canonical Name, alphabetically.\"\"\"\n NAME\n\n \"\"\"\n Order by the expiry of the Domain's latest Registration. A Domain that never expires (or has no Registration) is treated as +∞: it sorts last when `dir: ASC` (“expiring soonest first”) and first when `dir: DESC` (“expiring latest first”).\n \"\"\"\n REGISTRATION_EXPIRY\n\n \"\"\"\n Order by the start time of the Domain's latest Registration. A Domain with no Registration has no timestamp and sorts last when `dir: ASC` (“oldest registered first”) and first when `dir: DESC` (“most recently registered first”).\n \"\"\"\n REGISTRATION_TIMESTAMP\n}\n\n\"\"\"\nOrdering options for domains query. If no order is provided, the default is ASC.\n\"\"\"\ninput DomainsOrderInput {\n by: DomainsOrderBy!\n dir: OrderDirection = ASC\n}\n\n\"\"\"Filter for the top-level domains query.\"\"\"\ninput DomainsWhereInput {\n \"\"\"Filter the set of Domains by name.\"\"\"\n name: DomainsNameFilter!\n\n \"\"\"\n If set, filters the set of Domains to only those of the specified ENS protocol version.\n \"\"\"\n version: ENSProtocolVersion\n}\n\n\"\"\"An ENS protocol version.\"\"\"\nenum ENSProtocolVersion {\n ENSv1\n ENSv2\n}\n\n\"\"\"An ENSv1Domain represents an ENSv1 Domain.\"\"\"\ntype ENSv1Domain implements Domain {\n \"\"\"\n Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree.\n \"\"\"\n canonical: DomainCanonical\n\n \"\"\"All Events associated with this Domain.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection\n\n \"\"\"A unique and stable reference to this Domain.\"\"\"\n id: DomainId!\n\n \"\"\"The Label associated with this Domain in the ENS Namegraph.\"\"\"\n label: Label!\n\n \"\"\"The namehash of this ENSv1 Domain.\"\"\"\n node: Node!\n\n \"\"\"\n If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).\n \"\"\"\n owner: Account\n\n \"\"\"\n The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`.\n \"\"\"\n parent: Domain\n\n \"\"\"The latest Registration for this Domain, if exists.\"\"\"\n registration: Registration\n\n \"\"\"All Registrations for a Domain, including the latest Registration.\"\"\"\n registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection\n\n \"\"\"\n The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`).\n \"\"\"\n registry: Registry!\n\n \"\"\"Resolve protocol-level data for this Domain.\"\"\"\n resolve(\n \"\"\"\n When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled.\n @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration\n \"\"\"\n accelerate: Boolean = true\n ): ForwardResolve!\n\n \"\"\"Resolver relationship metadata for this Domain.\"\"\"\n resolver: DomainResolver!\n\n \"\"\"\n The rootRegistryOwner of this Domain, i.e. the owner() of this Domain within the ENSv1 Registry.\n \"\"\"\n rootRegistryOwner: Account\n\n \"\"\"\n All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection\n\n \"\"\"The Registry this Domain declares as its Subregistry, if exists.\"\"\"\n subregistry: Registry\n}\n\n\"\"\"\nAn ENSv1Registry is a concrete ENSv1 Registry contract (the mainnet ENS Registry, the Basenames shadow Registry, or the Lineanames shadow Registry).\n\"\"\"\ntype ENSv1Registry implements Registry {\n \"\"\"Whether the Registry is Canonical.\"\"\"\n canonical: Boolean!\n\n \"\"\"\n Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists.\n \"\"\"\n contract: AccountId!\n\n \"\"\"\n The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection\n\n \"\"\"A unique reference to this Registry.\"\"\"\n id: RegistryId!\n\n \"\"\"The Domains for which this Registry is a Subregistry.\"\"\"\n parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection\n\n \"\"\"The Permissions managed by this Registry.\"\"\"\n permissions: Permissions\n}\n\n\"\"\"\nAn ENSv1VirtualRegistry is the virtual Registry managed by an ENSv1 Domain that has children. It is keyed by `(chainId, address, node)` where `(chainId, address)` identify the concrete Registry that houses the parent Domain, and `node` is the parent Domain's namehash.\n\"\"\"\ntype ENSv1VirtualRegistry implements Registry {\n \"\"\"Whether the Registry is Canonical.\"\"\"\n canonical: Boolean!\n\n \"\"\"\n Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists.\n \"\"\"\n contract: AccountId!\n\n \"\"\"\n The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection\n\n \"\"\"A unique reference to this Registry.\"\"\"\n id: RegistryId!\n\n \"\"\"\n The namehash of the parent ENSv1 Domain that owns this virtual Registry.\n \"\"\"\n node: Node!\n\n \"\"\"The Domains for which this Registry is a Subregistry.\"\"\"\n parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection\n\n \"\"\"The Permissions managed by this Registry.\"\"\"\n permissions: Permissions\n}\n\n\"\"\"An ENSv2Domain represents an ENSv2 Domain.\"\"\"\ntype ENSv2Domain implements Domain {\n \"\"\"\n Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree.\n \"\"\"\n canonical: DomainCanonical\n\n \"\"\"All Events associated with this Domain.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection\n\n \"\"\"A unique and stable reference to this Domain.\"\"\"\n id: DomainId!\n\n \"\"\"The Label associated with this Domain in the ENS Namegraph.\"\"\"\n label: Label!\n\n \"\"\"\n If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).\n \"\"\"\n owner: Account\n\n \"\"\"\n The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`.\n \"\"\"\n parent: Domain\n\n \"\"\"\n Permissions for this Domain within its Registry, representing the roles granted to users for this Domain's token.\n \"\"\"\n permissions(after: String, before: String, first: Int, last: Int, where: DomainPermissionsWhereInput): ENSv2DomainPermissionsConnection\n\n \"\"\"The latest Registration for this Domain, if exists.\"\"\"\n registration: Registration\n\n \"\"\"All Registrations for a Domain, including the latest Registration.\"\"\"\n registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection\n\n \"\"\"\n The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`).\n \"\"\"\n registry: Registry!\n\n \"\"\"Resolve protocol-level data for this Domain.\"\"\"\n resolve(\n \"\"\"\n When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled.\n @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration\n \"\"\"\n accelerate: Boolean = true\n ): ForwardResolve!\n\n \"\"\"Resolver relationship metadata for this Domain.\"\"\"\n resolver: DomainResolver!\n\n \"\"\"\n All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection\n\n \"\"\"The Registry this Domain declares as its Subregistry, if exists.\"\"\"\n subregistry: Registry\n\n \"\"\"The ENSv2Domain's current Token Id.\"\"\"\n tokenId: BigInt!\n}\n\ntype ENSv2DomainPermissionsConnection {\n edges: [ENSv2DomainPermissionsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype ENSv2DomainPermissionsConnectionEdge {\n cursor: String!\n node: PermissionsUser!\n}\n\n\"\"\"An ENSv2Registry represents an ENSv2 Registry contract.\"\"\"\ntype ENSv2Registry implements Registry {\n \"\"\"Whether the Registry is Canonical.\"\"\"\n canonical: Boolean!\n\n \"\"\"\n Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists.\n \"\"\"\n contract: AccountId!\n\n \"\"\"\n The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection\n\n \"\"\"A unique reference to this Registry.\"\"\"\n id: RegistryId!\n\n \"\"\"The Domains for which this Registry is a Subregistry.\"\"\"\n parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection\n\n \"\"\"The Permissions managed by this Registry.\"\"\"\n permissions: Permissions\n}\n\n\"\"\"\nENSv2RegistryRegistration represents a Registration within an ENSv2 Registry.\n\"\"\"\ntype ENSv2RegistryRegistration implements Registration {\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n}\n\n\"\"\"\nENSv2RegistryReservation represents a Reservation within an ENSv2 Registry.\n\"\"\"\ntype ENSv2RegistryReservation implements Registration {\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n}\n\n\"\"\"Email represents a validated contact email address.\"\"\"\nscalar Email\n\n\"\"\"\nAn Event represents a discrete Log Event that was emitted on an EVM chain, including associated metadata.\n\"\"\"\ntype Event {\n \"\"\"Identifies the contract by which this Event was emitted.\"\"\"\n address: Address!\n\n \"\"\"Identifies the Block within which this Event was emitted.\"\"\"\n blockHash: Hex!\n\n \"\"\"The block number within which this Event was emitted.\"\"\"\n blockNumber: BigInt!\n\n \"\"\"The ChainId upon which this Event was emitted.\"\"\"\n chainId: ChainId!\n\n \"\"\"The non-indexed data of this Event's log.\"\"\"\n data: Hex!\n\n \"\"\"\n Identifies the sender of the Transaction within which this Event was emitted (`tx.from`). Never HCA-aware — always the EOA/relayer that submitted the transaction. Use `Event.sender` for the HCA-aware actor.\n \"\"\"\n from: Address!\n\n \"\"\"A unique reference to this Event.\"\"\"\n id: ID!\n\n \"\"\"The index of this Event's log within the Block.\"\"\"\n logIndex: Int!\n\n \"\"\"The HCA account address if used, otherwise Transaction.from.\"\"\"\n sender: Address!\n\n \"\"\"\n The UnixTimestamp indicating the moment in which this Event was emitted.\n \"\"\"\n timestamp: BigInt!\n\n \"\"\"\n Identifies the recipient of the Transaction within which this Event was emitted. Null if the transaction deployed a contract.\n \"\"\"\n to: Address\n\n \"\"\"The indexed topics of this Event's log.\"\"\"\n topics: [Hex!]!\n\n \"\"\"Identifies the Transaction within which this Event was emitted.\"\"\"\n transactionHash: Hex!\n\n \"\"\"The index of the Transaction within the Block.\"\"\"\n transactionIndex: Int!\n}\n\n\"\"\"\nFilter Events by `tx.from`. Not HCA-aware — use `sender` to filter by the HCA-aware actor. Exactly one of `eq` or `in` must be provided.\n\"\"\"\ninput EventsFromFilter @oneOf {\n \"\"\"Exact `tx.from` match.\"\"\"\n eq: Address\n\n \"\"\"\n `tx.from` matches any address in the set. Max 10 items. An empty set matches nothing.\n \"\"\"\n in: [Address!]\n}\n\n\"\"\"\nFilter Events by selector (event signature). Exactly one of `eq` or `in` must be provided.\n\"\"\"\ninput EventsSelectorFilter @oneOf {\n \"\"\"Exact selector match.\"\"\"\n eq: Hex\n\n \"\"\"\n Selector matches any value in the set. Max 10 items. An empty set matches nothing.\n \"\"\"\n in: [Hex!]\n}\n\n\"\"\"\nFilter Events by HCA-aware `sender` (the HCA account address if used, otherwise Transaction.from). Exactly one of `eq` or `in` must be provided.\n\"\"\"\ninput EventsSenderFilter @oneOf {\n \"\"\"Exact `sender` match.\"\"\"\n eq: Address\n\n \"\"\"\n `sender` matches any address in the set. Max 10 items. An empty set matches nothing.\n \"\"\"\n in: [Address!]\n}\n\n\"\"\"\nFilter Events by timestamp range. At least one bound must be provided. `gt`/`gte` are mutually exclusive; `lt`/`lte` are mutually exclusive.\n\"\"\"\ninput EventsTimestampFilter {\n \"\"\"Filter to events strictly after this UnixTimestamp.\"\"\"\n gt: BigInt\n\n \"\"\"Filter to events at or after this UnixTimestamp.\"\"\"\n gte: BigInt\n\n \"\"\"Filter to events strictly before this UnixTimestamp.\"\"\"\n lt: BigInt\n\n \"\"\"Filter to events at or before this UnixTimestamp.\"\"\"\n lte: BigInt\n}\n\n\"\"\"Filter conditions for an events connection.\"\"\"\ninput EventsWhereInput {\n \"\"\"\n Filter to events whose `tx.from` matches the provided filter. Not HCA-aware — use `sender` to filter by the HCA-aware actor.\n \"\"\"\n from: EventsFromFilter\n\n \"\"\"\n Filter to events whose selector (event signature) matches the provided filter.\n \"\"\"\n selector: EventsSelectorFilter\n\n \"\"\"\n Filter to events whose HCA-aware `sender` matches the provided filter (the HCA account address if used, otherwise Transaction.from).\n \"\"\"\n sender: EventsSenderFilter\n\n \"\"\"Filter to events whose UnixTimestamp falls within the provided range.\"\"\"\n timestamp: EventsTimestampFilter\n}\n\n\"\"\"\nNested domain resolution container exposing resolved data for the domain.\n\"\"\"\ntype ForwardResolve {\n \"\"\"\n Whether protocol acceleration was requested and attempted for this resolution.\n \"\"\"\n acceleration: AccelerationStatus!\n\n \"\"\"\n The interpreted profile of an ENS name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile records were selected).\n \"\"\"\n profile: DomainProfile\n\n \"\"\"\n Resolved ENS records via the ENS protocol. Null when the name is not resolvable (non-canonical, unnormalized, or no records field was selected).\n \"\"\"\n records: ResolvedRecords\n\n \"\"\"\n Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. This data model should be expected to experience breaking changes.\n \"\"\"\n trace: JSON\n}\n\n\"\"\"Hex represents viem#Hex.\"\"\"\nscalar Hex\n\n\"\"\"InterfaceId represents an ERC-165 interface id (4-byte hex selector).\"\"\"\nscalar InterfaceId\n\n\"\"\"InterpretedLabel represents an enssdk#InterpretedLabel.\"\"\"\nscalar InterpretedLabel\n\n\"\"\"InterpretedName represents an enssdk#InterpretedName.\"\"\"\nscalar InterpretedName\n\n\"\"\"JSON represents arbitrary JSON-serializable data.\"\"\"\nscalar JSON\n\n\"\"\"\nRepresents a Label within ENS, providing its hash and interpreted representation.\n\"\"\"\ntype Label {\n \"\"\"\n The Label as a BeautifiedLabel: the Interpreted Label beautified per ENSIP-15 (https://docs.ens.domains/ensip/15) for display. An Encoded LabelHash is preserved verbatim. Display-only; use `interpreted` for lookup keys. \n (@see https://ensnode.io/docs/reference/terminology#interpreted-label)\n \"\"\"\n beautified: BeautifiedLabel!\n\n \"\"\"\n The Label's LabelHash\n (@see https://ensnode.io/docs/reference/terminology#labels-labelhashes-labelhash-function)\n \"\"\"\n hash: Hex!\n\n \"\"\"\n The Label represented as an Interpreted Label. This is either a normalized Literal Label or an Encoded LabelHash. \n (@see https://ensnode.io/docs/reference/terminology#interpreted-label)\n \"\"\"\n interpreted: InterpretedLabel!\n}\n\n\"\"\"\nLitecoinAddress represents a Base58Check-encoded Litecoin address (coin type 2).\n\"\"\"\nscalar LitecoinAddress\n\n\"\"\"\nMonacoinAddress represents a Base58Check-encoded Monacoin address (coin type 22).\n\"\"\"\nscalar MonacoinAddress\n\n\"\"\"Constructs a reference to a specific Node via one of `name` or `node`.\"\"\"\ninput NameOrNodeInput @oneOf {\n name: InterpretedName\n node: Node\n}\n\n\"\"\"\nA NameWrapperRegistration represents a Registration initiated by the ENSv1 NameWrapper.\n\"\"\"\ntype NameWrapperRegistration implements Registration {\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"The Fuses for this Registration's Domain in the NameWrapper.\"\"\"\n fuses: Int!\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n}\n\n\"\"\"Node represents an enssdk#Node.\"\"\"\nscalar Node\n\n\"\"\"Sort direction\"\"\"\nenum OrderDirection {\n ASC\n DESC\n}\n\ntype PageInfo {\n endCursor: String\n hasNextPage: Boolean!\n hasPreviousPage: Boolean!\n startCursor: String\n}\n\n\"\"\"Permissions\"\"\"\ntype Permissions {\n \"\"\"The contract within which these Permissions are granted.\"\"\"\n contract: AccountId!\n\n \"\"\"All Events associated with these Permissions.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): PermissionsEventsConnection\n\n \"\"\"A unique reference to this Permission.\"\"\"\n id: PermissionsId!\n\n \"\"\"All PermissionResources managed by this contract.\"\"\"\n resources(after: String, before: String, first: Int, last: Int): PermissionsResourcesConnection\n\n \"\"\"The Root Resource.\"\"\"\n root: PermissionsResource!\n}\n\ntype PermissionsEventsConnection {\n edges: [PermissionsEventsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype PermissionsEventsConnectionEdge {\n cursor: String!\n node: Event!\n}\n\n\"\"\"PermissionsId represents an enssdk#PermissionsId.\"\"\"\nscalar PermissionsId\n\n\"\"\"Address Permissions by ID or AccountId.\"\"\"\ninput PermissionsIdInput @oneOf {\n contract: AccountIdInput\n id: PermissionsId\n}\n\n\"\"\"PermissionsResource\"\"\"\ntype PermissionsResource {\n \"\"\"The contract within which these Permissions are granted.\"\"\"\n contract: AccountId!\n\n \"\"\"A unique reference to this PermissionsResource.\"\"\"\n id: PermissionsResourceId!\n\n \"\"\"The Permissions within which this Resource is managed.\"\"\"\n permissions: Permissions!\n\n \"\"\"Identifies the Resource that this PermissionsResource represents.\"\"\"\n resource: BigInt!\n\n \"\"\"The PermissionUsers who have Roles within this Resource.\"\"\"\n users(after: String, before: String, first: Int, last: Int): PermissionsResourceUsersConnection\n}\n\n\"\"\"PermissionsResourceId represents an enssdk#PermissionsResourceId.\"\"\"\nscalar PermissionsResourceId\n\ntype PermissionsResourceUsersConnection {\n edges: [PermissionsResourceUsersConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype PermissionsResourceUsersConnectionEdge {\n cursor: String!\n node: PermissionsUser!\n}\n\ntype PermissionsResourcesConnection {\n edges: [PermissionsResourcesConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype PermissionsResourcesConnectionEdge {\n cursor: String!\n node: PermissionsResource!\n}\n\n\"\"\"PermissionsUser\"\"\"\ntype PermissionsUser {\n \"\"\"The contract within which these Permissions are granted.\"\"\"\n contract: AccountId!\n\n \"\"\"All Events associated with this PermissionsUser.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): PermissionsUserEventsConnection\n\n \"\"\"A unique reference to this PermissionsUser.\"\"\"\n id: PermissionsUserId!\n\n \"\"\"The Resource that this user has Roles within.\"\"\"\n resource: BigInt!\n\n \"\"\"The Roles this User has been granted within this Resource.\"\"\"\n roles: BigInt!\n\n \"\"\"\n The user/grantee address this Permission is granted to (the HCA account address if used).\n \"\"\"\n user: Account!\n}\n\ntype PermissionsUserEventsConnection {\n edges: [PermissionsUserEventsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype PermissionsUserEventsConnectionEdge {\n cursor: String!\n node: Event!\n}\n\n\"\"\"PermissionsUserId represents an enssdk#PermissionsUserId.\"\"\"\nscalar PermissionsUserId\n\n\"\"\"\nSelect a primary name lookup target. Exactly one of `coinType` or `chainName` must be provided.\n\"\"\"\ninput PrimaryNameByInput @oneOf {\n \"\"\"A `ChainName` to resolve the primary name for.\"\"\"\n chainName: ChainName\n\n \"\"\"The ENSIP-9 coin type to resolve the primary name for.\"\"\"\n coinType: CoinType\n}\n\n\"\"\"An ENSIP-19 primary name for an Account on a specific coin type.\"\"\"\ntype PrimaryNameRecord {\n \"\"\"\n The chain corresponding to `coinType`, or null when `coinType` is not represented in `ChainName`.\n \"\"\"\n chainName: ChainName\n\n \"\"\"The canonical ENSIP-9 coin type for this primary name lookup.\"\"\"\n coinType: CoinType!\n\n \"\"\"\n The validated primary name for this Account on this coin type, or null if none is set.\n \"\"\"\n name: CanonicalName\n\n \"\"\"Forward resolve data for this primary name.\"\"\"\n resolve: ForwardResolve!\n}\n\n\"\"\"\nFilter primary name lookups. Exactly one of `coinTypes` or `chainNames` must be provided.\n\"\"\"\ninput PrimaryNamesWhereInput @oneOf {\n \"\"\"`ChainName` values to resolve primary names for.\"\"\"\n chainNames: [ChainName!]\n\n \"\"\"Coin types to resolve primary names for.\"\"\"\n coinTypes: [CoinType!]\n}\n\n\"\"\"The interpreted addresses on the profile of an ENS name.\"\"\"\ntype ProfileAddresses {\n \"\"\"\n The interpreted Base address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n base: Address\n\n \"\"\"\n The interpreted Binance Chain (BNB) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n binance: BinanceAddress\n\n \"\"\"\n The interpreted Bitcoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n bitcoin: BitcoinAddress\n\n \"\"\"\n The interpreted Bitcoin Cash address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n bitcoincash: BitcoinCashAddress\n\n \"\"\"\n The interpreted Dogecoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n dogecoin: DogecoinAddress\n\n \"\"\"\n The interpreted Ethereum address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n ethereum: Address\n\n \"\"\"\n The interpreted Litecoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n litecoin: LitecoinAddress\n\n \"\"\"\n The interpreted Monacoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n monacoin: MonacoinAddress\n\n \"\"\"\n The interpreted Ripple (XRP) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n ripple: RippleAddress\n\n \"\"\"\n The interpreted Rootstock (RBTC) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n rootstock: RootstockAddress\n\n \"\"\"\n The interpreted Solana address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9.\n \"\"\"\n solana: SolanaAddress\n}\n\n\"\"\"The interpreted avatar image on the profile of an ENS name.\"\"\"\ntype ProfileAvatar {\n \"\"\"\n HTTP-compatible URL for fetching the avatar image in web browsers. Abstraction over the raw avatar record (IPFS, CAIP NFT references, etc.). Returns null when the raw value is not a direct http(s) URL and no fallback URL can be derived (including when the ENS Metadata Service is unavailable for this namespace). See https://docs.ens.domains/ensip/12.\n \"\"\"\n httpUrl: String\n}\n\n\"\"\"The interpreted header image on the profile of an ENS name.\"\"\"\ntype ProfileHeader {\n \"\"\"\n HTTP-compatible URL for fetching the header image in web browsers. Abstraction over the raw header record (IPFS, CAIP NFT references, etc.). Returns null when the raw value is not a direct http(s) URL and no fallback URL can be derived (including when the ENS Metadata Service is unavailable for this namespace). See https://docs.ens.domains/ensip/12.\n \"\"\"\n httpUrl: String\n}\n\n\"\"\"An interpreted social account on the profile of an ENS name.\"\"\"\ntype ProfileSocialAccount {\n \"\"\"The handle of the social account.\"\"\"\n handle: String!\n\n \"\"\"The HTTP-compatible url to the social account.\"\"\"\n httpUrl: String!\n}\n\n\"\"\"The interpreted social accounts on the profile of an ENS name.\"\"\"\ntype ProfileSocials {\n \"\"\"\n The interpreted GitHub account. Returns null when the raw record is unset, empty, or cannot be parsed as a GitHub handle or profile URL.\n \"\"\"\n github: ProfileSocialAccount\n\n \"\"\"\n The interpreted Keybase account. Returns null when the raw record is unset, empty, or cannot be parsed as a Keybase handle or profile URL.\n \"\"\"\n keybase: ProfileSocialAccount\n\n \"\"\"\n The interpreted LinkedIn account. Returns null when the raw record is unset, empty, or cannot be parsed as a LinkedIn handle or profile URL.\n \"\"\"\n linkedin: ProfileSocialAccount\n\n \"\"\"\n The interpreted Telegram account. Returns null when the raw record is unset, empty, or cannot be parsed as a Telegram handle or profile URL.\n \"\"\"\n telegram: ProfileSocialAccount\n\n \"\"\"\n The interpreted X (Twitter) account. Returns null when the raw record is unset, empty, or cannot be parsed as a X (Twitter) handle or profile URL.\n \"\"\"\n twitter: ProfileSocialAccount\n}\n\n\"\"\"The interpreted website on the profile of an ENS name.\"\"\"\ntype ProfileWebsite {\n \"\"\"\n The HTTP-compatible website URL. Returns null when the raw url record is unset, empty, not an http(s) URL, or cannot be parsed as a valid URL.\n \"\"\"\n httpUrl: String\n}\n\ntype Query {\n \"\"\"Identify an Account by ID or Address.\"\"\"\n account(by: AccountByInput!): Account\n\n \"\"\"Identify a Domain by Name or DomainId\"\"\"\n domain(by: DomainIdInput!): Domain\n\n \"\"\"\n Find Canonical Domains by Name. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: DomainsWhereInput!): QueryDomainsConnection\n\n \"\"\"Identify Permissions by ID or AccountId.\"\"\"\n permissions(by: PermissionsIdInput!): Permissions\n\n \"\"\"\n Identify a Registry by ID or AccountId. If querying by `contract`, only concrete Registries will be returned.\n \"\"\"\n registry(by: RegistryIdInput!): Registry\n\n \"\"\"Identify a Resolver by ID or AccountId.\"\"\"\n resolver(by: ResolverIdInput!): Resolver\n\n \"\"\"\n The Root Registry for this namespace. It will be the ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry.\n \"\"\"\n root: Registry!\n}\n\ntype QueryDomainsConnection {\n edges: [QueryDomainsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype QueryDomainsConnectionEdge {\n cursor: String!\n node: Domain!\n}\n\n\"\"\"\nA Registration represents a Domain's registration status within the various registries.\n\"\"\"\ninterface Registration {\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n}\n\n\"\"\"RegistrationId represents an enssdk#RegistrationId.\"\"\"\nscalar RegistrationId\n\ntype RegistrationRenewalsConnection {\n edges: [RegistrationRenewalsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype RegistrationRenewalsConnectionEdge {\n cursor: String!\n node: Renewal!\n}\n\n\"\"\"\nA Registry represents a Registry contract in the ENS namegraph. It may be an ENSv1Registry (a concrete ENSv1 Registry contract), an ENSv1VirtualRegistry (the virtual Registry managed by an ENSv1 domain that has children), or an ENSv2Registry.\n\"\"\"\ninterface Registry {\n \"\"\"Whether the Registry is Canonical.\"\"\"\n canonical: Boolean!\n\n \"\"\"\n Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists.\n \"\"\"\n contract: AccountId!\n\n \"\"\"\n The Domains managed by this Registry. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection\n\n \"\"\"A unique reference to this Registry.\"\"\"\n id: RegistryId!\n\n \"\"\"The Domains for which this Registry is a Subregistry.\"\"\"\n parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection\n\n \"\"\"The Permissions managed by this Registry.\"\"\"\n permissions: Permissions\n}\n\ntype RegistryDomainsConnection {\n edges: [RegistryDomainsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype RegistryDomainsConnectionEdge {\n cursor: String!\n node: Domain!\n}\n\n\"\"\"Filter for Registry.domains query.\"\"\"\ninput RegistryDomainsWhereInput {\n \"\"\"If set, filters the set of Domains in this Registry by name.\"\"\"\n name: DomainsNameFilter\n}\n\n\"\"\"RegistryId represents an enssdk#RegistryId.\"\"\"\nscalar RegistryId\n\n\"\"\"Address a Registry by ID or AccountId.\"\"\"\ninput RegistryIdInput @oneOf {\n contract: AccountIdInput\n id: RegistryId\n}\n\ntype RegistryParentsConnection {\n edges: [RegistryParentsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype RegistryParentsConnectionEdge {\n cursor: String!\n node: Domain!\n}\n\ntype RegistryPermissionsUser {\n \"\"\"A unique reference to this RegistryPermissionsUser.\"\"\"\n id: PermissionsUserId!\n\n \"\"\"The Registry in which this Permission is granted.\"\"\"\n registry: Registry!\n\n \"\"\"The Resource for which this Permission is granted.\"\"\"\n resource: BigInt!\n\n \"\"\"The Roles that this Permission grants.\"\"\"\n roles: BigInt!\n\n \"\"\"\n The user/grantee address this Permission is granted to (the HCA account address if used).\n \"\"\"\n user: Account!\n}\n\n\"\"\"A Renewal represents an extension of a Registration's expiry.\"\"\"\ntype Renewal {\n \"\"\"The `base` cost of a Renewal, in wei, if exists.\"\"\"\n base: BigInt\n\n \"\"\"The duration for which a Registration was extended.\"\"\"\n duration: BigInt!\n\n \"\"\"The Event for which this Renewal was created.\"\"\"\n event: Event!\n\n \"\"\"A unique reference to this Renewal.\"\"\"\n id: RenewalId!\n\n \"\"\"The `premium` cost of a Renewal, in wei, if exists.\"\"\"\n premium: BigInt\n\n \"\"\"The extra `referrer` data provided with a Renewal, if exists.\"\"\"\n referrer: Hex\n}\n\n\"\"\"RenewalId represents an enssdk#RenewalId.\"\"\"\nscalar RenewalId\n\n\"\"\"A resolved ABI record for an ENS name.\"\"\"\ntype ResolvedAbiRecord {\n contentType: BigInt!\n data: Hex!\n}\n\n\"\"\"A resolved address record for an ENS name.\"\"\"\ntype ResolvedAddressRecord {\n \"\"\"\n The \"raw\" resolved address record as hex, or null if not set, empty (\"0x\"), or zeroAddress. Decode with ENSIP-9 (https://docs.ens.domains/ensip/9) and address-encoder (https://github.com/ensdomains/address-encoder) for the associated coin type. Guaranteed to be at least one byte of hex data. There is no guarantee that an EVM CoinType returns an address value of any particular byte length.\n \"\"\"\n address: Hex\n\n \"\"\"The coin type for this address record.\"\"\"\n coinType: CoinType!\n}\n\n\"\"\"A resolved ERC-165 interface implementer record for an ENS name.\"\"\"\ntype ResolvedInterfaceRecord {\n implementer: Address\n interfaceId: InterfaceId!\n}\n\n\"\"\"A resolved PubkeyResolver (x, y) pair for an ENS name.\"\"\"\ntype ResolvedPubkeyRecord {\n x: Hex!\n y: Hex!\n}\n\n\"\"\"\nA resolved 'raw' text record for an ENS name. Value is any possible string and may require additional validation or preprocessing before use.\n\"\"\"\ntype ResolvedRawTextRecord {\n \"\"\"The text record key.\"\"\"\n key: String!\n\n \"\"\"\n The 'raw' text record value, or null if not set. Value is any possible string and may require additional validation or preprocessing before use.\n \"\"\"\n value: String\n}\n\n\"\"\"Records resolved for a specific ENS name via the ENS protocol.\"\"\"\ntype ResolvedRecords {\n \"\"\"\n The first stored ABI matching the requested content-type bitmask, or null if not set.\n \"\"\"\n abi(\n \"\"\"\n Content-type bitmask; the resolver returns the first stored ABI whose bit is set (lowest bit first).\n \"\"\"\n contentTypeMask: BigInt!\n ): ResolvedAbiRecord\n\n \"\"\"Resolved address records for the requested coin types.\"\"\"\n addresses(\n \"\"\"Coin types to resolve (e.g. `60` for ETH).\"\"\"\n coinTypes: [CoinType!]!\n ): [ResolvedAddressRecord!]!\n\n \"\"\"The ENSIP-7 contenthash record raw bytes, or null if not set.\"\"\"\n contenthash: Hex\n\n \"\"\"The IDNSZoneResolver zonehash raw bytes, or null if not set.\"\"\"\n dnszonehash: Hex\n\n \"\"\"Resolved ERC-165 interface implementer records for the requested ids.\"\"\"\n interfaces(\n \"\"\"ERC-165 interface ids to resolve (4-byte hex selectors).\"\"\"\n ids: [InterfaceId!]!\n ): [ResolvedInterfaceRecord!]!\n\n \"\"\"The PubkeyResolver (x, y) pair, or null if not set.\"\"\"\n pubkey: ResolvedPubkeyRecord\n\n \"\"\"\n The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. To reduce a common point of developer confusion the Omnigraph API represents this as the `reverseName` rather than the `name` record which is what this field actually resolves to onchain.\n \"\"\"\n reverseName: String\n\n \"\"\"Resolved text records for the requested keys.\"\"\"\n texts(\n \"\"\"Text record keys to resolve (e.g. `avatar`, `description`).\"\"\"\n keys: [String!]!\n ): [ResolvedRawTextRecord!]!\n\n \"\"\"The IVersionableResolver version, or null if not set or unavailable.\"\"\"\n version: BigInt\n}\n\n\"\"\"A Resolver represents a Resolver contract on-chain.\"\"\"\ntype Resolver {\n \"\"\"\n If Resolver is a Bridged Resolver, the Registry to which it Bridges resolution.\n \"\"\"\n bridged: Registry\n\n \"\"\"Contract metadata for this Resolver.\"\"\"\n contract: AccountId!\n\n \"\"\"All Events associated with this Resolver.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): ResolverEventsConnection\n\n \"\"\"\n Whether this Resolver implements ENSIP-10 wildcard resolution (`IExtendedResolver`, interfaceId `0x9061b923`), determined via a single cached `supportsInterface` RPC the first time the Resolver is observed.\n \"\"\"\n extended: Boolean!\n\n \"\"\"A unique reference to this Resolver.\"\"\"\n id: ResolverId!\n\n \"\"\"Permissions granted by this Resolver.\"\"\"\n permissions: Permissions\n\n \"\"\"ResolverRecords issued by this Resolver.\"\"\"\n records(after: String, before: String, first: Int, last: Int): ResolverRecordsConnection\n\n \"\"\"Identify a ResolverRecord by `name` or `node`.\"\"\"\n records_(by: NameOrNodeInput!): ResolverRecords\n}\n\ntype ResolverEventsConnection {\n edges: [ResolverEventsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype ResolverEventsConnectionEdge {\n cursor: String!\n node: Event!\n}\n\n\"\"\"ResolverId represents an enssdk#ResolverId.\"\"\"\nscalar ResolverId\n\n\"\"\"Address a Resolver by ID or AccountId.\"\"\"\ninput ResolverIdInput @oneOf {\n contract: AccountIdInput\n id: ResolverId\n}\n\ntype ResolverPermissionsUser {\n \"\"\"A unique reference to this ResolverPermissionsUser.\"\"\"\n id: PermissionsUserId!\n\n \"\"\"The Resolver in which this Permission is granted.\"\"\"\n resolver: Resolver!\n\n \"\"\"The Resource for which this Permission is granted.\"\"\"\n resource: BigInt!\n\n \"\"\"The Roles that this Permission grants.\"\"\"\n roles: BigInt!\n\n \"\"\"\n The user/grantee address this Permission is granted to (the HCA account address if used).\n \"\"\"\n user: Account!\n}\n\n\"\"\"ResolverRecords represents the _indexed_ records within a Resolver.\"\"\"\ntype ResolverRecords {\n \"\"\"Unique CoinTypes of `addr` records for this `node`.\"\"\"\n coinTypes: [CoinType!]!\n\n \"\"\"A unique reference to these ResolverRecords.\"\"\"\n id: ResolverRecordsId!\n\n \"\"\"Unique keys of `text` records for this `node`.\"\"\"\n keys: [String!]!\n\n \"\"\"The `name` record for this `node`, if any.\"\"\"\n name: String\n\n \"\"\"The Node for which these ResolverRecords are issued.\"\"\"\n node: Node!\n}\n\ntype ResolverRecordsConnection {\n edges: [ResolverRecordsConnectionEdge!]!\n pageInfo: PageInfo!\n totalCount: Int!\n}\n\ntype ResolverRecordsConnectionEdge {\n cursor: String!\n node: ResolverRecords!\n}\n\n\"\"\"ResolverRecordsId represents an enssdk#ResolverRecordsId.\"\"\"\nscalar ResolverRecordsId\n\n\"\"\"Nested account resolution container exposing primary name resolution.\"\"\"\ntype ReverseResolve {\n \"\"\"\n Whether protocol acceleration was requested and attempted for this reverse resolution.\n \"\"\"\n acceleration: AccelerationStatus!\n\n \"\"\"\n The primary name for this Account on a specific coin type or chain name.\n \"\"\"\n primaryName(\n \"\"\"Select a coin type or chain name to resolve a primary name for.\"\"\"\n by: PrimaryNameByInput!\n ): PrimaryNameRecord!\n\n \"\"\"\n Primary names for this Account on the requested coin types or chain names.\n \"\"\"\n primaryNames(\n \"\"\"Select coin types or chain names to resolve primary names for.\"\"\"\n where: PrimaryNamesWhereInput!\n ): [PrimaryNameRecord!]!\n\n \"\"\"\n Protocol trace tree emitted by reverse resolution, represented as JSON for schema stability. This data model should be expected to experience breaking changes.\n \"\"\"\n trace: JSON!\n}\n\n\"\"\"\nRippleAddress represents a Base58Check-encoded Ripple (XRP) address (coin type 144).\n\"\"\"\nscalar RippleAddress\n\n\"\"\"\nRootstockAddress represents an EIP-55 checksummed Rootstock (RBTC) address (coin type 137).\n\"\"\"\nscalar RootstockAddress\n\n\"\"\"\nSolanaAddress represents a Base58-encoded Solana address (coin type 501).\n\"\"\"\nscalar SolanaAddress\n\n\"\"\"Filter for Domain.subdomains query.\"\"\"\ninput SubdomainsWhereInput {\n \"\"\"If set, filters the set of subdomains by name.\"\"\"\n name: DomainsNameFilter\n}\n\n\"\"\"ThreeDNSRegistration represents a Registration within ThreeDNSToken.\"\"\"\ntype ThreeDNSRegistration implements Registration {\n \"\"\"The Domain for which this Registration exists.\"\"\"\n domain: Domain!\n\n \"\"\"The Event for which this Registration was created.\"\"\"\n event: Event!\n\n \"\"\"\n Indicates whether this Registration is expired. If the Registration is for an ENSv1Domain, a Registration is only considered `expired` after the Grace Period has elapsed.\n \"\"\"\n expired: Boolean!\n\n \"\"\"A UnixTimestamp indicating the Registration's expiry, if exists.\"\"\"\n expiry: BigInt\n\n \"\"\"A unique reference to this Registration.\"\"\"\n id: RegistrationId!\n\n \"\"\"The extra `referrer` data provided with a Registration, if exists.\"\"\"\n referrer: Hex\n\n \"\"\"\n The Registrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted registrant address (the HCA account address if used).\n \"\"\"\n registrant: Account\n\n \"\"\"The Registrar contract under which this Registration is managed.\"\"\"\n registrar: AccountId!\n\n \"\"\"\n Renewals that have occurred within this Registration's lifespan to extend its expiration.\n \"\"\"\n renewals(after: String, before: String, first: Int, last: Int): RegistrationRenewalsConnection\n\n \"\"\"A UnixTimestamp indicating when this Registration was created.\"\"\"\n start: BigInt!\n\n \"\"\"\n The Unregistrant of a Registration, if exists. For ENSv2 Registrations, the protocol-emitted unregistrant address (the HCA account address if used).\n \"\"\"\n unregistrant: Account\n}\n\n\"\"\"\nA resolvable-but-unindexed Domain: not present in the index, but resolvable because an ancestor in its namegraph path has an ENSIP-10 wildcard Resolver (e.g. off-chain / CCIP-Read names, unindexed 3DNS names, wildcard subnames).\n\"\"\"\ntype UnindexedDomain implements Domain {\n \"\"\"\n Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree.\n \"\"\"\n canonical: DomainCanonical\n\n \"\"\"All Events associated with this Domain.\"\"\"\n events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection\n\n \"\"\"A unique and stable reference to this Domain.\"\"\"\n id: DomainId!\n\n \"\"\"The Label associated with this Domain in the ENS Namegraph.\"\"\"\n label: Label!\n\n \"\"\"\n If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).\n \"\"\"\n owner: Account\n\n \"\"\"\n The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain. For an UnindexedDomain (which has no Registry of its own), this reflects the wildcard-bearing ancestor's Registry — see `Domain.registry`.\n \"\"\"\n parent: Domain\n\n \"\"\"The latest Registration for this Domain, if exists.\"\"\"\n registration: Registration\n\n \"\"\"All Registrations for a Domain, including the latest Registration.\"\"\"\n registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection\n\n \"\"\"\n The Registry under which this Domain exists. For an UnindexedDomain — a resolvable-but-unindexed Domain that has no Registry of its own — this is instead the Registry that manages the ancestor Domain bearing the wildcard Resolver (the same Registry encoded in its `id`).\n \"\"\"\n registry: Registry!\n\n \"\"\"Resolve protocol-level data for this Domain.\"\"\"\n resolve(\n \"\"\"\n When true (default), Protocol Acceleration will be conditionally used by the server to perform resolution when it is relevant. If false, Protocol Acceleration will be disabled.\n @see https://ensnode.io/docs/integrate/omnigraph/protocol-acceleration\n \"\"\"\n accelerate: Boolean = true\n ): ForwardResolve!\n\n \"\"\"Resolver relationship metadata for this Domain.\"\"\"\n resolver: DomainResolver!\n\n \"\"\"\n All Domains that are direct descendants of this Domain in the namegraph. Ordered by the `order` argument (default: NAME, ASC). When ordering by REGISTRATION_TIMESTAMP or REGISTRATION_EXPIRY, Domains lacking that value — no Registration for REGISTRATION_TIMESTAMP; no Registration or a never-expiring one (treated as +∞) for REGISTRATION_EXPIRY — sort last when `dir: ASC` and first when `dir: DESC`.\n \"\"\"\n subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection\n\n \"\"\"The Registry this Domain declares as its Subregistry, if exists.\"\"\"\n subregistry: Registry\n}\n\n\"\"\"\nAdditional metadata for BaseRegistrar Registrations wrapped by the NameWrapper (i.e. in the case of a wrapped .eth name)\n\"\"\"\ntype WrappedBaseRegistrarRegistration {\n \"\"\"The Fuses for this Registration's Domain in the NameWrapper.\"\"\"\n fuses: Int!\n\n \"\"\"\n The TokenId for this Domain in the NameWrapper. This is the bigint encoding of the Domain's Node.\n \"\"\"\n tokenId: BigInt!\n}"; diff --git a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts index 1bc266b197..312dd6deca 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts @@ -1,4 +1,3 @@ -import { omnigraphSchemaSdl } from "enssdk/omnigraph/schema-sdl"; import { buildSchema, type GraphQLArgument, @@ -14,6 +13,8 @@ import { } from "graphql"; import { z } from "zod/v4"; +import { omnigraphSchemaSdl } from "./generated/schema-sdl"; + /** Types rendered with full field listings in the condensed schema reference. */ export const OMNIGRAPH_CORE_TYPES = [ "Domain", @@ -31,7 +32,7 @@ export const OMNIGRAPH_CORE_TYPES = [ let cachedSchema: GraphQLSchema | undefined; -/** Loads the Omnigraph schema from the SDL bundled with enssdk (no network). */ +/** Loads the Omnigraph schema from SDL bundled with ensnode-sdk (no network). */ export function getOmnigraphSchema(): GraphQLSchema { cachedSchema ??= buildSchema(omnigraphSchemaSdl); return cachedSchema; diff --git a/packages/enssdk/package.json b/packages/enssdk/package.json index 18006a6784..a3f0827f7c 100644 --- a/packages/enssdk/package.json +++ b/packages/enssdk/package.json @@ -23,8 +23,7 @@ ".": "./src/index.ts", "./core": "./src/core/index.ts", "./omnigraph": "./src/omnigraph/index.ts", - "./omnigraph/schema.graphql": "./src/omnigraph/generated/schema.graphql", - "./omnigraph/schema-sdl": "./src/omnigraph/generated/schema-sdl.ts" + "./omnigraph/schema.graphql": "./src/omnigraph/generated/schema.graphql" }, "sideEffects": false, "publishConfig": { @@ -42,11 +41,7 @@ "types": "./dist/omnigraph/index.d.ts", "default": "./dist/omnigraph/index.js" }, - "./omnigraph/schema.graphql": "./src/omnigraph/generated/schema.graphql", - "./omnigraph/schema-sdl": { - "types": "./dist/omnigraph/generated/schema-sdl.d.ts", - "default": "./dist/omnigraph/generated/schema-sdl.js" - } + "./omnigraph/schema.graphql": "./src/omnigraph/generated/schema.graphql" } }, "scripts": { @@ -55,7 +50,7 @@ "lint:ci": "biome ci", "test": "vitest", "typecheck": "tsgo --noEmit", - "generate:gqlschema": "gql.tada generate-output && node scripts/inline-schema-sdl.mjs" + "generate:gqlschema": "gql.tada generate-output" }, "dependencies": { "@adraffy/ens-normalize": "catalog:", diff --git a/packages/enssdk/scripts/inline-schema-sdl.mjs b/packages/enssdk/scripts/inline-schema-sdl.mjs deleted file mode 100644 index 195f8d9a93..0000000000 --- a/packages/enssdk/scripts/inline-schema-sdl.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); -const SCHEMA_PATH = resolve(SCRIPT_DIR, "../src/omnigraph/generated/schema.graphql"); -const OUT_PATH = resolve(SCRIPT_DIR, "../src/omnigraph/generated/schema-sdl.ts"); - -const sdl = readFileSync(SCHEMA_PATH, "utf8"); -const content = `/** Auto-generated from schema.graphql — run \`pnpm generate:gqlschema\` to refresh. */ -export const omnigraphSchemaSdl = ${JSON.stringify(sdl)}; -`; - -writeFileSync(OUT_PATH, content); diff --git a/packages/enssdk/tsup.config.ts b/packages/enssdk/tsup.config.ts index 80bdeff07d..8792705715 100644 --- a/packages/enssdk/tsup.config.ts +++ b/packages/enssdk/tsup.config.ts @@ -5,7 +5,6 @@ export default defineConfig({ index: "src/index.ts", "core/index": "src/core/index.ts", "omnigraph/index": "src/omnigraph/index.ts", - "omnigraph/generated/schema-sdl": "src/omnigraph/generated/schema-sdl.ts", }, platform: "neutral", format: ["esm"], From d793f4f6c315df8ea4bfb9e53132432c5436244f Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 18 Jun 2026 22:01:17 +0200 Subject: [PATCH 09/16] feat(ensapi): enhance MCP API tests and update example queries --- .../src/handlers/api/mcp/mcp-api.test.ts | 51 ++++++++++++++++++- apps/ensapi/src/handlers/api/mcp/mcp-api.ts | 2 +- .../api/mcp/omnigraph-mcp-support.test.ts | 1 + .../handlers/api/mcp/omnigraph-mcp-support.ts | 8 +++ .../src/omnigraph-api/example-queries.ts | 8 ++- .../src/omnigraph-api/schema-reference.ts | 1 + 6 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts index 29d443eeef..07350550a7 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts @@ -78,6 +78,8 @@ describe("Omnigraph MCP server", () => { if (!("text" in contents[0])) throw new Error("expected text resource"); expect(contents[0].text).toContain("account(by: AccountByInput!)"); expect(contents[0].text).toContain("resolve(accelerate: Boolean): ReverseResolve!"); + expect(contents[0].text).toContain("ProfileSocials"); + expect(contents[0].text).toContain("github:"); }); it("reads an example resource by id", async () => { @@ -222,7 +224,54 @@ describe("Omnigraph MCP server", () => { }); const firstMessage = messages[0].content; if (firstMessage.type !== "text") throw new Error("expected text prompt"); - expect(firstMessage.text).toContain("hello-world"); + expect(firstMessage.text).toContain("account-profile"); + }); + + it("executes omnigraph_query by account-profile exampleId alias", async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: { account: null } }), { + headers: { "content-type": "application/json" }, + }), + ); + + const { client } = await connectClient(); + + await client.callTool({ + name: "omnigraph_query", + arguments: { + exampleId: "account-profile", + variables: { address: "0x0000000000000000000000000000000000000001" }, + }, + }); + + const [request] = fetchMock.mock.calls[0]; + const body = (await request.clone().json()) as { query: string }; + expect(body.query).toContain("primaryName(by: { chainName: ETHEREUM })"); + expect(body.query).toContain("v1DomainsCount"); + }); + + it("appends validation hints for invalid ProfileSocials fields", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + errors: [{ message: 'Cannot query field "discord" on type "ProfileSocials".' }], + }), + { headers: { "content-type": "application/json" } }, + ), + ); + + const { client } = await connectClient(); + + const result = await client.callTool({ + name: "omnigraph_query", + arguments: { + query: "{ domain(by: { name: \"vitalik.eth\" }) { resolve { profile { socials { discord { handle } } } } } }", + }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text) as { hints: string[] }; + expect(parsed.hints.some((hint) => hint.includes("ProfileSocials"))).toBe(true); }); it("returns a JSON-RPC parse error for invalid JSON on initialize", async () => { diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts index 640412dec9..659273bf23 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts @@ -180,7 +180,7 @@ export function createOmnigraphMcpServer(): McpServer { type: "text", text: [ `Look up ENS data for address ${address}.`, - "Call omnigraph_query with exampleId hello-world and variables { address }.", + "Call omnigraph_query with exampleId account-profile and variables { address }.", "Summarize: primary name, profile (avatar, description, socials), and ENSv1 vs ENSv2 domain counts.", ].join("\n"), }, diff --git a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts index 5a5aa77a50..dcd601e38d 100644 --- a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts +++ b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts @@ -6,5 +6,6 @@ describe("omnigraph MCP support", () => { it("documents anti-patterns in server instructions", () => { expect(OMNIGRAPH_MCP_INSTRUCTIONS).toContain("account(id:"); expect(OMNIGRAPH_MCP_INSTRUCTIONS).toContain("exampleId account-migrated-names"); + expect(OMNIGRAPH_MCP_INSTRUCTIONS).toContain("exampleId account-profile"); }); }); diff --git a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts index 011e0a0058..437d5e240b 100644 --- a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts +++ b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts @@ -28,6 +28,12 @@ const VALIDATION_HINTS: Array<{ match: RegExp; hint: string }> = [ match: /Field "account" argument "by" .* is required/, hint: "Pass account(by: { address: $address }) or account(by: { id: $address }).", }, + { + match: /Cannot query field "[^"]+" on type "ProfileSocials"/, + hint: + "ProfileSocials fields: github, keybase, linkedin, telegram, twitter only. " + + "Call omnigraph_schema({ type: 'ProfileSocials' }) or use exampleId domain-profile.", + }, ]; function getMcpNamespace() { @@ -118,10 +124,12 @@ export const OMNIGRAPH_MCP_INSTRUCTIONS = [ "- domains(where: { … }, first: N) — search canonical domains", "", "Common patterns:", + "- Address overview (primary name + profile + ENSv1/v2 counts): exampleId account-profile", "- Primary name: account.resolve.primaryName(by: { chainName: ETHEREUM })", "- Profile: domain.resolve.profile or primaryName.resolve.profile", "- ENSv1 vs ENSv2 counts: exampleId account-migrated-names", "- Pagination: edges { node }, pageInfo { hasNextPage endCursor }, totalCount", + "- Social fields: only ProfileSocials schema fields — do not guess (no discord/instagram)", "", "Anti-patterns (will fail validation):", "- account(id: …), Account.primaryName, connection.items", diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index 646a42c651..8f938b51ff 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -67,8 +67,14 @@ export type GraphqlApiExampleQuery = { variables: NamespaceSpecificValue>; }; +/** Alternate example ids that resolve to a canonical entry in {@link GRAPHQL_API_EXAMPLE_QUERIES}. */ +export const GRAPHQL_API_EXAMPLE_ID_ALIASES: Record = { + "account-profile": "hello-world", +}; + export function getGraphqlApiExampleQueryById(id: string): GraphqlApiExampleQuery { - const found = graphqlApiExampleQueryById.get(id); + const resolvedId = GRAPHQL_API_EXAMPLE_ID_ALIASES[id] ?? id; + const found = graphqlApiExampleQueryById.get(resolvedId); if (!found) { throw new Error(`Unknown GraphQL API example query id: ${id}`); } diff --git a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts index 312dd6deca..e32e1023f0 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts @@ -28,6 +28,7 @@ export const OMNIGRAPH_CORE_TYPES = [ "ForwardResolve", "ResolvedRecords", "PrimaryNameRecord", + "ProfileSocials", ] as const; let cachedSchema: GraphQLSchema | undefined; From 8b4c1f83a39fa817d91467789682ff40b5b681b2 Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 18 Jun 2026 22:29:43 +0200 Subject: [PATCH 10/16] feat(ensapi): enhance MCP API with new filter input types and validation hints --- .../src/handlers/api/mcp/mcp-api.test.ts | 49 +++++++++++++++++++ apps/ensapi/src/handlers/api/mcp/mcp-api.ts | 3 +- .../api/mcp/omnigraph-mcp-support.test.ts | 1 + .../handlers/api/mcp/omnigraph-mcp-support.ts | 30 +++++++++++- .../omnigraph-api/schema-reference.test.ts | 2 + .../src/omnigraph-api/schema-reference.ts | 28 +++++++++++ 6 files changed, 111 insertions(+), 2 deletions(-) diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts index 07350550a7..1edf1491ae 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts @@ -80,6 +80,24 @@ describe("Omnigraph MCP server", () => { expect(contents[0].text).toContain("resolve(accelerate: Boolean): ReverseResolve!"); expect(contents[0].text).toContain("ProfileSocials"); expect(contents[0].text).toContain("github:"); + expect(contents[0].text).toContain("DomainsNameFilter"); + expect(contents[0].text).toContain("starts_with:"); + }); + + it("includes defaultVariables in the examples index", async () => { + const { client } = await connectClient(); + + const { contents } = await client.readResource({ uri: "omnigraph://examples/index" }); + + if (!("text" in contents[0])) throw new Error("expected text resource"); + const payload = JSON.parse(contents[0].text) as { + examples: Array<{ id: string; defaultVariables?: Record }>; + }; + const registryDomains = payload.examples.find((example) => example.id === "registry-domains"); + expect(registryDomains?.defaultVariables?.registry).toMatchObject({ + chainId: expect.any(Number), + address: expect.any(String), + }); }); it("reads an example resource by id", async () => { @@ -274,6 +292,37 @@ describe("Omnigraph MCP server", () => { expect(parsed.hints.some((hint) => hint.includes("ProfileSocials"))).toBe(true); }); + it("appends validation hints for camelCase filter fields", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + errors: [ + { + message: + 'Variable "$name" got invalid value { startsWith: "vit" }; Field "startsWith" is not defined by type "DomainsNameFilter".', + }, + ], + }), + { headers: { "content-type": "application/json" } }, + ), + ); + + const { client } = await connectClient(); + + const result = await client.callTool({ + name: "omnigraph_query", + arguments: { + query: + "query($name: DomainsNameFilter!) { domains(where: { name: $name }, first: 1) { totalCount } }", + variables: { name: { startsWith: "vit" } }, + }, + }); + + const content = result.content as Array<{ type: string; text: string }>; + const parsed = JSON.parse(content[0].text) as { hints: string[] }; + expect(parsed.hints.some((hint) => hint.includes("starts_with"))).toBe(true); + }); + it("returns a JSON-RPC parse error for invalid JSON on initialize", async () => { const response = await mcpApi.fetch( new Request("http://ensapi.internal/", { diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts index 659273bf23..ef4241d822 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts @@ -129,7 +129,8 @@ export function createOmnigraphMcpServer(): McpServer { title: "Look up ENS Omnigraph schema", description: "Discover Omnigraph types and fields from the bundled schema (no network). Omit arguments for " + - "root query fields and type names; pass `type` for a type or Type.field; pass `search` to find matches.", + "root query fields and type names; pass `type` for a type or Type.field; pass `search` to find matches. " + + "Use type DomainsNameFilter or SubdomainsWhereInput before writing custom where filters.", inputSchema: OmnigraphSchemaLookupInputSchema, }, async ({ type, search }) => { diff --git a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts index dcd601e38d..cfc4d08714 100644 --- a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts +++ b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.test.ts @@ -7,5 +7,6 @@ describe("omnigraph MCP support", () => { expect(OMNIGRAPH_MCP_INSTRUCTIONS).toContain("account(id:"); expect(OMNIGRAPH_MCP_INSTRUCTIONS).toContain("exampleId account-migrated-names"); expect(OMNIGRAPH_MCP_INSTRUCTIONS).toContain("exampleId account-profile"); + expect(OMNIGRAPH_MCP_INSTRUCTIONS).toContain("starts_with"); }); }); diff --git a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts index 437d5e240b..5b10e8f700 100644 --- a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts +++ b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts @@ -34,6 +34,24 @@ const VALIDATION_HINTS: Array<{ match: RegExp; hint: string }> = [ "ProfileSocials fields: github, keybase, linkedin, telegram, twitter only. " + "Call omnigraph_schema({ type: 'ProfileSocials' }) or use exampleId domain-profile.", }, + { + match: /Field "startsWith" is not defined by type "DomainsNameFilter"/, + hint: + "Omnigraph filter inputs use snake_case: starts_with, not startsWith. " + + "Call omnigraph_schema({ type: 'DomainsNameFilter' }).", + }, + { + match: /Cannot query field "startsWith"/, + hint: + "Omnigraph filter inputs use snake_case: starts_with, not startsWith. " + + "Call omnigraph_schema({ type: 'DomainsNameFilter' }).", + }, + { + match: /Field "[^"]*[A-Z][^"]*" is not defined by type "(DomainsNameFilter|SubdomainsWhereInput)"/, + hint: + "Omnigraph filter inputs use snake_case field names (e.g. starts_with). " + + "Call omnigraph_schema({ type: 'DomainsNameFilter' }) before custom where filters.", + }, ]; function getMcpNamespace() { @@ -49,11 +67,19 @@ export function listOmnigraphExampleIds(): string[] { } export function buildOmnigraphExamplesIndex(): string { + const namespace = getMcpNamespace(); return JSON.stringify( { examples: listGraphqlApiExampleQueryIds().map((id) => { const { title, description } = getGraphqlApiExampleQueryById(id); - return { id, uri: omnigraphExampleUri(id), title, description }; + const { variables: defaultVariables } = resolveGraphqlApiExampleQuery(id, { namespace }); + return { + id, + uri: omnigraphExampleUri(id), + title, + description, + defaultVariables, + }; }), }, null, @@ -133,6 +159,8 @@ export const OMNIGRAPH_MCP_INSTRUCTIONS = [ "", "Anti-patterns (will fail validation):", "- account(id: …), Account.primaryName, connection.items", + "- Filter fields use snake_case: starts_with, not startsWith", + "- Before custom where filters, call omnigraph_schema({ type: 'DomainsNameFilter' })", "", "Interactive schema browser: /api/omnigraph (GraphiQL).", ].join("\n"); diff --git a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts index 9ab61b6167..3dc53aed66 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.test.ts @@ -8,6 +8,8 @@ describe("omnigraph schema reference", () => { expect(reference).toContain("account(by: AccountByInput!)"); expect(reference).toContain("#### Account"); expect(reference).toContain("resolve(accelerate: Boolean): ReverseResolve!"); + expect(reference).toContain("#### DomainsNameFilter"); + expect(reference).toContain("starts_with:"); }); it("searches schema fields by keyword", () => { diff --git a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts index e32e1023f0..94014ace47 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/schema-reference.ts @@ -31,6 +31,9 @@ export const OMNIGRAPH_CORE_TYPES = [ "ProfileSocials", ] as const; +/** Filter input types listed in the condensed schema reference (snake_case field names). */ +export const OMNIGRAPH_FILTER_INPUT_TYPES = ["DomainsNameFilter", "SubdomainsWhereInput"] as const; + let cachedSchema: GraphQLSchema | undefined; /** Loads the Omnigraph schema from SDL bundled with ensnode-sdk (no network). */ @@ -79,6 +82,11 @@ function renderFieldLine(field: GraphQLField): string { return `- ${field.name}${args}: ${field.type}${description ? ` — ${description}` : ""}`; } +function renderInputFieldLine(field: GraphQLInputField): string { + const description = oneLine(field.description); + return `- ${field.name}: ${field.type}${description ? ` — ${description}` : ""}`; +} + function renderTypeMarkdown(type: GraphQLNamedType): string { if (!isObjectType(type) && !isInterfaceType(type)) return ""; const lines = [`#### ${type.name}`]; @@ -87,6 +95,14 @@ function renderTypeMarkdown(type: GraphQLNamedType): string { return lines.join("\n"); } +function renderInputTypeMarkdown(type: GraphQLNamedType): string { + if (!isInputObjectType(type)) return ""; + const lines = [`#### ${type.name}`]; + if (type.description) lines.push(`_${oneLine(type.description)}_`); + for (const field of Object.values(type.getFields())) lines.push(renderInputFieldLine(field)); + return lines.join("\n"); +} + function describeType(type: GraphQLNamedType) { const base = { name: type.name, description: type.description ?? null }; if (isObjectType(type) || isInterfaceType(type)) { @@ -249,6 +265,18 @@ export function buildCondensedSchemaReference( } sections.push(["### Core types", coreSections.join("\n\n")].join("\n\n")); + const filterSections: string[] = []; + for (const name of OMNIGRAPH_FILTER_INPUT_TYPES) { + const type = schema.getType(name); + if (type) { + const rendered = renderInputTypeMarkdown(type); + if (rendered) filterSections.push(rendered); + } + } + if (filterSections.length > 0) { + sections.push(["### Filter inputs (snake_case)", filterSections.join("\n\n")].join("\n\n")); + } + const otherTypes = listMajorTypeNames(schema).filter( (name) => !OMNIGRAPH_CORE_TYPES.includes(name as (typeof OMNIGRAPH_CORE_TYPES)[number]), ); From ab4d17dbf0d4a8e8a87387ac5e88c5d593ea0b16 Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 18 Jun 2026 22:39:21 +0200 Subject: [PATCH 11/16] feat(ensnode-sdk): add offline Omnigraph schema lookup helpers and bundle SDL for Vite/Rollup compatibility --- .changeset/omnigraph-schema-reference.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/omnigraph-schema-reference.md b/.changeset/omnigraph-schema-reference.md index 2ac0b59dcd..1ca7f47ac1 100644 --- a/.changeset/omnigraph-schema-reference.md +++ b/.changeset/omnigraph-schema-reference.md @@ -1,6 +1,5 @@ --- "@ensnode/ensnode-sdk": patch -"enssdk": patch --- Add offline Omnigraph schema lookup helpers (`lookupOmnigraphSchema`, `buildCondensedSchemaReference`) on `@ensnode/ensnode-sdk/internal` for enscli, ENSApi MCP, and ensskills. Bundle Omnigraph SDL in ensnode-sdk (generated from `enssdk/omnigraph/schema.graphql`) so schema loading works in Vite/Rollup builds without Node `createRequire`. From 9836b3eefd0deac0a12b6c5822a7ddb518839fc6 Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 18 Jun 2026 22:52:21 +0200 Subject: [PATCH 12/16] feat(ensapi): add Omnigraph MCP server and enable custom GraphQL queries --- .changeset/omnigraph-mcp.md | 5 +++++ .changeset/omnigraph-schema-reference.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/omnigraph-mcp.md diff --git a/.changeset/omnigraph-mcp.md b/.changeset/omnigraph-mcp.md new file mode 100644 index 0000000000..86d0f8f958 --- /dev/null +++ b/.changeset/omnigraph-mcp.md @@ -0,0 +1,5 @@ +--- +"ensapi": patch +--- + +Add Omnigraph MCP server at `/api/mcp` (streamable HTTP). MCP clients can run vetted example queries or custom GraphQL via `omnigraph_query`, with schema and example resources. diff --git a/.changeset/omnigraph-schema-reference.md b/.changeset/omnigraph-schema-reference.md index 1ca7f47ac1..a9da3852ad 100644 --- a/.changeset/omnigraph-schema-reference.md +++ b/.changeset/omnigraph-schema-reference.md @@ -2,4 +2,4 @@ "@ensnode/ensnode-sdk": patch --- -Add offline Omnigraph schema lookup helpers (`lookupOmnigraphSchema`, `buildCondensedSchemaReference`) on `@ensnode/ensnode-sdk/internal` for enscli, ENSApi MCP, and ensskills. Bundle Omnigraph SDL in ensnode-sdk (generated from `enssdk/omnigraph/schema.graphql`) so schema loading works in Vite/Rollup builds without Node `createRequire`. +Add offline Omnigraph schema lookup helpers (`lookupOmnigraphSchema`, `buildCondensedSchemaReference`) on `@ensnode/ensnode-sdk/internal` for enscli and ensskills. Bundle Omnigraph SDL in ensnode-sdk (generated from `enssdk/omnigraph/schema.graphql`) so schema loading works in Vite/Rollup builds without Node `createRequire`. From 8ce8d0779e432864cf5696dfe3dd9eb0140fd27c Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 18 Jun 2026 23:23:46 +0200 Subject: [PATCH 13/16] feat(ensapi): improve MCP API session management and enhance validation for Omnigraph queries --- .../src/handlers/api/mcp/mcp-api.test.ts | 60 ++++++++++++------- apps/ensapi/src/handlers/api/mcp/mcp-api.ts | 41 +++++++++++-- .../handlers/api/mcp/omnigraph-mcp-support.ts | 10 ++-- .../src/omnigraph-api/example-queries.ts | 5 +- 4 files changed, 86 insertions(+), 30 deletions(-) diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts index 1edf1491ae..a8d7c6e087 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.test.ts @@ -3,10 +3,12 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// Mock the in-process Yoga instance so the tool can be exercised without a database. The tool +// Mock the Omnigraph API handler so the tool can be exercised without a database. The tool // imports it dynamically, so the mock is applied at call time. -const fetchMock = vi.fn<(request: Request, context: unknown) => Promise>(); -vi.mock("@/omnigraph-api/yoga", () => ({ yoga: { fetch: fetchMock } })); +const omnigraphApiFetchMock = vi.fn<(request: Request) => Promise>(); +vi.mock("@/handlers/api/omnigraph/omnigraph-api", () => ({ + default: { fetch: omnigraphApiFetchMock }, +})); import mcpApi, { createOmnigraphMcpServer } from "./mcp-api"; @@ -25,7 +27,7 @@ async function connectClient() { describe("Omnigraph MCP server", () => { beforeEach(() => { - fetchMock.mockReset(); + omnigraphApiFetchMock.mockReset(); }); afterEach(async () => { @@ -98,6 +100,7 @@ describe("Omnigraph MCP server", () => { chainId: expect.any(Number), address: expect.any(String), }); + expect(payload.examples.map((example) => example.id)).toContain("account-profile"); }); it("reads an example resource by id", async () => { @@ -112,9 +115,9 @@ describe("Omnigraph MCP server", () => { expect(payload.query).toContain("primaryName(by: { chainName: ETHEREUM })"); }); - it("executes omnigraph_query via yoga.fetch and returns the raw GraphQL JSON", async () => { + it("executes omnigraph_query via the omnigraph API handler and returns the raw GraphQL JSON", async () => { const graphqlResponse = { data: { __typename: "Query" } }; - fetchMock.mockResolvedValue( + omnigraphApiFetchMock.mockResolvedValue( new Response(JSON.stringify(graphqlResponse), { headers: { "content-type": "application/json" }, }), @@ -127,11 +130,10 @@ describe("Omnigraph MCP server", () => { arguments: { query: "{ __typename }" }, }); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [request, context] = fetchMock.mock.calls[0]; + expect(omnigraphApiFetchMock).toHaveBeenCalledTimes(1); + const [request] = omnigraphApiFetchMock.mock.calls[0]; expect(request.method).toBe("POST"); expect(new URL(request.url).pathname).toBe("/api/omnigraph"); - expect(context).toEqual({ canAccelerate: false }); await expect(request.clone().json()).resolves.toEqual({ query: "{ __typename }", variables: null, @@ -143,8 +145,25 @@ describe("Omnigraph MCP server", () => { expect(JSON.parse(content[0].text)).toEqual(graphqlResponse); }); + it("rejects whitespace-only query or exampleId", async () => { + const { client } = await connectClient(); + + for (const arguments_ of [ + { query: " " }, + { exampleId: " " }, + { query: " ", exampleId: " " }, + ]) { + const result = await client.callTool({ name: "omnigraph_query", arguments: arguments_ }); + expect(result.isError).toBe(true); + const content = result.content as Array<{ type: string; text: string }>; + expect(content[0].text).toMatch(/Provide exactly one of `query` or `exampleId`/); + } + + expect(omnigraphApiFetchMock).not.toHaveBeenCalled(); + }); + it("executes omnigraph_query by exampleId", async () => { - fetchMock.mockResolvedValue( + omnigraphApiFetchMock.mockResolvedValue( new Response(JSON.stringify({ data: { account: null } }), { headers: { "content-type": "application/json" }, }), @@ -160,7 +179,7 @@ describe("Omnigraph MCP server", () => { }, }); - const [request] = fetchMock.mock.calls[0]; + const [request] = omnigraphApiFetchMock.mock.calls[0]; const body = (await request.clone().json()) as { query: string; variables: Record; @@ -169,8 +188,8 @@ describe("Omnigraph MCP server", () => { expect(body.variables).toEqual({ address: "0x0000000000000000000000000000000000000001" }); }); - it("forwards GraphQL variables to yoga.fetch", async () => { - fetchMock.mockResolvedValue( + it("forwards GraphQL variables to the omnigraph API handler", async () => { + omnigraphApiFetchMock.mockResolvedValue( new Response(JSON.stringify({ data: null }), { headers: { "content-type": "application/json" }, }), @@ -186,7 +205,7 @@ describe("Omnigraph MCP server", () => { }, }); - const [request] = fetchMock.mock.calls[0]; + const [request] = omnigraphApiFetchMock.mock.calls[0]; await expect(request.clone().json()).resolves.toMatchObject({ variables: { id: "0x1234" }, }); @@ -207,7 +226,7 @@ describe("Omnigraph MCP server", () => { }); it("appends validation hints for common GraphQL mistakes", async () => { - fetchMock.mockResolvedValue( + omnigraphApiFetchMock.mockResolvedValue( new Response( JSON.stringify({ errors: [{ message: 'Unknown argument "id" on field "Query.account".' }], @@ -246,7 +265,7 @@ describe("Omnigraph MCP server", () => { }); it("executes omnigraph_query by account-profile exampleId alias", async () => { - fetchMock.mockResolvedValue( + omnigraphApiFetchMock.mockResolvedValue( new Response(JSON.stringify({ data: { account: null } }), { headers: { "content-type": "application/json" }, }), @@ -262,14 +281,14 @@ describe("Omnigraph MCP server", () => { }, }); - const [request] = fetchMock.mock.calls[0]; + const [request] = omnigraphApiFetchMock.mock.calls[0]; const body = (await request.clone().json()) as { query: string }; expect(body.query).toContain("primaryName(by: { chainName: ETHEREUM })"); expect(body.query).toContain("v1DomainsCount"); }); it("appends validation hints for invalid ProfileSocials fields", async () => { - fetchMock.mockResolvedValue( + omnigraphApiFetchMock.mockResolvedValue( new Response( JSON.stringify({ errors: [{ message: 'Cannot query field "discord" on type "ProfileSocials".' }], @@ -283,7 +302,8 @@ describe("Omnigraph MCP server", () => { const result = await client.callTool({ name: "omnigraph_query", arguments: { - query: "{ domain(by: { name: \"vitalik.eth\" }) { resolve { profile { socials { discord { handle } } } } } }", + query: + '{ domain(by: { name: "vitalik.eth" }) { resolve { profile { socials { discord { handle } } } } } }', }, }); @@ -293,7 +313,7 @@ describe("Omnigraph MCP server", () => { }); it("appends validation hints for camelCase filter fields", async () => { - fetchMock.mockResolvedValue( + omnigraphApiFetchMock.mockResolvedValue( new Response( JSON.stringify({ errors: [ diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts index ef4241d822..1155155978 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts @@ -12,6 +12,8 @@ import { OmnigraphSchemaLookupInputSchema, } from "@ensnode/ensnode-sdk/internal"; +import { makeLogger } from "@/lib/logger"; + import { buildOmnigraphExamplesIndex, executeOmnigraphQuery, @@ -20,6 +22,8 @@ import { resolveOmnigraphExample, } from "./omnigraph-mcp-support"; +const logger = makeLogger("mcp-api"); + const OmnigraphQueryInputSchema = z .object({ query: z @@ -38,8 +42,8 @@ const OmnigraphQueryInputSchema = z .describe("GraphQL variables. With `exampleId`, overrides the example defaults."), }) .superRefine((value, ctx) => { - const hasQuery = value.query !== undefined && value.query.length > 0; - const hasExample = value.exampleId !== undefined && value.exampleId.length > 0; + const hasQuery = value.query !== undefined && value.query.trim().length > 0; + const hasExample = value.exampleId !== undefined && value.exampleId.trim().length > 0; if (hasQuery === hasExample) { ctx.addIssue({ code: "custom", @@ -246,6 +250,7 @@ export function createOmnigraphMcpServer(): McpServer { type McpSession = { server: McpServer; transport: StreamableHTTPTransport; + lastActivityAt: number; }; type McpRequestContext = Parameters[0]; @@ -256,6 +261,9 @@ const sessions = new Map(); /** Cap stored sessions to limit memory growth from repeated initialize requests. */ const MAX_MCP_SESSIONS = 200; +/** Close sessions with no client activity for this long. */ +const MCP_SESSION_IDLE_TTL_MS = 30 * 60 * 1000; + function jsonRpcErrorResponse( c: { json: (data: unknown, status: number) => Response }, code: number, @@ -265,12 +273,32 @@ function jsonRpcErrorResponse( return c.json({ jsonrpc: "2.0", error: { code, message }, id: null }, status); } +function touchSession(session: McpSession) { + session.lastActivityAt = Date.now(); +} + +function closeSessionFireAndForget(sessionId: string) { + void closeSession(sessionId).catch((err) => { + logger.error({ err, sessionId }, "MCP session cleanup failed"); + }); +} + +function evictIdleSessions(exceptSessionId?: string) { + const now = Date.now(); + for (const [id, session] of sessions) { + if (id !== exceptSessionId && now - session.lastActivityAt > MCP_SESSION_IDLE_TTL_MS) { + closeSessionFireAndForget(id); + } + } +} + function storeSession(id: string, session: McpSession) { + evictIdleSessions(id); sessions.set(id, session); if (sessions.size > MAX_MCP_SESSIONS) { const oldestId = sessions.keys().next().value; if (typeof oldestId === "string" && oldestId !== id) { - void closeSession(oldestId); + closeSessionFireAndForget(oldestId); } } } @@ -304,6 +332,8 @@ app.all("/", async (c) => { if (c.req.method === "GET") { const session = sessionId ? sessions.get(sessionId) : undefined; if (!session) return invalidSessionResponse(c); + touchSession(session); + evictIdleSessions(sessionId); const response = await session.transport.handleRequest(mcpContext); return response ?? c.body(null, 202); } @@ -324,6 +354,8 @@ app.all("/", async (c) => { if (!session) { return jsonRpcErrorResponse(c, -32_000, "Session not found", 404); } + touchSession(session); + evictIdleSessions(sessionId); const response = await session.transport.handleRequest(mcpContext); return response ?? c.body(null, 202); } @@ -342,6 +374,7 @@ app.all("/", async (c) => { let initializedSessionId: string | undefined; const session: McpSession = { server, + lastActivityAt: Date.now(), transport: new StreamableHTTPTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (id) => { @@ -349,7 +382,7 @@ app.all("/", async (c) => { storeSession(id, session); }, onsessionclosed: (id) => { - void closeSession(id); + closeSessionFireAndForget(id); }, }), }; diff --git a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts index 5b10e8f700..932c2b3b59 100644 --- a/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts +++ b/apps/ensapi/src/handlers/api/mcp/omnigraph-mcp-support.ts @@ -47,7 +47,8 @@ const VALIDATION_HINTS: Array<{ match: RegExp; hint: string }> = [ "Call omnigraph_schema({ type: 'DomainsNameFilter' }).", }, { - match: /Field "[^"]*[A-Z][^"]*" is not defined by type "(DomainsNameFilter|SubdomainsWhereInput)"/, + match: + /Field "[^"]*[A-Z][^"]*" is not defined by type "(DomainsNameFilter|SubdomainsWhereInput)"/, hint: "Omnigraph filter inputs use snake_case field names (e.g. starts_with). " + "Call omnigraph_schema({ type: 'DomainsNameFilter' }) before custom where filters.", @@ -119,19 +120,18 @@ function appendValidationHints(responseText: string): string { return JSON.stringify({ ...parsed, hints }, null, 2); } -/** Executes a read-only Omnigraph query against the in-process Yoga instance. */ +/** Executes a read-only Omnigraph query via the /api/omnigraph handler (middleware + Yoga). */ export async function executeOmnigraphQuery( query: string, variables?: Record, ): Promise { - const { yoga } = await import("@/omnigraph-api/yoga"); - const response = await yoga.fetch( + const omnigraphApi = await import("@/handlers/api/omnigraph/omnigraph-api"); + const response = await omnigraphApi.default.fetch( new Request("http://ensapi.internal/api/omnigraph", { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ query, variables: variables ?? null }), }), - { canAccelerate: false }, ); return appendValidationHints(await response.text()); } diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index 8f938b51ff..94887b4669 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -82,7 +82,10 @@ export function getGraphqlApiExampleQueryById(id: string): GraphqlApiExampleQuer } export function listGraphqlApiExampleQueryIds(): string[] { - return GRAPHQL_API_EXAMPLE_QUERIES.map((example) => example.id); + return [ + ...GRAPHQL_API_EXAMPLE_QUERIES.map((example) => example.id), + ...Object.keys(GRAPHQL_API_EXAMPLE_ID_ALIASES), + ]; } export function resolveGraphqlApiExampleQuery( From 891db57e0957f8b2edddb404d94c8a4a01bdd0d1 Mon Sep 17 00:00:00 2001 From: djstrong Date: Thu, 18 Jun 2026 23:41:12 +0200 Subject: [PATCH 14/16] feat(ensapi): implement least-recently-used session eviction and update Omnigraph tools documentation --- apps/ensapi/src/handlers/api/mcp/mcp-api.ts | 15 +++++--- .../integration-options/omnigraph-mcp.mdx | 15 ++++---- .../ensnode-sdk/scripts/inline-schema-sdl.mjs | 15 ++++++++ .../src/omnigraph-api/example-queries.test.ts | 35 +++++++++++++++++++ .../src/omnigraph-api/example-queries.ts | 2 +- 5 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 packages/ensnode-sdk/scripts/inline-schema-sdl.mjs create mode 100644 packages/ensnode-sdk/src/omnigraph-api/example-queries.test.ts diff --git a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts index 1155155978..5933e29435 100644 --- a/apps/ensapi/src/handlers/api/mcp/mcp-api.ts +++ b/apps/ensapi/src/handlers/api/mcp/mcp-api.ts @@ -295,12 +295,19 @@ function evictIdleSessions(exceptSessionId?: string) { function storeSession(id: string, session: McpSession) { evictIdleSessions(id); sessions.set(id, session); - if (sessions.size > MAX_MCP_SESSIONS) { - const oldestId = sessions.keys().next().value; - if (typeof oldestId === "string" && oldestId !== id) { - closeSessionFireAndForget(oldestId); + if (sessions.size <= MAX_MCP_SESSIONS) return; + + // Evict the least-recently-active session (LRU), not the oldest-created. + let lruId: string | undefined; + let lruAt = Number.POSITIVE_INFINITY; + for (const [candidateId, candidate] of sessions) { + if (candidateId === id) continue; + if (candidate.lastActivityAt < lruAt) { + lruAt = candidate.lastActivityAt; + lruId = candidateId; } } + if (lruId) closeSessionFireAndForget(lruId); } function getSessionId(ctx: { diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-mcp.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-mcp.mdx index 5e1b7b9d3c..b98b9e75ad 100644 --- a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-mcp.mdx +++ b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/omnigraph-mcp.mdx @@ -12,11 +12,14 @@ It's first-party and built in: the same endpoint works locally (`http://localhos -## The tool +## Tools, resources, and prompts -The server exposes a single, read-only tool: +The server exposes two read-only tools: -- **`omnigraph_query`** — accepts a GraphQL `query` (and optional `variables`) and returns the raw `{ data, errors }` JSON, exactly as the [HTTP endpoint](/docs/integrate/integration-options/omnigraph-graphql-api#1-the-endpoint) does. Because it's the full Omnigraph behind one tool, an agent can answer any question the Omnigraph can — resolve records, search Domains, list a user's Domains, and much more — without a fixed, hand-written tool per use case. +- **`omnigraph_query`** — accepts a GraphQL `query` or vetted `exampleId` (and optional `variables`) and returns the raw `{ data, errors }` JSON, exactly as the [HTTP endpoint](/docs/integrate/integration-options/omnigraph-graphql-api#1-the-endpoint) does. Because it's the full Omnigraph behind one tool, an agent can answer any question the Omnigraph can — resolve records, search Domains, list a user's Domains, and much more — without a fixed, hand-written tool per use case. +- **`omnigraph_schema`** — looks up Omnigraph types and fields from the bundled schema (no network). Use it before writing custom queries, especially for filter inputs like `DomainsNameFilter`. + +It also ships **resources** (condensed schema at `omnigraph://schema/condensed`, example query index at `omnigraph://examples/index`, and per-example payloads at `omnigraph://examples/{exampleId}`) and **prompts** for common tasks such as account profiles and domain lookups. ## Cursor @@ -51,7 +54,7 @@ For a local ENSApi instance: Then: 1. Open **Cursor Settings → Tools & MCP** (or **Features → MCP**). -2. Confirm `ensnode-omnigraph` is listed with a green status and the `omnigraph_query` tool. +2. Confirm `ensnode-omnigraph` is listed with a green status and the `omnigraph_query` and `omnigraph_schema` tools. 3. In chat, ask ENS questions in plain language — e.g. _"Who owns vitalik.eth?"_ or _"List domains owned by 0x…"_ — and let the agent call `omnigraph_query`.