MCP Apps Development

Key concepts when developing MCP Apps


This document explains the core concepts of developing MCP Apps using React, Apollo Client, and Apollo MCP Server.

We recommend that you use the Apollo AI Apps Template to get started developing your app. The template includes much of the code referenced in this document.

Apollo Client initialization

If you have experience developing with Apollo Client, you already know most of what you need to know to develop with MCP Apps, which uses the @apollo/client-ai-apps package to manage GraphQL operations. However, this guide doesn't provide a comprehensive explanation of Apollo Client. To learn about it, go to the Apollo Client documentation.

You initialize ApolloClient by using the ApolloClient class provided by @apollo/client-ai-apps. The ApolloClient constructor needs at least an instance of ApolloCache (typically InMemoryCache) and the manifest file.

caution
Make sure you import ApolloClient from the @apollo/client-ai-apps package, not the core @apollo/client repository. The ApolloClient class from @apollo/client-ai-apps is for integrating with the MCP Apps environment.
TypeScript
src/main.tsx
1import { InMemoryCache } from "@apollo/client";
2import { ApolloClient } from "@apollo/client-ai-apps";
3// Note the manifest is written to the root of the app
4import manifest from "../.application-manifest.json";
5
6// You may also provide other options that the core ApolloClient accepts
7const client = new ApolloClient({
8  cache: new InMemoryCache(),
9  manifest,
10});
note
Unlike the ApolloClient class from the core @apollo/client package, the ApolloClient class from @apollo/client-ai-apps doesn't require a configured link. By default, the class uses ToolCallLink, which executes GraphQL queries through MCP tools.

You can provide a custom link chain to grant additional capabilities to ApolloClient; however, your terminating link needs to be a ToolCallLink.

Provide your client to ApolloProvider

After you've created your client instance, pass it to ApolloProvider. As with ApolloClient, you need to use the ApolloProvider component exported from the @apollo/client-ai-apps package.

TypeScript
src/main.tsx
1import { ApolloProvider } from "@apollo/client-ai-apps/react";
2
3const client = new ApolloClient({
4  // ...
5});
6
7createRoot(document.getElementById("root")!).render(
8  <ApolloProvider client={client}>
9    <App />
10  </ApolloProvider>,
11);

The ApolloProvider implementation from @apollo/client-ai-apps/react initializes your application with Apollo MCP Server and provides the client instance in React context for use with Apollo Client's data-fetching hooks.

Show a loading fallback during initialization

The ApolloProvider component uses React's Suspense functionality during initialization, which avoids rendering application code that relies on client initialization. The client is initialized when it receives the ui/notifications/tool-result notification from the host.

If you want to display a loading fallback while the app initializes, wrap ApolloProvider with React's Suspense component.

TypeScript
1import { Suspense } from "react";
2
3createRoot(document.getElementById("root")!).render(
4  <Suspense fallback={<LoadingFallback />}>
5    <ApolloProvider client={client}>
6      <App />
7    </ApolloProvider>
8  </Suspense>,
9);
note
Using a Suspense component to display a loading fallback is optional. If you don't provide a Suspense component, the screen remains blank until the host provides the tool result.

Register an MCP tool

To register MCP tools, combine your GraphQL queries with the @tool directive. By default, the operation name and description are used to define the tool name and description. @tool also accepts name and description arguments, which override the operation name and description used to register the tool.

note
To use operation descriptions, you must use graphql version 16.2.0 or greater.

For example, if you want to define a query that gets the highest-rated products in a marketplace application, define a query called TopProductsQuery in your React component, then use the useQuery hook from @apollo/client/react to read the query data. To register the query as its own tool, use the @tool directive.

TypeScript
1import { gql, TypedDocumentNode } from "@apollo/client";
2import { useQuery } from "@apollo/client/react";
3
4// Note: These types should be generated using a tool like GraphQL Codegen.
5// Avoid writing them by hand.
6const TOP_PRODUCTS_QUERY: TypedDocumentNode<
7  TopProductsQuery,
8  TopProductsQueryVariables
9> = gql`
10  "Shows a list of the highest-rated products."
11  query TopProductsQuery @tool {
12    topProducts {
13      id
14      title
15      rating
16      price
17      thumbnail
18    }
19  }
20`;
21
22function App() {
23  const { data, loading, error, dataState } = useQuery(TOP_PRODUCTS_QUERY);
24
25  if (loading) {
26    return <div>Loading...</div>;
27  }
28
29  if (error) {
30    return <div>Error! {error.message}</div>;
31  }
32
33  return (
34    <div>
35      {data.topProducts.map((product) => (
36        <Product key={product.id} product={product} />
37      ))}
38    </div>
39  );
40}

