Skip to main content

Class 5: Apollo in Practice and Its Limits

Duration: 45 minutes | Difficulty: Intermediate | Prerequisites: Class 4.


Companion code

This lesson carries on from Class 4. If you followed along, keep working on the project you already have. If you skipped it, clone the mcp_class_4 branch to start from the same place:

git clone --branch mcp_class_4 https://github.com/graphqlguy/graphQLMovieDB-MCP.git

By the end of this lesson we will have covered the four practical concerns that Class 4 left out. You will then be ready to ship Track A, or you will know that the Spring AI starter or the DIY route in later lessons suits your team better. Class 4 got you to working MCP tools in twenty minutes. This lesson gives you the information to choose a track on purpose.

This lesson uses several terms that may be new. Here is a short definition of each, and each one gets a fuller explanation where the lesson puts it to work:

  • Custom scalar is a scalar type your schema defines on top of GraphQL's built-in Int, Float, String, Boolean and ID, such as DateTime.
  • JSON Schema is the standard vocabulary for describing the shape of a JSON value, which MCP uses for tool inputs and outputs.
  • Resource server is a service that accepts a request only when it carries a valid access token from an authorization server it trusts.
  • Bearer token is an access token sent in the Authorization: Bearer <token> header.
  • Audience is the token claim that names the service a token was issued for.
  • Scope is a named permission a token carries, such as movies:read.
  • Header forwarding is Apollo MCP Server's forward_headers setting, which passes the listed request headers to the GraphQL endpoint unchanged.
  • GraphOS is Apollo's hosted platform for publishing schemas, persisted queries and contracts.
  • Supergraph is the single schema that Apollo Federation composes from many subgraph services.
  • Apollo Contracts is the GraphOS feature that publishes filtered views of one supergraph schema, each as its own schema variant.
  • Uplink is the source: uplink setting, which makes Apollo MCP Server fetch the schema or the operations from GraphOS.
  • Persisted-query manifest is a file listing the approved operations a server may run.
  • Sidecar is a second container in the same Kubernetes pod as your application, sharing its network.

The Four Concerns Class 4 Skipped, Plus Contracts​

Four practical questions come up as soon as you take the Class 4 configuration beyond a local demo:

  • Custom scalars: how does Apollo MCP Server handle schema types it cannot infer a JSON shape for?
  • Auth propagation: how does the agent's user identity reach your Spring security model through Apollo, so that field-level authorization keeps working?
  • Per-operation descriptions: how do you describe an operation for an agent separately from the schema description, which was written for frontend developers?
  • Deployment topology: where does the Apollo binary run in production?

Apollo Contracts come on top of those four. Contracts are the GraphOS feature for showing different agents different parts of the schema. A read-only research agent, a purchasing agent and an admin agent can each get their own view, with one server behind them. The next five sections cover these in order, with the configuration and the trade-offs for each.

Custom Scalars​

GraphQL's standard scalars (Int, Float, String, Boolean, ID) map cleanly to JSON, so Apollo handles them without configuration. Custom scalars need explicit help. Your moviedb schema has two, both registered from the graphql-java-extended-scalars library in the Spring GraphQL tutorial. DateTime sits on Review.createdAt, and CountryCode is the ISO 3166-1 alpha-2 code that Person.countryCode exposes and the person inputs accept.

Apollo MCP Server takes a separate JSON file that maps each custom scalar to a fragment, and you reference that file from your YAML config:

apollo/custom-scalars.json
{
"DateTime": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 timestamp in UTC, suitable for Java OffsetDateTime parsing."
},
"CountryCode": {
"type": "string",
"pattern": "^[A-Z]{2}$",
"description": "ISO 3166-1 alpha-2 country code, e.g. CZ."
}
}
apollo/mcp-config.yaml (after the custom-scalars step)
endpoint: http://localhost:8080/graphql
transport:
type: streamable_http
address: 127.0.0.1
port: 8000
schema:
source: local
path: ./schema.graphqls
operations:
source: local
paths:
- ./operations
custom_scalars: ./custom-scalars.json
overrides:
mutation_mode: explicit

