# MCP Tools Server

URL: /docs/experiments/mcp-tools-server
> Remote MCP server on Workers with Agents SDK McpAgent - expose DNS, headers, uptime, and hash tools to Cursor and Claude
Tags: ai, mcp, agents, durable-objects, tools, cursor, model-context-protocol
Bindings: DO
Markdown: /llms.mdx/experiments/mcp-tools-server/content.md



A remote &#x2A;*Model Context Protocol (MCP)** server built with the Agents SDK `McpAgent`. Each MCP session runs in its own SQLite-backed **Durable Object**, and clients such as Cursor, Claude Desktop, or the MCP Inspector connect over **Streamable HTTP** at `/mcp`.

## Features [#features]

* **`dns_lookup`** - Resolve `A`, `AAAA`, `MX`, `TXT`, `NS`, or `CNAME` records through Cloudflare DNS-over-HTTPS (1.1.1.1)
* **`http_headers`** - Fetch a URL from the edge and return status, final URL, and response headers
* **`is_it_up`** - Reachability check with latency in milliseconds
* **`hash_text`** - SHA-256 / SHA-384 / SHA-512 digests via Web Crypto
* **Ready-made client config** - `GET /` returns your endpoint URL and a paste-ready `mcp.json` snippet

## API Reference [#api-reference]

### GET / [#get-]

Returns app info, the MCP endpoint for the current host, the tool list, and an `mcp.json` snippet.

#### Example Request [#example-request]

```bash
curl "https://mcp-tools-server.your-subdomain.workers.dev/"
```

#### Success Response [#success-response]

```json
{
  "name": "mcp-tools-server",
  "description": "Remote MCP server exposing DNS, HTTP, uptime, and hashing tools",
  "usage": {
    "mcp": "POST /mcp (Streamable HTTP)",
    "inspector": "npx @modelcontextprotocol/inspector, then connect to https://mcp-tools-server.your-subdomain.workers.dev/mcp"
  },
  "mcpEndpoint": "https://mcp-tools-server.your-subdomain.workers.dev/mcp",
  "tools": ["dns_lookup", "http_headers", "is_it_up", "hash_text"],
  "mcpConfig": {
    "mcpServers": {
      "mcp-tools-server": {
        "command": "npx",
        "args": ["mcp-remote", "https://mcp-tools-server.your-subdomain.workers.dev/mcp"]
      }
    }
  },
  "cloudflareFeatures": ["Agents SDK (McpAgent)", "Durable Objects"]
}
```

#### Error Codes [#error-codes]

* `404` - Unknown path (`NOT_FOUND`)

### POST /mcp [#post-mcp]

The MCP Streamable HTTP endpoint, served by `ToolsMcp.serve("/mcp", { binding: "MCP_OBJECT" })`. Send JSON-RPC messages with an MCP client rather than by hand. The first `initialize` request creates a session, and the `mcp-session-id` response header routes later requests to the same Durable Object.

### Tools [#tools]

Each tool returns one `text` content block containing JSON. On failure the result has `isError: true` and the text is `{ "error": string, "code": string }`.

#### dns\_lookup [#dns_lookup]

**`hostname`** `string` (required)

Hostname such as `cloudflare.com`. An `http(s)` URL is also accepted, and its hostname is used.

**`type`** `"A" | "AAAA" | "MX" | "TXT" | "NS" | "CNAME"` (optional, default `A`)

```json
{
  "name": "cloudflare.com",
  "type": "A",
  "status": 0,
  "answers": [{ "name": "cloudflare.com", "type": 1, "ttl": 300, "data": "104.16.132.229" }]
}
```

Errors: `INVALID_HOSTNAME`, `DNS_ERROR`.

#### http\_headers [#http_headers]

**`url`** `string` (required) - http or https only.

```json
{
  "url": "https://www.cloudflare.com/",
  "finalUrl": "https://www.cloudflare.com/",
  "status": 200,
  "statusText": "OK",
  "headers": { "content-type": "text/html; charset=utf-8", "server": "cloudflare" }
}
```

Errors: `INVALID_URL`, `FETCH_ERROR`.

