从浏览器网络请求快速生成 TypeScript API 与 MCP Server:安全实操指南

本文整理自 ani 在 X 上发布的两帖方法,并补充了凭证清理、最小权限、验证与失败处理。原帖只给出 7 个步骤,没有提供完整代码、测试结果或生产安全方案;下文中的脚本、提示词和验收门槛属于“实践扩展”。

先说结论

这套方法适合快速理解网站实际调用的接口,并生成一个只读、可审查的候选实现。它不适合把浏览器会话原样复制进生产代码,因为 HAR 和 Fetch 文本可能包含 Cookie、Authorization、CSRF Token、个人数据和短期签名。

推荐的最小闭环是:

  1. 在 DevTools 中录制目标操作;
  2. 导出 HAR 和 Fetch 请求;
  3. 先清理敏感字段;
  4. 让编码代理生成只读 API 客户端和 MCP 工具;
  5. 用测试账号验证认证、分页、错误处理与日志脱敏;
  6. 写入、删除、发布类操作继续保留人工确认。

适用与不适用场景

适合

不适合

原文方法覆盖清单

原文方法 是否纳入 落点 若遗漏原因
打开目标网站 步骤 1
DevTools → Network → Keep Log 步骤 1
退出后重新登录 有条件纳入 步骤 1 只允许测试账号;避免暴露真实高权限会话
访问所有需要数据的页面 步骤 1、案例清单
Copy all as HAR 并保存 步骤 2
Copy all as fetch 并保存 步骤 2
放入空 Bun 项目,由 Plan Mode 生成 TypeScript API 和 MCP Server 步骤 4 增加了只读范围、验证和安全约束

准备工作

需要:

确认 Bun 可用:

bun --version
python3 --version

建议先建立隔离目录:

mkdir -p web-api-mcp-candidate/{captures,scripts,src,test}
cd web-api-mcp-candidate
bun init -y

项目最初只需要这些文件:

web-api-mcp-candidate/
├── captures/
│   ├── raw.har              # 原始文件,不提交版本库
│   ├── sanitized.har        # 清理后才允许交给模型
│   └── request.fetch.txt    # 清理后才允许交给模型
├── scripts/
│   └── sanitize_har.py
├── src/                     # 由编码代理生成候选实现
├── test/                    # 由编码代理生成测试
└── .gitignore

.gitignore 至少加入:

