Skip to content

MCP services

read as .md

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 McpServerMounts that aggregate external tools onto the same surface.
  • Zero or more McpServices — 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.

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 SharedHandlers 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)
└─────────────────────────────────┘
You wantReach for
Tools that should appear alongside ggui_render / ggui_consume in a generative-UI sessionMcpServerMount
Tools the LLM-driven agent should call as part of the same MCP connection that drives gguiMcpServerMount
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 otherMcpService (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.

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.
  • handlersSharedHandler[], 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 below.

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

PathWhy reserved
/Root — must not be swallowed by a service router.
/mcpThe agent-facing audience-filtered MCP route.
/protocolThe protocol-discovery audience-filtered route.
/opsThe operator-facing audience-filtered route.
/wsThe live-channel WebSocket upgrade path.
/healthReserved for health probing — the live endpoint is /ggui/health; /health is held back so a service can never shadow a future short alias.
/.well-knownRFC-reserved discovery prefix (OAuth metadata, security.txt, …).
/oauthOAuth dance endpoints (authorize / token / register).
/_gguiInternal ggui control surfaces (admin console, pairing, debug routes).
/gguiSame — 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.

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.

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.

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:

{
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.
  • 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.
  • 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.

Pass the service array to createGguiServer:

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 /docsdocs_search, docs_read, docs_list. No auth required.
  • POST /playground/todostodos_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.

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.
  • /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.
  • /playground/mdh@ggui-private/mcp-playground-mdh. Million-Dollar Homepage playground service. See 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. For running an analogous service stack under your own hostname, see ggui serve.