At this point that is the whole file, and the only line added to Class 4's version is custom_scalars. Two later steps in this lesson add to it, and the complete file appears at the end of the auth section. The overrides.mutation_mode: explicit line from Class 4 is still needed. Without it, AddToWatchlist disappears from the tool list, and the only sign is a single startup warning.

The description field is optional, and it replaces the scalar's description from your GraphQL schema. Apollo uses it when it generates the JSON Schema for tool inputs and outputs that contain the scalar, so the agent gets a precise hint about the format. JSON Schema has built-in formats such as date-time, uri, email and uuid, and date-time covers DateTime. JSON Schema does not define a format for country codes, so CountryCode uses a pattern with a regular expression. Where it helps, you can restrict the type further with minimum, maximum or enum.

You can check that the mapping works in MCP Inspector after one config addition. Tool output schemas are off by default, so turn them on in the YAML config:

apollo/mcp-config.yaml (inside the existing overrides block)
overrides:
mutation_mode: explicit
enable_output_schema: true

None of the three Class 4 operations selects a field of either custom scalar type, so they cannot show the mapping. SummarizeMovieReviews reads summary, themes and averageScore, which use standard types, and the other two operations do not read Review or Person at all. To see the mapping, add one temporary operation. Apollo treats it as a fourth tool for as long as the file is in the directory:

apollo/operations/_verify_scalars.graphql (temporary, for this section only)
query VerifyScalars($movieId: ID!, $personId: ID!) {
movie(id: $movieId) {
reviews {
createdAt
}
}
person(id: $personId) {
countryCode
}
}

Restart the server, refresh the tool list in MCP Inspector, and open VerifyScalars. Its output schema now describes createdAt as a string with format: date-time, and countryCode with the ^[A-Z]{2}$ pattern. Both come from the fragments in custom-scalars.json, which Apollo adds to the generated schema. The agent reads that hint when it decides how to handle either value.

None of the three Class 4 operations takes a custom scalar as an input, so their input schemas look the same as before. The CountryCode pattern would appear the same way on the input schema of any operation that accepts a country code. Delete _verify_scalars.graphql once you have confirmed the mapping. It is only for this check, and Apollo reloads the directory and removes the tool as soon as the file is gone.

One case is harder: a custom scalar that JSON cannot represent cleanly. Binary data, opaque cursors and internal ULIDs that an agent should not pass back are a schema-design problem, and Apollo configuration cannot solve them. When JSON Schema cannot describe a scalar sensibly, redesign the scalar, for example by base64-encoding the binary and documenting the encoding. A schema whose scalars JSON Schema can describe needs only the mapping file above. A schema built on opaque scalars needs that redesign first.

Auth Propagation​

The agent calls Apollo MCP Server holding some authorization material, typically an OAuth bearer token. Your Spring service expects that same token (or some token derived from it) in the Authorization header so its existing Spring Security configuration can validate it and populate the security context. Apollo sits between the two, so Apollo has to forward the right headers to Spring.

For production, Apollo recommends configuring Apollo MCP Server as an OAuth 2.1 resource server itself, because that matches the MCP specification's authorization model. The transport.auth block does this:

apollo/mcp-config.yaml (transport block only)
transport:
type: streamable_http
address: 127.0.0.1
port: 8000
auth:
servers:
- https://auth.example.com
audiences:
- api.example.com
resource: https://api.example.com
scopes:
- movies:read
- watchlist:write

In this setup, Apollo is the resource server that checks tokens. The client obtains a token from your authorization server. Apollo validates that against the same server, checks that it carries one of the audiences and scopes configured above, and only then forwards the request to your Spring service. The Spring service then has two options:

  • trust Apollo, in a network setup where only Apollo can reach the GraphQL endpoint
  • validate the token again with its own security filter chain, as a second layer of defense

Many production deployments do both, because each protects against a different failure.

