Skip to main content
Use the governance auth DX when one proxy serves multiple callers and upstream servers need per-user or per-group credentials.

Quick Start

import {
  bearer,
  credential,
  credentialEnv,
  credentialJson,
  fentaris,
  group,
  mcp,
  approval,
  policy,
  sensitive,
  streamableHttp,
  user,
} from "@fentaris/core";

const readOnly = policy("read-only")
  .mcp("github")
  .allow("search_issues");

const maintainers = policy("maintainers")
  .mcp("github")
  .allow("*")
  .mcp("github")
  .deny("delete_repo", sensitive({ reason: "destructive" }));

const proxy = fentaris({
  groups: [
    group({
      id: "support",
      users: [
        user("alice", {
          email: "alice@example.com",
          tenantId: "acme",
          apiKeys: [credentialJson("users.alice.apiKeys.0")],
        }),
      ],
      credentials: {
        "github.token": credentialEnv("SUPPORT_GITHUB_TOKEN"),
      },
      policy: readOnly,
    }),
    group({
      id: "maintainers",
      users: [
        user("bob", {
          email: "bob@example.com",
          tenantId: "acme",
          apiKeys: [credentialJson("users.bob.apiKeys.0")],
        }),
      ],
      credentials: {
        "github.token": credentialJson("groups.maintainers.github.token"),
      },
      policy: maintainers,
    }),
  ],
  servers: [
    mcp("github", {
      transport: streamableHttp({ url: "https://github.example/mcp" }),
      auth: bearer(credential("github.token")),
    }),
  ],
});

proxy.use((ctx, next) => {
  ctx.log.setTag("subject", ctx.subject?.id ?? "anonymous");
  ctx.log.setTag("groups", ctx.subject?.groups.map((group) => group.id).join(",") ?? "");
  ctx.log.setTag("authenticated", String(ctx.auth.authenticated));
  return next();
});
The same API is available through classes when you prefer object construction, but the helper style keeps users, groups, server bindings, and credential references close together.

Provision Local Auth And Secrets

The TypeScript declarations identify where Fentaris reads API-key hashes and upstream credentials. Provision the values after the project is built:
export FENTARIS_AUTH_KEY="<local-encryption-key>"

fentaris auth api-key add alice --generate
fentaris auth api-key add bob --generate

printf '%s' "$SUPPORT_GITHUB_TOKEN" \
  | fentaris secrets set github.token --group support --value-stdin --non-interactive
printf '%s' "$MAINTAINER_GITHUB_TOKEN" \
  | fentaris secrets set github.token --group maintainers --value-stdin --non-interactive

fentaris secrets manifest
fentaris check --offline
fentaris doctor
fentaris dev
Save each generated API key when the CLI prints it. Fentaris stores only its hash. Clients send the raw value in the x-fentaris-api-key header. Choose FENTARIS_AUTH_KEY outside the project and provide it through your shell session or secret manager. Keep the same value for later reads and updates. Do not commit it, write it into generated project files, or include it in an agent response. credentialJson("users.alice.apiKeys.0") points at the first CLI-managed API-key hash for alice. fentaris auth api-key add alice --generate writes that value; application code must not generate or encrypt it directly.

Team-Governed Proxy

Use this shape when one team proxy serves multiple users and different groups need different upstream permissions:
import {
  bearer,
  credential,
  credentialJson,
  fentaris,
  group,
  jsonConsoleLogger,
  mcp,
  policy,
  streamableHttp,
  user,
} from "@fentaris/core";

const readers = policy("readers")
  .mcp("github")
  .allow("search_issues");

const maintainers = policy("maintainers")
  .mcp("github")
  .allow("search_issues")
  .mcp("github")
  .allow("create_issue");

const app = fentaris({
  logger: jsonConsoleLogger(),
  autoLog: true,
  groups: [
    group({
      id: "readers",
      users: [user("reader", { apiKeys: [credentialJson("users.reader.apiKeys.0")] })],
      credentials: {
        "github.token": credentialJson("groups.readers.github.token"),
      },
      policy: readers,
    }),
    group({
      id: "maintainers",
      users: [user("alice", { apiKeys: [credentialJson("users.alice.apiKeys.0")] })],
      credentials: {
        "github.token": credentialJson("groups.maintainers.github.token"),
      },
      policy: maintainers,
    }),
  ],
  servers: [
    mcp("github", {
      transport: streamableHttp({ url: "https://github.example/mcp" }),
      auth: bearer(credential("github.token")),
    }),
  ],
});

app.use((ctx, next) => {
  ctx.log.setTag("subject", ctx.subject?.id ?? "anonymous");
  ctx.log.setTag("groups", ctx.subject?.groups.map((group) => group.id).join(",") ?? "");
  return next();
});
Store the API keys and upstream tokens with the local secrets commands, then commit only .fentaris/secrets.manifest.json.

App-Level Composition

Use app-level governance declarations when the proxy is assembled across modules or when upstream MCP servers are registered after construction.
import { fentaris, streamableHttp, user } from "@fentaris/core";

const app = fentaris();