Now that the query is registered as a tool, you can run the app in ChatGPT or an MCP Apps-compatible host and prompt the LLM to show you the top products. They render using data fetched by MCP Server.

Access tool information

To access the tool name, as well as input passed from the LLM to the tool, use the useToolInfo hook.

TypeScript
1import { useToolInfo } from "@apollo/client-ai-apps/react";
2
3function App() {
4  const { toolName, toolInput } = useToolInfo();
5
6  return (
7    <div>
8      <p>Tool: {toolName}</p>
9      <p>Input: {JSON.stringify(toolInput)}</p>
10    </div>
11  );
12}

toolName and toolInput are typed according to the tools defined in your app. To learn how to include the generated types in your app, go to the typescript configuration section.

note
To provide specific toolInput types, configure a schema with the apolloClientAiApps Vite plugin. For more information, go to the Vite configuration section.

Populate variables from tool input

When your query includes variables, the LLM decides what values to provide to those variables. That can be a problem because the variable values you provide to useQuery might not match the LLM provided variables.

You might consider using useToolInfo to populate the initial values for those variables, but that approach quickly reveals additional complexity that useToolInfo cannot handle on its own:

  • The tool called by the agent might not be the same tool

  • The input might differ from the original tool input when a user navigates away and back

  • Some variables might need to synchronize with an external source, such as props

  • Some variables might change as a result of user interaction

The createHydrationUtils utility bridges the gap. Call createHydrationUtils with the query that provides the tool definition. Doing that returns a useHydratedVariables hook used to manage variables for the query. The returned variables are provided to the useQuery hook in the variables option.

TypeScript
1import { createHydrationUtils } from "@apollo/client-ai-apps/react";
2
3const TOP_PRODUCTS: TypedDocumentNode<
4  TopProductsQuery,
5  TopProductsQueryVariables
6> = gql`
7  "Get list of top products"
8  query TopProducts($limit: Int) @tool {
9    topProducts(limit: $limit) {
10      id
11      title
12      price
13    }
14  }
15`;
16
17const { useHydratedVariables } = createHydrationUtils(TOP_PRODUCTS);
18
19function App() {
20  // Provide initial variable values to the `useHydratedVariables` hook.
21  // These are used when the tool call doesn't match this query
22  const [variables] = useHydratedVariables({ limit: 10 });
23
24  // Use the `variables` returned from `useHydratedVariables` for the query
25  const { data } = useQuery(TOP_PRODUCTS, { variables });
26
27  // ...
28}

useHydratedVariables compares the configured query to the tool called by the agent to determine if they match. When they match, variables is strictly set to the value of the tool input provided by the LLM. When they don't match, the variables argument passed to useHydratedVariables is used instead.

note
useHydratedVariables only returns variables defined by the query. Extra inputs, such as those added by extraInputs, are stripped away in the returned object.
caution
Always read values from the variables object returned by useHydratedVariables to ensure your UI accurately reflects the current state.

Optional variables

GraphQL queries can include optional variables in the query definition. When the tool executes a query that includes an optional variable, the LLM might omit any optional variables. When that happens, the returned variables object includes only the values from the tool input. The variable values provided to useHydratedVariables are ignored, ensuring that useQuery matches the exact tool call.

TypeScript
1const TOP_PRODUCTS: TypedDocumentNode<
2  TopProductsQuery,
3  TopProductsQueryVariables
4> = gql`
5  "Get list of top products"
6  query TopProducts($limit: Int, $order: Order, $sortBy: SortBy) @tool {
7    topProducts(limit: $limit, order: $order, sortBy: $sortBy) {
8      id
9      title
10      price
11    }
12  }
13`;
14
15const { useHydratedVariables } = createHydrationUtils(TOP_PRODUCTS);
16
17function App() {
18  // if tool input is:
19  // { limit: 5 }
20  const [variables] = useHydratedVariables({
21    limit: 10,
22    order: "asc",
23    sortBy: "title",
24  });
25  // => { limit: 5 }
26}

