---
title: MCP services
description: Multi-mount MCP servers — McpService primitive, path reservations, anonymous mode, and how it differs from McpServerMount.
---

A ggui server is not a single MCP server. One Node process composes:

- **The audience-filtered routes** (`/mcp`, `/protocol`, `/ops`) — ggui's native control plane, optionally extended by `McpServerMount`s that aggregate external tools onto the same surface.
- **Zero or more `McpService`s** — fully isolated MCP servers, each mounted at its own HTTP path with its own tool namespace.

This page covers the second half. For the audience model that drives the shared routes, see [Audience routes](/architecture/audience-routes/).

## What an `McpService` is

An `McpService` is a complete, self-contained MCP server reachable at a single HTTP path you pick (`/docs`, `/playground/todos`, `/internal/billing`, …). A client connecting to that path sees exactly the tools the service declares — no ggui-native tools, no other service's tools, no audience filtering. The path **is** the namespace boundary.

Contrast with `McpServerMount`. A mount is a named bundle of `SharedHandler`s aggregated onto the audience-filtered routes. Mounted tools compose alongside ggui's native tools (`ggui_render`, `ggui_handshake`, `ggui_consume`, …) so a single MCP session over `/mcp` enumerates the union of everything. Services do not compose; they isolate.

```
                           ┌─────────────────────────────────┐
  Agent (LLM-driven) ─────▶│ /mcp                            │
                           │   ggui-native tools             │
                           │   + every mount's tools         │  audience-filtered
                           │   (audience=agent|runtime)      │
                           └─────────────────────────────────┘
                           ┌─────────────────────────────────┐
  Operator console  ──────▶│ /ops                            │  audience-filtered
                           │   ggui_ops_*                    │  (audience=ops)
                           └─────────────────────────────────┘
                           ┌─────────────────────────────────┐
  Docs client       ──────▶│ /docs                           │
                           │   docs_search, docs_read,       │  isolated service
                           │   docs_list                     │  (own namespace)
                           └─────────────────────────────────┘
                           ┌─────────────────────────────────┐
  Playground user   ──────▶│ /playground/todos               │
                           │   todos_list, todos_add,        │  isolated service
                           │   todos_toggle, todos_delete    │  (own namespace)
                           └─────────────────────────────────┘
```

## When to use which

| You want                                                                                     | Reach for                          |
| -------------------------------------------------------------------------------------------- | ---------------------------------- |
| Tools that should appear alongside `ggui_render` / `ggui_consume` in a generative-UI session | `McpServerMount`                   |
| Tools the LLM-driven agent should call as part of the same MCP connection that drives ggui   | `McpServerMount`                   |
| A complete, standalone MCP server at its own URL (`mcp.example.com/docs`, `…/billing`, …)    | `McpService`                       |
| First-touch public surface for unauthenticated clients (docs lookup, marketing demos)        | `McpService` (+ `anonymous: true`) |
| Conceptually distinct tool surfaces that should NOT collide on names with each other         | `McpService` (one per surface)     |

Rubric:

- **Composes with ggui's core tools?** Mount.
- **Replaces ggui's core tools at its own URL?** Service.

If you find yourself disabling ggui-native tools on a mount because they don't make sense for the caller, you wanted a service. If you find yourself proxying ggui's `tools/list` output through a service to bolt on extra tools, you wanted a mount.

## The `McpService` shape

```ts
import type { SharedHandler } from "@ggui-ai/mcp-server-handlers";
import type { ZodRawShape } from "zod";

interface McpService {
  /**
   * Human-readable service identifier. Surfaced in validation
   * errors and telemetry. No uniqueness constraint across services
   * — only `path` must be unique.
   */
  readonly name: string;

  /**
   * HTTP path the service mounts at (e.g. `/docs`,
   * `/playground/todos`). Validated at compose time.
   */
  readonly path: string;

  /**
   * Tool handler bundle. Same `SharedHandler` shape ggui-native
   * handlers and mount handlers use.
   */
  readonly handlers: ReadonlyArray<SharedHandler<ZodRawShape, ZodRawShape>>;

  /**
   * Auth-optional: a valid bearer still resolves to the real
   * identity; missing/invalid credentials fall back to a
   * synthesized anonymous builder. Default `false` — same auth
   * posture as `/mcp`.
   */
  readonly anonymous?: boolean;
}
```

Field-by-field:

