novo agents

Tool servers

Register an MCP server and its tools become callable by agents that opt in to mcp.

Use transport: 'http' for Streamable HTTP MCP servers. transport: 'sse' is available for legacy HTTP+SSE servers.

MCP bearer auth uses a reusable Secret. The token is sent only on Novo-to-MCP requests. It is not exposed to a managed workspace unless you also bind the same secret through a Secret context with a workspace_env projection. For a public MCP server, use auth: { type: 'none' } (or omit auth); Novo sends no Authorization header at all. See Auth.

Methods

| Method | Route | |---|---| | novo.toolServers.register({...}) | POST /v1/tool-servers | | novo.toolServers.list() | GET /v1/tool-servers | | novo.toolServers.get(id) | GET /v1/tool-servers/:id | | novo.toolServers.update(id, {...}) | PATCH /v1/tool-servers/:id | | novo.toolServers.delete(id) | DELETE /v1/tool-servers/:id | | novo.toolServers.test(id) | POST /v1/tool-servers/:id/test |

Type signature

type ToolServersNamespace = {
  register(input: {
    name: string;
    transport: 'http' | 'sse';
    url: string;
    auth?: { type: 'bearer'; secretId: string } | { type: 'none' }; // omit = authless
    allowedTools?: string[];
    blockedTools?: string[]; // mutually exclusive with allowedTools
    approval?: 'once' | 'always' | 'never';
    gatedTools?: string[];
    resultCapBytes?: number;
    protocolVersion?: '2025-11-25';
    trusted?: boolean;
    defer?: boolean | null;
    requiredSkills?: string | string[] | null;
    enabled?: boolean;
    requestTimeoutMs?: number | null;
    metadata?: Metadata;
  }): Promise<ToolServer>;
  list(): Promise<Page<ToolServer>>;
  get(toolServerId: string): Promise<ToolServer>;
  update(toolServerId: string, input: {
    name?: string;
    url?: string;
    auth?: { type: 'bearer'; secretId: string } | { type: 'none' }; // { type: 'none' } drops the bearer
    allowedTools?: string[] | null;
    blockedTools?: string[] | null;
    approval?: 'once' | 'always' | 'never' | null;
    gatedTools?: string[] | null;
    resultCapBytes?: number;
    trusted?: boolean;
    defer?: boolean | null;
    requiredSkills?: string | string[] | null;
    enabled?: boolean;
    requestTimeoutMs?: number | null;
    metadata?: MetadataPatch;
  }): Promise<ToolServer>;
  delete(toolServerId: string): Promise<{ id: string; object: 'tool_server'; deleted: true }>;
  test(toolServerId: string): Promise<{
    connected: true;
    latencyMs: number;
    toolCount: number;
    tools: Array<{ name: string; namespacedName: string }>;
  }>;
};

Register

const bearer = await novo.secrets.create({
  name: 'internal-tools-mcp-bearer',
  value: process.env.INTERNAL_TOOLS_MCP_TOKEN!,
  contentType: 'text/plain',
});

const server = await novo.toolServers.register({
  name: 'internal-tools',
  transport: 'http',
  url: 'https://mcp.acme.dev',
  auth: { type: 'bearer', secretId: bearer.id },
  allowedTools: ['lookup_user', 'send_email'],
  requestTimeoutMs: 30000,
});

const probe = await novo.toolServers.test(server.id);
console.log(probe.tools.map((tool) => tool.name));

name is part of every model-facing tool name (mcp__{server}__{tool}), so it must be a short slug: 1-56 letters, numbers, underscores, or hyphens, and it cannot contain the reserved __ separator. Novo exposes only MCP tools whose final namespaced name fits provider tool-name limits (letters, numbers, underscores, or hyphens; 64 characters max).

Attach to an agent:

const agent = await novo.agents.create({
  context: '…',
  band: 'core',
  capabilities: {
    mcp: { toolServerIds: [server.id] },
  },
});