For local development, a quicker option is to pass the headers straight through. Add forward_headers to the YAML config, and list each header you want forwarded. The Movie Database app reads only authorization:

apollo/mcp-config.yaml (new top-level key)
forward_headers:
- authorization

Now, when the MCP client sends an Authorization: Bearer <token> header, it reaches your Spring service unchanged. Spring's security filter chain validates the JWT with the jjwt filter that the Spring GraphQL tutorial built, and fills in the security context. Your @PreAuthorize rules run, and user-scoped operations such as addToWatchlist keep working. addToWatchlist requires an authenticated caller, and it takes the watch-list owner only from the injected Principal, so an agent cannot choose the owner. Your Spring code stays the same. For local testing, the bearer token is whatever your own login mutation returned, because the Movie Database app issues and validates its own tokens. Set that header in MCP Inspector with forward_headers on, and the AddToWatchlist call that failed with an unauthorized error in Class 4 now succeeds.

If you run it twice, the second call fails with "This movie is already in your watch list". In the terms Class 2 introduced, the mutation is state-idempotent, because the repeat call leaves the list as it was. It is not response-idempotent, because it answers with an error instead of the entry. The per-operation description in the next section explains that to the agent. The server also logs a warning when you forward Authorization, Cookie or X-Api-Key this way. The MCP security best-practices document warns against passing through a token that the MCP server did not validate. That is why the resource-server configuration above is the default beyond your laptop.

Either way, Apollo is now a trust hop: a service in the middle that sees user tokens. If you already run Apollo Router in front of a federated graph, you already operate a service like that. If your deployment is a single Spring app, Apollo MCP Server adds a process that needs hardening, monitoring and patching. Class 15 covers the resource-server side from the Spring perspective. Auth propagation with Apollo is easy to configure, and it still changes your threat model.

This is the complete mcp-config.yaml for local development, with the custom scalars, the output schemas and the forwarded header:

The complete mcp-config.yaml, collapsed because it runs to 28 lines
apollo/mcp-config.yaml
endpoint: http://localhost:8080/graphql

transport:
type: streamable_http
address: 127.0.0.1
port: 8000

schema:
source: local
path: ./schema.graphqls

operations:
source: local
paths:
- ./operations

custom_scalars: ./custom-scalars.json

# Local-development shortcut (Class 5): pass the caller's bearer token straight
# through to Spring so its JWT filter and @PreAuthorize rules keep working.
# Outside local development, use the transport.auth resource-server block
# instead, which Class 5 shows and the Apollo MCP Server auth docs describe.
forward_headers:
- authorization

overrides:
mutation_mode: explicit
enable_output_schema: true

Per-Operation Descriptions​

Class 3 put a lot of work into schema descriptions, and Apollo uses that work in two ways. Apollo MCP Server generates parts of each tool's documentation from your schema's field descriptions. You can also add a description for each operation, as comments at the top of the operation file.

The convention is one block of # comments at the top of each .graphql operation file. Apollo uses those lines as the tool's main description, and adds the schema's field descriptions for the individual inputs and outputs. Add descriptions to the three operation files from Class 4:

