跳到正文
DEV Community· flisk·· 2 小時前AI 評分57

Local Loop Gateway 如何以 MCP 閘道管理多語言智能體的本地資源存取

The Local Loop Gateway

AI 導讀

Local Loop Gateway 是一種讓多語言智能體在本地共用企業資源的架構,透過 MCP 閘道集中存取權限、憑證和重複寫入控制。代理以封閉 schema 限定工具操作,並以 attempt id 避免同一寫入重複提交。

正文

Agents have stopped being a single chat process. A procurement system might keep its graph in Python because that is where the evaluation library lives, and keep the commercial node in TypeScript because that is where the rest of the product already shares types. Both nodes need the same budget check. Both will retry. Neither should hold the ERP password.

The usual local answer is a hand-written Compose file, a private network, and a growing set of environment variables. It works until the first agent is rewritten in another language, and then the wiring is rewritten with it.

This article describes a local pattern for that situation. I will call it a Local Loop Gateway. Flisk is one way to run the loop: it turns a service.json per service into Docker and Tilt, routes the stack through a local gateway, and does not deploy. The pattern does not depend on Flisk. The tool only removes the part that is usually rewritten by hand.

The problem

Consider two agents and one ledger.

flowchart LR
  UI[Next.js BFF] --> Legal[Legal node\nPython]
  UI --> Commercial[Commercial node\nTypeScript]
  Legal --> ERP[(ERP / ledger)]
  Commercial --> ERP

Each arrow into the ledger is a place where a runtime can grow its own driver, its own secret, and its own idea of what "retry" means. The legal node calls a Python client. The commercial node calls a generated TypeScript client. A third node, added next quarter, copies one of those two and drifts.

Three failures follow, and they are ordinary rather than exotic.

  • A credential lives in every agent image that needs the ledger.
  • A retry after a dropped connection writes the ledger twice, because the agent and the ledger do not share a notion of "this attempt."
  • Changing the budget check means changing every agent, in every language, in the same pull request.

The fragmentation people notice first is language. The fragmentation that costs more is tool ownership: each runtime owns a private path to the same corporate resource.

The structure

Put the tools in a process that is not an agent. Agents talk to that process over MCP. The process is the only thing on the local loop that can reach the ledger.

flowchart TB
  UI[Next.js BFF]
  subgraph loop [Local loop]
    Legal[Legal node\nPython + graph runtime]
    Commercial[Commercial node\nTypeScript]
    Bus[Event broker]
    GW[Gateway proxy\nMCP server]
    Legal <-->|pub/sub| Bus
    Commercial <-->|pub/sub| Bus
    Legal -->|MCP| GW
    Commercial -->|MCP| GW
  end
  UI -->|session id| Legal
  UI -->|session id| Commercial
  GW -->|bounded query| ERP[(Database / ERP)]

MCP is the contract, not the security boundary. The boundary is the proxy: it is the only process with the connection string, the only process allowed to call the ledger, and the only process that can reject a repeated write.

Agents still talk to each other, but not by calling each other's HTTP ports. A negotiation is an event on the broker. A budget check is a tool call. Mixing those two paths is how a "quick RPC" becomes a hidden dependency.

The gateway

The proxy advertises a small tool list. Small is the point. An agent that can run arbitrary SQL is not behind a boundary; it is holding a wider keyboard.

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "check_department_budget",
        description: "Returns the remaining budget for one department. Read only.",
        inputSchema: {
          type: "object",
          properties: {
            departmentId: { type: "string", minLength: 1, maxLength: 64 },
          },
          required: ["departmentId"],
          additionalProperties: false,
        },
      },
    ],
  };
});

Three rules keep this from becoming a pass-through.

  1. Arguments are a closed schema. Unknown fields are rejected in the proxy, before any query is built.
  2. The tool names an operation, not a table. check_department_budget can be implemented as a view, a stored procedure, or a mock. The agent does not get to choose.
  3. Writes carry the caller's attempt id. The proxy stores that id. A second call with the same id returns the first result and does not append another ledger row.

Credentials stay in the proxy's environment. Agent containers receive a URL, not a password. That split is what makes a Python node and a TypeScript node interchangeable from the ledger's point of view: both are MCP clients, and neither can become a database client by editing an env file.