app.policy("readonly")
  .mcp("github")
  .allow("search_issues");

app.policy("maintainers")
  .mcp("github")
  .allow("*");

app.group("guests")
  .users(user("guest"))
  .policy("readonly");

app.group("maintainers")
  .users(user("alice"))
  .users(user("bob"))
  .policy("maintainers");

app.mcp("github", {
  transport: streamableHttp({ url: "https://github.example/mcp" }),
});
app.policy(name) is a named policy registry. Repeating the same name returns the same policy instance, and app.group(id).policy(name) resolves that name before Fentaris serves requests.
Fluent groups must include at least one user and a policy before startup. A named policy reference must be declared through app.policy(name) or Fentaris reports a configuration diagnostic.
Use the declarative group({ ... }) form when a group owns credential sources or group-scoped server declarations. The fluent group handle currently configures users, policy, middleware, operations, and events only.
app.group("maintainers").mcp("github") scopes middleware, operation handlers, and events to the github server for that group. It does not declare the server. Register the upstream first with app.mcp("github", options) or mcp("github", options).

Local auth files

The generated local credential store uses one encrypted file by default:
.fentaris/
  credentials.enc.json
  secrets.manifest.json
credentials.enc.json is encrypted and stores user API keys plus user, group, and default credential values. New writes use a versioned AES-256-GCM envelope with PBKDF2 key derivation metadata, while legacy SHA-256-derived files are still readable and migrate on the next successful write. On Unix, the file is written with owner-only permissions (0600). Keep it private and rotate the encryption key using your deployment secret process. For automation, prefer environment and stdin inputs so secrets do not appear in process arguments:
export FENTARIS_AUTH_KEY="local-encryption-key"
printf '%s' "$GITHUB_TOKEN" | fentaris secrets set github.token --value-stdin
--key and --value remain available for compatibility, but Fentaris warns because those values can be visible through process inspection. For agent-created projects, leave the encryption key and secret values for the user to provide after code generation. The handoff must list the exact CLI commands and scopes without inventing, printing, or persisting secret values. secrets.manifest.json lists required credential references only. Commit this file so teammates know which secrets a project needs without sharing encrypted values. Server bindings live next to the MCP server declaration in TypeScript:
mcp("github", {
  transport: streamableHttp({ url: "https://github.example/mcp" }),
  auth: bearer(credential("github.token")),
});

API-key identity

When users declare apiKeys, Fentaris uses API-key identity by default and requires the configured header. The default header is x-fentaris-api-key. API keys resolve to declared user ids from encrypted local auth storage. Raw API keys are not exposed to middleware, hooks, logs, or policy callbacks. Trusted user id headers remain available through headerIdentityStrategy, but they should only be used behind a trusted internal gateway that already authenticated the caller. Do not expose a direct x-user-id identity mode to untrusted clients.

Credential precedence

For an upstream binding such as github -> bearer github.token, Fentaris resolves the credential in this order:
  1. User credential for the authenticated subject.
  2. Group credential from the subject’s matching groups.
  3. Default credential.
If multiple groups provide the same credential reference, the default behavior follows configured group order. Middleware receives credential source metadata through ctx.credentials.sources, such as { reference: "github.token", source: "group", groupId: "support" }, never the decrypted credential value.

Credential Source Placement

Match the TypeScript source declaration to the scope used by fentaris secrets set:
CLI scopeTypeScript declarationEncrypted JSON path
defaultdefaults.credentials["github.token"]defaults.github.token
groupgroup({ credentials: { "github.token": ... } })groups.<group-id>.github.token
useruser(id, { credentials: { "github.token": ... } })users.<user-id>.credentials.github.token
A global server can resolve user-, group-, or default-scoped credentials for the authenticated subject. A server declared in group({ servers: [...] }) can resolve user credentials from that group, that group’s credentials, or defaults.
A credential reference validates only when at least one source is visible in the server’s scope or an auth-backed store is configured. Runtime resolution can still fail for a specific subject that has no matching user, group, or default value.

Policy And Runtime Behavior

Keep durable authorization in groups and policies. Use server-scoped tool routes for runtime behavior after policy allows a call. Fentaris fails closed: a tool call or governed capability is denied unless a global policy, matching group policy, or explicit development Policy.allowAll() / allowAll() policy allows it. Deny decisions are terminal and run before call hooks, middleware, routes, or upstream dispatch. If a subject belongs to multiple groups, any explicit deny from one matching group wins over allows from another group.
const supportPolicy = policy("support").mcp("github").allow("create_issue");

const support = group({
  id: "support",
  users: [user("alice")],
  policy: supportPolicy,
});

const proxy = fentaris({
  servers: [
    mcp("github", {
      transport: streamableHttp({ url: "https://github.example/mcp" }),
    }),
  ],
  groups: [support],
});