apollo/operations/recommend_movies_for_mood.graphql
# Recommend movies for the user's current mood.
# Use this when the user asks for something to watch and provides a mood
# or sentiment word (cozy, exciting, scary, etc.). Map their phrasing to the
# closest Mood value before calling. Returns up to ten matches; if none fit,
# the result is empty instead of null.
query RecommendMoviesForMood($mood: Mood!, $excludeWatched: Boolean = false) {
recommendMoviesForMood(mood: $mood, excludeWatched: $excludeWatched) {
id
title
releaseYear
genre
rating
}
}
apollo/operations/summarize_movie_reviews.graphql
# Summarize user reviews for a specific movie.
# Use this only after the user has identified a movie they care about; do not
# call it speculatively across many movies. Always returns a summary object;
# when there are fewer than three reviews the summary field is null instead,
# and the user should be told "not enough reviews yet," not that the movie
# has no reviews. reviewCount says exactly how many reviews were found.
query SummarizeMovieReviews($movieId: ID!) {
summarizeMovieReviews(movieId: $movieId) {
movieId
reviewCount
summary
themes
averageScore
}
}
apollo/operations/add_to_watchlist.graphql
# Add a movie or TV show to the signed-in user's watch list.
# Provide exactly one of subject.movieId or subject.tvShowId. Omit status
# unless the user says they have already seen the title; the server defaults
# to WANT_TO_WATCH. State-idempotent but not response-idempotent: a repeat
# add can never create a second row, but it returns an "already in your
# watch list" error instead of the entry; treat that error as success, not
# failure, and never retry it.
mutation AddToWatchlist($subject: WatchlistSubjectInput!, $status: WatchStatus) {
addToWatchlist(subject: $subject, status: $status) {
id
status
content {
title
}
}
}

The two kinds of description answer different questions:

  • The schema description, which you wrote in Class 3, says what a field or operation means: semantics, nullability, units and caveats.
  • The operation description says what this operation is for: when to call it, when to avoid it, and what to do with the result.

The agent sees both. If you notice you are writing the same paragraph twice, keep it in the schema description and shorten the operation description. If the two say different things, keep both.

Save the files and refresh the tool list in MCP Inspector. The server reloads operation files and directories automatically, so the edits appear without a restart. The tool descriptions now combine the schema-level documentation Apollo derived with the operation-level guidance you wrote.

Apollo Contracts: Per-Agent Schema Views​

Suppose three agents call the same backend:

  • a customer-support copilot that reads movies, reviews and watch lists, and must not see User.email
  • a marketing agent that runs aggregate analytics queries and must only see aggregate data
  • an internal admin agent that may call mutations the other two cannot

Three separate Spring applications with three schemas would enforce this, at a high cost. One Spring application with three Apollo MCP Server processes, each with its own operations directory, is cleaner, but it repeats a lot of configuration three times.

Apollo Contracts is the GraphOS feature that solves this. A contract is a filtered view of a supergraph schema, defined by including or excluding types and fields based on @tag directives applied in your schema. The Apollo docs assume one prerequisite. Contracts operate on federated graphs, so the moviedb schema has to be published to GraphOS as a supergraph first. That holds even when the supergraph contains exactly one subgraph, a normal setup that rover subgraph publish handles in one command. After that, the same Spring backend serves several contracts at once. Each contract is a separately published schema variant that downstream consumers, Apollo MCP Server among them, can target.

First, tag your schema fields with role labels:

schema.graphqls (the tagged types)
directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT

type User {
id: ID!
username: String!
email: String @tag(name: "internal") @tag(name: "admin")
}

type Query {
watchlist: [WatchlistItem!]! @tag(name: "support") @tag(name: "admin")
}

type Mutation {
addToWatchlist(subject: WatchlistSubjectInput!, status: WatchStatus): WatchlistItem!
@tag(name: "support") @tag(name: "admin")
deleteMovie(id: ID!): DeleteMovieResponse!
@tag(name: "admin")
}

The directive @tag declaration on the first line is required because @tag is not built in: graphql-java fails the schema build at startup with "The directive 'tag' is not known" when a schema uses a directive it has not declared. In a federation 2 subgraph the declaration would come from the federation spec instead, through extend schema @link(url: "https://specs.apollo.dev/federation/v2.5", import: ["@tag"]). In the plain Spring app this course uses, the declaration above lets the application start.

Publish three contracts in GraphOS. Each one becomes a separately named schema variant:

  • support includes support-tagged fields and excludes admin-only fields.
  • marketing excludes everything tagged internal or admin.
  • admin includes everything.

Then point Apollo MCP Server at one contract:

