{
  "$schema": "https://hyperjs.ai/schema/registry-item.json",
  "name": "dev-mcp",
  "version": "0.1.0",
  "description": "Dev-mode app-as-MCP server — expose /.hyper/mcp with introspection + replay tools.",
  "readme": "# @hyper/dev-mcp\n\nDev-mode app-as-MCP server — exposes `/.hyper/mcp` with introspection + replay tools.\n\n## Install\n\nComponents are installed as source into your repo, not pulled from npm:\n\n```bash\nbunx hyper add dev-mcp\n```\n\nWires the alias `@hyper/dev-mcp` to `src/hyper/dev-mcp/` (configurable in `hyper.config.json`). See [hyperjs.ai](https://hyperjs.ai) for the full registry.\n\n## Usage\n\n```ts\nimport { Hyper } from \"@hyper/core\"\nimport { devMcp } from \"@hyper/dev-mcp\"\n\nconst app = new Hyper()\nif (process.env.NODE_ENV !== \"production\") app.use(devMcp())\nexport default app.listen(3000)\n```\n\n## Docs\n\nSee the [main README](../../README.md) and [docs/](../../docs) for guides and integration recipes.\n\n## License\n\nMIT\n",
  "registryDeps": [
    "core"
  ],
  "peerDeps": {},
  "optionalPeerDeps": {},
  "files": [
    {
      "path": "dev-mcp/index.ts",
      "contents": "/**\n * @hyper/dev-mcp — dev-time MCP surface mounted at /.hyper/mcp.\n *\n * Zero prod exposure: the plugin becomes a no-op unless `enabled: true`\n * or `NODE_ENV !== \"production\"`. `hyper build` strips it automatically\n * because the plugin short-circuits to an empty object in production\n * envs (see `devMcpPlugin`).\n */\n\nexport { buildTools, devMcpPlugin, DevRecorder } from \"./plugin.ts\"\nexport type { DevMcpConfig, DevTool, RecordedError, RecordedRequest } from \"./plugin.ts\"\n",
      "sha256": "14bc3f2bea70c600399bfc96afaf99ddd0f8c16b023542806f9100eff1b88e44"
    },
    {
      "path": "dev-mcp/plugin.ts",
      "contents": "/**\n * @hyper/dev-mcp — localhost-only MCP server embedded under /.hyper/mcp.\n *\n *   app({ plugins: [devMcpPlugin({ enabled: process.env.NODE_ENV !== \"production\" })] })\n *\n * Safety:\n *   - Hard-disabled unless enabled=true (or NODE_ENV !== \"production\").\n *   - Denies requests not coming from loopback.\n *   - Never projects routes tagged `meta.internal: true`.\n */\n\nimport type { HyperApp, HyperPlugin } from \"@hyper/core\"\nimport { DevRecorder, type RecordedError, type RecordedRequest } from \"./recorder.ts\"\nimport { type DevTool, buildTools } from \"./tools.ts\"\n\nexport { DevRecorder } from \"./recorder.ts\"\nexport type { RecordedError, RecordedRequest } from \"./recorder.ts\"\nexport { buildTools } from \"./tools.ts\"\nexport type { DevTool } from \"./tools.ts\"\n\nexport interface DevMcpConfig {\n  readonly enabled?: boolean\n  /** URL path the dev MCP server lives at. Default: /.hyper/mcp */\n  readonly path?: string\n  /** Extra IP prefixes permitted in addition to 127.* and ::1. */\n  readonly allowedHosts?: readonly string[]\n  readonly recorder?: DevRecorder\n}\n\nconst LOOPBACK = new Set([\"127.0.0.1\", \"::1\", \"localhost\"])\n\nexport function devMcpPlugin(config: DevMcpConfig = {}): HyperPlugin {\n  const enabled = config.enabled ?? process.env.NODE_ENV !== \"production\"\n  const base = config.path ?? \"/.hyper/mcp\"\n  const recorder = config.recorder ?? new DevRecorder()\n  const allowed = new Set([...LOOPBACK, ...(config.allowedHosts ?? [])])\n  let tools: readonly DevTool[] = []\n  let appRef: HyperApp | undefined\n\n  const requestIds = new WeakMap<Request, string>()\n  const startTimes = new WeakMap<Request, number>()\n\n  return {\n    name: \"@hyper/dev-mcp\",\n    build(app) {\n      if (!enabled) return\n      appRef = app\n      tools = buildTools(app, recorder)\n    },\n    request: {\n      async preRoute({ req }) {\n        if (!enabled) return\n        const url = new URL(req.url)\n        if (url.pathname !== base) return\n        if (!isLocal(req, allowed)) {\n          return new Response(JSON.stringify({ error: \"forbidden\" }), {\n            status: 403,\n            headers: { \"content-type\": \"application/json\" },\n          })\n        }\n        if (req.method !== \"POST\") {\n          return new Response(JSON.stringify({ error: \"method_not_allowed\" }), {\n            status: 405,\n            headers: { \"content-type\": \"application/json\" },\n          })\n        }\n        const body = (await req.json().catch(() => null)) as JsonRpc | null\n        if (!body || body.jsonrpc !== \"2.0\" || typeof body.method !== \"string\") {\n          return json({ jsonrpc: \"2.0\", id: null, error: { code: -32600, message: \"bad request\" } })\n        }\n        return handle(body, tools)\n      },\n      before({ req }) {\n        if (!enabled) return\n        requestIds.set(req, crypto.randomUUID())\n        startTimes.set(req, performance.now())\n      },\n      async after({ req, res, route }) {\n        if (!enabled || !appRef) return\n        const url = new URL(req.url)\n        if (url.pathname === base) return\n        if (route?.meta.internal) return\n        recorder.push({\n          id: requestIds.get(req) ?? crypto.randomUUID(),\n          method: req.method,\n          path: url.pathname,\n          route: route?.path,\n          status: res.status,\n          durationMs: performance.now() - (startTimes.get(req) ?? performance.now()),\n          startedAt: Date.now(),\n          headers: Object.fromEntries(req.headers.entries()),\n          query: Object.fromEntries(url.searchParams.entries()),\n          ...(req.body\n            ? {\n                body: await req\n                  .clone()\n                  .text()\n                  .catch(() => \"\"),\n              }\n            : {}),\n        })\n      },\n      onError({ req, error, route }) {\n        if (!enabled) return\n        const url = new URL(req.url)\n        recorder.pushError({\n          id: requestIds.get(req) ?? crypto.randomUUID(),\n          method: req.method,\n          path: url.pathname,\n          route: route?.path,\n          at: Date.now(),\n          message: error instanceof Error ? error.message : String(error),\n          ...(error instanceof Error && error.stack ? { stack: error.stack } : {}),\n        })\n      },\n    },\n  }\n}\n\ninterface JsonRpc {\n  readonly jsonrpc: \"2.0\"\n  readonly id?: string | number | null\n  readonly method: string\n  readonly params?: unknown\n}\n\nasync function handle(rpc: JsonRpc, tools: readonly DevTool[]): Promise<Response> {\n  switch (rpc.method) {\n    case \"initialize\":\n      return json({\n        jsonrpc: \"2.0\",\n        id: rpc.id ?? null,\n        result: {\n          protocolVersion: \"2024-11-05\",\n          capabilities: { tools: {} },\n          serverInfo: { name: \"hyper-dev\", version: \"0.0.0\" },\n        },\n      })\n    case \"tools/list\":\n      return json({\n        jsonrpc: \"2.0\",\n        id: rpc.id ?? null,\n        result: {\n          tools: tools.map((t) => ({\n            name: t.name,\n            description: t.description,\n            inputSchema: t.input,\n          })),\n        },\n      })\n    case \"tools/call\": {\n      const p = rpc.params as { name: string; arguments?: Record<string, unknown> } | undefined\n      if (!p?.name) {\n        return json({\n          jsonrpc: \"2.0\",\n          id: rpc.id ?? null,\n          error: { code: -32602, message: \"missing tool name\" },\n        })\n      }\n      const tool = tools.find((t) => t.name === p.name)\n      if (!tool) {\n        return json({\n          jsonrpc: \"2.0\",\n          id: rpc.id ?? null,\n          error: { code: -32601, message: `unknown tool: ${p.name}` },\n        })\n      }\n      try {\n        const value = await tool.call(p.arguments ?? {})\n        return json({\n          jsonrpc: \"2.0\",\n          id: rpc.id ?? null,\n          result: {\n            content: [{ type: \"text\", text: JSON.stringify(value) }],\n            structuredContent: value,\n          },\n        })\n      } catch (e) {\n        return json({\n          jsonrpc: \"2.0\",\n          id: rpc.id ?? null,\n          error: {\n            code: -32000,\n            message: e instanceof Error ? e.message : String(e),\n          },\n        })\n      }\n    }\n    default:\n      return json({\n        jsonrpc: \"2.0\",\n        id: rpc.id ?? null,\n        error: { code: -32601, message: `unknown method: ${rpc.method}` },\n      })\n  }\n}\n\nfunction json(o: unknown): Response {\n  return new Response(JSON.stringify(o), {\n    headers: { \"content-type\": \"application/json; charset=utf-8\" },\n  })\n}\n\nfunction isLocal(req: Request, allowed: Set<string>): boolean {\n  const host = req.headers.get(\"host\")?.split(\":\")[0] ?? \"\"\n  if (allowed.has(host)) return true\n  // When behind a proxy / inside Bun.serve, fall back to x-forwarded-for.\n  const xff = req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim()\n  if (xff && allowed.has(xff)) return true\n  return !host || host === \"local\"\n}\n",
      "sha256": "e46356dd9cce05f886cb310a79f5a4d112094dc604bd699d69a3db172862df50"
    },
    {
      "path": "dev-mcp/recorder.ts",
      "contents": "/**\n * Rolling in-memory recorder for recent requests + errors.\n *\n * Used by the dev MCP tools (recent_requests, recent_errors, replay_request).\n * Bounded by a max size so memory stays flat across long dev sessions.\n */\n\nexport interface RecordedRequest {\n  readonly id: string\n  readonly method: string\n  readonly path: string\n  readonly status: number\n  readonly durationMs: number\n  readonly startedAt: number\n  readonly route?: string\n  readonly headers: Record<string, string>\n  readonly query: Record<string, string>\n  readonly body?: string\n}\n\nexport interface RecordedError {\n  readonly id: string\n  readonly method: string\n  readonly path: string\n  readonly at: number\n  readonly message: string\n  readonly stack?: string\n  readonly route?: string\n}\n\nconst MAX = 200\n\nexport class DevRecorder {\n  #requests: RecordedRequest[] = []\n  #errors: RecordedError[] = []\n\n  push(r: RecordedRequest): void {\n    this.#requests.push(r)\n    if (this.#requests.length > MAX) this.#requests.shift()\n  }\n  pushError(e: RecordedError): void {\n    this.#errors.push(e)\n    if (this.#errors.length > MAX) this.#errors.shift()\n  }\n  requests(limit = 50): readonly RecordedRequest[] {\n    return this.#requests.slice(-limit).reverse()\n  }\n  errors(limit = 50): readonly RecordedError[] {\n    return this.#errors.slice(-limit).reverse()\n  }\n  find(id: string): RecordedRequest | undefined {\n    return this.#requests.find((r) => r.id === id)\n  }\n  clear(): void {\n    this.#requests.length = 0\n    this.#errors.length = 0\n  }\n}\n",
      "sha256": "4bdaf4390550eb2e14a0af9f2a44ed62f021910eb69b8cfb3573dc61ba64d52c"
    },
    {
      "path": "dev-mcp/tools.ts",
      "contents": "/**\n * Dev MCP tool implementations.\n *\n * Tools expose localhost-only introspection + replay helpers. Internal\n * routes (meta.internal) are never surfaced here.\n */\n\nimport type { HyperApp, Route } from \"@hyper/core\"\nimport type { DevRecorder } from \"./recorder.ts\"\n\nexport interface DevTool {\n  readonly name: string\n  readonly description: string\n  readonly input: Record<string, unknown>\n  readonly call: (args: Record<string, unknown>) => Promise<unknown> | unknown\n}\n\nexport function buildTools(app: HyperApp, rec: DevRecorder): readonly DevTool[] {\n  return [\n    {\n      name: \"list_routes\",\n      description: \"List every non-internal route in the running app.\",\n      input: { type: \"object\", additionalProperties: false },\n      call: () =>\n        publicRoutes(app).map((r) => ({\n          method: r.method,\n          path: r.path,\n          name: r.meta.name,\n          tags: r.meta.tags ?? [],\n          deprecated: !!r.meta.deprecated,\n          mcp: !!r.meta.mcp,\n        })),\n    },\n    {\n      name: \"get_route\",\n      description: \"Fetch detailed metadata (params, query, body, examples) for a route.\",\n      input: {\n        type: \"object\",\n        properties: {\n          method: { type: \"string\" },\n          path: { type: \"string\" },\n        },\n        required: [\"method\", \"path\"],\n      },\n      call: (args) => {\n        const { method, path } = args as { method: string; path: string }\n        const r = publicRoutes(app).find(\n          (x) => x.method.toUpperCase() === method.toUpperCase() && x.path === path,\n        )\n        if (!r) return { error: \"route_not_found\" }\n        return {\n          method: r.method,\n          path: r.path,\n          meta: r.meta,\n          hasParams: !!r.params,\n          hasQuery: !!r.query,\n          hasBody: !!r.body,\n          hasHeaders: !!r.headers,\n          throws: r.throws ? Object.keys(r.throws).map(Number) : [],\n          errors: r.errors ? Object.keys(r.errors) : [],\n        }\n      },\n    },\n    {\n      name: \"recent_requests\",\n      description: \"Return the last N handled HTTP requests (default 50).\",\n      input: {\n        type: \"object\",\n        properties: { limit: { type: \"integer\", minimum: 1, maximum: 200 } },\n      },\n      call: (args) => rec.requests((args?.limit as number) ?? 50),\n    },\n    {\n      name: \"recent_errors\",\n      description: \"Return the last N errors captured while handling requests.\",\n      input: {\n        type: \"object\",\n        properties: { limit: { type: \"integer\", minimum: 1, maximum: 200 } },\n      },\n      call: (args) => rec.errors((args?.limit as number) ?? 50),\n    },\n    {\n      name: \"invoke_route\",\n      description:\n        \"Invoke a route in-process (no network). Same path as HTTP — runs middleware, validators, handler.\",\n      input: {\n        type: \"object\",\n        properties: {\n          method: { type: \"string\" },\n          path: { type: \"string\" },\n          params: { type: \"object\", additionalProperties: true },\n          query: { type: \"object\", additionalProperties: true },\n          body: {},\n          headers: { type: \"object\", additionalProperties: { type: \"string\" } },\n        },\n        required: [\"method\", \"path\"],\n      },\n      call: async (args) => {\n        const { method, path, params, query, body, headers } = args as {\n          method: string\n          path: string\n          params?: Record<string, string>\n          query?: Record<string, unknown>\n          body?: unknown\n          headers?: Record<string, string>\n        }\n        return app.invoke({\n          method: method.toUpperCase() as \"GET\",\n          path,\n          ...(params && { params }),\n          ...(query && { query }),\n          ...(body !== undefined && { body }),\n          ...(headers && { headers }),\n        })\n      },\n    },\n    {\n      name: \"replay_request\",\n      description: \"Replay a previously recorded request by id (dev-only).\",\n      input: {\n        type: \"object\",\n        properties: { id: { type: \"string\" } },\n        required: [\"id\"],\n      },\n      call: async (args) => {\n        const { id } = args as { id: string }\n        const r = rec.find(id)\n        if (!r) return { error: \"request_not_found\" }\n        return app.invoke({\n          method: r.method as \"GET\",\n          path: r.path,\n          query: Object.fromEntries(Object.entries(r.query)),\n          headers: r.headers,\n          ...(r.body !== undefined && {\n            body: safeParseJson(r.body),\n          }),\n        })\n      },\n    },\n  ]\n}\n\nfunction publicRoutes(app: HyperApp): readonly Route[] {\n  return app.routeList.filter((r) => !r.meta.internal)\n}\n\nfunction safeParseJson(s: string): unknown {\n  try {\n    return JSON.parse(s)\n  } catch {\n    return s\n  }\n}\n",
      "sha256": "d196b3706d12526022f26805cb959627eb88c0a5f201b0f2a20f71f94de107a8"
    }
  ],
  "subpaths": {}
}