proxy.mcp("github").tool("create_issue", async (ctx, next) => {
  if (typeof ctx.args?.title !== "string") {
    return ctx.deny("Issue title is required.");
  }

  ctx.log.info("github.create_issue.validated", {
    allowed: ctx.policy.allowed,
    groups: ctx.policy.matchedGroups,
  });

  return next();
});
Use ctx.subject for resolved subject data and ctx.policy for the effective authorization decision:
proxy.mcp("github").use(async (ctx, next) => {
  if (!ctx.subject?.hasGroup("maintainers")) {
    return ctx.deny("Maintainer group required.");
  }

  if (!ctx.policy.can("github", "delete_repo")) {
    return ctx.deny("delete_repo is not allowed for this subject.");
  }

  ctx.log.info("github.delete_repo.capability", {
    subject: ctx.subject.id,
    email: ctx.subject.email,
    tenant: ctx.subject.tenant,
    groups: ctx.subject.groups.map((group) => group.id),
    matchedPermissions: ctx.policy.matchedPermissions,
  });

  return next();
});
ctx.policy.can(server, tool) evaluates allow/deny permissions only. It does not consume rate limits, invoke manual approval callbacks, or expose raw API keys, bearer tokens, decrypted credentials, or environment secret values. listTools is filtered both before and after list hooks. Synthetic tools added by onListTools or tools:list:after must use the normal <server>__<tool> proxy name and match policy, or they are hidden from the final response.

Local Namespace Policy

Local capabilities declared with app.local(name) are authorized through the same .mcp(name) policy namespace as upstream MCP servers.
const workspacePolicy = policy("workspace")
  .mcp("workspace")
  .allow("status")
  .mcp("workspace")
  .allowCapability({
    operation: "resource:read",
    target: "config://current",
    targetKind: "resource",
  })
  .mcp("workspace")
  .allowCapability({
    operation: "prompt:get",
    target: "review_pr",
    targetKind: "prompt",
  });

const app = fentaris({ policy: workspacePolicy });

app.local("workspace")
  .tool("status", { inputSchema: { type: "object" } }, async () => ({ content: [] }))
  .resource("config://current", { name: "Current config" }, async () => ({ contents: [] }))
  .prompt("review_pr", { arguments: [{ name: "diff" }] }, async () => ({ messages: [] }));
Denied local list entries are hidden from tools/list, resources/list, resources/templates/list, and prompts/list. Denied execution returns before the local handler runs.

Capability Permissions

Tool permissions are still supported, but Fentaris also accepts operation-based capability permissions for proxied resources, prompts, and completion.
const supportPolicy = policy("support")
  .mcp("github")
  .allow("create_issue")
  .mcp("docs")
  .allowCapability({
    operation: "resources:list",
    targetKind: "resource",
  })
  .mcp("docs")
  .allowCapability({
    operation: "resource:read",
    target: "file:///handbook.md",
    targetKind: "resource",
  })
  .mcp("docs")
  .denyCapability({
    operation: "prompt:get",
    target: "admin_review",
    targetKind: "prompt",
  });
List operations can be allowed or denied at the operation level. Returned resources, resource templates, and prompts are then filtered using the matching direct operation permission, so denied entries are hidden from downstream clients. Use operation routes when runtime validation should apply to non-tool operations:
proxy.mcp("docs").operation("resource:read", async (ctx, next) => {
  ctx.log.info("docs.resource.read", {
    uri: ctx.resource?.uri,
    allowed: ctx.policy.allowed,
  });

  return next();
});
Resource, prompt, and completion operations emit resource:*, prompt:*, and completion:* events with start, success, error, and after phases. Audit logs include operation, subject, server, target, policy outcome, and credential source metadata.

Migration From Tool Policies

Existing tool policy declarations continue to work:
const existingPolicy = policy("existing")
  .mcp("github")
  .allow("search_issues")
  .mcp("github")
  .deny("delete_repo");
Fentaris adapts those declarations internally to tool:call capability permissions. Existing ToolPermission, SimplePolicy, Policy.evaluate(...), group policy, middleware, and ctx.policy.can(server, tool) behavior remain compatible for tool calls. Add allowCapability(...) or denyCapability(...) only when you need to govern resources, resource templates, prompts, or completion.

CLI Helpers

Preferred workflow for local encrypted credentials:
export FENTARIS_AUTH_KEY="your-local-encryption-key"

fentaris secrets set github.token
fentaris secrets set github.token --group support
fentaris secrets list
fentaris secrets manifest
fentaris secrets doctor
fentaris doctor
fentaris secrets list shows credential reference names and scopes only. Secret values are never printed. .fentaris/secrets.manifest.json is the committed schema: reference names and scopes with no values. Regenerate it after adding new credential("...") references in your entrypoint:
fentaris secrets manifest
git add .fentaris/secrets.manifest.json
Use fentaris secrets manifest --check in CI to fail when the manifest is out of date.

Low-Level API

The class constructors remain available for compatibility and advanced embedding. Prefer fentaris(...), mcp(...), streamableHttp(...), group(...), user(...), and policy(...) for new applications.
import { McpProxy, Policy } from "@fentaris/core";

const legacyPolicy = new Policy({ name: "legacy" })
  .mcp("github")
  .allow("search_issues");

const proxy = new McpProxy({
  policy: legacyPolicy,
  servers: [githubServer],
});