apollo/mcp-config-support.yaml
# Class 5: Apollo Contracts example. Targets the `support` contract variant of
# the moviedb graph in GraphOS; both the uplink schema and the GraphOS-managed
# operations are fetched for that variant. Requires a GraphOS account and a
# published contract; shown here as the per-agent schema-view pattern.
endpoint: http://localhost:8080/graphql
graphos:
apollo_graph_ref: moviedb@support
schema:
source: uplink
operations:
source: uplink

The graph reference lives in the top-level graphos.apollo_graph_ref key, or the APOLLO_GRAPH_REF environment variable, and the part after the @ names the contract variant. Apollo then fetches both the schema and the GraphOS-managed operations for that variant. It exposes only tools that use fields visible in the support variant. deleteMovie is outside that contract, so it cannot become an MCP tool, even if someone writes the operation file.

This adds to the runtime check that the Spring GraphQL tutorial already has, an admin-only @PreAuthorize rule on the service method. The authorization rule stops an unauthorized call, and the contract removes the tool from that agent's view. The rules about who sees what then live in the schema variants, and the same Spring backend serves all three contracts without code changes.

The cost is operational complexity:

  • Contracts require GraphOS.
  • They add a publishing step to your CI pipeline.
  • Each contract is a separate variant to manage.

For a single agent, contracts add more work than they save. For three or more agents, each with a different blast radius, they express the restriction in one place, the schema, instead of spreading it across operations, server configs and runtime authorization checks. Class 16 covers agent-specific security on the Spring side, and Contracts cover the GraphOS side.

If your team does not use GraphOS, the equivalent pattern at the Spring level is multiple GraphQlSource beans behind different endpoints, with schema transformers filtering types and fields per agent. It takes more code, but the idea is the same, and only the tooling differs.

Deployment Topology​

Apollo MCP Server is a separate process. Three production topologies are reasonable, and the right choice depends on what you already operate.

The first is co-located with the Spring app on the same host. Both processes run on the same VM or container host and communicate over loopback. This is the simplest production setup, and it works well for small deployments, development environments and teams that do not run Kubernetes yet. The downside is that the MCP server competes with the Spring app for resources, and both go down when the host does.

The second is a sidecar in the same Kubernetes pod. Apollo MCP Server runs as a second container next to your Spring container, with shared network namespace so they communicate over localhost. This is the cleanest small-to-medium production setup. The two containers restart together and scale together. The trust boundary stays small, because only code inside the pod can reach the GraphQL endpoint. Many teams that run Spring on Kubernetes choose this setup.

The third is a separate service behind its own load balancer. Apollo MCP Server runs as its own deployment, scales independently, and reaches your Spring service over the cluster network. This is the right shape for federated graphs, where Apollo MCP Server fronts an Apollo Router that fronts several subgraph services. It also suits teams where a different group owns MCP than owns the Spring service. It costs one more thing to monitor and one more network hop, and in return each service can be operated independently.

Whichever topology you choose, the same configuration practices apply:

  • Pin the Apollo MCP Server version in your deployment manifest.
  • Mount the operations directory from a config map, or build it into the container image. Keep it versioned with your code, and avoid editing it live in production.
  • Forward only the headers you need. Forwarding every header leaks internal headers.
  • Send Apollo MCP Server's logs and metrics to the same observability stack as your Spring app, so agent latency and errors appear next to your existing dashboards.

These are the same practices you would apply to any new web service.

Operation sources beyond local files​

Class 4 used operations.source: local and pointed at a local directory. That works for development and for small single-team deployments. Apollo supports two other operation sources that matter for federated and multi-team production setups.

Persisted query manifests come in two forms:

  • The local form is operations.source: manifest, with a path to the JSON manifest your persisted-documents tooling already generates. It works without GraphOS, and the manifest is one more file to mount.
  • The GraphOS form is operations.source: uplink. It pulls your graph variant's persisted query list from GraphOS, so approved operations ship with the schema-publishing workflow.

With uplink, operations are registered in CI, and nobody has to mount a file at deploy time. The persisted-query setup that your iOS and Android clients already use then covers the agent's tools too. Federated organizations with many teams can use this to expose MCP across services, without a separate operations directory for each team.