Auth

  • Bearer: auth: { type: 'bearer', secretId } sends Authorization: Bearer <secret> on every MCP request. secretId is a reusable Secret; its value is not exposed to a managed workspace unless you also bind it through a Secret context.
  • Authless: auth: { type: 'none' }, or simply omitting auth, registers a server that sends no Authorization header. Use it for public MCP servers. This avoids minting a throwaway Secret, and it is the correct choice for public endpoints that reject an unexpected Authorization header (some public servers accept the header on initialize/tools/list but reject it on tools/call, so a stray bearer passes the connection test yet fails every real tool call).
// Public MCP server: no Secret required.
const docs = await novo.toolServers.register({
  name: 'deepwiki',
  transport: 'http',
  url: 'https://mcp.deepwiki.com/mcp',
  auth: { type: 'none' },
});

Switch an existing server between the two with update: auth: { type: 'none' } drops the bearer, auth: { type: 'bearer', secretId } sets one.

Get or create (idempotent setup)

Server name is unique per organization, so re-running a setup script that calls register with a name that already exists returns 409 name_conflict rather than reusing the row. For idempotent, IaC-style setup, look the server up by name first and only register when it is missing:

async function getOrCreateToolServer(input) {
  const existing = (await novo.toolServers.list()).data.find(
    (s) => s.name === input.name,
  );
  if (existing) {
    return novo.toolServers.update(existing.id, input);
  }
  return novo.toolServers.register(input);
}

update reconciles the found row to your desired config (it bumps configVersion on any structural change), so the script converges whether or not the server already existed.

Trust boundary

By default, tool-server output is wrapped in an "untrusted data" envelope before the model sees it, as defense against prompt injection from external data sources. Set trusted: true only for first-party servers you fully control.

allowedTools

Cap which tools from the server the agent can call. Omit allowedTools to expose every advertised tool. Set a non-empty list for least privilege. Set allowedTools: [] only when you intentionally want the server registered but no tools exposed.

blockedTools is the inverse: expose every advertised tool except the listed names. It is useful when a server adds tools over time and you want new read operations to appear automatically while permanently excluding a small destructive set. allowedTools and blockedTools are mutually exclusive. To switch modes in one update, clear the old field with null and set the new field in the same request.

Declarative approval

Set approval when a connection needs a stable review policy without custom webhook logic:

  • once: pause until this exact tool-and-input approval is accepted, then remember it for the session.
  • always: require review on every matching call.
  • never: never review the call.

With gatedTools, the selected mode applies only to those original MCP tool names; unlisted tools behave as never. gatedTools requires approval, and cannot be combined with approval: 'never'. If the agent also has a tool approval hook, the connection policy runs first: calls that still need review flow to the hook; waived calls skip it.

Deferred tool loading

Tool-server tools are deferred by default. The model always sees each tool's name and description, but a tool's full input schema is loaded on demand through a built-in load_tools tool instead of being carried on every step. The model loads the schemas it needs (one call can load several tools, or a whole server at once), then calls the tools normally by name. Deferral changes when a tool's schema enters context, never whether the tool is available.

This keeps the model-facing context lean and the prompt-cache prefix stable as tools load: the wire tool list stays constant, so loading a schema never resets the cache. It applies to every mcp__* tool, api__* HTTP-connection operation, and managed-capability tool. (Tools your host passes directly into a self-managed run are always loaded.)

defer

Override the default per server:

  • omit / null (default): the server's tools defer. Name + description stay in context, the schema loads on demand.
  • false: keep this server's tool schemas always loaded (hot). Use for a small, always-used server where you'd rather skip the one-time load_tools round-trip.
  • true: defer explicitly (the default for tool servers).

Because a deferred tool's description is the only thing the model sees until it loads the schema, keep server-side tool descriptions clear and self-contained: say what the tool does and when to use it without relying on the parameter schema. Put parameter-level detail in the schema (it travels with load_tools). Tool descriptions are capped at 2048 characters on the wire.

