A minimal MCP (Model Context Protocol) server library for Bun with auth forwarding support.
bunx create-futurity-plugin@latest my-plugin
cd my-plugin
cp .env.example .env
bun run keygen # copy the private key into FUTURITY_SIGNING_KEY in .env
bun run devbun add @futurity/pluginsimport { mcp, t } from "@futurity/plugins";
const signingKey = process.env.FUTURITY_SIGNING_KEY;
if (!signingKey) throw new Error("Set FUTURITY_SIGNING_KEY to your Ed25519 private key.");
const pluginManifest = {
pluginId: "my-plugin",
developerEmail: "developer@example.com",
name: "My Plugin",
version: "1.0.0",
mcpUrl: `${process.env.PUBLIC_URL ?? "http://localhost:3000"}/mcp`,
auth: { type: "none" as const },
signingKey,
};
const app = mcp({
pluginManifest,
name: "my-plugin",
version: "1.0.0",
});
app.tool("hello", {
description: "Say hello",
input: t.obj({ name: t.str }),
handler: async ({ name }) => ({
greeting: `Hello, ${name}!`,
}),
});
// Option A: Use the fetch handler (works in any runtime)
Bun.serve({ port: 3000, fetch: app.fetch });
// Option B: Convenience method (Bun only)
await app.listen(3000);Note: The WebSocket transport (
app.listen(port, "websocket")) is Bun-only. The HTTP transport (app.fetch) works in any runtime that supports the Fetch API.
Create an MCP application. pluginManifest is required, including for WebSocket
servers; omitting it throws at construction. The manifest is validated and signed
when app.fetch is first accessed or a transport starts. The SDK supplies
specVersion automatically; you maintain only your plugin's release version.
The snippets below reuse pluginManifest from Basic usage; configure its identity,
auth, and public MCP URL for your own plugin.
const app = mcp({
name: "my-server", // required
version: "1.0.0", // required
path: "/mcp", // default: "/mcp"
instructions: "...", // optional system instructions
capabilities: { ... }, // optional MCP capabilities
pluginManifest, // required signed v4 manifest
});Register a tool.
app.tool("add", {
description: "Add two numbers",
input: z.object({
a: z.number(),
b: z.number(),
}),
output: z.object({ sum: z.number() }),
handler: async ({ a, b }) => {
return { sum: a + b };
},
});An optional output declares the final handler result, not the upstream
response. Supported Zod object schemas are advertised as MCP outputSchema;
successful calls return the same parsed result as structuredContent and JSON
text in content. Output parsing validates the complete schema, including async
refinements and strict-object rules, applies defaults, and strips unknown fields
unless the object schema preserves or rejects them. Invalid results become tool
errors. Parsed results are also checked against the generated JSON Schema using
the MCP SDK's default AJV validator, so mismatches (such as regex flags lost in
conversion) become tool errors rather than client validation failures. The MCP
SDK also validates the normalized structured content.
Schemas must be directly object-shaped, convertible to draft-7 JSON Schema in
output mode, and compilable by the MCP SDK's default AJV validator. Non-object
schemas, unrepresentable fields (such as z.date()), invalid JSON Schema patterns,
pipelines (including transforms and preprocessors), and .overwrite() checks,
even nested ones, retain unparsed text-only behavior with no advertised schema or
output validation. Tools without output are
unchanged. Wrap arrays or primitives in an object to expose structured output.
Tools without an input schema receive {} as their handler's first argument:
app.tool("ping", {
description: "Health check",
handler: async () => {
return { status: "ok" };
},
});Handlers also receive a per-call ToolContext with an AbortSignal:
app.tool("status", {
handler: async (_input, { signal }) => {
const response = await fetch("https://example.com/status", { signal });
return response.json();
},
});The signal aborts when the MCP client cancels that request or the SDK connection
closes. Cancellation is cooperative: pass the signal to downstream I/O or check
signal.throwIfAborted() during work. It does not undo completed side effects.
Existing handlers that accept only the input remain supported.
Register a resource.
app.resource("config://settings", {
description: "Application settings",
fetch: async () => {
return { theme: "dark", language: "en" };
},
});Apply a plugin.
import { cors } from "@futurity/plugins";
app.use(
cors({
allowOrigin: "*",
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
})
);Add middleware directly.
app.middleware(async (req, next) => {
console.log(`${req.method} ${req.url}`);
return next(req);
});The standard Fetch API handler. Works in any runtime (Bun, Deno, Cloudflare Workers, Node with a fetch-compatible adapter, etc.):
Bun.serve({ port: 3000, fetch: app.fetch });Convenience method to start the server. Requires Bun.
// HTTP (default)
await app.listen(3000);
// WebSocket (Bun only)
await app.listen(3000, "websocket");import { mcp, cors } from "@futurity/plugins";
const app = mcp({ name: "server", version: "1.0.0", pluginManifest });
app.use(
cors({
allowOrigin: "*",
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization", "Accept", "Mcp-Session-Id"],
exposeHeaders: ["Mcp-Session-Id"],
maxAge: 86400,
credentials: false,
})
);
app.listen(3000);Configure OAuth metadata for /.well-known/oauth-authorization-server:
const app = mcp({
name: "server",
version: "1.0.0",
pluginManifest,
oauth: {
issuer: "https://auth.example.com",
authorizationEndpoint: "https://auth.example.com/oauth/authorize",
tokenEndpoint: "https://auth.example.com/oauth/token",
jwksUri: "https://auth.example.com/.well-known/jwks.json",
scopesSupported: ["openid", "profile"],
},
});SDK v3 (@futurity/plugins@^3.0.0) emits custom-plugin manifests using specVersion: 4 only. See Manifest v4
for migration, signed metadata, organization credentials, and governance.
Custom registration supports forwarding (platform-managed OAuth) and none
(no OAuth exchange, optionally with explicit organization credentials).
Chained authentication is not supported by custom registration.
Auth forwarding lets the Futurity platform manage OAuth tokens on behalf of your plugin. Instead of implementing OAuth end-to-end, you declare your auth requirements in a signed manifest.
bun run keygenIn a generated project, this prints an Ed25519 keypair. Set FUTURITY_SIGNING_KEY
to the private key before starting the server. Keep the private key secret; register the public key with the Futurity API.
const app = mcp({
name: "my-plugin",
version: "1.0.0",
pluginManifest: {
pluginId: "my-plugin",
developerEmail: "developer@example.com",
name: "My Plugin",
version: "1.0.0",
signingKey, // required key loaded above
auth: {
type: "forwarding",
grantType: "authorization_code",
tokenEndpoint: "https://auth.example.com/oauth2/token",
authorizationEndpoint: "https://auth.example.com/oauth2/authorize",
requiredScopes: ["read", "write"],
deliveryMethod: "header", // "header" (default) or "query"
maxTokenTtl: 3600, // optional, seconds
},
mcpUrl: "https://my-plugin.example.com/mcp",
},
});The signed manifest is automatically served at:
GET /.well-known/futurity/plugin
The response includes an X-Futurity-Signature header with an Ed25519 JWS signature.
The session, token, and chainedAuth routing utilities remain available for
separately integrated hosts. They are not a v4 manifest authentication option;
PluginManifestSchema rejects auth.type: "chained". Do not declare none as a
workaround for a chained plugin.
See docs/chained-auth.md for the runtime protocol and host integration requirements.
import { generateKeyPair, signPayload, verifyPayload } from "@futurity/plugins";
const { privateKey, publicKey } = generateKeyPair();
const jws = signPayload('{"hello":"world"}', privateKey);
const valid = verifyPayload('{"hello":"world"}', jws, publicKey); // trueCustom auth middleware:
const app = mcp({
name: "server",
version: "1.0.0",
pluginManifest,
auth: async (req) => {
const token = req.headers.get("authorization")?.replace("Bearer ", "");
if (!token) return false;
return await validateToken(token);
},
});const state = {
counter: 0,
items: new Map<string, string>(),
};
app.tool("increment", {
handler: async () => {
state.counter++;
return { counter: state.counter };
},
});
app.tool("set", {
input: z.object({ key: z.string(), value: z.string() }),
handler: async ({ key, value }) => {
state.items.set(key, value);
return { key, value };
},
});The HTTP transport supports multiple concurrent client sessions.
const app = mcp({ name: "server", version: "1.0.0", pluginManifest });
app.tool("example", { handler: async () => ({ ok: true }) });
await app.listen(3000);
console.log(app.activeSessions);
await app.stop();import { StreamableHttpServer, StreamableHttpTransport } from "@futurity/plugins";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new StreamableHttpServer({
port: 3000,
path: "/mcp",
onSession: async (transport: StreamableHttpTransport) => {
const mcp = new McpServer({ name: "server", version: "1.0.0" });
await mcp.connect(transport);
},
});
await server.start();The examples/ directory in this repo contains runnable examples. Clone the repo and run them directly:
git clone https://github.com/futuritywork/plugins.git
cd plugins
# Generate a keypair; keep the private key secret.
bun -e 'import { generateKeyPair } from "./packages/plugins/src/signing"; console.log(generateKeyPair());'
export FUTURITY_SIGNING_KEY="<privateKey from the output>"
bun examples/calculator.ts # Math operations and unit conversion
bun examples/cors.ts # CORS configuration
bun examples/database.ts # Document database
bun examples/filesystem.ts # Virtual filesystem
bun examples/oauth.ts # OAuth metadata
bun examples/stateful.ts # Counter, notes, key-value store
bun examples/todo-app.ts # Todo list with CRUD
bun examples/weather-api.ts # Weather data API
bun examples/monday/index.ts # monday.com integration with auth forwardingEach example requires FUTURITY_SIGNING_KEY. The local examples publish
http://localhost:3000/mcp by default; set PUBLIC_URL to the public HTTPS base
URL before registration. They use auth.type: "none" because their tools do not
require OAuth; oauth.ts demonstrates discovery metadata only.
bun install --frozen-lockfile
bun run check
bun run test
bun run buildcheck typechecks the SDK, all examples, and the scaffold template's TypeScript
against the local SDK source, without requiring a prior build.
A complete monday.com MCP server is included demonstrating auth forwarding:
# Set environment variables
export FUTURITY_SIGNING_KEY="your-private-key-base64"
# Run the server
bun examples/monday/index.tsFeatures:
- Boards: List and get board details
- Items: Full CRUD operations
- Updates: Read and create comments
- Groups: Create new groups
import type {
// App
McpApp,
McpAppOptions,
ToolOptions,
ResourceOptions,
Middleware,
AuthMiddleware,
WellKnownEntry,
// Plugin Manifest
PluginManifest,
PluginManifestOptions,
Auth,
AuthForwarding,
ChainedAuth,
// Chained Auth
Session,
PendingSession,
SessionStore,
UserContext,
ChainedAuthConfig,
ChainedAuthHandlers,
PlatformAssertionClaims,
VerifiedPlatformAssertion,
// Transports
StreamableHttpServer,
StreamableHttpServerOptions,
StreamableHttpTransport,
WebSocketTransport,
WebSocketTransportOptions,
} from "@futurity/plugins";import {
// Session management
createPendingSession,
activateSession,
expireSession,
revokeSession,
isSessionValid,
InMemorySessionStore,
// Token generation
generatePluginToken,
validatePluginToken,
generateRefreshToken,
// State management
generateChainedState,
parseChainedState,
// Request binding
hashPluginToken,
hashRequest,
validatePlatformAssertion,
validateAuthenticatedRequest,
// JWT validation
validateUserContextJwt,
clearJwksCache,
} from "@futurity/plugins";Copyright © 2025 Futurity Technologies Pte Ltd
This software is licensed under the Business Source License 1.1 (BSL). You may use, copy, modify, and redistribute for non-production purposes. Production use requires a commercial license until the Change Date (2030-01-01), after which the software becomes available under the GNU General Public License v3.0 or later.
See LICENSE for full terms.