Schema introspection uses operations.source: introspect, the dynamic-discovery mode that Class 4b covered. In this mode the server exposes the introspection meta-tools, which the introspection config section from Class 4b turns on, and the agent writes operations against the live schema at runtime. As Class 4b explained, this mode is safe in production only with these controls in place:

  • contract-scoped schema views
  • depth and cost limits at the gateway
  • the validate tool enabled

For most teams, the persisted-operations principle from Class 1 stays the default. Use introspect where Class 4b's criteria really apply, such as very large or fast-changing schemas and exploratory internal agents. Use the curated sources for everything users see.

Choosing between local files and persisted manifests is the same choice your frontend persisted-documents pipeline already made. If you already use Apollo GraphOS for persisted documents, switching to uplink is one config change. Otherwise, local files are simpler.

When Apollo Is the Right Choice, and When It Is Not​

The choice depends on your current stack and on where you want to invest. The table compares Apollo with the two tracks that follow:

DimensionApollo MCP Server (Track A)Spring AI MCP Starter (Track B)DIY in Spring (Track C)
Process modelSeparate binaryIn-processIn-process
Backend language stackAny (Spring, Node, Go, etc.)Spring onlySpring only
Federation supportNative (designed for it)Build it yourself if neededBuild it yourself
Customization ceilingYAML config plus Rhai script hooksHigh via Spring beansUnbounded
Operational footprintOne extra processZero (same JAR)Zero (same JAR)
Tool authoring.graphql filesJava methodsJava methods
Auth integrationForward headers or OAuthReuse Spring SecurityReuse Spring Security

Apollo is the right choice when any of these hold:

  • your team already operates Apollo Router for federation
  • your backend is polyglot and a Java-only solution is the wrong shape
  • you want the agent surface owned by an operations team distinct from the GraphQL backend team
  • you want the persisted-operations directory to be a deployable artifact that does not need a Spring rebuild

The typical case is a federated graph with many teams, where adding MCP is a config change to an existing gateway.

Apollo is a poor fit when your stack is a single Spring application, your team is small, and your security and observability setup depends on Spring conventions. There, a separate Apollo process means ongoing operational cost for capabilities you do not use. The Spring AI MCP starter (Track B, Classes 6 through 12) keeps everything in one Boot application, with the same Spring Security configuration and the same Micrometer registry. Tool methods call ExecutionGraphQlService directly, and the deployable is one JAR. We keep the MCP server inside the application to keep the lessons simple, and Class 17 explains when a production setup would move it into its own service.

The DIY route (Track C, Classes 13 and 14) fits non-standard transports, embedded use cases, and teams that want to understand every layer the other two tracks hide. It is for specific situations, and it is the one track you have to read in order, because Class 13's controller builds on the operation constants from Class 7.

If you have read this far and you are unsure, do Track B end-to-end, and return to Track A if your team adopts federation later. For a Spring team learning MCP, the Spring AI starter is the simplest place to start, and Track A and Track C are best read as informed comparisons instead of competing recommendations.

Summary​

Class 4's twenty-minute demo becomes production-ready by working through four concerns. Custom scalars map to JSON Schema fragments. Auth is handled by OAuth resource-server validation, with header pass-through as the local-development shortcut. Per-operation descriptions layer over the schema descriptions from Class 3. And the deployment topology is a deliberate choice. Apollo Contracts come on top when different agents need different schema views. Apollo MCP Server is worth its extra process in federated, polyglot, or separately-owned deployments. For a single Spring application, the Spring AI starter that Track B opens next is the simplest route to learn with.

Further Reading​

What's Next​

Class 6 begins Track B. The Apollo binary, the operations directory and the YAML config all go away. Instead, you add spring-ai-starter-mcp-server-webmvc to your existing Spring Boot project, expose your first @McpTool, and see MCP Inspector find it in a single Spring deployable. Track B reaches the same result with very different code and different operational trade-offs. Once you have seen both tracks, you can choose based on your team and your stack.