Update variables during user interaction

Similar to the useState hook in React, useHydratedVariables returns both the current variables value and a setVariables function, used to update the value of a variable. Call setVariables with a partial variables object that includes the variables you want to update. setVariables shallow-merges the current variables object with the updated variables object so you don't need to provide the full variables object each time you want to make an update.

TypeScript
1const TOP_PRODUCTS: TypedDocumentNode<
2  TopProductsQuery,
3  TopProductsQueryVariables
4> = gql`
5  "Get list of top products"
6  query TopProducts($limit: Int) @tool {
7    topProducts(limit: $limit) {
8      id
9      title
10      price
11    }
12  }
13`;
14
15const { useHydratedVariables } = createHydrationUtils(TOP_PRODUCTS);
16
17function App() {
18  const [variables, setVariables] = useHydratedVariables({ limit: 10 });
19
20  const { data } = useQuery(TOP_PRODUCTS, { variables });
21
22  function handleShowMore() {
23    setVariables({ limit: variables.limit + 5 });
24
25    // Use a callback to get the previous variables value
26    setVariables((prev) => ({ limit: prev.limit + 5 }));
27  }
28
29  return (
30    <>
31      <button onClick={handleShowMore}>Show 5 more results</button>
32
33      {/* ... */}
34    </>
35  );
36}

Sync variables with props

You might have variable values that are provided from an external source like props. You typically want to keep those values up-to-date without having to sync the values manually.

To mark a variable as reactive, use the reactive function.

TypeScript
1import { createHydrationUtils, reactive } from "@apollo/client-ai-apps/react";
2
3const GET_PRODUCTS_BY_CATEGORY = gql`
4  "Get an ordered list of products for a category"
5  query GetProductsByCategory($category: Category!, $order: Order, $limit: Int)
6  @tool {
7    productsByCategory(category: $category, order: $order, limit: $limit) {
8      id
9      title
10      price
11    }
12  }
13`;
14
15const { useHydratedVariables } = createHydrationUtils(GET_PRODUCTS_BY_CATEGORY);
16
17interface AppProps {
18  id: string;
19}
20
21function App({ id }: AppProps) {
22  // As `id` changes, `variables.id` is kept in sync with the value
23  const [variables, setVariables] = useHydratedVariables({
24    id: reactive(id),
25    order: "asc",
26    limit: 10,
27  });
28
29  const { data } = useQuery(GET_PRODUCTS_BY_CATEGORY, { variables });
30  // ...
31}

Marking a variable reactive prevents you from using that variable in the setVariables function, which avoids accidental bugs that might desynchronize the value with the input. If a reactive variable is provided to setVariables, a warning is logged and the update is ignored.

note
When the value of a reactive variable differs from the tool input value, the tool input value is used. It remains the value of the tool input value until the external value provided to reactive changes. From then on, the new value is used.

Access host context

Use the useHostContext hook to access context provided by the MCP host. useHostContext keeps the host context up-to-date for you as it changes.

TypeScript
MyComponent.mcp.tsx
1import { useHostContext } from "@apollo/client-ai-apps";
2
3export function MyComponent() {
4  const hostContext = useHostContext();
5
6  return <div>{JSON.stringify(hostContext)}</div>;
7}

Platform-specific modules

To create platform-specific modules, append the file extension that corresponds with the host you want to target.

  • *.openai.ts(x) - Code specific to a ChatGPT app

  • *.mcp.ts(x) - Code specific to an MCP app

Those extensions allow you to write code only available to the targeted host.

For example, the @apollo/client-ai-apps/openai entry point provides a useWidgetState hook that is only available to ChatGPT apps. To use that hook, create a component file with the .openai.tsx extension.

TypeScript
MyComponent.openai.tsx
1import { useWidgetState } from "@apollo/client-ai-apps/openai";
2
3export function MyComponent() {
4  const [widgetState, setWidgetState] = useWidgetState();
5
6  return (
7    <div>
8      <div>{widgetState.foo.toString()}</div>
9      <button onClick={() => setWidgetState({ foo: true })}>Set foo</button>
10    </div>
11  );
12}
tip
For additional type safety when developing a platform-specific module, please reference the TypeScript configuration section to update your configuration.