On Flisk, this proxy is an mcp resource in the same stack as the agents. flisk up starts it with the rest of the loop, and its health route is what other services wait on. The scaffold is for *.localhost. It is not an internet-facing ERP facade, and it should not gain one by publishing the port.

One file per service

The loop is several processes with one declaration each. Flisk's unit is a service.json beside the code, not one document that lists every container. From those files it generates the Dockerfile, the Compose entry, the gateway routes, and the Tiltfile. Editing the generated files is how the loop drifts; the next flisk up treats that drift as a mistake.

A backend, in the shape the tool actually expects:

{
  "schemaVersion": 1,
  "appName": "agent-legal",
  "appType": "backend",
  "command": "python agent.py",
  "port": 4001,
  "healthCheckPath": "/health",
  "dependsOn": ["secure-gateway", "event-broker"]
}

The commercial node is the same kind of file, with a different command. The MCP server is a different type — appType: "mcp" — because it is a tool host, not another agent. The broker is a dependency both agents name, so the stack starts in an order a health check can enforce.

flisk doctor
flisk up enterprise-procurement

doctor is worth running first. It fails on a missing runtime or a port clash before Tilt has started half the graph. flisk up then starts the stack and live-updates a container when its code changes. A change to service.json itself is not a live update: routes and dependencies are regenerated on the next up.

What you do not get, and should not pretend you got, is a production orchestrator. Flisk's own description is local-only. The same boundary still has to be rebuilt with your real gateway, your real secret store, and your real network policy. The local loop is a place to discover that the boundary is wrong. It is not the boundary.

Session, bus, and the duplicate write

Two autonomous nodes in a loop will race. The commercial node will counter-offer while the legal node is still writing its checkpoint. A timeout will cause a retry of a call that already committed. None of this is fixed by picking Python or TypeScript.

Keep three identifiers apart.

Identifier Who mints it What it means
x-session-id The BFF, once per user task Sticky routing and trace correlation. Not a write key.
Event id The publishing agent Ordering on the bus. Consumers dedupe on it.
Attempt id The agent, once per tool call The proxy's idempotency key for a ledger write.

Using the session id as the write key is the mistake that looks tidy in a diagram. A session covers many writes. An attempt covers one. If the commercial node retries reserve_budget after a timeout, the proxy must see the same attempt id and return the reserved row, not reserve again.

Agents publish events. They do not call each other.

legal.checkpoint.recorded   { session, attempt, departmentId }
commercial.offer.proposed   { session, eventId, terms }

A direct HTTP call from the commercial node to the legal node works on a laptop and hides the ordering bug until both are slow. The broker makes the ordering visible, which is the property you want while the system is still small enough to understand.

What this does not solve

The pattern moves credentials and write policy into one process. It does not make that process correct.

  • A tool that accepts a raw query string has moved the vulnerability into the proxy, which is worse, because every agent can now reach it.
  • Hot reload does not preserve a careful test. Tilt updating a Python node does not prove the idempotency filter works; a test that posts the same attempt twice does.
  • Local *.localhost routing is not authentication. A tool with no auth is acceptable on a laptop and unacceptable the moment the port is reachable from another machine.

There is also a product boundary worth stating plainly. "Flisk" is used by more than one company. This article means the local-development CLI at flisk.sh — service.json, Docker, Tilt, no deploy — not the tag-management product at flisk.ai. Mixing the two in a design doc wastes the afternoon the names were supposed to save.

When to use it

Use a Local Loop Gateway when at least two runtimes must share a privileged resource and you are still changing both. The MCP schema is the stable edge. The agent processes are allowed to be boring, replaceable, and written in whatever language already has the library.

Do not use it to avoid an API. If there is one service and one language, a typed client is simpler. MCP pays for itself when the second runtime appears, and when you want the credential, the rate limit, and the duplicate-write rule in one place rather than in every agent.

The local loop is where that rule should fail first. flisk doctor, then flisk up, then a retry of the same attempt id. If the ledger grew a second row, the gateway is still a proxy in name only.

來源:DEV Community · dev.to