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
25 changes: 25 additions & 0 deletions apps/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,31 @@ See
[docs/mcp-client-setup.md](https://github.com/marmar9615-cloud/agentbridge-protocol/blob/main/docs/mcp-client-setup.md)
for everything else.

### HTTP transport (experimental, opt-in)

Hosted/centralized MCP clients that cannot launch a local
subprocess can use the opt-in **Streamable HTTP** transport.
stdio remains the default.

```bash
export AGENTBRIDGE_TRANSPORT=http
export AGENTBRIDGE_HTTP_AUTH_TOKEN=$(openssl rand -hex 32)
# Optional: only needed if a browser-based MCP client will connect.
export AGENTBRIDGE_HTTP_ALLOWED_ORIGINS=http://localhost:5173
npx -y @marmarlabs/agentbridge-mcp-server
# → listens on http://127.0.0.1:3333/mcp
```

Clients send `Authorization: Bearer <token>`. Tokens in URL
query strings are rejected with `400`. Default bind is loopback;
non-loopback bind requires both auth and an Origin allowlist or
the server fails closed at startup. Full env-var table:
[docs/security-configuration.md](https://github.com/marmar9615-cloud/agentbridge-protocol/blob/main/docs/security-configuration.md).

> The HTTP transport is **experimental** in v0.4.0. See
> [docs/production-readiness.md](https://github.com/marmar9615-cloud/agentbridge-protocol/blob/main/docs/production-readiness.md)
> for what AgentBridge is and isn't safe for today.

## Tools exposed

| Tool | Purpose |
Expand Down
122 changes: 122 additions & 0 deletions apps/mcp-server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,42 @@
// allowed range so that an operator can tune for their environment without
// being able to weaken the safety story (e.g. setting an absurd 0ms timeout
// or a 10GB response cap).
//
// Two layers:
// resolveConfig() — universal runtime config, used by both transports.
// resolveTransport() — transport selection (stdio vs http).
// resolveHttpConfig() — HTTP-transport-specific config. Parses env vars
// but does NOT enforce "must be set" — the HTTP
// adapter validates the combination at startup so
// the failure message can be specific.

const DEFAULTS = {
ACTION_TIMEOUT_MS: 10_000,
MAX_RESPONSE_BYTES: 1_000_000,
CONFIRMATION_TTL_SECONDS: 5 * 60,
HTTP_HOST: "127.0.0.1",
HTTP_PORT: 3333,
} as const;

const BOUNDS = {
ACTION_TIMEOUT_MS: { min: 1_000, max: 120_000 },
MAX_RESPONSE_BYTES: { min: 1024, max: 10 * 1024 * 1024 },
CONFIRMATION_TTL_SECONDS: { min: 30, max: 3600 },
// 0 is allowed because tests need ephemeral ports and there is no real
// production reason to reject it. Out-of-range values (negative, > 65535)
// fall back to the default with a stderr warning.
HTTP_PORT: { min: 0, max: 65_535 },
} as const;

// Hosts treated as loopback / safe-by-default. Anything else is "public bind"
// and the HTTP adapter requires both auth and an Origin allowlist.
export const LOOPBACK_HOSTS: ReadonlySet<string> = new Set([
"127.0.0.1",
"localhost",
"::1",
"[::1]",
]);

function readClampedInt(
envVar: string,
raw: string | undefined,
Expand Down Expand Up @@ -83,5 +106,104 @@ export function resolveConfig(opts: ResolveConfigOptions = {}): ResolvedConfig {
};
}

export type TransportKind = "stdio" | "http";

/**
* Pick the transport. Defaults to stdio; HTTP is opt-in via
* AGENTBRIDGE_TRANSPORT=http. Unknown values fall back to stdio with a
* stderr warning so a typo does not silently expose an HTTP listener.
*/
export function resolveTransport(opts: ResolveConfigOptions = {}): TransportKind {
const env = opts.env ?? process.env;
const warn = opts.warn ?? ((msg: string) => process.stderr.write(`${msg}\n`));
const raw = env.AGENTBRIDGE_TRANSPORT;
if (raw === undefined || raw === "") return "stdio";
const normalized = raw.trim().toLowerCase();
if (normalized === "stdio" || normalized === "http") return normalized;
warn(
`[agentbridge] AGENTBRIDGE_TRANSPORT="${raw}" is not one of "stdio" | "http"; falling back to stdio.`,
);
return "stdio";
}

export interface HttpConfig {
host: string;
/** True when host is a loopback address. False is "public bind" and forces stricter validation in the HTTP adapter. */
isLoopbackBind: boolean;
port: number;
/** Bearer token from AGENTBRIDGE_HTTP_AUTH_TOKEN. Undefined means "operator did not set one"; the HTTP adapter rejects http-mode startup in that case. */
authToken: string | undefined;
/** Set of allowed inbound Origin headers, parsed from AGENTBRIDGE_HTTP_ALLOWED_ORIGINS. `null` means "operator did not set the env var"; an empty Set means "set but empty after parsing." Only requests carrying an Origin header consult this set; non-browser clients with no Origin still go through bearer auth. */
allowedOrigins: ReadonlySet<string> | null;
}

/**
* Parse the AGENTBRIDGE_HTTP_* env vars. Pure function — does NOT decide
* whether http mode is allowed. The HTTP adapter (transports/http.ts)
* runs the safety check ("auth required for http; public bind needs both
* auth and origins") at startup and emits a transport-specific error
* message there.
*/
export function resolveHttpConfig(opts: ResolveConfigOptions = {}): HttpConfig {
const env = opts.env ?? process.env;
const warn = opts.warn ?? ((msg: string) => process.stderr.write(`${msg}\n`));

const rawHost = env.AGENTBRIDGE_HTTP_HOST;
const host = rawHost && rawHost.trim() !== "" ? rawHost.trim() : DEFAULTS.HTTP_HOST;
const isLoopbackBind = LOOPBACK_HOSTS.has(host);

const port = readClampedInt(
"AGENTBRIDGE_HTTP_PORT",
env.AGENTBRIDGE_HTTP_PORT,
DEFAULTS.HTTP_PORT,
BOUNDS.HTTP_PORT,
warn,
);

// Auth token is read raw; do NOT log its value or contents on parse failure.
// The HTTP adapter validates "is this set when http transport is selected".
const rawToken = env.AGENTBRIDGE_HTTP_AUTH_TOKEN;
const authToken = rawToken && rawToken !== "" ? rawToken : undefined;

const allowedOrigins = parseAllowedOrigins(env.AGENTBRIDGE_HTTP_ALLOWED_ORIGINS, warn);

return { host, isLoopbackBind, port, authToken, allowedOrigins };
}

function parseAllowedOrigins(
raw: string | undefined,
warn: (msg: string) => void,
): ReadonlySet<string> | null {
if (raw === undefined) return null;
const trimmed = raw.trim();
if (trimmed === "") return null;
const out = new Set<string>();
for (const piece of trimmed.split(",")) {
const candidate = piece.trim();
if (candidate === "") continue;
let url: URL;
try {
url = new URL(candidate);
} catch {
// Skip malformed origins with a clear stderr warning rather than
// crashing the process. The empty/effectively-empty result causes
// the HTTP adapter to reject every request that supplies an Origin
// header, which is the conservative fail-closed posture.
warn(
`[agentbridge] AGENTBRIDGE_HTTP_ALLOWED_ORIGINS contains invalid origin "${candidate}"; ignored.`,
);
continue;
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
warn(
`[agentbridge] AGENTBRIDGE_HTTP_ALLOWED_ORIGINS only supports http(s) origins (got "${candidate}"); ignored.`,
);
continue;
}
out.add(url.origin);
}
return out;
}

export const CONFIG_BOUNDS = BOUNDS;
export const CONFIG_DEFAULTS = DEFAULTS;
24 changes: 17 additions & 7 deletions apps/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
/* AgentBridge MCP server entry point.
*
* Speaks MCP over stdio by default. The shared server (tools, resources,
* prompts, dispatcher) lives in ./server.ts; the transport adapter lives
* in ./transports/stdio.ts. This file is intentionally tiny — it picks a
* Default transport: stdio. HTTP is opt-in via
* `AGENTBRIDGE_TRANSPORT=http`. The shared server (tools, resources,
* prompts, dispatcher) lives in ./server.ts; transport adapters live
* in ./transports/*.ts. This file is intentionally tiny — it picks a
* transport and routes top-level startup errors to stderr.
*
* v0.4.0 will add an opt-in HTTP transport (see
* docs/designs/http-mcp-transport-auth.md). Until then, stdio is the
* only runtime transport.
* v0.4.0 design: docs/designs/http-mcp-transport-auth.md.
*/

import { resolveTransport } from "./config";
import { runStdioServer } from "./transports/stdio";
import { runHttpServer } from "./transports/http";

runStdioServer().catch((err) => {
async function main(): Promise<void> {
const transport = resolveTransport();
if (transport === "http") {
await runHttpServer();
return;
}
await runStdioServer();
}

main().catch((err) => {
console.error("[agentbridge-mcp] fatal:", err);
process.exit(1);
});
Loading
Loading