#### is\_it\_up [#is_it_up]

**`url`** `string` (required) - http or https only.

```json
{ "url": "https://www.cloudflare.com/", "up": true, "status": 200, "latencyMs": 84 }
```

When the request fails, `up` is `false`, `status` is `null`, and an `error` message is included. Errors: `INVALID_URL`.

#### hash\_text [#hash_text]

**`text`** `string` (required) - Up to 100,000 characters.

**`algorithm`** `"SHA-256" | "SHA-384" | "SHA-512"` (optional, default `SHA-256`)

```json
{
  "algorithm": "SHA-256",
  "hex": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
}
```

## Connecting clients [#connecting-clients]

### Cursor and Claude Desktop [#cursor-and-claude-desktop]

Both can use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) to bridge a local stdio client to a remote server. Add this to `~/.cursor/mcp.json` (Cursor) or `claude_desktop_config.json` (Claude Desktop), then restart the client:

```json
{
  "mcpServers": {
    "mcp-tools-server": {
      "command": "npx",
      "args": ["mcp-remote", "https://mcp-tools-server.your-subdomain.workers.dev/mcp"]
    }
  }
}
```

You can copy the same snippet, filled in with your hostname, from `GET /`. Clients that support remote Streamable HTTP servers natively can use the `/mcp` URL directly.

### MCP Inspector [#mcp-inspector]

```bash
npx @modelcontextprotocol/inspector
```

In the Inspector UI, choose **Streamable HTTP** as the transport, enter `https://<your-worker>/mcp` (or `http://localhost:8787/mcp` locally), click **Connect**, and then open **Tools → List Tools** to call each tool interactively.

## Implementation Details [#implementation-details]

* `src/mcp-agent.ts` - `ToolsMcp extends McpAgent` creates an SDK `McpServer` and calls `registerTools()` in `init()`
* `src/lib/tools.ts` - Registers the four tools with Zod input schemas and read-only annotations
* `src/lib/dns.ts`, `src/lib/http.ts`, `src/lib/hash.ts` - Plain functions with no MCP dependency, unit-tested with a mocked `fetch`
* `src/index.ts` - Sends `/mcp` to the `McpAgent` handler and everything else to Hono

<Callout type="info">
  Cloudflare now recommends the stateless `createMcpHandler` (MCP SDK v2) for new servers, and
  `McpAgent` is feature-frozen. This experiment uses `McpAgent` to show the Durable Object–backed
  session model. Because the tools live in `src/lib/tools.ts`, you can move them to a stateless
  handler later.
</Callout>

## Use Cases [#use-cases]

* Give Cursor or Claude network-debugging tools that run from Cloudflare's edge rather than your laptop
* Learn the `McpAgent` + Durable Object session model before building stateful MCP tools
* Use as a starting point for an internal MCP server (add your own tools in `src/lib/tools.ts`)
* Test MCP clients and the Inspector against a real remote server

## Limitations [#limitations]

* **No authentication**: anyone with the URL can call the tools. Add OAuth (Workers OAuth Provider) or put the Worker behind Cloudflare Access before exposing internal tools
* `http_headers` and `is_it_up` follow redirects and time out after 10 seconds
* Each MCP session creates a Durable Object; idle sessions hibernate but still count toward storage
* `McpAgent` is deprecated in favor of stateless `createMcpHandler`; see the callout above

## Use in your project [#use-in-your-project]

