@markmnl/fmsg-mcp 1.1.1 → 1.2.1

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 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 = { key, caller: { client, address, tokenExpiresAt: async () => (await client.getToken()).expiresAtMs }, lastUsed: Date.now(), active: 0 };
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), extra: { cacheKey: key, callerLease: lease },
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) {
@@ -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. */
@@ -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 token = Object.freeze({ accessToken: value.accessToken, address, expiresAtMs: value.expiresAtMs });
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) {
@@ -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
- return { client: this.client, address, tokenExpiresAt: async () => (await this.client.getToken()).expiresAtMs };
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, { ...(authInfo ? { address: address(authInfo) } : {}), shutdown: shutdown.signal }), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
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 knownAddress = async () => {
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), timeout]).catch(() => undefined);
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 address = await knownAddress();
137
- return createFmsgMcpServer(provider, cfg, { ...(address ? { address } : {}), shutdown: shutdown.signal });
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.
@@ -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. */
@@ -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 routine, how to
6
- * send files, the irreversible-send rule and untrusted content. Per-tool detail lives in the tool
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
- "Use its tools for everything fmsg; other fmsg tools or local credentials may act as a different address.",
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 " +
@@ -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 deps = { provider, config, ...(options.shutdown ? { shutdown: options.shutdown } : {}) };
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);
@@ -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
  };
@@ -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, {
@@ -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 short names resolve; the text " +
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)
@@ -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: recipients, resolved } = resolveRecipients(to, resolverFor(deps, caller));
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 { to, resolved } = target;
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);
@@ -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.1.1",
4
+ "version": "1.2.1",
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.1.1",
11
+ "version": "1.2.1",
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.1.1",
17
+ "version": "1.2.1",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },