Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ The canonical contract is the fmsg-webapi README and
`src/client/` is written against it directly. Key facts:

- `POST /fmsg/token` exchanges an `fmsgk_…` API key for a short-lived JWT whose `sub` is the address.
The client refreshes it 5 minutes before expiry and retries once on 401.
The client also accepts a caller-bound `TokenProvider` (see `docs/token-providers.md`). It renews
5 minutes before expiry, capped at half the acquired lifetime, and retries once on 401.
- `id`/`pid` are int64 JSON numbers. They are decimal **strings** everywhere in this codebase; only
`src/client/message-id.ts` converts at the JSON boundary (reviver with `context.source`).
- Sending is draft → attach → send; the draft is deleted if a later step fails.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ This is the next planned release; publication still happens through a `v0.2.0` G

### Fixes and improvements

- Accept caller-bound `TokenProvider` implementations in the client library alongside API keys.
Share renewal across concurrent requests, pin the address, bound acquisition time, and propagate
cancellation. Cap early renewal for short-lived tokens and reuse renewal after late 401 responses.
This is the OAuth foundation; hosted OAuth remains separate integration work.
- Retry protected reads when a WebSocket announces a message before it is readable. If retries run
out, schedule a delayed inbox catch-up without requiring another push. Fix pre-cancelled waits
and preserve request deadlines.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ client.close();
Its result includes the replacement count (`redactions`) and transmitted `topic`. Attachments are
unchanged. Use `streamAttachment()` to consume large files incrementally; consume or cancel its stream.

Applications with their own authorization integration can pass a `TokenProvider` instead of an
API-key string. The client shares renewal across concurrent requests and keeps the authenticated
address fixed. See the [token-provider contract](./docs/token-providers.md). This library interface
does not enable hosted OAuth in the MCP executable yet.

## Development

```sh
Expand Down
5 changes: 5 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ Support claims must name tested clients and versions; agents without MCP need an
Ship A first, then B–D. Plan E with the host/account-system maintainer. Release work in F can proceed
earlier; hosted-service promises depend on verified OAuth and operational behavior.

E's [client token-provider foundation](docs/token-providers.md) supports renewable upstream credentials
and local isolation tests. Incoming OAuth validation, discovery, token exchange and actual hosted-client
acceptance remain open. Coordinate exchanged-token scope enforcement with the Web API before enabling
OAuth: messaging credentials must not inherit owner credential-management privileges.

Release-triggered npm publication, OIDC trusted publishing, provenance generation and version
synchronization already exist in [publish.yml](.github/workflows/publish.yml). Preserve them.
[CI](https://github.com/markmnl/fmsg-mcp/actions/workflows/tests.yml) already covers Node 22/24,
Expand Down
93 changes: 93 additions & 0 deletions docs/token-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Web API token providers

`FmsgClient` accepts either the existing `fmsgk_…` API-key string or a `TokenProvider`.
This is a client-library extension point. The MCP executable still uses API keys;
HTTP OAuth discovery, incoming-token validation and RFC 8693 exchange are separate work.

Import `TokenProvider`, `TokenProviderRequest` and `AccessToken` from
`@markmnl/fmsg-mcp/client` (also exported from the package root):

```ts
interface TokenProvider {
getToken(request: {
apiUrl: string;
signal: AbortSignal;
forceRefresh: boolean;
}): Promise<{
accessToken: string;
address: string;
expiresAtMs: number;
}>;
close?(): void;
}
```

Supply an implementation as `new FmsgClient(apiUrl, tokenProvider, options)`.
One provider and client belong to one caller, authorization grant and Web API URL.
Sharing a provider between clients risks mixing grants or closing another client's credentials.

## Provider responsibilities

- Obtain a fresh **Web API** bearer access token whenever called. `apiUrl` is the
normalized, fixed upstream URL; never derive credential destinations from model input.
`forceRefresh: true` means the previous token was rejected with 401 or renewal was
explicitly requested. It must bypass any provider cache of that rejected token.
- Return the authenticated fmsg address that the token authorizes, preserving user-name
case, and a finite future expiry in milliseconds since the Unix epoch. Use trusted
authorization/exchange metadata; do not invent an expiry or infer identity from an
unverified incoming token. The generic client does not decode provider tokens.
- Honour `signal` for all network work. It aborts when renewal times out, the client
closes, or all callers waiting for that renewal cancel. Reject on failed/revoked
authorization; never fall back to an owner token, API key or another grant.
- Keep credentials out of errors and logs. `close()` synchronously releases retained
credentials/resources; it is called once when the owning client closes. Closing a
client is local cleanup, not remote OAuth-grant revocation.

## Client responsibilities

The client caches immutable token snapshots and shares concurrent renewal. It renews
five minutes before expiry by default, capped at half the remaining lifetime when the
token is acquired, so a one-minute token is usable without constant re-exchange.
`refreshMarginMs: 0` disables early renewal, but never permits an expired cached token.
Each acquisition has the client's `timeoutMs` budget (60 seconds by default).

The first successful acquisition pins the address. A renewal for another address is
rejected before any request uses that token; changing identities requires a new client.
This is caller binding, not an additional messaging permission system. The Web API
still validates the actual credential, identity, scopes, visibility and host limits.

Protected HTTP requests retry once on 401 after renewal. Concurrent or late 401s for
the same old token reuse an already renewed token; 403 never triggers renewal.
Failed renewal discards the cache. Provider output must be a bearer token with a valid
address and expiry; an API key passed back as an access token is rejected.

`getToken(force?, signal?)` and `address(signal?)` support cancellation, as do existing
request methods that accept a signal. Cancelling one waiter leaves renewal available
to others; cancelling the last aborts acquisition. Late provider completion cannot
replace a later token. `close()` aborts outstanding token acquisition and HTTP work.

`openFmsgWebSocket(client, signal?)` obtains its bearer from the same provider/cache.
The optional signal cancels token acquisition before opening the socket. The caller
owns the returned WebSocket and must handle its events and close it; long-lived
connections do not gain automatic token renewal or revocation handling from this helper.
`wait_for_message` already owns its socket and passes cancellation into acquisition.

## Contract for a future OAuth adapter

Validate the incoming MCP access token for the configured issuer and MCP audience,
then use authenticated [RFC 8693 token exchange](https://www.rfc-editor.org/rfc/rfc8693.html)
to obtain a separate upstream token. [MCP forbids passing the incoming token through
to the Web API](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations).
Keep issuer, JWKS/discovery URLs, audiences, address-claim mapping and client credentials
configurable; no particular identity provider is required by this interface.

The IdP and Web API must agree on scopes and consented-identity binding before hosted
OAuth is enabled. Exchanged messaging tokens must not acquire owner key-management
rights or broaden identity via `X-FMSG-Act-As`. The Web API enforces those restrictions;
the tool list is not an authorization boundary. OAuth refresh/revocation and browser
consent are adapter/authorization-service work. Offline JWT validation alone does not
provide immediate revocation of already issued upstream tokens.

Tests in `test/token-provider.test.ts` use registered opaque token fixtures to exercise
renewal, cancellation, caller isolation, and HTTP/WebSocket credential delivery. They
do not establish OAuth conformance, JWT verification, or compatibility with a real IdP.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"CHANGELOG.md",
"ROADMAP.md",
"docs/http-deployment.md",
"docs/token-providers.md",
"LICENSE",
"server.json"
],
Expand Down
Loading
Loading