trusted controls the untrusted-data envelope and has no effect on loading; allowedTools or blockedTools shrinks the surface before anything loads.

Required skills

Set requiredSkills to gate this server's tools behind a Skill: the model must load the named skill before any of the server's tools can run. Skill loading is separate from deferred schema loading. If a server tool is deferred and also requires a Skill, the model should call load_tools for the tool and load_skill for the Skill in the same loader step, then call the tool after both loader results are visible.

await novo.toolServers.register({
  name: 'billing-api',
  // ...transport, auth...
  requiredSkills: 'billing-tools', // a skill name, or an array of names
});
  • Pass a skill name or an array of names (any one of them satisfies the gate). Omit (or null) for no requirement.
  • The named skill must be one the agent has: an inline skill in capabilities.skills, or a skill implied by a managed capability. A requiredSkills that names a skill the agent doesn't have leaves the tool permanently un-callable (its gate can never be satisfied), so make the skill part of every agent that uses the server.
  • Because loading the skill is how the model learns the required Skill-specific guidance, write the skill to document the gated tools: what each tool does, argument conventions, and any ordering or safety rules.

This is the same mechanism Novo's own managed tools use (e.g. browser/Office tools require their platform skill). Use it for tools whose correct or safe use depends on guidance the bare schema can't carry.

Result cap

resultCapBytes bounds what a tool result puts in the model's context. When the agent has workspace spill (a filesystem-capable workspace), an over-cap result is spilled to .novo/artifacts/... and the model receives a compact preview plus a deterministic recovery hint it can follow with read_file / grep / glob. List-shaped results, such as search results or tool catalogs, prefer a digest preview (total, shown, items) over an arbitrary byte slice. When only a result handler is configured, the full bytes are archived for the customer and the model receives an opaque preview. Either way an overflow surfaces as a data-novo-tool-result-overflow chunk on the stream, carrying recovery: 'workspace' | 'remote-handler'. Only when no recovery is configured is the result head-truncated and lost, with the chunk reporting recovery: 'none'.

Rotate auth

Rotate the underlying Secret value:

await novo.secrets.rotate(bearer.id, {
  value: process.env.INTERNAL_TOOLS_MCP_TOKEN_NEXT!,
});

To switch a server to a different Secret without changing the Secret value:

await novo.toolServers.update(server.id, {
  auth: { type: 'bearer', secretId: otherSecret.id },
});

Test

novo.toolServers.test(id) connects to the server with auth.secretId, performs the MCP handshake, runs tools/list, applies the configured allow/block filter, and closes the transport. Use it after registration and after Secret rotation before attaching the server to production agents.

The result returns the exposed tool names and their Novo namespaced forms (mcp__{server}__{tool}), which are the names that appear in stream/tool telemetry.

Enabled

enabled: false registers a server in disabled state. Runs that start while the server is disabled fail tool calls to it; flip enabled: true to bring it online without re-registering.

Server guidance

  • Prefer Streamable HTTP (transport: 'http') for remote servers.
  • For a private server, require authentication on every MCP request; register it with auth: { type: 'bearer' } and Novo sends Authorization: Bearer <secret>. For a public server, register with auth: { type: 'none' } so no header is sent.
  • Validate MCP inputs and return tool execution errors with isError: true for recoverable business/input failures.
  • Provide tight input schemas, concise descriptions, and outputSchema when output is structured.
  • Use allowedTools or blockedTools, declarative approval, requestTimeoutMs, and resultCapBytes to scope blast radius per agent.

SSRF guard

Novo requires HTTPS and verifies the URL is publicly reachable and not in a private IP range before delivery. Internal network URLs (10.*, 172.16.*, 192.168.*, link-local, loopback) are rejected at registration. Redirect hops are revalidated; credentials are stripped when a redirect changes origin.

Errors

  • 404 tool_server_not_found: bad ID.
  • 400 invalid_request_error: bad URL, unsupported transport, private IP.
  • 502 service_unavailable: toolServers.test(id) could not connect, initialize, or list tools from the server.