<UseInYourProject
  experiment="mcp-tools-server"
  dependencies="[&#x22;agents&#x22;, &#x22;@modelcontextprotocol/sdk&#x22;, &#x22;zod&#x22;]"
  bindings="[&#x22;Durable Objects (MCP_OBJECT, new_sqlite_classes)&#x22;]"
  platform="[&#x22;Agents SDK McpAgent&#x22;, &#x22;Web Crypto (`crypto.subtle`)&#x22;, &#x22;nodejs_compat&#x22;]"
  files="[
  &#x22;src/lib/tools.ts&#x22;,
  &#x22;src/lib/dns.ts&#x22;,
  &#x22;src/lib/http.ts&#x22;,
  &#x22;src/lib/hash.ts&#x22;,
  &#x22;src/lib/url.ts&#x22;,
  &#x22;src/utils/mcp.ts&#x22;,
  &#x22;src/constants/defaults.ts&#x22;,
  &#x22;src/types/tools.ts&#x22;,
  &#x22;examples/usage.ts&#x22;,
]"
  sources="[{&#x22;path&#x22;:&#x22;src/lib/tools.ts&#x22;,&#x22;title&#x22;:&#x22;tools.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { McpServer } from \&#x22;@modelcontextprotocol/sdk/server/mcp.js\&#x22;;\nimport { z } from \&#x22;zod\&#x22;;\nimport { DNS_RECORD_TYPES, HASH_ALGORITHMS, MAX_HASH_INPUT_LENGTH } from \&#x22;../constants/defaults\&#x22;;\nimport { toolError, toolResult } from \&#x22;../utils/mcp\&#x22;;\nimport { dnsLookup, normalizeHostname } from \&#x22;./dns\&#x22;;\nimport { hashText } from \&#x22;./hash\&#x22;;\nimport { checkUptime, getHttpHeaders } from \&#x22;./http\&#x22;;\nimport { validateUrl } from \&#x22;./url\&#x22;;\n\nconst READ_ONLY = { readOnlyHint: true, openWorldHint: true } as const;\n\n/** Registers every tool on an MCP server. Works with any SDK v1 `McpServer`. */\nexport function registerTools(server: McpServer): void {\n  server.registerTool(\n    \&#x22;dns_lookup\&#x22;,\n    {\n      title: \&#x22;DNS lookup\&#x22;,\n      description: \&#x22;Resolve DNS records for a hostname via Cloudflare DNS-over-HTTPS (1.1.1.1).\&#x22;,\n      inputSchema: {\n        hostname: z.string().describe(\&#x22;Hostname or URL, e.g. cloudflare.com\&#x22;),\n        type: z.enum(DNS_RECORD_TYPES).default(\&#x22;A\&#x22;).describe(\&#x22;Record type\&#x22;),\n      },\n      annotations: READ_ONLY,\n    },\n    async ({ hostname, type }) => {\n      const host = normalizeHostname(hostname);\n      if (!host) return toolError(\&#x22;Invalid hostname\&#x22;, \&#x22;INVALID_HOSTNAME\&#x22;);\n      try {\n        return toolResult(await dnsLookup(host, type));\n      } catch (e) {\n        return toolError(e instanceof Error ? e.message : \&#x22;DNS lookup failed\&#x22;, \&#x22;DNS_ERROR\&#x22;);\n      }\n    }\n  );\n\n  server.registerTool(\n    \&#x22;http_headers\&#x22;,\n    {\n      title: \&#x22;HTTP headers\&#x22;,\n      description:\n        \&#x22;Fetch a URL from Cloudflare's edge and return the status code and response headers.\&#x22;,\n      inputSchema: { url: z.string().describe(\&#x22;http(s) URL to fetch\&#x22;) },\n      annotations: READ_ONLY,\n    },\n    async ({ url }) => {\n      const target = validateUrl(url);\n      if (!target) return toolError(\&#x22;Invalid URL (http/https only)\&#x22;, \&#x22;INVALID_URL\&#x22;);\n      try {\n        return toolResult(await getHttpHeaders(target));\n      } catch (e) {\n        return toolError(e instanceof Error ? e.message : \&#x22;Fetch failed\&#x22;, \&#x22;FETCH_ERROR\&#x22;);\n      }\n    }\n  );\n\n  server.registerTool(\n    \&#x22;is_it_up\&#x22;,\n    {\n      title: \&#x22;Is it up?\&#x22;,\n      description: \&#x22;Check whether a URL is reachable from Cloudflare's edge and measure latency.\&#x22;,\n      inputSchema: { url: z.string().describe(\&#x22;http(s) URL to check\&#x22;) },\n      annotations: READ_ONLY,\n    },\n    async ({ url }) => {\n      const target = validateUrl(url);\n      if (!target) return toolError(\&#x22;Invalid URL (http/https only)\&#x22;, \&#x22;INVALID_URL\&#x22;);\n      return toolResult(await checkUptime(target));\n    }\n  );\n\n  server.registerTool(\n    \&#x22;hash_text\&#x22;,\n    {\n      title: \&#x22;Hash text\&#x22;,\n      description: \&#x22;Hash UTF-8 text with SHA-256, SHA-384, or SHA-512 using Web Crypto.\&#x22;,\n      inputSchema: {\n        text: z.string().max(MAX_HASH_INPUT_LENGTH).describe(\&#x22;Text to hash\&#x22;),\n        algorithm: z.enum(HASH_ALGORITHMS).default(\&#x22;SHA-256\&#x22;).describe(\&#x22;Hash algorithm\&#x22;),\n      },\n      annotations: { readOnlyHint: true, openWorldHint: false },\n    },\n    async ({ text, algorithm }) => toolResult(await hashText(text, algorithm))\n  );\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/dns.ts&#x22;,&#x22;title&#x22;:&#x22;dns.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { DOH_ENDPOINT, FETCH_TIMEOUT_MS } from \&#x22;../constants/defaults\&#x22;;\nimport type { DnsAnswer, DnsLookupResult, DnsRecordType } from \&#x22;../types/tools\&#x22;;\n\nconst HOSTNAME_PATTERN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$/i;\n\n/**\n * Accepts a bare hostname or an http(s) URL and returns the lowercase hostname,\n * or null if it is not a valid public DNS name.\n */\nexport function normalizeHostname(input: string): string | null {\n  const trimmed = input.trim().replace(/\\.$/, \&#x22;\&#x22;);\n  if (!trimmed) return null;\n  let host = trimmed;\n  if (/^https?:\\/\\//i.test(trimmed)) {\n    try {\n      host = new URL(trimmed).hostname;\n    } catch {\n      return null;\n    }\n  }\n  return HOSTNAME_PATTERN.test(host) ? host.toLowerCase() : null;\n}\n\ninterface DohResponse {\n  Status: number;\n  Answer?: { name: string; type: number; TTL: number; data: string }[];\n}\n\n/** Resolves a record via Cloudflare's DNS-over-HTTPS JSON API (1.1.1.1). */\nexport async function dnsLookup(hostname: string, type: DnsRecordType): Promise<DnsLookupResult> {\n  const url = new URL(DOH_ENDPOINT);\n  url.searchParams.set(\&#x22;name\&#x22;, hostname);\n  url.searchParams.set(\&#x22;type\&#x22;, type);\n\n  const res = await fetch(url.href, {\n    headers: { Accept: \&#x22;application/dns-json\&#x22; },\n    signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n  });\n  if (!res.ok) {\n    throw new Error(`DNS-over-HTTPS request failed with status ${res.status}`);\n  }\n\n  const body = (await res.json()) as DohResponse;\n  return {\n    name: hostname,\n    type,\n    status: body.Status,\n    answers: (body.Answer ?? []).map(\n      ({ name, type: recordType, TTL, data }): DnsAnswer => ({\n        name,\n        type: recordType,\n        ttl: TTL,\n        data,\n      })\n    ),\n  };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/http.ts&#x22;,&#x22;title&#x22;:&#x22;http.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { FETCH_TIMEOUT_MS, USER_AGENT } from \&#x22;../constants/defaults\&#x22;;\nimport type { HttpHeadersResult, UptimeResult } from \&#x22;../types/tools\&#x22;;\n\nfunction request(url: string, method: \&#x22;GET\&#x22; | \&#x22;HEAD\&#x22;): Promise<Response> {\n  return fetch(url, {\n    method,\n    redirect: \&#x22;follow\&#x22;,\n    headers: { \&#x22;User-Agent\&#x22;: USER_AGENT },\n    signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n  });\n}\n\n/** Fetches a URL and returns its status and response headers (body is discarded). */\nexport async function getHttpHeaders(url: string): Promise<HttpHeadersResult> {\n  const res = await request(url, \&#x22;GET\&#x22;);\n  await res.body?.cancel();\n  return {\n    url,\n    finalUrl: res.url || url,\n    status: res.status,\n    statusText: res.statusText,\n    headers: Object.fromEntries(res.headers.entries()),\n  };\n}\n\n/** Checks whether a URL responds with a 2xx/3xx status and measures latency. */\nexport async function checkUptime(url: string): Promise<UptimeResult> {\n  const start = Date.now();\n  try {\n    const res = await request(url, \&#x22;GET\&#x22;);\n    await res.body?.cancel();\n    return { url, up: res.ok, status: res.status, latencyMs: Date.now() - start };\n  } catch (e) {\n    return {\n      url,\n      up: false,\n      status: null,\n      latencyMs: Date.now() - start,\n      error: e instanceof Error ? e.message : \&#x22;Unknown error\&#x22;,\n    };\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/hash.ts&#x22;,&#x22;title&#x22;:&#x22;hash.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { HashAlgorithm, HashResult } from \&#x22;../types/tools\&#x22;;\n\n/** Hashes UTF-8 text with Web Crypto and returns lowercase hex. */\nexport async function hashText(text: string, algorithm: HashAlgorithm): Promise<HashResult> {\n  const digest = await crypto.subtle.digest(algorithm, new TextEncoder().encode(text));\n  const hex = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, \&#x22;0\&#x22;)).join(\&#x22;\&#x22;);\n  return { algorithm, hex };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/url.ts&#x22;,&#x22;title&#x22;:&#x22;url.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { ALLOWED_SCHEMES } from \&#x22;../constants/defaults\&#x22;;\n\n/**\n * Validates and normalizes a URL for safe fetching.\n * Returns the URL string or null if invalid/not allowed.\n */\nexport function validateUrl(input: string | undefined): string | null {\n  if (!input || typeof input !== \&#x22;string\&#x22;) return null;\n  const trimmed = input.trim();\n  if (!trimmed) return null;\n  try {\n    const url = new URL(trimmed);\n    if (!ALLOWED_SCHEMES.includes(url.protocol as (typeof ALLOWED_SCHEMES)[number])) {\n      return null;\n    }\n    return url.href;\n  } catch {\n    return null;\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/utils/mcp.ts&#x22;,&#x22;title&#x22;:&#x22;mcp.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { CallToolResult } from \&#x22;@modelcontextprotocol/sdk/types.js\&#x22;;\n\nexport function toolResult<T>(data: T): CallToolResult {\n  return { content: [{ type: \&#x22;text\&#x22;, text: JSON.stringify(data, null, 2) }] };\n}\n\nexport function toolError(message: string, code: string): CallToolResult {\n  return {\n    content: [{ type: \&#x22;text\&#x22;, text: JSON.stringify({ error: message, code }) }],\n    isError: true,\n  };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/constants/defaults.ts&#x22;,&#x22;title&#x22;:&#x22;defaults.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export const SERVER_NAME = \&#x22;mcp-tools-server\&#x22;;\nexport const SERVER_VERSION = \&#x22;1.0.0\&#x22;;\nexport const MCP_PATH = \&#x22;/mcp\&#x22;;\n\nexport const ALLOWED_SCHEMES = [\&#x22;http:\&#x22;, \&#x22;https:\&#x22;] as const;\nexport const FETCH_TIMEOUT_MS = 10_000;\nexport const USER_AGENT = \&#x22;Cloudflare-Experiments-McpToolsServer/1.0\&#x22;;\n\nexport const DOH_ENDPOINT = \&#x22;https://cloudflare-dns.com/dns-query\&#x22;;\nexport const DNS_RECORD_TYPES = [\&#x22;A\&#x22;, \&#x22;AAAA\&#x22;, \&#x22;MX\&#x22;, \&#x22;TXT\&#x22;, \&#x22;NS\&#x22;, \&#x22;CNAME\&#x22;] as const;\n\nexport const HASH_ALGORITHMS = [\&#x22;SHA-256\&#x22;, \&#x22;SHA-384\&#x22;, \&#x22;SHA-512\&#x22;] as const;\nexport const MAX_HASH_INPUT_LENGTH = 100_000;&#x22;},{&#x22;path&#x22;:&#x22;src/types/tools.ts&#x22;,&#x22;title&#x22;:&#x22;tools.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { DNS_RECORD_TYPES, HASH_ALGORITHMS } from \&#x22;../constants/defaults\&#x22;;\n\nexport type DnsRecordType = (typeof DNS_RECORD_TYPES)[number];\nexport type HashAlgorithm = (typeof HASH_ALGORITHMS)[number];\n\nexport interface DnsAnswer {\n  name: string;\n  type: number;\n  ttl: number;\n  data: string;\n}\n\nexport interface DnsLookupResult {\n  name: string;\n  type: DnsRecordType;\n  status: number;\n  answers: DnsAnswer[];\n}\n\nexport interface HttpHeadersResult {\n  url: string;\n  finalUrl: string;\n  status: number;\n  statusText: string;\n  headers: Record<string, string>;\n}\n\nexport interface UptimeResult {\n  url: string;\n  up: boolean;\n  status: number | null;\n  latencyMs: number;\n  error?: string;\n}\n\nexport interface HashResult {\n  algorithm: HashAlgorithm;\n  hex: string;\n}&#x22;},{&#x22;path&#x22;:&#x22;examples/usage.ts&#x22;,&#x22;title&#x22;:&#x22;usage.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/**\n * Minimal paste example for docs (\&#x22;Use in your project\&#x22;).\n * Not part of the Worker runtime — copy into your own project.\n *\n * Needs a Durable Object binding named MCP_OBJECT for ToolsMcp\n * (migration: new_sqlite_classes) and the nodejs_compat flag.\n * Connect clients to https://<your-worker>/mcp.\n */\nimport { McpAgent } from \&#x22;agents/mcp\&#x22;;\nimport { McpServer } from \&#x22;@modelcontextprotocol/sdk/server/mcp.js\&#x22;;\nimport { registerTools } from \&#x22;../src/lib/tools\&#x22;;\n\nexport class ToolsMcp extends McpAgent {\n  server = new McpServer({ name: \&#x22;my-tools\&#x22;, version: \&#x22;1.0.0\&#x22; });\n\n  async init(): Promise<void> {\n    registerTools(this.server);\n  }\n}\n\nexport default ToolsMcp.serve(\&#x22;/mcp\&#x22;, { binding: \&#x22;MCP_OBJECT\&#x22; });&#x22;}]"
