@us-all MCP Server Standard

May 15, 2026 · View on GitHub

Status: Living document. Reflects patterns proven across 6 production MCP servers as of 2026-05.

Audience: Authors of new @us-all/* MCP servers, or contributors evolving existing ones.

Why this exists: 2026 saw upstream vendors (Datadog, Google, Databricks, OpenMetadata 1.12, etc.) ship official MCP servers in most categories we operated in. The differentiation that survived was not "more tools" but "feature-rich + token-efficient by design" — concrete patterns that keep LLM context costs low while preserving deep coverage. This document captures those patterns so the next MCP server starts at the right place.

Use the toolkit

Most patterns below are pre-implemented in @us-all/mcp-toolkit. Install it instead of copying code:

pnpm add @us-all/mcp-toolkit
import {
  ToolRegistry,
  createSearchToolsMetaTool,
  parseEnvList,
  applyExtractFields,
  extractFieldsDescription,
  createWrapToolHandler,
  wrapToolHandler,
} from "@us-all/mcp-toolkit";

This document explains the why behind each pattern; the toolkit provides the how.

Reference repositories

reponpmsize
openmetadata-mcp-server@us-all/openmetadata-mcp156 tools
datadog-mcp-server@us-all/datadog-mcp159 tools
google-drive-mcp-server@us-all/google-drive-mcp96 tools
mlflow-mcp-server@us-all/mlflow-mcp79 tools
unifi-mcp-server@us-all/unifi-mcp52 tools
android-mcp-server@us-all/android-mcp73 tools

The four token-efficiency patterns

1. Tool registry + category-based ENV toggle

File: src/tool-registry.ts. Define a CATEGORIES const array that fits your domain (Datadog: metrics, monitors, logs, apm, rum, ...; OpenMetadata: core, governance, quality, ...). Wrap server.tool with a tool() helper that:

  1. Registers every tool in the registry (so search-tools can find them even if disabled).
  2. Conditionally calls the underlying server.tool only if the current category is enabled per <PREFIX>_TOOLS (allowlist) / <PREFIX>_DISABLE (denylist) env vars.

In src/index.ts:

let currentCategory: Category = "default-cat";
function tool(name: string, description: string, schema: any, handler: any): void {
  registry.register(name, description, currentCategory);
  if (registry.isEnabled(currentCategory)) {
    server.tool(name, description, schema, handler);
  }
}

// Section header sets the category for everything below it
currentCategory = "metrics";
tool("query-metrics", "...", schema, handler);

Measured impact (real tools/list JSON sizes, ~4 chars/token):

ServerDefaultNarrow toggleReduction
openmetadata24K tokens4.6K (OM_TOOLS=search,core)−81%
datadog25K tokens3.8K (DD_TOOLS=metrics,monitors)−85%
google-drive18K tokens4.0K (GD_TOOLS=drive)−78%
android9.2K tokens2.5K (ANDROID_TOOLS=device,ui)−73%

2. search-tools meta-tool (always enabled)

A search tool that queries the registry and returns matching tool names + descriptions. Always loaded regardless of category toggles, so even with a narrow allowlist users can discover what else exists. Re-launch with broader categories if needed.

export async function searchTools(params: { query: string; category?: string; limit?: number }) {
  const matches = registry.search(params.query, params.category, params.limit);
  return { query: params.query, matchCount: matches.length, summary: registry.summary(), matches };
}

3. extractFields response projection (auto-applied)

File: src/tools/extract-fields.ts. A pure helper that takes a comma-separated dotted-path expression with * wildcards and projects only the requested fields from a response.

extractFields="id,owner.name,columns.*.name,columns.*.dataType"
→ keeps only those fields, drops everything else

Wire it through createWrapToolHandler in the toolkit — the factory handles extractFields projection, MCP response shaping, and error sanitization for you:

import { createWrapToolHandler } from "@us-all/mcp-toolkit";

export const wrapToolHandler = createWrapToolHandler({
  redactionPatterns: [/DD_API_KEY/i, /DD_APP_KEY/i],   // merged with toolkit defaults
  errorExtractors: [
    {
      match: (e) => e instanceof WriteBlockedError,
      extract: (e) => ({ kind: "passthrough", text: (e as Error).message }),
    },
    {
      match: (e) => e instanceof DatadogApiError,
      extract: (e) => ({
        kind: "structured",
        data: { message: (e as Error).message, status: (e as DatadogApiError).code },
      }),
    },
  ],
});

For a zero-config wrapper, import the prebuilt wrapToolHandler from the toolkit instead. Default redaction covers api_key, app_key, authorization, bearer …, password, secret, token.

Then declare the field on read tool schemas you want LLMs to use it on:

const ef = z.string().optional().describe(extractFieldsDescription);

export const getTableSchema = z.object({
  id: z.string(),
  // ...
  extractFields: ef,
});

⚠ Schemas must opt in. Even with auto-apply wired in wrapToolHandler, the MCP SDK validates input against each tool's zod schema and drops unknown fields before the handler sees them. So params.extractFields is undefined unless the tool's schema declares it. Either add the field per tool, or use .passthrough() if you want it implicit:

export const listHostsSchema = z.object({
  count: z.coerce.number().optional(),
  // ...
}).passthrough();   // ← extractFields will pass through to wrapToolHandler

Real-world impact (live measurement, datadog v1.11.1):

ToolDefaultWith extractFieldsReduction
get-monitors (5 monitors)594 tokens148 tokens−75%
get-monitors (20 monitors)3,108 tokens622 tokens−80%
list-hosts (10 hosts, schema not declared)3,965 tokens3,965 tokens0% (SDK drops unknown field)

4. MCP Resources for hot entities

File: src/resources.ts. Use the SDK's server.registerResource(name, ResourceTemplate, metadata, callback) to expose hot entities by URI. Resources are application-driven (host UI picks them) and don't consume tool schema tokens until read.

server.registerResource(
  "table",
  new ResourceTemplate("om://table/{fqn}", { list: undefined }),
  { title: "OpenMetadata Table", mimeType: "application/json" },
  async (uri, vars) => {
    const data = await omClient.get(`/tables/name/${encodeURIComponent(String(vars.fqn))}`, {
      fields: "columns,owners,tags",
    });
    return { contents: [{ uri: uri.toString(), mimeType: "application/json", text: JSON.stringify(data) }] };
  },
);

Use a custom URI scheme matching your domain (om://, mlflow://, dd://, etc.).

Aggregation tools (round-trip elimination)

When LLMs reliably issue 3+ tool calls in sequence to assemble a "summary view," provide a single aggregation tool that does it server-side with Promise.allSettled for partial-failure tolerance.

// openmetadata: get-table-summary
//   = get-table-by-name + get-lineage-by-name + (opt) get-table-sample-data + (opt) list-test-cases
// mlflow: summarize-run
//   = get-run + (opt) get-metric-history-per-key + (opt) list-artifacts

Naming: <get|analyze|summarize>-<entity>-<view>. Always include a summary object in the response with metadata (counts, what was/wasn't fetched).

Project layout

src/
├── index.ts              # entry point: server setup + tool() helper + currentCategory + registrations
├── config.ts             # env vars incl. enabledCategories / disabledCategories parsing
├── client.ts             # HTTP client wrapper for the upstream API
├── tool-registry.ts      # CATEGORIES const + ToolRegistry class + searchTools meta-tool
├── resources.ts          # MCP Resources via registerResource (hot entity URIs + Apps SDK ui:// templates)
├── ui/                   # Apps SDK card HTML templates (when shipped) — copied to dist/ui/ at build
└── tools/
    ├── utils.ts          # wrapToolHandler built via createWrapToolHandler factory + domain extractors, assertWriteAllowed, custom error classes
    ├── extract-fields.ts # applyExtractFields helper + extractFieldsDescription
    ├── aggregations.ts   # round-trip-elimination tools (get-X-summary)
    └── <category>.ts     # one file per logical category, exporting Schema + handler pairs

If you ship Apps SDK cards (see Apps SDK Cards below), add this to the build script so HTML templates land alongside compiled JS:

"build": "tsc && node -e \"require('fs').cpSync('src/ui','dist/ui',{recursive:true})\""

Runtime & transport

Use startMcpServer from @us-all/mcp-toolkit/runtime (v1.2.0+) instead of hand-rolling stdio bootstrap. One line replaces 12, and the same code transparently supports Streamable HTTP for ChatGPT Apps SDK / remote clients.

import { startMcpServer } from "@us-all/mcp-toolkit/runtime";

startMcpServer(server).catch((error) => {
  console.error("Fatal error:", error);
  process.exit(1);
});

Transport is selected by env (default stdio — no breaking change for existing users):

Env varDefaultDescription
MCP_TRANSPORTstdiohttp to enable Streamable HTTP
MCP_HTTP_TOKENBearer token. Required when MCP_TRANSPORT=http (unless MCP_HTTP_SKIP_AUTH=true)
MCP_HTTP_PORT3000HTTP listen port
MCP_HTTP_HOST127.0.0.1Bind host. Localhost binds auto-enable DNS rebinding protection.
MCP_HTTP_SKIP_AUTHfalseSkip Bearer auth — only when an upstream proxy already authenticates

HTTP mode exposes POST/GET/DELETE /mcp (Bearer-auth JSON-RPC) and GET /health (public liveness check).

If your index.ts does pre-startup work (e.g. capability detection in google-drive-mcp), keep main() and replace only the transport call:

async function main() {
  validateConfig();
  await detectCapabilities();   // pre-flight
  await startMcpServer(server); // transport
}
main().catch(...);

Known limitation (2026-05): stateless HTTP mode (the toolkit default) handles initialize within a single request fine, but separate follow-up HTTP requests don't share session state — fine for short-lived ChatGPT calls, may need stateful mode for long-running custom clients. Tracked for a future minor.

Apps SDK cards

When a tool has structured output that benefits from visual rendering (an SLO snapshot, a comparison table, a permission audit), ship it as a ChatGPT Apps SDK card. Claude clients ignore the metadata and use the existing JSON text — non-breaking.

1. Author the HTML template at src/ui/<card>.html. The template runs in a sandboxed iframe with access to window.openai.toolOutput (your tool's structuredContent):

<script>
  function update() {
    document.getElementById('app').innerHTML = render(window.openai && window.openai.toolOutput);
  }
  update();
  window.addEventListener('openai:set_globals', update); // re-render on tool re-call
</script>

Keep templates self-contained (inline CSS, vanilla JS, dark/light via prefers-color-scheme). No external CDNs, no module imports. Sandbox limits apply.

2. Register the template as an MCP resource with mime type text/html+skybridge (OpenAI's accepted value as of 2026-05) plus both _meta aliases for cross-vendor portability:

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const UI_DIR = join(dirname(fileURLToPath(import.meta.url)), "ui");
const CARD_HTML = readFileSync(join(UI_DIR, "my-card.html"), "utf-8");

server.registerResource(
  "my-card",
  "ui://widget/my-card.html",
  {
    title: "My card",
    mimeType: "text/html+skybridge",
    _meta: {
      "openai/outputTemplate": "ui://widget/my-card.html",
      "ui.resourceUri": "ui://widget/my-card.html",
    },
  },
  async (uri) => ({
    contents: [{
      uri: uri.toString(),
      mimeType: "text/html+skybridge",
      text: CARD_HTML,
    }],
  }),
);

3. Wrap the target tool so its result includes structuredContent + _meta["openai/outputTemplate"]. Reuse wrapToolHandler for redaction/error handling and post-process the success path:

const CARD_URI = "ui://widget/my-card.html";
const wrappedHandler = wrapToolHandler(myToolFn);

async function myToolWithCard(args: Parameters<typeof wrappedHandler>[0]) {
  const result = await wrappedHandler(args);
  if (result.isError) return result;
  try {
    const structured = JSON.parse(result.content[0].text);
    return {
      ...result,
      structuredContent: structured,
      _meta: {
        "openai/outputTemplate": CARD_URI,
        "ui.resourceUri": CARD_URI,
      },
    };
  } catch { return result; }
}

tool("my-tool", "...", mySchema.shape, myToolWithCard);

Conventions:

  • One render-only tool per card. Don't decorate every tool with outputTemplate — only the one you want surfaced visually.
  • URI scheme ui://widget/<card>.html (OpenAI's documented namespace; works alongside server-specific schemes like dd://, mlflow://).
  • Card name in the tool description (e.g. "Renders an Apps SDK card on ChatGPT clients") so the model knows it's available.
  • Verify with Playwright + mock data before shipping; visual rendering only happens in ChatGPT.

Other conventions

  • Read-only by default: gate all create/update/delete behind <PREFIX>_ALLOW_WRITE=true.
  • Sensitive token redaction: handled by createWrapToolHandler defaults (api/app key, authorization, bearer, password, secret, token). Pass domain-specific patterns (literal env var names like DD_API_KEY, OPENMETADATA_TOKEN, X-API-KEY) via the redactionPatterns option.
  • Schema-first: every tool exports <name>Schema (zod) + <name> handler. Every field has .describe().
  • Categories cover everything: include even infrequent tools (events, audit) so users can disable them via <PREFIX>_DISABLE rather than fork.
  • packageManager: "pnpm@10.30.2" + pnpm.overrides for transitive vulnerability pinning.
  • CI: Node 24 in publish workflow (Node 22 has the npm install -g npm@latest MODULE_NOT_FOUND issue with promise-retry). Trusted publishing requires npm >= 11.5.1 (bundled in Node 24).
  • Versioning: SemVer. New schema field on existing tool → minor. New tool/category → minor. Pure refactor or transitive dep pin → patch.

When to deviate

  • Tiny tool surface (<20 tools): skip the registry/category complexity. unifi-mcp ran fine without categories at v1.0; only added them when the connector tools brought it past 50.
  • Highly heterogeneous responses: extractFields works best on consistent JSON. For binary or streamed responses (e.g. Android screenshot), don't wire it through — let those handlers return wrapImageToolHandler directly.
  • Single-tenant / restricted use case: ENV toggles add value mainly for shared MCP installations where users have varied workflows. For a single-team internal tool, you can skip them.

Maintenance

  • Run node scripts/measure-tokens.mjs (when added by E-6) on every PR; flag if default schema tokens grow more than 20%.
  • Keep this doc updated as new patterns prove out across multiple repos.

v2 Migration Notes (@modelcontextprotocol/sdk 1.x → 2.x)

Status (2026-05-15): SDK 2.x is alpha.2. v1.x remains the recommended pin. PoC verified the migration path on mcp-toolkit#migrate/sdk-2-alpha (7feb5e1, 54/54 tests) and datadog-mcp-server#migrate/sdk-2-alpha (fe4d6be, 23/23 tests). Apply when stable v2 ships.

Package layout change

v1v2
@modelcontextprotocol/sdk@modelcontextprotocol/server (McpServer, ResourceTemplate, StdioServerTransport, types)
(same)@modelcontextprotocol/node (NodeStreamableHTTPServerTransport)
(n/a)@cfworker/json-schema — declared optional peer but must be installed until #2093 is fixed

Consumer migration (≈25 lines per repo)

Key insight: change the local tool() helper once and the hundreds of registration call sites stay unchanged. Wrap ZodRawShape inside the helper.

// before (v1)
function tool(name, description, schema: any, handler: any) {
  if (registry.isEnabled(currentCategory)) {
    server.tool(name, description, schema, handler);
  }
}

// after (v2)
import { z, type ZodRawShape } from "zod";
function tool(name, description, shape: ZodRawShape, handler: any) {
  if (registry.isEnabled(currentCategory)) {
    server.registerTool(
      name,
      { description, inputSchema: z.object(shape) },
      handler,
    );
  }
}

Other touch points

  • All McpServer / ResourceTemplate imports: @modelcontextprotocol/sdk/server/mcp.js@modelcontextprotocol/server
  • StdioServerTransport: also exports from @modelcontextprotocol/server (no /server/stdio subpath despite what the upstream migration guide says — it's wrong)
  • StreamableHTTPServerTransportNodeStreamableHTTPServerTransport from @modelcontextprotocol/node
  • registerPrompt argsSchema: must be a Standard Schema object (z.object({...})), not a raw shape
  • Test mocks: vi.mock("@modelcontextprotocol/sdk/server/mcp.js", …)vi.mock("@modelcontextprotocol/server", …). The mock's .tool() method becomes .registerTool(name, config, handler) where description lives on config.description.

Known v2-alpha gotchas

  1. @cfworker/json-schema hard-import — see upstream #2093. Add it as a direct dep until fixed.
  2. /server/stdio subpath does not exist — migration guide is incorrect. Import StdioServerTransport from @modelcontextprotocol/server main.
  3. Node 18 dropped — minimum is Node 20 (we're on Node 22, no impact).
  4. CommonJS dropped — ESM only (we're already ESM, no impact).