@aeolus-fleet/gateway

中文文档
0.38.5发布于 2026/11/08Apache-2.0TypeScriptES Module构建来源已验证近 30 天下载 3580

Aeolus gateway: outside MCP servers and clients join a fleet as ships, through one gateway beside core.

原始使用文档
文档目录13 节
  1. Run
  2. Connect
  3. Slack
  4. Parties
  5. The parties pages
  6. Bridge
  7. argo's inbox
  8. MCP out
  9. MCP clients sign in
  10. The ChatGPT plugin
  11. MCP clients list and call tools
  12. Develop
  13. Licence

Aeolus gateway

@aeolus-fleet/gateway: outside MCP servers and clients join an Aeolus fleet as ships, through one gateway beside core. Its design is aeolus-fleet's docs/design/interop/interop.md.

Run

npm install && npm run build
DATABASE_URL=postgresql://... FLEET_URL=https://fleet.example.com npm start
VariableDefaultMeaning
DATABASE_URLrequiredThe gateway's own Postgres 16 or newer database, with a direct connection
FLEET_URLrequiredThe fleet's public URL (http:// or https://)
HOST127.0.0.1Interface to listen on
PORT4200Port to listen on
GATEWAY_PUBLIC_URLhttp://127.0.0.1:<PORT>Where outside MCP clients reach the gateway: the OAuth issuer, with the MCP endpoint at /mcp
GATEWAY_SECRETrequiredAt least 32 characters. Keys the ship aliases outside MCP clients see: the same secret keeps every alias across restarts, a new one renames every ship to them
MCP_WAIT_MAX_MS10000How long an outside MCP client's tool call waits for its answer before it is answered its handle; 0 answers the handle at once
INSTALLATION_TOKENunsetThe token a hosting service presents to the installation procedures (at least 32 characters, decision 0021). Unset: they are off and every fleet is served, as a self-hosted gateway needs; set: a fleet is served once switched on
PAGASAE_URL, PAGASAE_CLIENT_ID, PAGASAE_CLIENT_SECRETunsetpagasae, where people sign in (decision 0054), and the gateway's client there, registered with the redirect URI <GATEWAY_PUBLIC_URL>/pagasae/callback. All three or none; unset, nobody signs in, so no MCP client connects (only an account asks for one)
PAGASAE_APP_CLIENT_ID, PAGASAE_APP_CLIENT_SECRETunsetThe gateway's pages as an app at pagasae (its APP_CLIENTS, redirect URI <GATEWAY_PUBLIC_URL>/sign-in/done), apart from the client for outside MCP clients: people sign in to the pages with the account sign-in every Aeolus app shares (aeolus-fleet's header design, H5). Both or neither, beside PAGASAE_URL; unset, nobody signs in to the web app's pages
EMAIL_WAKE_WINDOW_MINUTES60At least 1. The email channel sends one person ship at most one wake per this many minutes; what waits meanwhile goes in the next wake (aeolus-fleet's wake a person design, W3, W10). Email wakes need PAGASAE_URL and INSTALLATION_TOKEN, which the gateway presents to pagasae's /api/wakes
WEB_PUSH_PUBLIC_KEY, WEB_PUSH_PRIVATE_KEY, WEB_PUSH_SUBJECTunsetThe gateway's push keys (VAPID, as npx web-push generate-vapid-keys makes them), which sign each web push wake, and the mailto: or https: URL a push service may contact about them. All three or none; unset, Bridge's settings offer no web push
WEB_PUSH_WAKE_WINDOW_MINUTES10At least 1. Web push sends one person ship at most one wake per this many minutes; what waits meanwhile goes in the next wake (W3, W10)
ROUTING_ON_BY_DEFAULTdecide:chat,do:chat,stay-aware:chat,everything-else:chatThe jobs and places on while a person chose nothing of their own, as comma-separated job:place pairs (docs/design/bridge, section 8, CC19). Jobs: decide, do, stay-aware, everything-else; places: slack, chat, web-push, email. Every other place is off until the person turns it on; empty turns everything off but Bridge
SLACK_API_URLhttps://slack.com/apiSlack's Web API, where the gateway makes and installs a fleet's Slack bots; tests set it to their Slack double
OPENAI_APPS_CHALLENGEunsetThe token OpenAI's plugin portal shows to prove the domain, served as plain text at /.well-known/openai-apps-challenge (chatgpt-plugin design, 7.5). Public by design; unset, there is no such address
LOG_LEVELinfofatal, error, warn, info, debug, trace or silent
CommandDoes
aeolus-gateway startReads core's version and exits 1 when core is older than it needs (core 0.28.0 or later), saying both versions; migrates its database; serves
aeolus-gateway migrateMigrates its database, and nothing else. Needs DATABASE_URL alone

The gateway's pages are its web app's, a Next.js app in web/ (aeolus-fleet's header design, H3), which the same server answers at the same address: npm run build builds it beside the server, and started from source (npx tsx src/bin/aeolus-gateway.ts start) it compiles as it is asked. It answers an address with no page of the server's own, its Not found among them, in its frame, whose header takes the shared header from @aeolus-fleet/common/header once common ships it.

GET /api/version answers the version it runs, and GET /api/health answers { "status": "ok" } while its database answers, else 503.

Connect

argo commissions the gateway's ship in the console as an app ship (decision 0044: it is software, so its messages state no model), with fleet:read, fleet:commission, fleet:on-behalf and scopes:declare, and connects the gateway with it; the gateway registers with the ship's secret, keeps only the crew token, and declares its own scopes, gateway:approve (decision 0056): any actor the fleet grants it approves outside parties, and gateway:slack: it connects the fleet to a Slack workspace (aeolus-fleet's gateway scopes design, B6). As it starts, the gateway declares them again in each fleet it is connected to. A fleet that does not take the declaration, as when another ship holds the gateway namespace, is refused (CONFLICT). The gateway connects and is switched on whatever network the fleet has (interop I-3): the fleet decides its network. A fleet's outside parties (its outside MCP servers and clients) are a switch, off by default: while off, the gateway approves no party, takes no party's calls and refuses a client party's token at /mcp (403). outsideParties.status reads it; outsideParties.set needs fleet:network. Turning it on while the fleet applies no network rules (fleet.networkRules, read as the actor) answers PRECONDITION_FAILED with a warning until the call confirms it with isOpenNetworkConfirmed.

Slack

An account holding gateway:slack connects the fleet to one Slack workspace on the Slack page by pasting an app configuration refresh token, which Slack issues per user and workspace on its app management page (aeolus-fleet's gateway scopes design, 9.1). The gateway keeps the token in its database and rotates it before its 12 hours end, then makes its own bot, a Slack app named after the fleet, which the account installs through Slack's install screen; Slack sends it back to <GATEWAY_PUBLIC_URL>/gw/slack/install. A token Slack refuses stays refused until someone connects again; bots already installed keep working. Disconnect removes every bot the gateway made, then forgets the workspace.

An actor holding gateway:slack, within manage reach of the ship, exposes an agent ship or an outside A2A agent's proxy ship as a Slack bot (never an outside MCP server's): the gateway makes its app from a manifest, named after the ship with its statement's summary, and installs it the same way. The bot is tied to the ship by its id: every 10 minutes the gateway renames the bot of a renamed ship in Slack and deletes the bot of a retired one. Slack sends each app's events to <GATEWAY_PUBLIC_URL>/gw/slack/events, taken only signed with that app's signing secret within 5 minutes; an uninstall or revoked tokens shows the bot Removed in Slack. slack.view reads it all for any actor; slack.connect, slack.install, slack.exposable, slack.expose, slack.remove and slack.disconnect need gateway:slack.

A person links their Slack account from Bridge's settings with Link Slack (9.3): /slack/link signs them in to pagasae if needed, then to Sign in with Slack through the fleet's own bot, which sends them back to <GATEWAY_PUBLIC_URL>/gw/slack/link. One Slack user links to one account per fleet, and an account to one Slack user; a second link is refused, naming the first. An unlinked person who messages or mentions a bot gets an answer only they see, with a Link Slack button, and their message is not kept. When pagasae tells the gateway an account was disabled, its link stops, and its Slack user is told the account cannot message the fleet now. slack.link, slack.startLink and slack.unlink are the person's own.

A linked person's direct message to a bot, or mention of it in a channel, goes into the fleet as their person ship (9.4): the gateway keeps it by Slack's event id, answers Slack, and sends it to the bot's ship as Markdown (the mention removed, a shared file as its Slack link), keyed by the event id, every 5 seconds until core stores or refuses it. Stored, the gateway reacts to the Slack message; refused, it says why in the thread ("You may not message release-notes", "release-notes was retired"). A Slack retry changes nothing. A taken event's id is kept an hour; one core has not taken in 24 hours is deleted and the person told in the thread.

What waits for a linked person's person ship shows in Slack (9.5) for each job the person turned Slack on for in Where things show (docs/design/bridge, section 8), only what arrived since they turned it on: a reply to a message from Slack in that conversation or thread, a bot ship's message in that bot's direct messages, anything else through the fleet's own bot, headed by the sender. With Slack off for everything else, a bot ship's messages and replies into Slack still show there; with it off for another job, nothing of that job does. Text and Markdown show as text, a question with a button per option (a press goes to <GATEWAY_PUBLIC_URL>/gw/slack/actions and answers as the person ship), JSON as a code block; anything else, or more than Slack shows in full, as a link to Bridge. Each delivery is handed with channel slack and posted, never acknowledged: only the person's action does that, a button press or a reply in its thread (CC6). Nothing wakes by web push or email for what Slack showed. An answer in the thread under a posted delivery goes in reply to it and hands it. A bot removed in Slack falls back to the fleet's own bot with Slack on for the job, else to web push and email, as does a post Slack refuses.

The API is tRPC at /trpc. Its procedures take an actor (decision 0057): an account by the shared sign-in a page of the web app forwarded, argo's or a viewer's console session cookie, checked with the fleet's console.session, or any ship's own crew token as a bearer token, for a fleet the gateway is connected to. What an actor may call is the scopes the fleet holds for it, read by the gateway's ship on the ship's own behalf (so its own read reach holds it), or for an account the scopes its account token carries; a change needs fleet:manage. requests.toDecide and requests.decide need gateway:approve (decision 0056): every open pending request of the fleet, and a decision on one. Approving names the proxy ship's name, its owner (a person ship) and its scopes, only scopes the approver holds beside messages:send and messages:receive, and for an outside MCP server its credential and concurrency; the gateway commissions and crews the proxy ship as its own ship, and the fleet refuses a scope the gateway's ship does not hold. The first decision counts. A request is kept 7 days, then expires; a refused proxy ship leaves it pending. The outcome of a request for an outside MCP server is told to each requester with a person ship, as a text message from the gateway's ship: written with the decision or the expiry in one transaction, sent at once, and sent again every minute under the same idempotency key until the fleet stores it. One the fleet refuses is left for the gateway's page. Due requests expire in the same round. When a request opens, the gateway's ship asks its owner (read from the fleet; argo when argo owns it) a question (decision 0038) pointing to the gateway's /bridge/settings, where any holder of gateway:approve decides; it withdraws the question once the request is decided or expired. Both are kept and sent again under the same key until the fleet stores them. installation.setEnabled, installation.get and installation.delete take the installation token in x-aeolus-installation-token.

Parties

argo approves an outside MCP server as a party with parties.approve (argo's console session): its name, its Streamable HTTP URL, the credential the gateway calls it with (none, for a server reached only on a private network, so the gateway sends no Authorization header; a static bearer; OAuth client credentials with their issuer; or each person signing in, below), the owner argo picks for it (a person's ship or argo), how many calls it takes at once (1 to 10, the most core's pushReceive hands at once; 4 by default) and, from an actor that also holds gateway:approve, tools: each tool the check answered, switched on or off. The gateway, as its own ship, commissions the party's app proxy ship of type mcp-server with that owner, crews it, and keeps its crew token. argo labels the proxy ship in the console, as any ship, for the network rules to select it. parties.list shows each party without its credential or crew token, with its proxy ship's owner and scopes as the fleet holds them, read as the actor who asked (decision 0056: the owner is core's, and the gateway keeps none of its own; null outside the actor's read reach, while there is no proxy ship, or while the fleet does not answer). parties.get {name} shows one. parties.changeOwner {name, ownerShipId} (needs fleet:owner) and parties.changeScopes {name, grant, revoke} (needs scopes:grant, only scopes the actor holds) change the proxy ship in the fleet as the actor, so the fleet checks its manage reach (decision 0056); the gateway keeps neither. parties.retire retires a party (decision 0056): it needs fleet:manage, and the gateway retires the proxy ship in the fleet as the actor who asked, so the fleet checks its manage reach; only then does the gateway forget the party and revoke its client's tokens. A proxy ship retired already counts as retired. Adding it again is a new request. A proxy ship left awaiting crew (the commission's answer was lost, and its secret with it) takes a new secret from a new starting prompt with parties.crew.

Once a proxy ship is crewed, the gateway's ship writes what the gateway checked about it as verified facts (fleet:verify, decision 0059), which discovery shows beside its statement: tlsHost for an MCP server reached over https, clientIdDocumentDomain for an MCP client known by an https metadata document. A write the fleet refuses is logged and leaves the approval as it is; in a fleet that applies its own network rules the gateway's ship has no manage reach over its proxy ships, so the fleet refuses them there. The gateway's ship needs fleet:verify to connect.

A server that takes only an OAuth sign-in is added with the credential { kind: 'personSignIn' } (decision 0067): each person signs in to it as their own outside account. The gateway finds its authorization server from its 401's resource_metadata, else its well-known protected resource metadata (RFC 9728), then the authorization server's metadata (RFC 8414, else OpenID Connect), whose issuer must be the one named. It refuses a server that names no authorization server, an authorization server whose metadata is missing, names another issuer or does not state PKCE S256, and one that offers no dynamic registration, keeping nothing. Otherwise it registers one confidential client for the party (RFC 7591), named Aeolus: <party>, with the redirect URI /outside-parties/sign-in/done and the outside scopes each sign-in asks for (the 401's, else every scope the server lists, with offline_access and openid when listed), keeps it bound to its issuer as the party's credential, and commissions and crews the proxy ship, writing the issuer as its verified fact (oauthSubject, the issuer and the party's client id, never a person's outside account). The proxy ship stays unstated until a person signs in; parties.state answers PRECONDITION_FAILED meanwhile.

Every account of the fleet with a person ship signs in to such a server for itself (Q16). parties.startSignIn answers the address of the party's authorization server to send the browser to: the code flow with PKCE S256, the client's scope and resource (RFC 8707), and a state sealing whose sign-in it is and its verifier, for 10 minutes. The authorization server sends the browser back to /outside-parties/sign-in/done. There the code is redeemed only within those 10 minutes, in a browser whose shared sign-in names the account that started it, and with the party's issuer where the response names one or the server states it always does (RFC 9207); the ID token, read straight from the token endpoint, must name the party's issuer and client. The grant (the outside account the ID token names, the access and refresh tokens, when the access token ends) is kept for the person in place of their earlier one, and the proxy ship is stated from it. A sign-in as an outside account another person's grant of the party names is turned away and its tokens dropped (Q13). The browser goes on to /outside-parties/<party>?fleet=<fleet id>&sign-in=<outcome>, the outcome signed-in, not-signed-in (consent denied), failed, turned-away or unavailable, with the server's OAuth error code where it answered one. parties.ownSignIn answers the person's own grant, never a token; parties.removeOwnSignIn revokes its refresh and access tokens where the server offers revocation (RFC 7009), then drops it. A server where each person signs in is listed, for its statement, its tools and their switches, under any grant whose access token has not ended, the most recently used first (Q15).

A ship's call to such a server goes out under the grant of the person who owns the asking ship, a person ship owning itself (Q1, Q11). The gateway reads the owner from core at each hand-over, as its own ship on the asking ship's behalf, after the tool switches and scopes are checked; while core does not answer, the call is handed again after a wait and never made without its grant. A ship argo owns gets an error result saying no person can sign in for it (Q10). An owner with no grant gets an error result naming their missing sign-in and the party's page, /outside-parties/<party>?fleet=<fleet id>, and the server never sees the call (Q5); the gateway's ship asks them once per party, by a step (application/vnd.aeolus.step+json, docs/design/bridge CC14) to their person ship with the page as its link, and again only after a sign-in in between (Q6, Q9). Their sign-in finishes that step with no press: the gateway acknowledges it as their person ship if they had not opened it, reports it handled, and keeps it done by the gateway. Either error result is kept before it is replied, so a restart replies it again. A grant whose access token ends within 10 minutes is refreshed before the call goes out. A call the server refuses 401 refreshes the grant once with the party's client and goes out again; a second 401, a refresh refused, or a grant with no refresh token answers the missing sign-in's error result and tells the person. A refresh the authorization server does not answer keeps the grant: the call goes out with the access token held while it has not ended, and is otherwise left unsent, handed again after a wait. A call refused 403 insufficient_scope gets an error result naming the page to sign in again on, and the grant is kept. A call the server answered sets the grant's last used.

Grants are kept alive (Q7). A refresh holds the grant's row (FOR UPDATE) while it runs, so one refresh at a time runs per grant, also across gateway instances on one database, and the new tokens are kept before they are used; a grant refreshed or signed in again meanwhile is used as it is, so a rotated refresh token is never sent twice. Every gateway instance also runs a round, every 5 minutes, that refreshes the grants whose access token ends within 10 minutes and those not refreshed for a day since their sign-in or last refresh, at most 100 a round. A refresh answered invalid_grant loses the grant: it is kept with when and why, serves no call (the missing sign-in's error result), and the person is told once that their sign-in ended, with the page to sign in again on; parties.ownSignIn answers it lost. A new sign-in replaces it. Any other refusal, or no answer, keeps the grant as it was, and the next round tries it again.

Grants end (Q6, Q14). Every grant ends the same way: its refresh and access tokens are revoked where the server offers revocation (RFC 7009), then it is dropped, so its person's ships get the missing sign-in's error result. An actor holding gateway:approve within manage reach of the proxy ship, as the fleet answers, sees every person's grant with parties.signIns {name, after}, 20 a page by person ship (next names where the next page starts), never a token, each with its person ship's name (one fleet.list read for the page, naming every person ship on it) and whether it is the actor's own, and removes any with parties.removeSignIn {name, personShipId} (Q12); anyone else is refused, and sees only their own. installation.accountDisabled ends every grant of the account in the fleet. The grant round also reads each person ship holding a grant, as the gateway's ship on its behalf, and ends every grant of a retired one (core answers a read on behalf of a retired ship as no such ship). parties.retire of such a server ends every grant of it, then deletes its client where its registration answered a client configuration endpoint (RFC 7592). A new owner of the proxy ship changes no grant.

The gateway keeps outside credentials and crew tokens as they are in its own database, as squadrons keeps crew tokens: a key on the same server would protect only backups. Guard the database and its backups accordingly.

parties.state states the proxy ship anew from the server's tools/list: each tool is one capability with its MCP fields and annotations, called with application/vnd.aeolus.mcp-tool+json; name=<tool> (the tool arguments as a JSON object) and answered with application/vnd.aeolus.mcp-result+json; name=<tool> (the tool result). The summary names the party only, never its URL or credential. Each capability states the gateway's own timing: waitMs, how long an outside client waits for the answer (MCP_WAIT_MAX_MS), and deadlineMs, when the gateway gives up on the server's tools/call (TOOL_CALL_TIMEOUT_MS). parties.markTool marks a tool repeatable, or takes the mark off, and states it at once: only argo's mark lets the gateway hand a call that died unanswered again; the server's hints never do.

Each tool of a server has a switch, per party: every ship that may reach the party reaches the same tools (aeolus-fleet#1151). The statement holds only the tools switched on, and a call to a tool not switched on is answered the error result tool not enabled for this party and never reaches the server. parties.switchTool {name, tool, isOn} (needs gateway:approve) keeps one switch, then states the proxy ship at once; while it cannot be stated, the switch waits for its next statement, and calls are refused at once all the same. parties.tools answers each tool the server lists now with its switch, isNew for one listed after the switches were kept, and isReadOnly when its annotations say so. A tool the server adds later starts off. A party added with no tool choice (before switches, through a request, or by an actor without gateway:approve) keeps every tool it lists at its next statement on.

The parties pages

The parties pages are the web app's, for accounts by scope, in the fleet their address names (?fleet=<fleet id>): /parties (the parties with their state, and for an account holding fleet:manage a form to approve an MCP server and per party the forms to hand over a new secret, state its tools and mark a tool repeatable), /outside-parties (the switch, for fleet:network, and every outside party), one outside party at /outside-parties/<name> (changing its owner and scopes, retiring it, and for an account holding gateway:approve a switch per tool of a server, each saying what it did; for a server where each person signs in, your own sign-in between its facts and its tools: not signed in with Sign in, signed in with the outside account, when and last used, Sign in again and Remove, or lost with when and why, and how a sign-in went when the browser comes back; an account with no person ship is told it needs one; below it, for an account holding gateway:approve, everyone's sign-ins: each person, their outside account, state and last used, 20 a page with Show more, and Remove confirmed in place) and adding a server at /outside-parties/add (its tools to switch for an account holding gateway:approve; a server where each person signs in goes Address, Owner, Sign in, its sign-in checked when Add is pressed, a refusal said in place, then the approver's own Sign in to it, or Later on its page). Each page asks the API, from the web app's server at the gateway's own port on this machine, as the browser that asked; each form is a server action the same server answers, and the API checks every scope.

Bridge

/bridge?fleet=<fleet id> is the web app's, for an account signed in with the shared sign-in, and where signing in lands (docs/design/bridge, CC18). Bridge home lists what waits on the person, questions and steps (decide and do), with one count: what the gateway takes for their person ship and what they opened and are not done with yet (people.home). Beside it, the stay-aware feed lists the alerts and notices they have not seen, outside the count. Opening an item acknowledges it and shows it whole (people.open); people.item shows one whole by its delivery id in any state, waiting, opened or done, and acknowledges and keeps nothing, refusing a delivery that is not the person ship's own as not found; people.exchange pages the person ship's exchange with one other ship (the chat with it), both ways, whole, 20 a page, newest first, from the newest, before or after a delivery, or around one message, people.exchanges lists the latest exchange per other ship, 20 a page, with how many of its messages are not yet seen, from core's rollup, and people.seenUpTo marks one ship's plain messages seen up to one message in one call, none of them keeping a copy of a payload, and reading marking no question, step, alert or notice (core's ship/exchange, ship/exchanges and ship/ackUpTo); a message is answered or done in place, a question's answer goes into the basket, and an alert or a notice is reported handled at once (CC16). A step shows why, its link with the host it leads to, and its note field before Done and Cannot; either sends its step outcome (application/vnd.aeolus.step-outcome+json) to the asker at once, with the note if one was written, in reply to the step, never through the basket (CC14, CC23), and the step is handled. Done (/bridge/done) reads the person ship's record from core a page of 20 at a time, newest first (people.record, core's ship/record), each item with its job, its state and, once the person acted, what came of it and where: Bridge, a chat (the person's MCP tools), Slack, or the gateway for a step of its own it saw complete, kept by the gateway in item_outcomes. When core does not answer, Bridge says the record cannot be read now and reads it again every 10 seconds. Bridge's settings (/bridge/settings) link to Where things show and hold the requests to decide for a holder of gateway:approve, each decided at /bridge/settings/requests/<request id>, and the requests they asked. Each page reads itself again whenever its fleet's requests or the person's items change, following the API's requests.changes (server-sent events) through /bridge/changes.

The basket (/bridge/basket, docs/design/bridge section 9) holds the answers to questions a person picked in Bridge and has not sent, kept by the gateway in basket_entries per fleet and person ship, one per question (people.putInBasket, people.removeFromBasket, people.basket; Bridge's navigation shows its count from people.basketCount). A question in Bridge has only Put option N in the basket; nothing is sent, and it still waits on the person. Send N answers (people.sendBasket) stores each entry as sending and wakes the gateway's sender, which sends each on its own, in the order added, as the person ship: an answer in reply to its question under the key answer-<question uuid>, so the fleet stores it once however often it is sent. Each the fleet stores is reported handled and leaves the basket; one it did not answer is sent again with the same key every 5 seconds and after a restart; one it refuses stays in the basket saying why, and Remove marks its question done, not sent. An entry whose question its asker withdrew or replaced, or that Slack or a chat answered first, is not sent: Send skips it, says so and takes it out.

Where things show (/bridge/settings/routing, docs/design/bridge section 8) holds the places on top, the person's Slack link, a chat, web push per browser (turned on asking the browser's permission, listed, removed one by one, where the gateway holds push keys) and email, then per job (decide, do, stay aware, everything else) each place on or off, Bridge always on (people.routing, people.turnRoute, kept in routes per fleet, person ship, job and place; the defaults from ROUTING_ON_BY_DEFAULT where the person chose nothing). A change applies at once and loses nothing: a chat turned on lists every item of the job not done; Slack, web push and email only what arrives after. Each delivery is handed once, by the first place that shows it. While a Bridge page is in focus it holds /bridge/focus open (the API's people.focus), and the gateway hands what arrives for the person through channel page, sending no wake (wake a person, W6); Slack, on for the job, still posts it. A web push wake says only the fleet's name, that something waits and Bridge's link, shown by the /wake-worker.js service worker. A browser whose push service answers 404 or 410 takes no wake again, and the section says it is gone.

argo's inbox

The gateway's own ship reads argo's open inbox for the people who may act as argo (aeolus-fleet's docs/design/argo-inbox, AI1 and AI9), by a read rule argo adds by hand on the console's Network page: docs/argo-inbox-read-rule.md is the guide. In each fleet it is connected to, it reads argo's open inbox whole, then follows the fleet's events about argo (fleet.follow) and reads each message that changed again; a restart, or the rule coming back, reads it whole again. What it read is held in memory only: argo's inbox stays the one record, and core sends the gateway nothing. Without the rule it reads nothing, and Bridge home tells an account allowed as argo so, linking the guide (people.argoInboxNotice). The accounts allowed as argo, with their person ships, are kept per fleet in argo_accounts from each account's latest account token (its argo claim): kept at every sign-in, ID-JAG, refresh and shared sign-in exchange, dropped once a token says it is no longer allowed, or once pagasae refuses its refresh, as for a disabled account.

MCP out

From its start, and as soon as a party is crewed, the gateway takes the calls of each crewed party's proxy ship as its crew (interop 5.2). A ship of the fleet calls a tool by sending the proxy ship the tool's input type, application/vnd.aeolus.mcp-tool+json; name=<tool>, with the tool arguments as a JSON object. The gateway reports it handed, makes the tools/call with the vault's credential, and replies with the result (an error result included) naming the request, then acknowledges and reports it handled. It keeps each call in its database, so a restart replies again and never calls again what may have run.

  • A call the server never got (it was unreachable, or refused the credential) is handed again after a wait, and the fleet stops it at the fifth attempt.
  • A call sent with no answer (a broken stream, no answer within 5 minutes) is handed again only when argo marked its tool repeatable. Otherwise the gateway reports it not handled: it may have run, and a human who can check the outside system resends or dismisses it.
  • A party takes at most its concurrency limit of calls at once.

Each hand-over names its channel, mcp (interop 5.1). A call reported not handled shows in the console's Needs attention with why, for argo to resend or dismiss; it is never called again.

MCP clients sign in

The gateway is an OAuth 2.1 authorization server for outside MCP clients, such as Claude, ChatGPT or Cursor (MCP 2026-07-28):

  • /.well-known/oauth-protected-resource (RFC 9728) names the gateway as the authorization server of its MCP endpoint, <GATEWAY_PUBLIC_URL>/mcp; /.well-known/oauth-authorization-server (RFC 8414) states what it serves.
  • The plugin's address, <GATEWAY_PUBLIC_URL>/plugin/mcp, serves the same tools (chatgpt-plugin design, 6). Its metadata is /.well-known/oauth-protected-resource/plugin/mcp, naming exactly that address. /authorize with it as resource shows no pick and sends the person to pagasae signed in as themselves; asking for access for the client there is refused (invalid_request). It grants nothing more: the person gets what core answers for their person ship.
  • A client is pre-registered by argo with oauth.registerClient (its name, its redirect URIs, and whether it keeps a secret, answered once), or known by its client metadata document (CIMD): its client id is the https URL of that document. There is no dynamic registration.
  • /authorize checks the request and sends the browser to the web app's /authorize/pick, which shows the client and two picks (decision 0056, Q1 a): sign in as yourself, or ask for access for this client. Either sends the person to pagasae to sign in. Whatever stops the browser, an unknown client or a sign-in from another browser among them, is the web app's page /authorize/failed. Only the authorization code flow with PKCE S256 is served, and the resource must be one of the gateway's two MCP addresses (RFC 8707); a token is taken only at the address it was issued for.
  • A person signs in through pagasae (decision 0054). pagasae answers their only fleet, or asks which, and sends them back to /pagasae/callback; signed in as themselves, the gateway keeps their account's grant (the account, its fleet, its person ship and pagasae's tokens) and sends their client back with its code, with no approval. The sign-in under way travels sealed in pagasae's state, so nothing is stored before the fleet is known. The browser that starts it holds a secret sealed with it, in an HttpOnly cookie sent only to /pagasae/callback for its 10 minutes, so no other browser finishes it (login CSRF). A fleet the gateway does not serve is refused. A person's client is served their person ship's tools: aeolus_setup (called first in every conversation, as the server's instructions and its description say, with the client the chat runs in: claude, claude-code, chatgpt or copilot; it answers the rules that make the chat check the inbox as often as that client allows, the Monitor tool where the chat has one, else at the start and end of every turn, and the one-time steps that make every new conversation call it first), aeolus_inbox (what waits, by titles only, at most 10, of the jobs on for a chat, every one not done whenever it arrived), aeolus_open (one delivery whole; opening it acknowledges it, and an alert or a notice opened is handled at once, Bridge CC16), aeolus_answer (a reply to its sender, then it is handled) and aeolus_done (handled with no answer; a step's outcome done or cannot, with an optional note, sent to its sender first). Every person tool's answer keeps its own block first and adds a second text block with the person's inbox state: how many deliveries wait, from one core call that takes none, and how many they opened and have not dealt with; a state the fleet does not answer leaves the answer alone. Each person tool states readOnlyHint, destructiveHint and openWorldHint, and a securitySchemes entry of type oauth2; a call refused because the person's sign-in ended carries _meta["mcp/www_authenticate"], so the client asks them to sign in again (chatgpt-plugin design, 7.4). The gateway makes these calls as the crew of the account's own person ship, with the account's token (decision 0057): it crews the ship once, shared by every client of the person, and crews it again when its lease ended. An account with no person ship is told so, and the fleet is asked nothing. aeolus_ask_for_server asks for an outside MCP server by its http or https address, as the account (decision 0056): it opens a pending request, or joins the one pending for the same server, and an approver adds the server with its credential. A server that is a party already is said so. While the fleet's outside parties are off, nobody asks. Where things show turns email on and off per job for the person's person ship, nothing on by default (docs/design/bridge, section 8). The gateway waits on every person ship it crews; once a delivery that was never handed waits, of a job email is on for and sent since it was turned on, it asks pagasae to send the person's account a wake, which says only that something waits in the fleet, with Bridge's link, at most once per EMAIL_WAKE_WINDOW_MINUTES; each delivery it covers is then handed no. 1 with channel email. A wake pagasae refuses or does not answer hands nothing and is tried again, after 1, 5, then every 15 minutes.
  • The web app's pages sign people in with the shared account sign-in (header design H5): /sign-in/start?return_to=<a path of the gateway's> sends the browser to pagasae with the code flow and PKCE, naming no fleet, and /sign-in/done takes it back to the page; the gateway keeps nothing, as pagasae keeps the sign-in in its own cookie aeolus_account_session on the domain every Aeolus app shares. A page names its fleet in its address (?fleet=<fleet id>) and the web app's server forwards that sign-in alone and that fleet in the aeolus-fleet header; the API then acts as the account by the token pagasae exchanges the sign-in for in that fleet, kept until a minute before it ends. account.inView answers the page who it shows the fleet as (its account and scopes), the fleet to go to when it names none (the first whose gateway the account may open), or the app to go to where the account may not open the gateway (H9); UNAUTHORIZED with no shared sign-in. What went wrong is the page /sign-in/failed. A form posting to /sign-in/end from the gateway's own pages ends the shared sign-in at pagasae, signing the person out of every Aeolus app.
  • A person who asks for access for the client asks as their account. A client that is a party already is sent back with its party's code at once. Otherwise the account opens a pending request for the client, or joins the one pending, and its browser waits at the web app's /authorize/waiting/approval, which follows the request on /authorize/waiting/live and goes back to /authorize/waiting once it no longer waits, there to be sent on: approved and its proxy ship crewed, the client gets its party's code; denied or expired, access_denied. Closed meanwhile, the next try after a deny or expiry ends with access_denied once, and a later try asks anew. The waiting travels sealed in the page's address, and the browser holds the sign-in's secret in an HttpOnly cookie sent only to the waiting addresses, for the request's 7 days. While the fleet's outside parties are off, nobody asks. Each client party has its own app proxy ship, of type mcp-client; every token of a client party names its party, so every fleet call the client makes is its proxy ship's, within its reach; a person's token names their account's grant. parties.list shows client parties beside server parties.
  • /token takes a code once, within its minute, for an access token (one hour) and a refresh token (30 days). Refreshing rotates the pair; a code or a refresh token used twice revokes what it gave.
  • ID-JAG (aeolus-fleet company sign-in design, slice 6): a person's MCP client whose company manages it (enterprise-managed authorization) posts to /token the ID-JAG its company identity provider issued for the gateway's issuer, with the JWT bearer grant (urn:ietf:params:oauth:grant-type:jwt-bearer, the ID-JAG as assertion), and needs no browser. Only a client argo registered for the code flow does, with its secret if it has one; anyone else is invalid_client. The gateway hands the ID-JAG to pagasae by token exchange with its own client, naming the client, and pagasae checks it against the organisation's connected Okta and finds the account a sign-in there recorded. The account's grant is kept as after a browser sign-in, and the client gets an access token (one hour) acting as the account in the client's fleet, with no refresh token: it asks its identity provider for a new ID-JAG. ID-JAG is only available for accounts in one fleet: pagasae refuses an account with rights in several (invalid_grant), and the person signs in at the gateway's address to choose. A refusal at pagasae is invalid_grant; pagasae not answering is temporarily_unavailable (503). The metadata states the grant and authorization_grant_profiles_supported: ["urn:ietf:params:oauth:grant-profile:id-jag"] where people sign in through pagasae.

Codes and tokens are kept only as hashes, and so are client secrets.

Serve the gateway at its own origin, such as https://gateway.example.com, with GATEWAY_PUBLIC_URL set to it: RFC 8414 and RFC 9728 put the metadata at the origin's root, and /authorize and /token are served there.

The ChatGPT plugin

plugin/ holds the plugin listed in ChatGPT's Plugins directory (chatgpt-plugin design, 7.2): plugin.json (the listing's texts and the review cases OpenAI runs), mcp.json (one server at https://gateway.aeolus-fleet.dev/plugin/mcp, signing in by OAuth with CIMD), the logo and skills/aeolus/SKILL.md (what the model needs to know of Aeolus for a person; OpenAI asks for at least one skill). npm run plugin:build sets the gateway's version in plugin.json and writes dist/aeolus-<version>.zip, dist/aeolus-<version>.tools.json (the person tools as tools/list answers them) and dist/aeolus-<version>.notes.md; --out <dir> writes them elsewhere. It refuses, naming the field, a package that breaks OpenAI's limits, a review case or a skill naming a tool the gateway does not serve, a package without a skill or with one that breaks the Agent Skills format, and a person tool without its three hints. Given --previous <dir> holding the last release's ZIP and tools, the notes say whether the package changed (the version aside), and name the tools that are new, changed or removed; a package name other than the last release's is refused (plugin_name_mismatch). The release workflow runs it before publishing and attaches the ZIP and tools to the GitHub Release.

MCP clients list and call tools

<GATEWAY_PUBLIC_URL>/mcp is the gateway's MCP endpoint (Streamable HTTP, stateless). A call without a token, or with one the gateway did not issue for it, gets 401 with resource_metadata naming where to sign in (RFC 9728).

  • tools/list answers the capabilities of the ships the client's proxy ship may send to now, as the fleet's discover answers it: the gateway adds no reach of its own.
  • A tool is named <alias>__<capability>. The alias is 8 letters and digits derived from the ship under GATEWAY_SECRET: the client never sees a ship's name or id.
  • Its description is the capability's, then the ship's summary. A capability that takes plain text first gets one text argument. Every tool takes an optional aeolus_idempotency_key.
  • Left out: argo, ships with no statement by their crew, capabilities whose first input type is neither JSON nor plain text or whose input schema is not an object, and tool names over 128 characters.
  • No tool states an output schema: a call may answer a handle, and MCP clients check every answer against it.
  • tools/call sends the arguments, without aeolus_idempotency_key, as a message from the client's proxy ship to the tool's ship, under the capability's first input type. The client is answered only once the fleet answered the send. The call then waits for its answer as long as the capability's waitMs states, at most MCP_WAIT_MAX_MS (interop 5.3), and not at all when it states none: answered within it, the client gets the answer itself; else {"handle": "...", "state": "submitted"} (or working). A fleet that refuses the send gets a tool error, and one that cannot be reached gives no reason why.
  • The same aeolus_idempotency_key again sends no second message. The send's key is derived from it under GATEWAY_SECRET, and the handle from the message, so the client never sees a msg_ id.
  • aeolus_result({ handle }) reads the client's own call: submitted, working once a reply of another type came, its answer once completed, or a tool error once it failed (interop 3.4).
  • MCP Tasks (io.modelcontextprotocol/tasks, stated at server/discover): a client that declares the extension on its tools/call is answered a task in place of the handle, its taskId the handle. tasks/get reads it: working while submitted or working, completed with the answer as its result, or failed once its deadline passed. A task never expires (ttlMs null), as the gateway keeps every call. tasks/cancel and tasks/update are acknowledged and change nothing: a sent call cannot be unsent, and no call asks for input yet.
  • The first reply of the capability's first output type completes it; plain text completes a call to a ship that states no output type. An outside tool's result, as MCP out replies one, is answered as that result.
  • A call fails at its capability's deadlineMs after it was sent without its answer, and never by time when the capability states none (interop 3.4). A reply after the deadline is kept and changes nothing.
  • Each client party's worker takes the replies to its calls with receiveReply (aeolus-fleet#953) for each call neither completed nor past its deadline, at most 10 calls at once, each in its turn: it keeps each reply on its call, then acknowledges and handles its delivery. It never takes a delivery with pushReceive, so the proxy ship's lease stays plain, as receiveReply needs. A message to the proxy ship that replies to no call waits in the fleet and keeps no reply out.

Develop

npm install
npm run typecheck && npm run lint && npm test

The integration tests need Docker or Podman: they start Postgres in Testcontainers and a real core from the @aeolus-fleet/core devDependency.

Licence

Apache-2.0.

版本与发布记录

@aeolus-fleet/gateway 共发布 30 个版本,首次发布于 2026/10/20;过去 12 个月发布了 30 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.38.5latest2026/11/08今天
0.38.42026/11/07今天
0.38.32026/11/07今天
0.38.22026/11/071 天前
0.38.12026/11/061 天前
0.38.02026/11/061 天前

关于 @aeolus-fleet/gateway 的常见问题

@aeolus-fleet/gateway 是什么?

Aeolus gateway: outside MCP servers and clients join a fleet as ships, through one gateway beside core.

如何安装 @aeolus-fleet/gateway?

在项目目录运行 npm install @aeolus-fleet/gateway,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 aeolus-gateway,可以用 npx aeolus-gateway 直接运行。

@aeolus-fleet/gateway 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@aeolus-fleet/gateway 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@aeolus-fleet/gateway 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=26。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@aeolus-fleet/gateway 可以商用吗?

@aeolus-fleet/gateway 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@aeolus-fleet/gateway 的下载量有多少?

根据 npm 官方下载统计,@aeolus-fleet/gateway 在 2026/10/05 – 2026/11/03 这 30 天内被下载 3,580 次,日均约 119 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@aeolus-fleet/gateway 还在维护吗?

最新版本 0.38.5 发布于 2026/11/08(今天);至今共发布 30 个版本,其中过去 12 个月发布了 30 个;首次发布于 2026/10/20。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。