captures/raw.har
captures/sanitized.har
captures/request.fetch.txt
captures/*.raw.txt
.env
node_modules/

步骤 1:录制一个最小、可解释的操作流程

打开目标网站后:

  1. 打开 DevTools;
  2. 进入 Network
  3. 勾选 Preserve log / Keep Log
  4. 清空旧请求;
  5. 只执行一个明确流程;
  6. 记录操作与预期结果。

不要一次把整个网站都录进去。每次只录一个业务动作,后面更容易识别真正的接口。

案例 A:公开只读列表

操作:打开产品列表页,再翻到下一页。

需要观察:

验收:候选客户端能读取第一页、下一页和空页,不需要登录凭证。

案例 B:登录后的只读详情

操作:使用测试账号登录,再打开一条详情记录。

需要观察:

验收:凭证只能从环境变量或运行时会话注入;源码、日志和测试快照中不得出现真实凭证。

案例 C:写入操作

操作:仅在测试环境观察“创建草稿”请求,不提交真实业务数据。

验收:第一版 MCP 工具不直接执行写入,而是输出待确认的请求摘要;真正调用必须经过人工批准。

步骤 2:导出 HAR 与 Fetch 请求

在 Network 面板中:

  1. 右键请求列表,选择 Copy all as HARSave all as HAR with content
  2. 保存为 captures/raw.har
  3. 对关键请求选择 Copy as fetch
  4. 保存为 captures/request.fetch.txt

此时不要提交 Git,也不要把文件直接交给模型。 先假设其中包含凭证。

步骤 3:离线清理 HAR

文件:scripts/sanitize_har.py

#!/usr/bin/env python3
from __future__ import annotations

import json
import sys
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

SENSITIVE_HEADERS = {
    "authorization",
    "cookie",
    "proxy-authorization",
    "set-cookie",
    "x-api-key",
    "x-csrf-token",
    "x-xsrf-token",
}
SENSITIVE_QUERY = {
    "access_token",
    "api_key",
    "auth",
    "code",
    "key",
    "password",
    "signature",
    "token",
}


def sanitize_url(url: str) -> str:
    parts = urlsplit(url)
    query = [
        (key, "[REDACTED]" if key.lower() in SENSITIVE_QUERY else value)
        for key, value in parse_qsl(parts.query, keep_blank_values=True)
    ]
    return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))


def sanitize_headers(headers: list[dict[str, str]]) -> list[dict[str, str]]:
    return [
        {**header, "value": "[REDACTED]"}
        if header.get("name", "").lower() in SENSITIVE_HEADERS
        else header
        for header in headers
    ]


def sanitize_har(data: dict) -> dict:
    for entry in data.get("log", {}).get("entries", []):
        request = entry.get("request", {})
        response = entry.get("response", {})

        request["url"] = sanitize_url(request.get("url", ""))
        request["headers"] = sanitize_headers(request.get("headers", []))
        request["cookies"] = []
        request["queryString"] = [
            {**item, "value": "[REDACTED]"}
            if item.get("name", "").lower() in SENSITIVE_QUERY
            else item
            for item in request.get("queryString", [])
        ]
        if "postData" in request:
            request["postData"]["text"] = "[REDACTED — review manually]"

        response["headers"] = sanitize_headers(response.get("headers", []))
        response["cookies"] = []

    return data


def self_test() -> None:
    sample = {
        "log": {
            "entries": [
                {
                    "request": {
                        "url": "https://example.test/items?token=secret&page=2",
                        "headers": [{"name": "Authorization", "value": "Bearer secret"}],
                        "cookies": [{"name": "session", "value": "secret"}],
                        "queryString": [
                            {"name": "token", "value": "secret"},
                            {"name": "page", "value": "2"},
                        ],
                        "postData": {"text": "private body"},
                    },
                    "response": {
                        "headers": [{"name": "Set-Cookie", "value": "session=secret"}],
                        "cookies": [{"name": "session", "value": "secret"}],
                    },
                }
            ]
        }
    }
    result = sanitize_har(sample)
    entry = result["log"]["entries"][0]
    rendered = json.dumps(result)
    assert "secret" not in rendered
    assert "page=2" in entry["request"]["url"]
    assert entry["request"]["postData"]["text"].startswith("[REDACTED")
    print("self-test: ok")


def main() -> None:
    if sys.argv[1:] == ["--self-test"]:
        self_test()
        return
    if len(sys.argv) != 3:
        raise SystemExit("usage: sanitize_har.py INPUT.har OUTPUT.har")

    source, destination = map(Path, sys.argv[1:])
    data = json.loads(source.read_text(encoding="utf-8"))
    cleaned = sanitize_har(data)
    destination.write_text(json.dumps(cleaned, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"wrote: {destination}")


if __name__ == "__main__":
    main()

先运行自检,再处理真实 HAR:

python3 scripts/sanitize_har.py --self-test
python3 scripts/sanitize_har.py captures/raw.har captures/sanitized.har

预期输出:

self-test: ok
wrote: captures/sanitized.har

再做一次关键词扫描:

python3 -c 'import json; from pathlib import Path; d=json.loads(Path("captures/sanitized.har").read_text()); sensitive={"authorization","cookie","proxy-authorization","set-cookie","x-api-key","x-csrf-token","x-xsrf-token"}; parts=(part for e in d.get("log",{}).get("entries",[]) for part in (e.get("request",{}),e.get("response",{}))); assert all(not part.get("cookies") and all(h.get("value")=="[REDACTED]" for h in part.get("headers",[]) if h.get("name","").lower() in sensitive) for part in parts); print("basic secret scan: ok")'

这只是基础清理,不是完整的数据防泄漏工具。仍需人工检查:

Copy as fetch 文件建议人工保留 URL、方法和普通请求头,删除所有凭证值和真实请求体。若无法确认某字段是否敏感,直接删掉。

步骤 4:让编码代理生成候选 API 和 MCP Server

captures/sanitized.har 和清理后的 captures/request.fetch.txt 放在空 Bun 项目中,然后在 Plan Mode 使用以下提示词:

读取 captures/sanitized.har 和 captures/request.fetch.txt,先不要写代码。

目标:生成一个最小 TypeScript API 客户端和 MCP Server 候选实现。

边界:
1. 第一版只允许只读接口;不要实现创建、修改、删除、发布、付款等操作。
2. 不得把 Cookie、Token、CSRF 值或其他凭证写入源码、测试、日志或示例。
3. 认证信息只能从环境变量或调用方注入。
4. 从抓包中识别 base URL、请求方法、参数、分页方式、成功响应和错误响应。
5. 对来源证据不足的字段标记 TODO,不要猜测。
6. MCP 工具使用明确、互不重叠的名称和输入 schema。
7. 为每个客户端方法生成 mock 测试,至少覆盖成功、空结果、401/403、429 和 5xx。
8. 所有网络调用设置超时;只对安全的只读请求做有上限的重试。

先输出:
- 接口清单;
- 认证与敏感数据风险;
- 拟生成文件树;
- 缺失信息;
- 验收计划。

我确认计划后再生成代码。

计划确认后,要求生成的最小结构可以是:

src/
├── client.ts       # HTTP 客户端、超时、错误映射
├── schemas.ts      # 输入与响应类型
├── tools.ts        # 只读 MCP 工具定义
└── server.ts       # MCP Server 入口
test/
├── client.test.ts
└── tools.test.ts

不要为了“架构完整”增加单实现工厂、插件系统或多层注册器。一个客户端、几个只读工具和对应测试足够验证方法。

步骤 5:验证生成结果

1. 静态检查

bun install
bun run typecheck
bun test

如果代理没有生成 typecheck 脚本,先让它补充到 package.json,不要跳过类型检查。

2. 凭证检查

python3 -c 'from pathlib import Path; files=list(Path("src").rglob("*.ts"))+list(Path("test").rglob("*.ts")); text="\n".join(p.read_text() for p in files); assert "Bearer ey" not in text and "session=" not in text; print("source secret scan: ok")'

3. Mock 验收

至少验证:

场景 预期行为
正常列表 返回经过类型校验的数据
空列表 返回空数组,不抛出伪错误
401/403 返回明确的认证/权限错误,不打印凭证
429 尊重服务端提示;有上限重试或直接返回限流错误
5xx/超时 返回带上下文的错误,不无限重试
分页 不重复、不漏项,并有终止条件

4. 测试账号烟雾验证

只有 Mock 通过后,才使用低权限测试账号:

  1. 从环境变量注入测试凭证;
  2. 只调用一个只读工具;
  3. 检查请求范围和返回字段;
  4. 检查日志没有凭证与个人数据;
  5. 删除本地测试凭证并确认未进入 Git。

第一轮不要测试写入工具。

步骤 6:为 MCP 工具设置安全边界

只读工具也应明确:

写入类能力如果以后确实需要,再单独增加:

步骤 7:选择企业内网部署模式

企业内网不要默认共享一组浏览器 Cookie。先按上游系统的认证方式选择部署模式:

上游系统情况 推荐模式 原因
数据公开或所有员工共享、只读 共享 Streamable HTTP MCP 容易集中运维、审计和限流
上游支持员工身份或 OAuth On-Behalf-Of 共享 Streamable HTTP MCP,并下推用户身份 保留调用者权限,避免共享超级账号
只能依赖个人浏览器 Cookie 每用户本地 stdio MCP Cookie 留在用户设备,不进入中心服务
需要写入、删除、发布等高风险操作 暂不共享;先做只读服务 等审批、幂等、审计和回滚闭环完成后再加

推荐的企业内网最小架构是:

员工的 AI/MCP Client
        │ HTTPS + 企业访问令牌
        ▼
内网 API Gateway / SSO
  - TLS
  - OIDC/JWT 校验
  - 清除外部身份头
  - 转发原始 Bearer Token 供 MCP 二次验签
  - 限流、审计、请求大小限制
        │ 仅内网或同机回环访问
        ▼
只读 MCP Server(Streamable HTTP,/mcp)
        │ 用户身份下推 / 受限服务账号
        ▼
目标网站或企业内部 API

MCP TypeScript SDK 的官方建议是:远程、多客户端连接使用 Streamable HTTP;由宿主进程启动的本地服务使用 stdio。共享服务必须在 MCP handler 前验证 Host、Origin 和访问令牌;createMcpHandler 本身不会替你完成这些检查。

三条部署红线

  1. 不要把 raw.har、浏览器 Cookie 或抓包 Token 打进镜像;
  2. 不要让 MCP 容器直接暴露到办公网,更不要暴露到公网;
  3. 不要使用一个共享超级账号代表所有员工访问上游系统。

步骤 8:增加企业内网 HTTP 入口

以下是实践扩展:假设步骤 4 生成的 src/server.ts 已被整理为可复用的 buildServer(authInfo) 工厂。不要再复制一套工具定义;stdio 和 HTTP 两种入口复用同一个工厂。

调整后的文件结构:

src/
├── client.ts
├── schemas.ts
├── tools.ts
├── server-factory.ts   # 唯一的 MCP 工具注册入口
├── server-stdio.ts     # 个人本地模式
└── server-http.ts      # 企业共享模式
Dockerfile
compose.yaml

安装官方 SDK 依赖;版本应由企业制品库或 lockfile 固定:

bun add @modelcontextprotocol/server jose zod
bun add -d typescript @types/bun

HTTP 入口

文件:src/server-http.ts

import {
  createMcpHandler,
  OAuthError,
  OAuthErrorCode,
  requireBearerAuth,
  type AuthInfo,
} from "@modelcontextprotocol/server";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { buildServer } from "./server-factory";

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`missing required environment variable: ${name}`);
  return value;
}

const issuer = requiredEnv("OIDC_ISSUER");
const audience = requiredEnv("OIDC_AUDIENCE");
const jwks = createRemoteJWKSet(new URL(requiredEnv("OIDC_JWKS_URL")));
const allowedHosts = new Set(
  requiredEnv("ALLOWED_HOSTS").split(",").map((value) => value.trim()).filter(Boolean),
);
const allowedOrigins = new Set(
  requiredEnv("ALLOWED_ORIGINS").split(",").map((value) => value.trim()).filter(Boolean),
);

function scopesFrom(payload: JWTPayload): string[] {
  if (typeof payload.scope === "string") return payload.scope.split(" ").filter(Boolean);
  const scp = payload.scp;
  return Array.isArray(scp)
    ? scp.filter((value): value is string => typeof value === "string")
    : [];
}

async function verifyAccessToken(token: string): Promise<AuthInfo> {
  try {
    const { payload } = await jwtVerify(token, jwks, { issuer, audience });
    if (!payload.sub || typeof payload.exp !== "number") throw new Error("missing sub/exp");
    return {
      token,
      clientId: payload.sub,
      scopes: scopesFrom(payload),
      expiresAt: payload.exp,
    };
  } catch {
    throw new OAuthError(OAuthErrorCode.InvalidToken, "invalid or expired access token");
  }
}

const gate = requireBearerAuth({
  verifier: { verifyAccessToken },
  requiredScopes: ["mcp"],
});
const handler = createMcpHandler(
  ({ authInfo }) => buildServer(authInfo),
  { responseMode: "json", legacy: "reject" },
);

function forbidden(message: string): Response {
  return Response.json({ error: message }, { status: 403 });
}

export default {
  hostname: "0.0.0.0",
  port: Number(process.env.PORT ?? "3000"),

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/healthz") {
      return Response.json({ status: "ok" });
    }
    if (url.pathname !== "/mcp") {
      return new Response("not found", { status: 404 });
    }
    if (!allowedHosts.has(url.hostname)) {
      return forbidden("host not allowed");
    }

    const origin = request.headers.get("origin");
    if (origin && !allowedOrigins.has(origin)) {
      return forbidden("origin not allowed");
    }

    const auth = await gate(request);
    if (auth instanceof Response) return auth;
    return handler.fetch(request, { authInfo: auth });
  },
};

process.on("SIGTERM", async () => {
  await handler.close();
  process.exit(0);
});

这里采用“企业网关先做接入控制,MCP 服务再次验证 Bearer JWT”的默认模式:

如果企业 IdP 只支持 opaque Token,把本地 JWKS 验证替换为 RFC 7662 introspection;仍需校验 active、audience、scope、subject 与过期时间。

工具级授权

文件:src/server-factory.ts

import { McpServer, type AuthInfo } from "@modelcontextprotocol/server";
import { z } from "zod/v4";
import { ApiClient } from "./client";

export function buildServer(authInfo?: AuthInfo): McpServer {
  const server = new McpServer({ name: "internal-web-api", version: "1.0.0" });
  const client = new ApiClient({ callerId: authInfo?.clientId });

  server.registerTool(
    "list-items",
    {
      description: "读取当前用户有权查看的条目;最多返回 100 条",
      inputSchema: z.object({
        cursor: z.string().optional(),
        limit: z.number().int().min(1).max(100).default(20),
      }),
    },
    async ({ cursor, limit }) => {
      if (!authInfo?.scopes.includes("mcp")) {
        return {
          content: [{ type: "text", text: "insufficient_scope: requires mcp" }],
          isError: true,
        };
      }

      const result = await client.listItems({ cursor, limit });
      return {
        content: [{ type: "text", text: JSON.stringify(result) }],
      };
    },
  );

  return server;
}

ApiClientlistItems 的具体字段来自步骤 4 的抓包结果;上面代码定义的是部署与授权接口,不应凭空替换真实上游协议。callerId 必须参与上游权限下推或审计,不能只写进日志就宣称实现了用户隔离。

步骤 9:容器化并部署在网关后面

文件:Dockerfile

ARG BUN_IMAGE=oven/bun:1
FROM ${BUN_IMAGE}

WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile --production
COPY src ./src

USER bun
EXPOSE 3000
CMD ["bun", "run", "src/server-http.ts"]

oven/bun:1 只是教程默认值。企业部署应把 BUN_IMAGE 固定到内部镜像仓库的版本或 digest,并经过镜像扫描。

文件:compose.yaml

services:
  mcp:
    build:
      context: .
      args:
        BUN_IMAGE: ${BUN_IMAGE:-oven/bun:1}
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp:size=64m,noexec,nosuid
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      PORT: "3000"
      ALLOWED_HOSTS: "mcp.intra.example.com"
      ALLOWED_ORIGINS: "https://ai.intra.example.com"
      OIDC_ISSUER: "https://idp.intra.example.com/"
      OIDC_AUDIENCE: "https://mcp.intra.example.com/mcp"
      OIDC_JWKS_URL: "https://idp.intra.example.com/.well-known/jwks.json"
    healthcheck:
      test:
        - CMD
        - bun
        - -e
        - "fetch('http://127.0.0.1:3000/healthz').then(r => { if (r.status !== 200) process.exit(1) }).catch(() => process.exit(1))"
      interval: 30s
      timeout: 5s
      retries: 3

启动并检查:

BUN_IMAGE=registry.intra.example.com/base/bun@sha256:REPLACE_ME \
  docker compose up -d --build
docker compose ps
curl --fail http://127.0.0.1:3000/healthz

预期:容器状态为 healthy,健康检查返回:

{"status":"ok"}

企业网关必须实现的合同

网关产品可以是现有 API Gateway、Ingress、Service Mesh 或带 OIDC 的反向代理,不必为了 MCP 再引入一套网关。最小合同是:

  1. 仅将 /mcp 转发到 127.0.0.1:3000 或私有 Pod 地址;
  2. 对客户端执行 OIDC/JWT 接入校验,拒绝明显无效流量;
  3. 删除客户端提交的 X-Authenticated-UserX-Authenticated-Scopes 等可伪造身份头,不再向 MCP 注入普通身份头;
  4. 将原始 Bearer Token 转发给 MCP 服务做第二次签名、issuer、audience、有效期和 scope 验证;
  5. Authorization 必须在网关、MCP 和 APM 日志中脱敏,且不得转发给错误 audience 的上游系统;
  6. 限制请求体、连接时长、并发数和调用频率;
  7. 禁止缓存 /mcp 响应;
  8. 记录调用者、工具名、状态、耗时、审批结果和关联 ID,但不记录凭证及完整敏感结果。

即使客户端绕过网关抵达 MCP 端口,也必须因为缺少有效的目标 audience Token 而被 MCP 服务拒绝;网络隔离仍然保留为第二道边界。

步骤 10:连接和使用 MCP

开发验收:MCP Inspector

MCP Inspector 是官方测试客户端。受保护的内网端点可以通过企业访问令牌连接:

export MCP_TOKEN='由企业 IdP 或开发者门户签发的短期令牌'
npx @modelcontextprotocol/inspector \
  --server-url https://mcp.intra.example.com/mcp \
  --transport http \
  --header "Authorization: Bearer $MCP_TOKEN"

列出工具:

npx @modelcontextprotocol/inspector --cli \
  https://mcp.intra.example.com/mcp \
  --transport http \
  --header "Authorization: Bearer $MCP_TOKEN" \
  --method tools/list

调用只读工具:

npx @modelcontextprotocol/inspector --cli \
  https://mcp.intra.example.com/mcp \
  --transport http \
  --header "Authorization: Bearer $MCP_TOKEN" \
  --method tools/call \
  --tool-name list-items \
  --tool-args-json '{"limit":5}' \
  --format json

预期结果不是固定业务数据,而是:

接入企业 AI 客户端

不同 MCP Host 的配置字段不同,不要复制未经验证的固定 JSON。需要提供给客户端管理员的合同只有:

接入后先用三条低风险提示验证:

列出我有权查看的 5 条记录,只返回名称和更新时间。
查询记录 ID demo-123;如果无权访问,原样报告权限错误,不要尝试其他账号。
告诉我当前可用的工具及其只读/写入属性,不执行任何工具。

如果上游网站只能使用个人浏览器 Cookie,中心化 Streamable HTTP 方案不成立。此时让每个用户在本机运行 stdio MCP,并由本地 MCP Host 启动:

MCP Host → stdio → 用户本机 server-stdio.ts → 用户有权访问的上游系统

Cookie 放在操作系统凭证库或受限本地会话中,不放入 MCP 配置文件、命令行参数、Git 或中心服务器。只有上游提供可安全委派的 OAuth/OBO 机制后,再迁移到共享 HTTP 服务。

步骤 11:企业上线检查

网络与认证

数据与工具

运维与审计

常见失败与处理

HAR 太大,代理无法理解

只保留一个业务流程对应的请求;删除图片、字体、分析脚本和重复轮询。不要先建复杂解析器,缩小输入通常更有效。

生成代码只能复现一次成功请求

要求补充认证续期、分页、空结果、429、5xx 和超时测试。一次成功回放不等于可维护 API。

Token 很快过期

不要固化 Token。先识别合法的认证续期方式;无法确认时,让用户每次显式提供短期凭证,或停止该集成。

网站接口变化

把响应解析集中在少量 schema/mapper 中,并让失败明确暴露。不要静默吞掉未知字段或把空数据当成功。

MCP 工具选择混乱

缩短并区分工具说明,避免多个工具使用近似描述;优先保留更少、更明确的工具。

涉及网站条款或访问权限

停止自动化,先确认你有权访问、保存和处理目标数据。技术上能调用不代表被授权调用。

最终验收清单

官方参考

方法边界

原文事实: 原帖建议用 DevTools Keep Log 记录登录与页面访问,导出 HAR 和 Fetch 请求,再放入空 Bun 项目,让 Plan Mode 生成 TypeScript API 与 MCP Server。

本文提炼: 将原帖步骤整理为“录制 → 清理 → 计划 → 生成 → Mock 验证 → 测试账号烟雾验证”的最小闭环。

实践扩展: HAR 清理脚本、三类案例、提示词、安全边界、测试矩阵、Streamable HTTP/stdio 部署选择、企业网关合同、容器示例和验收清单均为本文补充,不是原帖声称已经验证的内容。