@markmnl/fmsg-mcp 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +6 -1
- package/dist/auth.js +6 -2
- package/dist/client/client.d.ts +2 -0
- package/dist/client/client.js +10 -1
- package/dist/client/token-provider.d.ts +2 -0
- package/dist/client/token-provider.js +8 -1
- package/dist/client/types.d.ts +5 -0
- package/dist/context.d.ts +2 -0
- package/dist/context.js +2 -1
- package/dist/http.js +5 -1
- package/dist/index.js +5 -4
- package/dist/instructions.d.ts +2 -0
- package/dist/instructions.js +8 -5
- package/dist/owner.d.ts +15 -0
- package/dist/owner.js +27 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +6 -1
- package/dist/tools/common.d.ts +2 -0
- package/dist/tools/draft.js +5 -1
- package/dist/tools/identity.js +6 -2
- package/dist/tools/send.js +10 -4
- package/docs/token-providers.md +3 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- A sub-address (such as `@user_agent@example.com`, owned by `@user@example.com`) now sends to
|
|
6
|
+
its owner by default: `send_message`, `reply` and `create_draft` add the owner as a recipient so
|
|
7
|
+
they can follow and reply to the thread, with a new `include_owner` argument (default true) to
|
|
8
|
+
leave them off. The owner comes from the API-key token's `owner` claim; the instructions and
|
|
9
|
+
`whoami` (new `owner` field) name it, and `include_owner` is only offered when the caller may be a
|
|
10
|
+
sub-address. `FmsgClient.owner()` and the token provider's optional `owner` field expose it.
|
|
5
11
|
- An upload the fmsg host refused part-way, such as a large file PUT to someone else's draft or a
|
|
6
12
|
sent message, no longer hangs until the proxy times out. The server reads the rest of the body
|
|
7
13
|
and answers with the host's reason; if the host closed the connection before giving one, the
|
package/README.md
CHANGED
|
@@ -40,6 +40,11 @@ Use the API URL and key for local installation below, or use your provider's API
|
|
|
40
40
|
with an `Authorization: Bearer fmsgk_...` header. The endpoint must use the fmsg Web API that accepts
|
|
41
41
|
your key.
|
|
42
42
|
|
|
43
|
+
An agent's key is usually for a **sub-address** such as `@user_agent@example.com`, owned by
|
|
44
|
+
`@user@example.com`. The owner can switch to the sub-address in their client, but can only follow
|
|
45
|
+
and reply to threads they are a participant of. So `send_message`, `reply` and `create_draft` add the
|
|
46
|
+
owner as a recipient unless `include_owner` is false; `whoami` reports the owner.
|
|
47
|
+
|
|
43
48
|
### MCP Registry
|
|
44
49
|
|
|
45
50
|
Find `io.github.markmnl/fmsg-mcp` in the [official MCP Registry](https://registry.modelcontextprotocol.io/?q=io.github.markmnl%2Ffmsg-mcp).
|
|
@@ -128,7 +133,7 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
|
|
|
128
133
|
|
|
129
134
|
| Tool | What it does |
|
|
130
135
|
|---|---|
|
|
131
|
-
| `whoami` | The address this server acts as, the public API URL (when known), the transport (`stdio` or `http`, which is Streamable HTTP) and short-name defaults |
|
|
136
|
+
| `whoami` | The address this server acts as, its `owner` if it is a sub-address, the public API URL (when known), the transport (`stdio` or `http`, which is Streamable HTTP) and short-name defaults |
|
|
132
137
|
| `resolve_address` | Turn a short name into `@user@domain` (directory, then default domain) |
|
|
133
138
|
| `list_messages` | Inbox, newest first, with previews, attachment types and `size` (`compressed` marks a body sent deflated); reactions hidden and never counted as unread unless `include_reactions` |
|
|
134
139
|
| `list_sent` | Sent messages with per-recipient delivery state |
|
package/dist/auth.js
CHANGED
|
@@ -47,9 +47,12 @@ export class ApiKeyCallerProvider {
|
|
|
47
47
|
this.pendingClients.add(client);
|
|
48
48
|
try {
|
|
49
49
|
const address = await client.address();
|
|
50
|
+
const owner = await client.owner();
|
|
50
51
|
if (this.closed)
|
|
51
52
|
throw new Error("server is closing");
|
|
52
|
-
const entry = {
|
|
53
|
+
const entry = {
|
|
54
|
+
key, caller: { client, address, ...(owner ? { owner } : {}), tokenExpiresAt: async () => (await client.getToken()).expiresAtMs }, lastUsed: Date.now(), active: 0,
|
|
55
|
+
};
|
|
53
56
|
this.entries.set(key, entry);
|
|
54
57
|
this.log(safeErrorMessage(`authenticated ${address} (key ${key.slice(0, 8)}…)`));
|
|
55
58
|
return entry;
|
|
@@ -93,7 +96,8 @@ export class ApiKeyCallerProvider {
|
|
|
93
96
|
this.leases.set(lease, entry);
|
|
94
97
|
return {
|
|
95
98
|
token: key, clientId: entry.caller.address, scopes: [FMSG_SCOPE],
|
|
96
|
-
expiresAt: Math.floor(expiresAtMs / 1000),
|
|
99
|
+
expiresAt: Math.floor(expiresAtMs / 1000),
|
|
100
|
+
extra: { cacheKey: key, callerLease: lease, ...(entry.caller.owner ? { owner: entry.caller.owner } : {}) },
|
|
97
101
|
};
|
|
98
102
|
}
|
|
99
103
|
catch (error) {
|
package/dist/client/client.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export declare class FmsgClient {
|
|
|
44
44
|
get reconnectOnTokenExpiry(): boolean;
|
|
45
45
|
/** The provider's authenticated address, pinned for this client's lifetime. */
|
|
46
46
|
address(signal?: AbortSignal): Promise<string>;
|
|
47
|
+
/** The address that owns the authenticated sub-address, or undefined when acting as an owner's own address. */
|
|
48
|
+
owner(signal?: AbortSignal): Promise<string | undefined>;
|
|
47
49
|
getToken(force?: boolean, signal?: AbortSignal): Promise<AccessToken>;
|
|
48
50
|
private startRenewal;
|
|
49
51
|
/** Release credentials and cancel outstanding work when the client is no longer used. */
|
package/dist/client/client.js
CHANGED
|
@@ -82,6 +82,10 @@ export class FmsgClient {
|
|
|
82
82
|
async address(signal) {
|
|
83
83
|
return (await this.getToken(false, signal)).address;
|
|
84
84
|
}
|
|
85
|
+
/** The address that owns the authenticated sub-address, or undefined when acting as an owner's own address. */
|
|
86
|
+
async owner(signal) {
|
|
87
|
+
return (await this.getToken(false, signal)).owner;
|
|
88
|
+
}
|
|
85
89
|
async getToken(force = false, signal) {
|
|
86
90
|
signal?.throwIfAborted();
|
|
87
91
|
this.lifetime.signal.throwIfAborted();
|
|
@@ -125,7 +129,12 @@ export class FmsgClient {
|
|
|
125
129
|
if (this.boundAddress !== undefined && address !== this.boundAddress)
|
|
126
130
|
throw new Error("token provider changed the authenticated address; create a new client for a different identity");
|
|
127
131
|
this.boundAddress = address;
|
|
128
|
-
const
|
|
132
|
+
const owner = typeof value.owner === "string" ? normalizeFmsgAddress(value.owner) : undefined;
|
|
133
|
+
if (value.owner !== undefined && !owner)
|
|
134
|
+
throw new Error("token provider returned an invalid owner address");
|
|
135
|
+
const token = Object.freeze({
|
|
136
|
+
accessToken: value.accessToken, address, ...(owner && owner.toLowerCase() !== address.toLowerCase() ? { owner } : {}), expiresAtMs: value.expiresAtMs,
|
|
137
|
+
});
|
|
129
138
|
this.refreshAtMs = token.expiresAtMs - Math.min(this.options.refreshMarginMs ?? 300_000, (token.expiresAtMs - now) / 2);
|
|
130
139
|
this.token = token;
|
|
131
140
|
return token;
|
|
@@ -25,3 +25,5 @@ export declare class ApiKeyTokenProvider implements TokenProvider {
|
|
|
25
25
|
getToken({ apiUrl, signal }: TokenProviderRequest): Promise<AccessToken>;
|
|
26
26
|
close(): void;
|
|
27
27
|
}
|
|
28
|
+
/** The `owner` claim as an address, when it is one and differs from the subject. */
|
|
29
|
+
export declare function ownerOf(payload: Record<string, unknown>, address: string): string | undefined;
|
|
@@ -25,6 +25,8 @@ export class ApiKeyTokenProvider {
|
|
|
25
25
|
const address = typeof payload.sub === "string" ? normalizeFmsgAddress(payload.sub) : undefined;
|
|
26
26
|
if (!address)
|
|
27
27
|
throw new Error("token JWT sub is not an fmsg address");
|
|
28
|
+
// The Web API's `owner` claim names the account a sub-address belongs to; an owner's own token has none.
|
|
29
|
+
const owner = ownerOf(payload, address);
|
|
28
30
|
const expiries = [
|
|
29
31
|
typeof body.expires_at === "string" ? Date.parse(body.expires_at) : Number.NaN,
|
|
30
32
|
typeof payload.exp === "number" ? payload.exp * 1000 : Number.NaN,
|
|
@@ -32,10 +34,15 @@ export class ApiKeyTokenProvider {
|
|
|
32
34
|
].filter(Number.isFinite);
|
|
33
35
|
// Never extend the JWT lifetime using a later response expiry or a guessed TTL.
|
|
34
36
|
const expiresAtMs = expiries.length ? Math.min(...expiries) : Number.NaN;
|
|
35
|
-
return { accessToken: body.access_token, address, expiresAtMs };
|
|
37
|
+
return { accessToken: body.access_token, address, ...(owner ? { owner } : {}), expiresAtMs };
|
|
36
38
|
}
|
|
37
39
|
close() { this.apiKey = ""; }
|
|
38
40
|
}
|
|
41
|
+
/** The `owner` claim as an address, when it is one and differs from the subject. */
|
|
42
|
+
export function ownerOf(payload, address) {
|
|
43
|
+
const owner = typeof payload.owner === "string" ? normalizeFmsgAddress(payload.owner) : undefined;
|
|
44
|
+
return owner && owner.toLowerCase() !== address.toLowerCase() ? owner : undefined;
|
|
45
|
+
}
|
|
39
46
|
// Only the first-party API-key contract requires sub/exp in a JWT. Custom
|
|
40
47
|
// providers supply address/expiry metadata; the client does not decode their tokens.
|
|
41
48
|
function decodeJwtPayload(token) {
|
package/dist/client/types.d.ts
CHANGED
|
@@ -103,6 +103,11 @@ export type AccessToken = {
|
|
|
103
103
|
readonly accessToken: string;
|
|
104
104
|
/** Authenticated fmsg address supplied by the provider; not necessarily a JWT sub. */
|
|
105
105
|
readonly address: string;
|
|
106
|
+
/**
|
|
107
|
+
* The address that owns `address` when it is a sub-address (a derived sub-account such as
|
|
108
|
+
* `@user_agent@example.com`, or a delegated identity); absent when the token is the owner's own.
|
|
109
|
+
*/
|
|
110
|
+
readonly owner?: string;
|
|
106
111
|
readonly expiresAtMs: number;
|
|
107
112
|
};
|
|
108
113
|
export type OutboundAttachment = {
|
package/dist/context.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { FmsgClient } from "./client/client.js";
|
|
|
4
4
|
export type Caller = {
|
|
5
5
|
client: FmsgClient;
|
|
6
6
|
address: string;
|
|
7
|
+
/** The address that owns `address` when it is a sub-address; the owner sees none of this caller's messages. */
|
|
8
|
+
owner?: string;
|
|
7
9
|
/** When the current Web API access token expires (ms since epoch), for whoami. */
|
|
8
10
|
tokenExpiresAt: () => Promise<number>;
|
|
9
11
|
};
|
package/dist/context.js
CHANGED
|
@@ -7,7 +7,8 @@ export class StaticCallerProvider {
|
|
|
7
7
|
forRequest() {
|
|
8
8
|
this.caller ??= (async () => {
|
|
9
9
|
const address = await this.client.address();
|
|
10
|
-
|
|
10
|
+
const owner = await this.client.owner();
|
|
11
|
+
return { client: this.client, address, ...(owner ? { owner } : {}), tokenExpiresAt: async () => (await this.client.getToken()).expiresAtMs };
|
|
11
12
|
})().catch((error) => {
|
|
12
13
|
this.caller = undefined;
|
|
13
14
|
throw error;
|
package/dist/http.js
CHANGED
|
@@ -138,7 +138,11 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
138
138
|
const metadataPath = resource ? `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}` : undefined;
|
|
139
139
|
const metadataUrl = resource ? `${resource.origin}${metadataPath}` : "";
|
|
140
140
|
const shutdown = new AbortController();
|
|
141
|
-
const mcpHandler = (provider, address) => createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, {
|
|
141
|
+
const mcpHandler = (provider, address) => createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, {
|
|
142
|
+
...(authInfo ? { address: address(authInfo) } : {}),
|
|
143
|
+
...(typeof authInfo?.extra?.owner === "string" ? { owner: authInfo.extra.owner } : {}),
|
|
144
|
+
shutdown: shutdown.signal,
|
|
145
|
+
}), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
|
|
142
146
|
const oauthHandler = oauthProvider && mcpHandler(oauthProvider, (authInfo) => String(authInfo.extra?.address ?? ""));
|
|
143
147
|
const apiKeyHandler = apiKeyProvider && mcpHandler(apiKeyProvider, (authInfo) => authInfo.clientId);
|
|
144
148
|
const active = new Map();
|
package/dist/index.js
CHANGED
|
@@ -125,16 +125,17 @@ async function main() {
|
|
|
125
125
|
}
|
|
126
126
|
// Resolve the address once so the instructions can name it; never let a slow
|
|
127
127
|
// or unreachable host hold up initialize.
|
|
128
|
-
const
|
|
128
|
+
const knownCaller = async () => {
|
|
129
129
|
if (!cfg.apiUrl || !cfg.apiKey)
|
|
130
130
|
return undefined;
|
|
131
131
|
const timeout = new Promise((resolve) => setTimeout(() => resolve(undefined), 5000).unref());
|
|
132
|
-
return Promise.race([provider.forRequest(undefined).then((c) => c.address
|
|
132
|
+
return Promise.race([provider.forRequest(undefined).then((c) => ({ address: c.address, ...(c.owner ? { owner: c.owner } : {}) })), timeout])
|
|
133
|
+
.catch(() => undefined);
|
|
133
134
|
};
|
|
134
135
|
const shutdown = new AbortController();
|
|
135
136
|
const handle = serveStdio(async () => {
|
|
136
|
-
const
|
|
137
|
-
return createFmsgMcpServer(provider, cfg, { ...
|
|
137
|
+
const known = await knownCaller();
|
|
138
|
+
return createFmsgMcpServer(provider, cfg, { ...known, shutdown: shutdown.signal });
|
|
138
139
|
});
|
|
139
140
|
onShutdown(async () => {
|
|
140
141
|
// Let an in-flight wait write its "interrupted" result before the transport closes.
|
package/dist/instructions.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { FileRoutes } from "./tools/attachments.js";
|
|
|
2
2
|
export type InstructionsContext = {
|
|
3
3
|
/** The address this server acts as, when already known (HTTP callers; stdio after a token exchange). */
|
|
4
4
|
address?: string;
|
|
5
|
+
/** The address that owns `address` when it is a sub-address, when known. */
|
|
6
|
+
owner?: string;
|
|
5
7
|
/** FMSG_DEFAULT_DOMAIN: a domain, or CALLER_DOMAIN for the caller's own domain. */
|
|
6
8
|
defaultDomain?: string;
|
|
7
9
|
/** Large-file routes this server offers; none when omitted. */
|
package/dist/instructions.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Server instructions: returned in the MCP `initialize` result and folded into
|
|
3
3
|
* the model's system prompt by hosts that support them (some chat hosts do not, so
|
|
4
4
|
* nothing a tool needs may live only here). Rules that span tools, each stated once:
|
|
5
|
-
* which address this server acts as, the fmsg terms, the inbox
|
|
6
|
-
* send files, the irreversible-send rule and untrusted content. Per-tool detail lives in
|
|
7
|
-
* descriptions; situational guidance in results.
|
|
5
|
+
* which address this server acts as (and its owner, for a sub-address), the fmsg terms, the inbox
|
|
6
|
+
* routine, how to send files, the irreversible-send rule and untrusted content. Per-tool detail lives in
|
|
7
|
+
* the tool descriptions; situational guidance in results. Keep it short: hosts fold it into every prompt.
|
|
8
8
|
*/
|
|
9
9
|
import { CALLER_DOMAIN, effectiveDefaultDomain } from "./address.js";
|
|
10
|
+
import { ownerSentence } from "./owner.js";
|
|
10
11
|
/** How to send files, naming only the routes this server offers. */
|
|
11
12
|
function filesParagraph(files) {
|
|
12
13
|
const large = [
|
|
@@ -17,8 +18,10 @@ function filesParagraph(files) {
|
|
|
17
18
|
}
|
|
18
19
|
export function buildInstructions(ctx = {}) {
|
|
19
20
|
const identity = ctx.address
|
|
20
|
-
? `you are acting as ${ctx.address}`
|
|
21
|
+
? `you are acting as ${ctx.address}${ctx.owner ? `, a sub-address of ${ctx.owner}` : ""}`
|
|
21
22
|
: "call whoami to see which";
|
|
23
|
+
// An owner's own address needs no owner sentence; an unknown caller gets the conditional one.
|
|
24
|
+
const owner = ctx.address && !ctx.owner ? "" : ` ${ownerSentence(ctx.owner)}`;
|
|
22
25
|
const domain = effectiveDefaultDomain(ctx.defaultDomain, ctx.address);
|
|
23
26
|
const shortNames = domain
|
|
24
27
|
? `; short names resolve to @name@${domain}`
|
|
@@ -27,7 +30,7 @@ export function buildInstructions(ctx = {}) {
|
|
|
27
30
|
: "";
|
|
28
31
|
return [
|
|
29
32
|
`This server sends and receives fmsg messages as one fmsg address, and only that one: ${identity}. ` +
|
|
30
|
-
|
|
33
|
+
`Use its tools for everything fmsg; other fmsg tools or local credentials may act as a different address.${owner}`,
|
|
31
34
|
"fmsg is threaded messaging between @user@domain addresses. Only a thread's first message has a topic; " +
|
|
32
35
|
"replies have none. A reaction is a small message carrying one emoji. Terminal messages (such as reactions) " +
|
|
33
36
|
"cannot be replied or reacted to; no-reply asks recipients not to answer. Added recipients are people " +
|
package/dist/owner.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-addresses, stated once. `@user_agent@example.com` is a sub-address of `@user@example.com`: the
|
|
3
|
+
* owner can switch to it in their client, but can only follow and reply to a thread they are a
|
|
4
|
+
* participant of. So messages a sub-address sends include the owner as a recipient unless the
|
|
5
|
+
* model passes include_owner: false.
|
|
6
|
+
*/
|
|
7
|
+
import * as z from "zod/v4";
|
|
8
|
+
/** The instructions sentence after the identity: concrete when the owner is known, conditional otherwise. */
|
|
9
|
+
export declare function ownerSentence(owner: string | undefined): string;
|
|
10
|
+
/** The include_owner argument for send-type tools; undefined when the caller is known to be an owner's own address. */
|
|
11
|
+
export declare function includeOwnerSchema(address: string | undefined, owner: string | undefined): z.ZodDefault<z.ZodBoolean> | undefined;
|
|
12
|
+
/** The recipients with the caller's owner appended, unless include_owner is false (unknown: the argument is only registered for possible sub-addresses) or they are already present. */
|
|
13
|
+
export declare function withOwner(caller: {
|
|
14
|
+
owner?: string;
|
|
15
|
+
}, recipients: string[], includeOwner: unknown): string[];
|
package/dist/owner.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-addresses, stated once. `@user_agent@example.com` is a sub-address of `@user@example.com`: the
|
|
3
|
+
* owner can switch to it in their client, but can only follow and reply to a thread they are a
|
|
4
|
+
* participant of. So messages a sub-address sends include the owner as a recipient unless the
|
|
5
|
+
* model passes include_owner: false.
|
|
6
|
+
*/
|
|
7
|
+
import * as z from "zod/v4";
|
|
8
|
+
import { sameAddress } from "./address.js";
|
|
9
|
+
/** The instructions sentence after the identity: concrete when the owner is known, conditional otherwise. */
|
|
10
|
+
export function ownerSentence(owner) {
|
|
11
|
+
return owner
|
|
12
|
+
? `Messages you send include ${owner} as a recipient unless include_owner is false, so they can follow and reply.`
|
|
13
|
+
: "If whoami shows an owner, messages you send include them as a recipient unless include_owner is false.";
|
|
14
|
+
}
|
|
15
|
+
/** The include_owner argument for send-type tools; undefined when the caller is known to be an owner's own address. */
|
|
16
|
+
export function includeOwnerSchema(address, owner) {
|
|
17
|
+
if (address && !owner)
|
|
18
|
+
return undefined;
|
|
19
|
+
return z.boolean().default(true).describe(owner ? `also send to your owner ${owner}` : "also send to your owner, if whoami shows one");
|
|
20
|
+
}
|
|
21
|
+
/** The recipients with the caller's owner appended, unless include_owner is false (unknown: the argument is only registered for possible sub-addresses) or they are already present. */
|
|
22
|
+
export function withOwner(caller, recipients, includeOwner) {
|
|
23
|
+
const owner = caller.owner;
|
|
24
|
+
if (!owner || includeOwner === false || recipients.some((r) => sameAddress(r, owner)))
|
|
25
|
+
return recipients;
|
|
26
|
+
return [...recipients, owner];
|
|
27
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export declare const SERVER_NAME = "fmsg";
|
|
|
5
5
|
export type CreateServerOptions = {
|
|
6
6
|
/** The caller's address when already known; makes the instructions name it. */
|
|
7
7
|
address?: string;
|
|
8
|
+
/** The address that owns the caller's sub-address, when known; the instructions and descriptions then name it. */
|
|
9
|
+
owner?: string;
|
|
8
10
|
/** Aborted when the process begins shutting down, so wait_for_message returns "interrupted". */
|
|
9
11
|
shutdown?: AbortSignal;
|
|
10
12
|
};
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { buildInstructions } from "./instructions.js";
|
|
3
|
+
import { includeOwnerSchema } from "./owner.js";
|
|
3
4
|
import { registerPrompts } from "./prompts.js";
|
|
4
5
|
import { registerResources } from "./resources.js";
|
|
5
6
|
import { fileRoutes } from "./tools/attachments.js";
|
|
@@ -20,11 +21,15 @@ export const SERVER_NAME = "fmsg";
|
|
|
20
21
|
export function createFmsgMcpServer(provider, config, options = {}) {
|
|
21
22
|
const instructions = buildInstructions({
|
|
22
23
|
...(options.address ? { address: options.address } : {}),
|
|
24
|
+
...(options.owner ? { owner: options.owner } : {}),
|
|
23
25
|
...(config.defaultDomain ? { defaultDomain: config.defaultDomain } : {}),
|
|
24
26
|
files: fileRoutes(config),
|
|
25
27
|
});
|
|
26
28
|
const server = new McpServer({ name: SERVER_NAME, title: "fmsg", version: VERSION }, { instructions });
|
|
27
|
-
const
|
|
29
|
+
const includeOwner = includeOwnerSchema(options.address, options.owner);
|
|
30
|
+
const deps = {
|
|
31
|
+
provider, config, ...(includeOwner ? { includeOwner } : {}), ...(options.shutdown ? { shutdown: options.shutdown } : {}),
|
|
32
|
+
};
|
|
28
33
|
registerIdentityTools(server, deps);
|
|
29
34
|
registerListTools(server, deps);
|
|
30
35
|
registerReadTools(server, deps);
|
package/dist/tools/common.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { type Caller, type CallerProvider } from "../context.js";
|
|
|
7
7
|
export type ToolDeps = {
|
|
8
8
|
provider: CallerProvider;
|
|
9
9
|
config: Config;
|
|
10
|
+
/** The include_owner argument of send-type tools; absent when the caller is known to be an owner's own address. */
|
|
11
|
+
includeOwner?: z.ZodDefault<z.ZodBoolean>;
|
|
10
12
|
/** Aborted when the server begins shutting down; long waits then return early. */
|
|
11
13
|
shutdown?: AbortSignal;
|
|
12
14
|
};
|
package/dist/tools/draft.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
2
|
import { downloadBaseUrl } from "../download.js";
|
|
3
3
|
import { toolError } from "../errors.js";
|
|
4
|
+
import { withOwner } from "../owner.js";
|
|
4
5
|
import { addressList, headerValue, isoTime } from "../render.js";
|
|
5
6
|
import { CHECKSUM_HEADER, UPLOAD_ENCODINGS, draftUploadUrlTemplate } from "../upload.js";
|
|
6
7
|
import { attachmentInput, base64Hint, draftWithAttachments, fileRoutes, prepareAttachments } from "./attachments.js";
|
|
@@ -10,6 +11,7 @@ import { IMMUTABLE, IMPORTANT, NO_REPLY, UPLOAD_STEPS, hintOutput, redactionNote
|
|
|
10
11
|
export const registerDraftTools = (server, deps) => {
|
|
11
12
|
const baseUrl = downloadBaseUrl(deps.config);
|
|
12
13
|
const routes = fileRoutes(deps.config);
|
|
14
|
+
const includeOwner = deps.includeOwner ? { include_owner: deps.includeOwner } : {};
|
|
13
15
|
server.registerTool("create_draft", {
|
|
14
16
|
title: "Create fmsg draft",
|
|
15
17
|
description: "Create an unsent draft: a new message, or a reply when reply_to is given (reply rules and reply-all apply). " +
|
|
@@ -25,6 +27,7 @@ export const registerDraftTools = (server, deps) => {
|
|
|
25
27
|
important: z.boolean().default(false).describe(IMPORTANT),
|
|
26
28
|
no_reply: z.boolean().default(false).describe(NO_REPLY),
|
|
27
29
|
allow_no_reply: z.boolean().default(false).describe("draft a reply even though the parent asked for no replies"),
|
|
30
|
+
...includeOwner,
|
|
28
31
|
attachments: z.array(attachmentInput(deps.config)).optional().describe("files to attach now"),
|
|
29
32
|
}),
|
|
30
33
|
outputSchema: outputObject({
|
|
@@ -48,7 +51,7 @@ export const registerDraftTools = (server, deps) => {
|
|
|
48
51
|
hint: hintOutput,
|
|
49
52
|
}),
|
|
50
53
|
annotations: { ...SENDS, destructiveHint: false },
|
|
51
|
-
}, async ({ to, topic, reply_to, body, type, important, no_reply, allow_no_reply, attachments }, ctx) => {
|
|
54
|
+
}, async ({ to, topic, reply_to, body, type, important, no_reply, allow_no_reply, include_owner, attachments }, ctx) => {
|
|
52
55
|
if (reply_to && topic)
|
|
53
56
|
return toolError("a reply has no topic: pass topic only for a new message");
|
|
54
57
|
if (!reply_to && !to?.length)
|
|
@@ -68,6 +71,7 @@ export const registerDraftTools = (server, deps) => {
|
|
|
68
71
|
else {
|
|
69
72
|
({ to: recipients, resolved } = resolveRecipients(to, resolverFor(deps, caller)));
|
|
70
73
|
}
|
|
74
|
+
recipients = withOwner(caller, recipients, include_owner);
|
|
71
75
|
const outbound = await prepareAttachments(attachments, deps.config);
|
|
72
76
|
const hint = base64Hint(attachments, routes);
|
|
73
77
|
const draft = await draftWithAttachments(caller.client, {
|
package/dist/tools/identity.js
CHANGED
|
@@ -9,10 +9,11 @@ const TRANSPORT_LABELS = { stdio: "stdio", http: "Streamable HTTP" };
|
|
|
9
9
|
export const registerIdentityTools = (server, deps) => {
|
|
10
10
|
server.registerTool("whoami", {
|
|
11
11
|
title: "Show fmsg identity",
|
|
12
|
-
description: "Report the fmsg address this server acts as, the fmsg Web API URL and how
|
|
13
|
-
"also gives the server version. Call this first if unsure who you are sending as.",
|
|
12
|
+
description: "Report the fmsg address this server acts as, its owner if it is a sub-address, the fmsg Web API URL and how " +
|
|
13
|
+
"short names resolve; the text also gives the server version. Call this first if unsure who you are sending as.",
|
|
14
14
|
outputSchema: outputObject({
|
|
15
15
|
address: z.string(),
|
|
16
|
+
owner: z.string().optional().describe("owner of this sub-address, included as a recipient of messages you send unless include_owner is false; absent for an owner's own address"),
|
|
16
17
|
api_url: z.string().nullable(),
|
|
17
18
|
token_expires_at: z.string().nullable().describe("internal: when the server's current upstream access token expires; it is renewed automatically, so no action is needed"),
|
|
18
19
|
transport: openEnum(["stdio", "http"], "http is Streamable HTTP"),
|
|
@@ -27,6 +28,7 @@ export const registerIdentityTools = (server, deps) => {
|
|
|
27
28
|
const transport = TRANSPORT_LABELS[deps.config.transport];
|
|
28
29
|
const structured = {
|
|
29
30
|
address: caller.address,
|
|
31
|
+
...(caller.owner ? { owner: caller.owner } : {}),
|
|
30
32
|
api_url: deps.config.apiPublicUrl ?? null,
|
|
31
33
|
token_expires_at: expires,
|
|
32
34
|
transport: deps.config.transport,
|
|
@@ -39,6 +41,8 @@ export const registerIdentityTools = (server, deps) => {
|
|
|
39
41
|
: `You are ${addressText(caller.address)}, connected over ${transport}.`,
|
|
40
42
|
`Access is renewed automatically. Server version ${VERSION}.`,
|
|
41
43
|
];
|
|
44
|
+
if (caller.owner)
|
|
45
|
+
lines.push(`Sub-address of ${addressText(caller.owner)}, who is a recipient of messages you send unless include_owner is false.`);
|
|
42
46
|
if (defaultDomain)
|
|
43
47
|
lines.push(`Short names resolve to @name@${defaultDomain}.`);
|
|
44
48
|
if (directoryNames.length)
|
package/dist/tools/send.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
2
|
import { resolveAddress, sameAddress } from "../address.js";
|
|
3
3
|
import { toolError } from "../errors.js";
|
|
4
|
+
import { withOwner } from "../owner.js";
|
|
4
5
|
import { addressList, addressText, attachmentType, headerValue, isoTime, participantsOf, sortAttachments } from "../render.js";
|
|
5
6
|
import { attachmentInput, base64Hint, fileRoutes, prepareAttachments, sendWithAttachments } from "./attachments.js";
|
|
6
7
|
import { SENDS, attachmentItem, idSchema, ok, outputObject, resolverFor, withCaller } from "./common.js";
|
|
@@ -99,6 +100,7 @@ export function resolvedNote(resolved) {
|
|
|
99
100
|
export const registerSendTools = (server, deps) => {
|
|
100
101
|
const attachments = z.array(attachmentInput(deps.config)).optional().describe("files");
|
|
101
102
|
const guidance = attachmentGuidance(deps.config);
|
|
103
|
+
const includeOwner = deps.includeOwner ? { include_owner: deps.includeOwner } : {};
|
|
102
104
|
const routes = fileRoutes(deps.config);
|
|
103
105
|
server.registerTool("send_message", {
|
|
104
106
|
title: "Send new fmsg message",
|
|
@@ -113,12 +115,14 @@ export const registerSendTools = (server, deps) => {
|
|
|
113
115
|
type: z.string().default("text/markdown; charset=utf-8").describe("body media type"),
|
|
114
116
|
important: z.boolean().default(false).describe(IMPORTANT),
|
|
115
117
|
no_reply: z.boolean().default(false).describe(NO_REPLY),
|
|
118
|
+
...includeOwner,
|
|
116
119
|
attachments,
|
|
117
120
|
}),
|
|
118
121
|
outputSchema: sentOutput,
|
|
119
122
|
annotations: SENDS,
|
|
120
|
-
}, async ({ to, topic, body, type, important, no_reply, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
121
|
-
const { to:
|
|
123
|
+
}, async ({ to, topic, body, type, important, no_reply, include_owner, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
124
|
+
const { to: resolvedTo, resolved } = resolveRecipients(to, resolverFor(deps, caller));
|
|
125
|
+
const recipients = withOwner(caller, resolvedTo, include_owner);
|
|
122
126
|
const outbound = await prepareAttachments(attachments, deps.config);
|
|
123
127
|
const hint = base64Hint(attachments, routes);
|
|
124
128
|
const sent = await sendWithAttachments(caller.client, { to: recipients, topic, body, type, important, noReply: no_reply }, outbound, signal);
|
|
@@ -156,16 +160,18 @@ export const registerSendTools = (server, deps) => {
|
|
|
156
160
|
important: z.boolean().default(false).describe(IMPORTANT),
|
|
157
161
|
no_reply: z.boolean().default(false).describe(NO_REPLY),
|
|
158
162
|
allow_no_reply: z.boolean().default(false).describe("reply even though the parent asked for no replies"),
|
|
163
|
+
...includeOwner,
|
|
159
164
|
attachments,
|
|
160
165
|
}),
|
|
161
166
|
outputSchema: sentOutput,
|
|
162
167
|
annotations: SENDS,
|
|
163
|
-
}, async ({ id, body, recipients, type, important, no_reply, allow_no_reply, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
168
|
+
}, async ({ id, body, recipients, type, important, no_reply, allow_no_reply, include_owner, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
164
169
|
const parent = await caller.client.getMessage(id, signal);
|
|
165
170
|
const target = replyRecipients(deps, caller, parent, recipients, allow_no_reply);
|
|
166
171
|
if ("content" in target)
|
|
167
172
|
return target;
|
|
168
|
-
const {
|
|
173
|
+
const { resolved } = target;
|
|
174
|
+
const to = withOwner(caller, target.to, include_owner);
|
|
169
175
|
const outbound = await prepareAttachments(attachments, deps.config);
|
|
170
176
|
const hint = base64Hint(attachments, routes);
|
|
171
177
|
const sent = await sendWithAttachments(caller.client, { to, pid: parent.id, body, type, important, noReply: no_reply }, outbound, signal);
|
package/docs/token-providers.md
CHANGED
|
@@ -16,6 +16,7 @@ interface TokenProvider {
|
|
|
16
16
|
}): Promise<{
|
|
17
17
|
accessToken: string;
|
|
18
18
|
address: string;
|
|
19
|
+
owner?: string;
|
|
19
20
|
expiresAtMs: number;
|
|
20
21
|
}>;
|
|
21
22
|
close?(): void;
|
|
@@ -36,6 +37,8 @@ Sharing a provider between clients risks mixing grants or closing another client
|
|
|
36
37
|
case, and a finite future expiry in milliseconds since the Unix epoch. Use trusted
|
|
37
38
|
authorization/exchange metadata; do not invent an expiry or infer identity from an
|
|
38
39
|
unverified incoming token. The generic client does not decode provider tokens.
|
|
40
|
+
- Optionally return `owner`, the address that owns `address` when it is a sub-address (the Web
|
|
41
|
+
API's `owner` claim); omit it for an owner's own address. `FmsgClient.owner()` exposes it.
|
|
39
42
|
- Honour `signal` for all network work. It aborts when renewal times out, the client
|
|
40
43
|
closes, or all callers waiting for that renewal cancel. Reject on failed/revoked
|
|
41
44
|
authorization; never fall back to an owner token, API key or another grant.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markmnl/fmsg-mcp",
|
|
3
3
|
"mcpName": "io.github.markmnl/fmsg-mcp",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.0",
|
|
5
5
|
"description": "MCP server for fmsg: send and receive federated messages from any AI agent via a deployed fmsg Web API",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
package/server.json
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
"url": "https://github.com/markmnl/fmsg-mcp",
|
|
9
9
|
"source": "github"
|
|
10
10
|
},
|
|
11
|
-
"version": "1.
|
|
11
|
+
"version": "1.2.0",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
16
16
|
"identifier": "@markmnl/fmsg-mcp",
|
|
17
|
-
"version": "1.
|
|
17
|
+
"version": "1.2.0",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|