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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

- Add `FMSG_DEFAULT_DOMAIN=caller` (case-insensitive): short names resolve on the domain of the
address the server acts as for each request, so a deployment serving callers on several domains
resolves `bob` to `@bob@example.org` for `@mark@example.org` and `@bob@example.net` for a caller
on `example.net`. `whoami`'s `default_domain` and the server instructions show the caller's
domain. Over stdio, `resolve_address` first looks up the configured key's address. Directory
entries still take precedence; a fixed or unset default domain is unchanged.
- Add HTTP `FMSG_MCP_AUTH_MODE=oauth+api-key`, serving OAuth and API-key callers on one endpoint.
`Bearer fmsgk_...` requests use API-key authentication; all others, including unauthenticated
requests, use OAuth with its discovery challenge and scope checks. Providers and caches stay
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
| `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key`, `oauth`, or `oauth+api-key` to accept both on one endpoint; see [OAuth settings](docs/oauth.md#operator-configuration) and [combined mode](docs/oauth.md#combined-oauth-and-api-key-mode) |
| `FMSG_MCP_PUBLIC_URL` | OAuth resource URL, otherwise unset | Public MCP endpoint, including `/mcp`; enables the HTTP download-link tool. HTTPS required except loopback; when OAuth is enabled must equal `FMSG_MCP_OAUTH_RESOURCE_URL` |
| `FMSG_ALLOW_INSECURE_HTTP` | disabled | Set to `1` only to permit cleartext API access on a trusted development/private network; loopback HTTP is allowed by default |
| `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>` |
| `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>`. `caller` resolves them on the domain of the address the server acts as for each request, so `@mark@example.org` gets `@bob@example.org`; directory entries still take precedence |
| `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses |
| `FMSG_MCP_DOWNLOAD_DIR` | — | Enable `save_attachment` in stdio; folder for new files named from message ID and filename |
| `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call |
Expand Down
5 changes: 5 additions & 0 deletions docs/http-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ npx -y @markmnl/fmsg-mcp --http 127.0.0.1:8765

`FMSG_API_KEY` must be unset in HTTP mode. Include only browser origins you use.

When callers have addresses on several domains, set `FMSG_DEFAULT_DOMAIN=caller` rather than one
fixed domain: short names then resolve on each caller's own domain, so `bob` is `@bob@example.org`
for `@mark@example.org` and `@bob@example.net` for a caller on `example.net`. `whoami` and the
server instructions show that caller's domain. `FMSG_DIRECTORY` entries still take precedence.

If the server reaches the Web API at an internal address instead, for example
`FMSG_API_URL=http://10.0.0.5:8000` with `FMSG_ALLOW_INSECURE_HTTP=1` on a private network, also set
`FMSG_API_PUBLIC_URL=https://api.example.com` so `whoami` reports the URL users know. Without it,
Expand Down
25 changes: 22 additions & 3 deletions src/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,36 @@ export function isFmsgAddress(value: string): boolean {
return normalizeFmsgAddress(value) !== undefined;
}

/** Reserved FMSG_DEFAULT_DOMAIN value: short names resolve on the caller's own domain. */
export const CALLER_DOMAIN = "caller";

/**
* The domain short names resolve to: the configured default domain, or with
* `caller` the domain of the address the server acts as. Undefined when neither
* is known (no default domain, or `caller` before the caller address is known).
*/
export function effectiveDefaultDomain(defaultDomain: string | undefined, callerAddress?: string): string | undefined {
if (defaultDomain !== CALLER_DOMAIN) return defaultDomain;
const match = callerAddress ? ADDRESS.exec(callerAddress.trim()) : null;
return match ? match[2]!.toLowerCase() : undefined;
}

export type Resolution = "literal" | "directory" | "default_domain";

export type AddressResolver = {
/** A domain, or CALLER_DOMAIN to use the domain of `callerAddress`. */
defaultDomain?: string;
directory?: Record<string, string>;
/** The address the server acts as, when known. */
callerAddress?: string;
};

export type ResolvedAddress = { address: string; resolution: Resolution };

/**
* Resolve a full address or short name: literal `@user@domain` first, then a
* configured directory entry, then `@name@<default domain>`.
* configured directory entry, then `@name@<default domain>` (the caller's own
* domain when the default domain is `caller`).
*/
export function resolveAddress(name: string, resolver: AddressResolver = {}): ResolvedAddress {
const trimmed = name.trim();
Expand All @@ -51,8 +69,9 @@ export function resolveAddress(name: string, resolver: AddressResolver = {}): Re
return { address, resolution: "directory" };
}
}
if (resolver.defaultDomain) {
const address = normalizeFmsgAddress(`@${trimmed}@${resolver.defaultDomain}`);
const domain = effectiveDefaultDomain(resolver.defaultDomain, resolver.callerAddress);
if (domain) {
const address = normalizeFmsgAddress(`@${trimmed}@${domain}`);
if (address) return { address, resolution: "default_domain" };
}
throw new Error(
Expand Down
6 changes: 4 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync } from "node:fs";
import { normalizeFmsgAddress } from "./address.js";
import { CALLER_DOMAIN, normalizeFmsgAddress } from "./address.js";
import { normalizeApiUrl, normalizeOrigin } from "./client/url.js";
import { loadOAuthConfig, oauthUrl, type OAuthConfig } from "./oauth/config.js";

Expand Down Expand Up @@ -31,6 +31,7 @@ export type Config = {
allowInsecureHttp?: boolean;
/** Only set in stdio mode. */
apiKey?: string;
/** Domain for short names, or CALLER_DOMAIN for each caller's own domain. */
defaultDomain?: string;
directory?: Record<string, string>;
/** Trusted local destination; enables save_attachment over stdio only. */
Expand Down Expand Up @@ -136,7 +137,8 @@ export function loadConfig(
);
}

const defaultDomain = env.FMSG_DEFAULT_DOMAIN?.trim().replace(/^@/u, "") || undefined;
const rawDefaultDomain = env.FMSG_DEFAULT_DOMAIN?.trim().replace(/^@/u, "") || undefined;
const defaultDomain = rawDefaultDomain?.toLowerCase() === CALLER_DOMAIN ? CALLER_DOMAIN : rawDefaultDomain;
const directoryPath = env.FMSG_DIRECTORY?.trim();

const port = overrides.port ?? intEnv(env, "FMSG_MCP_PORT", DEFAULT_HTTP_PORT, 0);
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ Environment:
FMSG_MCP_OAUTH_* OAuth resource/issuer URLs, client ID/secret and exchange
audience; see docs/oauth.md for required settings
FMSG_ALLOW_INSECURE_HTTP 1 to allow a trusted private HTTP API outside loopback
FMSG_DEFAULT_DOMAIN lets short names resolve: bob -> @bob@<domain>
FMSG_DEFAULT_DOMAIN lets short names resolve: bob -> @bob@<domain>; caller
uses the domain of each caller's own address
FMSG_DIRECTORY JSON file mapping short names to @user@domain
FMSG_MCP_DOWNLOAD_DIR enables save_attachment to this local folder (stdio)
FMSG_MCP_WAIT_MAX_SECONDS cap on one wait_for_message call (default 230)
Expand Down
12 changes: 9 additions & 3 deletions src/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@
* fmsg access paths, the irreversible-send rule, and the usage facts a model
* otherwise gets wrong. Per-tool detail lives in the tool descriptions.
*/
import { CALLER_DOMAIN, effectiveDefaultDomain } from "./address.js";

export type InstructionsContext = {
/** The address this server acts as, when already known (HTTP callers; stdio after a token exchange). */
address?: string;
/** FMSG_DEFAULT_DOMAIN: a domain, or CALLER_DOMAIN for the caller's own domain. */
defaultDomain?: string;
};

export function buildInstructions(ctx: InstructionsContext = {}): string {
const identity = ctx.address
? `you are acting as ${ctx.address}`
: "call whoami to see which";
const shortNames = ctx.defaultDomain
? `; short names resolve to @name@${ctx.defaultDomain}`
: "";
const domain = effectiveDefaultDomain(ctx.defaultDomain, ctx.address);
const shortNames = domain
? `; short names resolve to @name@${domain}`
: ctx.defaultDomain === CALLER_DOMAIN
? "; short names resolve to @name@<your domain>, the domain of the address you act as"
: "";
return [
`This server sends and receives fmsg messages as one fmsg address: ${identity}. ` +
"Use its tools for everything fmsg: inbox, threads, attachments, sending, replying, reactions, " +
Expand Down
6 changes: 6 additions & 0 deletions src/tools/common.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CallToolResult, McpServer, ServerContext, ToolAnnotations } from "@modelcontextprotocol/server";
import * as z from "zod/v4";
import type { AddressResolver } from "../address.js";
import type { FmsgMessage, RecipientDelivery } from "../client/types.js";
import type { Config } from "../config.js";
import { type Caller, type CallerProvider, callerFor } from "../context.js";
Expand Down Expand Up @@ -85,6 +86,11 @@ export function ok(text: string, structured: Record<string, unknown>): CallToolR
return { content: [{ type: "text", text }], structuredContent: structured };
}

/** Short-name resolution for this caller: the configured directory and default domain. */
export function resolverFor(deps: ToolDeps, caller: Caller): AddressResolver {
return { ...deps.config, callerAddress: caller.address };
}

/** Resolve the caller and run a tool body, turning any failure into an `isError` result. */
export async function withCaller(
deps: ToolDeps,
Expand Down
28 changes: 17 additions & 11 deletions src/tools/identity.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as z from "zod/v4";
import { resolveAddress } from "../address.js";
import { type AddressResolver, CALLER_DOMAIN, effectiveDefaultDomain, resolveAddress } from "../address.js";
import { isoTime } from "../render.js";
import { READ_ONLY, type Register, ok, withCaller } from "./common.js";
import { READ_ONLY, type Register, ok, resolverFor, withCaller } from "./common.js";
import { toolError } from "../errors.js";

export const registerIdentityTools: Register = (server, deps) => {
Expand All @@ -27,12 +27,13 @@ export const registerIdentityTools: Register = (server, deps) => {
async (ctx) =>
withCaller(deps, ctx, async (caller) => {
const expires = isoTime((await caller.tokenExpiresAt()) / 1000);
const defaultDomain = effectiveDefaultDomain(deps.config.defaultDomain, caller.address);
const structured = {
address: caller.address,
api_url: deps.config.apiPublicUrl ?? null,
token_expires_at: expires,
transport: deps.config.transport,
default_domain: deps.config.defaultDomain ?? null,
default_domain: defaultDomain ?? null,
directory_names: Object.keys(deps.config.directory ?? {}),
};
const lines = [
Expand All @@ -41,7 +42,7 @@ export const registerIdentityTools: Register = (server, deps) => {
: `You are **${caller.address}** (${deps.config.transport}).`,
`Access token expires ${expires ?? "unknown"} and is renewed automatically.`,
];
if (deps.config.defaultDomain) lines.push(`Short names resolve to @name@${deps.config.defaultDomain}.`);
if (defaultDomain) lines.push(`Short names resolve to @name@${defaultDomain}.`);
if (structured.directory_names.length) lines.push(`Directory names: ${structured.directory_names.join(", ")}.`);
return ok(lines.join("\n"), structured);
}),
Expand All @@ -59,13 +60,18 @@ export const registerIdentityTools: Register = (server, deps) => {
outputSchema: z.object({ address: z.string(), resolution: z.enum(["literal", "directory", "default_domain"]) }),
annotations: { ...READ_ONLY, openWorldHint: false },
},
async ({ name }) => {
try {
const resolved = resolveAddress(name, deps.config);
return ok(`${name} → ${resolved.address} (${resolved.resolution})`, resolved);
} catch (error) {
return toolError(error instanceof Error ? error.message : String(error));
}
async ({ name }, ctx) => {
const resolve = (resolver: AddressResolver) => {
try {
const resolved = resolveAddress(name, resolver);
return ok(`${name} → ${resolved.address} (${resolved.resolution})`, resolved);
} catch (error) {
return toolError(error instanceof Error ? error.message : String(error));
}
};
// Only the caller's own domain needs the caller; otherwise resolution is local.
if (deps.config.defaultDomain !== CALLER_DOMAIN) return resolve(deps.config);
return withCaller(deps, ctx, async (caller) => resolve(resolverFor(deps, caller)));
},
);
};
8 changes: 4 additions & 4 deletions src/tools/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { resolveAddresses, sameAddress } from "../address.js";
import type { OutboundAttachment } from "../client/types.js";
import { toolError } from "../errors.js";
import { isoTime, participantsOf } from "../render.js";
import { READ_ONLY, SENDS, type Register, idSchema, ok, withCaller } from "./common.js";
import { READ_ONLY, SENDS, type Register, idSchema, ok, resolverFor, withCaller } from "./common.js";

const IMMUTABLE = "fmsg messages are immutable: once sent they cannot be edited or recalled. Send within the user's requested task or authorized automation.";

Expand Down Expand Up @@ -57,7 +57,7 @@ export const registerSendTools: Register = (server, deps) => {
},
async ({ to, topic, body, type, important, no_reply, attachments }, ctx) =>
withCaller(deps, ctx, async (caller, signal) => {
const recipients = resolveAddresses(to, deps.config);
const recipients = resolveAddresses(to, resolverFor(deps, caller));
const sent = await caller.client.send({
to: recipients,
topic,
Expand Down Expand Up @@ -117,7 +117,7 @@ export const registerSendTools: Register = (server, deps) => {
}
const warnings: string[] = [];
const to = recipients?.length
? resolveAddresses(recipients, deps.config)
? resolveAddresses(recipients, resolverFor(deps, caller))
: participantsOf(parent).filter((a) => !sameAddress(a, caller.address));
if (to.length === 0) return toolError(`message ${id} has no other participants to reply to; pass recipients`);
const sent = await caller.client.send({
Expand Down Expand Up @@ -163,7 +163,7 @@ export const registerSendTools: Register = (server, deps) => {
},
async ({ id, add_to }, ctx) =>
withCaller(deps, ctx, async (caller, signal) => {
const addresses = resolveAddresses(add_to, deps.config);
const addresses = resolveAddresses(add_to, resolverFor(deps, caller));
const result = await caller.client.addRecipients(id, addresses, signal);
return ok(`Added ${result.added} recipient(s) to message ${id}: ${addresses.join(", ")}`, { ...result, add_to: addresses });
}),
Expand Down
4 changes: 2 additions & 2 deletions src/tools/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { resolveAddress } from "../address.js";
import { assembleThread, renderThread } from "../thread.js";
import { DATA_NOT_INSTRUCTIONS, fence, headerValue, messageData, messageLine } from "../render.js";
import { waitForMessage } from "../wait.js";
import { READ_ONLY, type Register, idSchema, messageItem, ok, toItem, withCaller } from "./common.js";
import { READ_ONLY, type Register, idSchema, messageItem, ok, resolverFor, toItem, withCaller } from "./common.js";

export const registerWaitTools: Register = (server, deps) => {
const maxWait = deps.config.waitMaxSeconds;
Expand Down Expand Up @@ -56,7 +56,7 @@ export const registerWaitTools: Register = (server, deps) => {
{
...(after_id !== undefined ? { afterId: after_id } : {}),
...(thread_of !== undefined ? { threadOf: thread_of } : {}),
...(from !== undefined ? { from: resolveAddress(from, deps.config).address } : {}),
...(from !== undefined ? { from: resolveAddress(from, resolverFor(deps, caller)).address } : {}),
timeoutMs: timeout_seconds * 1000,
settleMs: settle_seconds * 1000,
onTick: (elapsed) => {
Expand Down
Loading
Loading