How to import platform-specific modules

To import platform-specific modules, use the base name. For example, if you create a platform-specific Home component for OpenAI (Home.openai.tsx) and MCP (Home.mcp.tsx), you import it like this:

TypeScript
1import { Home } from "./Home";
caution
Make sure each configured environment has a module it can import. If one doesn't exist, the application fails to build. For example, if you create a Home.openai.tsx file, you also need to create either a Home.mcp.tsx or Home.tsx file.

Platform utility

In some cases, you might want to change a value in your application depending on the running platform. The Platform utility provides a simple way to detect the current platform and provide platform-specific values at runtime.

TypeScript
1import { Platform } from "@apollo/client-ai-apps";
2
3function App() {
4  // Use `Platform.target` to get a string representing the current platform
5  const currentPlatform = Platform.target; // => "openai" | "mcp"
6
7  // The `Platform.select` function selects the value for you using the current platform
8  const label = Platform.select({
9    mcp: "Running in an MCP host",
10    openai: "Running in ChatGPT",
11  });
12
13  // `Platform.select` also accepts a function that is called immediately.
14  // Useful when you want to avoid expensive computation for a value not used in
15  // the current platform
16  const value = Platform.select({
17    mcp: () => factorial(10),
18    openai: () => factorial(20),
19  });
20
21  return <div>{label}</div>;
22}
note
Any value can be returned by select. You aren't limited to strings.

TypeScript configuration

@apollo/client-ai-apps provides extendable TypeScript configurations that make sure you only use code available to your application from platform-specific modules.

  • @apollo/client-ai-apps/tsconfig/core — For shared modules that run in both environments. This config ensures that included files only use shared utilities available to all environments. Use this when type-checking shared files for all environments.

  • @apollo/client-ai-apps/tsconfig/mcp — For developing MCP-specific modules. This configuration allows you to access utilities exported by @apollo/client-ai-apps/mcp. Use this when type-checking your .mcp.* files.

  • @apollo/client-ai-apps/tsconfig/openai — For developing ChatGPT-specific modules. This configuration allows you to use utilities exported by @apollo/client-ai-apps/openai. Use this when type-checking your .openai.* files.

Use TypeScript project references to apply a TypeScript configuration to a subset of files. We recommend these configuration files:

Text
1.
2├── tsconfig.app.json
3├── tsconfig.base.json
4├── tsconfig.mcp.json
5├── tsconfig.openai.json
6└── tsconfig.json

tsconfig.base.json

Shared configuration for all TypeScript files. This is be where you should configure most of your compilerOptions.

jsonc
tsconfig.base.json
1{
2  "compilerOptions": {
3    "strict": true,
4    // ... all other preferred TypeScript settings
5  },
6}

tsconfig.app.json

Configuration that targets all shared modules. Extend @apollo/client-ai-apps/tsconfig/core and exclude platform-specific modules (.mcp.* and .openai.* files). Make sure you also include the generated types from the apolloClientAiApps Vite plugin.

jsonc
tsconfig.app.json
1{
2  "extends": ["@apollo/client-ai-apps/tsconfig/core", "./tsconfig.base.json"],
3  "include": ["src", ".apollo-client-ai-apps/types"],
4  "exclude": ["src/**/*.mcp.*", "src/**/*.openai.*"],
5}

tsconfig.mcp.json

Configuration that targets MCP app specific modules. Extend @apollo/client-ai-apps/tsconfig/mcp and include .mcp.* files.

jsonc
tsconfig.mcp.json
1{
2  "extends": ["@apollo/client-ai-apps/tsconfig/mcp", "./tsconfig.base.json"],
3  "include": [
4    ".apollo-client-ai-apps/types",
5    "src/**/*.mcp.ts",
6    "src/**/*.mcp.tsx",
7  ],
8}
note
If your app doesn't have any MCP Apps-specific files, don't include this file because TypeScript might be unable to match files in the include setting.

tsconfig.openai.json

Configuration that targets ChatGPT App-specific modules. Extend @apollo/client-ai-apps/tsconfig/openai and include .openai.* files. Include the openai/globals types in the types configuration, which provides types for window.openai.