- **`name`** — diagnostic-only. Surfaces in `validateMcpServices` error messages and composition telemetry so an operator with several services can tell which one is misconfigured. Does NOT appear on the wire; tool names stay whatever each handler declares.
- **`path`** — the HTTP path Express mounts the service handler at. Branded `ServicePath` after passing `validateServicePath`. Must be unique across the `mcpServices` array.
- **`handlers`** — `SharedHandler[]`, the same canonical shape every ggui-native handler satisfies. The server registers each handler through `buildMcpServer`'s regular path; validation, logging, and output-schema parsing all happen uniformly.
- **`anonymous`** — makes auth optional. Default `false`. See [Anonymous mode](#anonymous-mode) below.

## Path reservations

Ten paths are reserved at validation time. Declaring a service at any of them throws at server-construction:

| Path           | Why reserved                                                           |
| -------------- | ---------------------------------------------------------------------- |
| `/`            | Root — must not be swallowed by a service router.                      |
| `/mcp`         | The agent-facing audience-filtered MCP route.                          |
| `/protocol`    | The protocol-discovery audience-filtered route.                        |
| `/ops`         | The operator-facing audience-filtered route.                           |
| `/ws`          | The live-channel WebSocket upgrade path.                               |
| `/health`      | Reserved for health probing — the live endpoint is `/ggui/health`; `/health` is held back so a service can never shadow a future short alias. |
| `/.well-known` | RFC-reserved discovery prefix (OAuth metadata, security.txt, …).       |
| `/oauth`       | OAuth dance endpoints (authorize / token / register).                  |
| `/_ggui`       | Internal ggui control surfaces (admin console, pairing, debug routes). |
| `/ggui`        | Same — public-facing `/ggui/*` endpoints.                              |

A typo in a host config (`path: '/mpc'` instead of `/mcp`) can no longer silently shadow OAuth discovery, health, or per-app traffic.

:::caution
Reservations apply to **exact equality**, not to prefixes you build on top. A service at `/.well-known/example` is rejected because of the `/.well-known` reservation, but a service at `/wellknown-example` is fine. Pick a distinct first segment.
:::

## Path validation rules

`validateServicePath` enforces:

1. **Regex** `^/[a-zA-Z0-9_/-]+$` — must start with `/` and contain only letters, digits, `-`, `_`, and `/`. No whitespace, no `.`, no path traversal.
2. **No trailing slash** — `/docs/` is rejected; use `/docs`. Prevents trailing-slash variant collisions where `/docs` and `/docs/` would resolve to different routes.
3. **Non-empty after the leading slash** — at least one character after `/`.
4. **Not a reserved path** — see the table above.

Throwing happens at server construction, before any request is served — misconfiguration cannot become a runtime mystery.

## Compose-time invariants

`validateMcpServices(services)` walks the whole array and enforces:

- **Non-empty `name`** on every entry. The name appears in every other error message; empty names defeat the diagnostic.
- **`path` passes `validateServicePath`** — all the rules above.
- **Service paths are unique** across the `mcpServices` array. Two services cannot mount at the same path.
- **Every handler declares a non-empty `outputSchema`.** An empty `ZodRawShape` (`{}`) silently strips `structuredContent` at the MCP SDK boundary — the handler can return `{ items: [...] }` and the wire answer is `{}`. Operators hitting this see success-looking responses with missing data and no diagnostic. Rejected at compose time so the failure arrives with the service + tool names attached.
- **No `audience` tag on service handlers.** Services bypass audience filtering entirely — the path IS the audience. An explicit `audience: ['ops']` on a service handler is silently meaningless. Rejected loudly.
- **Tool names are unique within a service.** Two handlers declaring the same `name` inside one service collide; one would shadow the other at registration. Rejected.
- **Cross-service tool-name collisions ARE allowed.** Services are isolated namespaces. A client connects to one path and only ever sees that path's tools, so `docs_search` on `/docs` and `docs_search` on `/internal/docs` coexist without ambiguity. This is by design — collapsing into a global tool namespace would defeat the isolation that makes services worth having.

:::note
`validateMcpServices` is pure and idempotent — safe to call multiple times. It returns the input reference unchanged when the input is non-empty and valid; absent or `undefined` input resolves to a fresh empty array.
:::

## Anonymous mode

`anonymous: true` makes auth **optional**, not skipped. The server always attempts to resolve a presented bearer — a valid credential resolves to the real identity (letting one service mix public reads with authenticated capabilities); only a missing or invalid credential makes the binding layer fall back to the synthesized:

```ts
{
  identity: { kind: 'builder' },
  source: 'anonymous',
}
```

Two things to note about the shape:

- **`identity.kind` does NOT widen.** The `Identity` union still has its three variants (`'builder'` / `'user'` / `'app'`). Anonymous traffic collapses to `'builder'` so handlers that pattern-match on `kind` keep working without an extra arm. Adding a `'anonymous'` kind would force every consumer in the codebase to add a defensive case — the synthesized `'builder'` is the lighter touch.
- **The signal lives on `source`.** `AuthResult.source` carries a dedicated `'anonymous'` variant. Handlers that need to distinguish "this request was authenticated" from "this request was let through anonymously" read `source` directly — pattern-matching on `identity.kind` alone cannot answer the question.

### When to use it

- **Read-only public surfaces.** Documentation lookup, public catalog browse, marketing demos.
- **First-touch onboarding flows** where requiring a bearer token would block the use case (a brand-new visitor to `mcp.example.com/docs` has nothing to present).
- **Server-to-server probes** that verify protocol shape without claiming an identity.

### When NOT to use it

- **Any write path.** An anonymous caller has no accountability surface; you cannot audit who created the row.
- **Anything tenant-scoped.** `ctx.appId` for an anonymous request collapses to the single builder. Reads will return data the caller has no business seeing, or writes will land in a tenant they don't own.
- **Anything sensitive enough that you'd want rate limits per-caller.** Anonymous mode pairs with per-IP / per-session rate limits (the `RateLimiter` seam composes the same way for authenticated and anonymous paths), but it cannot give you per-user attribution.

:::tip
Handlers can defend in depth by reading `source` and rejecting `'anonymous'` for sensitive paths even on a service declared `anonymous: true`. The flag opens the door at the binding layer; handlers retain the right to slam it on writes.
:::

## Wiring it up

Pass the service array to `createGguiServer`:

:::note[Service packages are monorepo-internal]
The `@ggui-private/mcp-docs` and `@ggui-private/mcp-playground-*` packages used below are first-party ggui services that ship inside the monorepo — they're not published to npm. The example shows the shape; substitute your own service-package imports and handler factories when wiring your own services. The `McpService` primitive itself is in `@ggui-ai/mcp-server`, which IS public.
:::

```ts
import { createGguiServer } from "@ggui-ai/mcp-server";
// monorepo-internal first-party services (not published):
import { createDocsHandlers, loadDocsCorpus } from "@ggui-private/mcp-docs";
import {
  createPlaygroundTodosHandlers,
  createInMemoryTodoStore,
} from "@ggui-private/mcp-playground-todos";

const corpus = await loadDocsCorpus("./docs");
const todoStore = createInMemoryTodoStore();

const server = await createGguiServer({
  // ... ggui-native options (renderChannel, blueprintStore, …)

  mcpServices: [
    {
      name: "docs",
      path: "/docs",
      handlers: createDocsHandlers({ corpus }),
      anonymous: true, // read-only doc lookup; no token required
    },
    {
      name: "playground-todos",
      path: "/playground/todos",
      handlers: createPlaygroundTodosHandlers({ store: todoStore }),
      // no `anonymous: true` — handlers throw when ctx.userId is missing
    },
  ],
});

await server.listen(6781);
```

After `listen()`:

- `POST /mcp` — ggui-native + every mount's tools (audience-filtered).
- `POST /docs` — `docs_search`, `docs_read`, `docs_list`. No auth required.
- `POST /playground/todos` — `todos_list`, `todos_add`, `todos_toggle`, `todos_delete`. Auth-gated (handlers reject anonymous callers).

The audience-filtered routes and the service routes coexist on the same Node process, the same Express app, the same WebSocket binding. There is no second server to run.

## Examples in the wild

Three first-party services are built on this primitive for the hosted deployment (coming soon):

- **`/docs`** — `@ggui-private/mcp-docs`. Three read-only tools (`docs_search`, `docs_read`, `docs_list`) over the ggui documentation corpus. Anonymous-mode; the canonical "public surface" example. See [MCP Docs Service](/api/mcp-docs/).
- **`/playground/todos`** — `@ggui-private/mcp-playground-todos`. Four tools (`todos_list`, `todos_add`, `todos_toggle`, `todos_delete`) for the landing-page playground. Auth-gated per-user state (handlers reject anonymous callers). See [Playground · todos](/clients/playground-todos/).
- **`/playground/mdh`** — `@ggui-private/mcp-playground-mdh`. Million-Dollar Homepage playground service. See [Playground · MDH](/clients/playground-mdh/).

The hosted deployment's unified `/dev` endpoint (coming soon) is the fullest example: one anonymous service co-hosting public docs + protocol tools with ops tools that re-impose auth per-tool — possible precisely because `anonymous` is auth-optional, so a presented connector key still resolves to the real identity.

For the operator-facing audience-filtered route alongside these services, see [Ops MCP](/api/ops-mcp/). For running an analogous service stack under your own hostname, see [`ggui serve`](/cli/serve/).

## Where to next

- [Audience routes](/architecture/audience-routes/) — the audience model behind `/mcp` / `/protocol` / `/ops`
- [Architecture overview](/architecture/overview/) — the three-channel topology services live inside
- [Event System](/architecture/event-system/) — live-channel traffic is shared by every service on the same host
- [MCP Docs Service](/api/mcp-docs/) — the canonical anonymous-mode example
- [Ops MCP](/api/ops-mcp/) — the operator-facing audience-filtered route
- [Playground · todos](/clients/playground-todos/) and [Playground · MDH](/clients/playground-mdh/) — authenticated-service examples
- [`ggui serve`](/cli/serve/) — self-host an analogous service stack