/>

## Deployment [#deployment]

<Steps>
  <Step>
    ### Click the deploy button [#click-the-deploy-button]

    [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/mcp-tools-server)
  </Step>

  <Step>
    ### Deploy [#deploy]

    `wrangler.json` already declares the `MCP_OBJECT` Durable Object binding, the `new_sqlite_classes` migration for `ToolsMcp`, and the `nodejs_compat` flag.
  </Step>

  <Step>
    ### Test your deployment [#test-your-deployment]

    ```bash
    curl "https://mcp-tools-server.your-subdomain.workers.dev/"
    npx @modelcontextprotocol/inspector
    ```
  </Step>
</Steps>

## Local Development [#local-development]

```bash
cd apps/experiments/mcp-tools-server
npm install
npm run dev
```

```bash
curl "http://localhost:8787/"
# MCP endpoint for clients and the Inspector: http://localhost:8787/mcp
```

## Configuration [#configuration]

`wrangler.json` declares:

* **Durable Object binding** `MCP_OBJECT` → class `ToolsMcp` (the default binding name `McpAgent.serve()` looks for)
* **Migration** `v1` with `new_sqlite_classes: ["ToolsMcp"]`
* **Compatibility flag** `nodejs_compat` (required by the Agents SDK)

## Cloudflare Features Used [#cloudflare-features-used]

* **[Agents SDK - MCP](https://developers.cloudflare.com/agents/model-context-protocol/)** - `McpAgent` and Streamable HTTP transport
* **[Durable Objects](https://developers.cloudflare.com/durable-objects/)** - One SQLite-backed instance per MCP session
* **[DNS over HTTPS](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/)** - JSON API used by `dns_lookup`
* **[Web Crypto](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)** - `crypto.subtle.digest` for `hash_text`