jsonc
tsconfig.openai.json
1{
2  "extends": ["@apollo/client-ai-apps/tsconfig/openai", "./tsconfig.base.json"],
3  "compilerOptions": {
4    "types": ["@apollo/client-ai-apps/openai/globals"],
5  },
6  "include": [
7    ".apollo-client-ai-apps/types",
8    "src/**/*.openai.ts",
9    "src/**/*.openai.tsx",
10  ],
11}

Note that, if compilerOptions.types is specified, only those packages are included in the global scope. That is the recommendation for TypeScript version 5.9 and the default in version 6.0

note
If your app doesn't have any ChatGPT App-specific files, don't include this file because TypeScript might complain that it is unable to match files in the include setting.

tsconfig.json

The base configuration that should include all project references.

jsonc
tsconfig.openai.json
1{
2  "files": [],
3  "references": [
4    { "path": "./tsconfig.app.json" },
5    // Omit if you don't create this file
6    { "path": "./tsconfig.mcp.json" },
7    // Omit if you don't create this file
8    { "path": "./tsconfig.openai.json" },
9  ],
10}

Vite configuration

The @apollo/client-ai-apps package includes a Vite plugin that configures your application for developing ChatGPT and MCP apps.

To use it, import apolloClientAiApps from @apollo/client-ai-apps/vite and provide it to the plugins option in your Vite configuration. Set the targets option to define what environments you are building for.

  • mcp - Creates a build artifact for MCP apps.

  • openai - Creates a build artifact for ChatGPT apps.

TypeScript
vite.config.ts
1import { defineConfig } from "vite";
2import { apolloClientAiApps } from "@apollo/client-ai-apps/vite";
3
4export default defineConfig({
5  plugins: [
6    apolloClientAiApps({
7      targets: ["mcp", "openai"],
8      appsOutDir: "../apps", // path where build artifacts are written
9    }),
10    // ... other plugins
11  ],
12  // ... other config
13});

The appsOutDir option controls where build artifacts (including the manifest file and built resources) are written. That directory must be named apps, and it should point to the directory that Apollo MCP Server reads from.

tip
The Vite plugin creates a .apollo-client-ai-apps/ directory with generated files, such as TypeScript types for your application. This directory is fully generated by the Vite plugin and can be safely added to your .gitignore.

Development mode

You can only run one environment in development mode at a time. Set the devTarget option to define what environment you are developing for.

TypeScript
1apolloClientAiApps({
2  devTarget: "openai",
3  // ...
4});
note
If the configured targets option only includes one value, you can omit the devTarget option because the plugin infers the devTarget as the value in targets.

Use environment variables to set devTarget

If you are developing both ChatGPT and MCP apps, switching the devTarget each time you want to develop for a different environment is cumbersome. We recommend that you use environment variables to configure those kinds of dynamic values so that you can easily change the environment without having to edit the build configuration.

TypeScript
1apolloClientAiApps({
2  devTarget: process.env.DEV_TARGET,
3  // ...
4});

However, this results in a TypeScript error. devTarget only accepts the values mcp or openai, but process.env.DEV_TARGET is a string type. To fix this issue, use the devTarget helper exported from @apollo/client-ai-apps/vite which validates the input and returns the correctly typed value.

TypeScript
1import { apolloClientAiApps, devTarget } from "@apollo/client-ai-apps/vite";
2
3apolloClientAiApps({
4  devTarget: devTarget(process.env.DEV_TARGET),
5  // ...
6});
tip
We recommend that you add separate development scripts in package.json that target each environment so you don't need to remember to set the environment variable.
JSON
package.json
1{
2  "scripts": {
3    "dev:mcp": "DEV_TARGET=mcp vite",
4    "dev:openai": "DEV_TARGET=openai vite"
5  }
6}

Schema types

apolloClientAiApps generates TypeScript types for tools defined in your application by the @tool directive, including tool names and inputs. For input types to resolve correctly, ensure the Vite plugin knows about input types defined by your GraphQL schema. Configure your schema using the schema option.

TypeScript
1apolloClientAiApps({
2  schema: "./path/to/schema.graphql",
3  // ...
4});

If you don't configure a schema, the resolved tool input types are defined as Record<string, unknown>.

note
The apolloClientAiApps Vite plugin uses GraphQL Codegen internally. The schema option accepts any value that GraphQL Codegen accepts.
Feedback

Edit on GitHub

Ask Community