@apollo/client-ai-apps 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.github/workflows/pr.yaml +35 -0
  2. package/.github/workflows/prep-release.yml +4 -3
  3. package/.github/workflows/release.yaml +8 -5
  4. package/.github/workflows/verify-changeset.yml +4 -4
  5. package/CHANGELOG.md +16 -0
  6. package/dist/utilities/getToolNamesFromDocument.d.ts +1 -1
  7. package/dist/utilities/getToolNamesFromDocument.d.ts.map +1 -1
  8. package/dist/utilities/getToolNamesFromDocument.js +14 -5
  9. package/dist/utilities/getToolNamesFromDocument.js.map +1 -1
  10. package/dist/vite/apolloClientAiApps.d.ts.map +1 -1
  11. package/dist/vite/apolloClientAiApps.js +8 -2
  12. package/dist/vite/apolloClientAiApps.js.map +1 -1
  13. package/integration-tests/docker-compose.yml +30 -0
  14. package/integration-tests/global-teardown.js +9 -0
  15. package/integration-tests/graphql-server/Dockerfile +12 -0
  16. package/integration-tests/graphql-server/package.json +10 -0
  17. package/integration-tests/graphql-server/server.ts +22 -0
  18. package/integration-tests/mcp-config.yaml +29 -0
  19. package/integration-tests/mock-app/index.html +12 -0
  20. package/integration-tests/mock-app/package.json +24 -0
  21. package/integration-tests/mock-app/src/App.tsx +23 -0
  22. package/integration-tests/mock-app/src/main.tsx +22 -0
  23. package/integration-tests/mock-app/src/tools/Echo.tsx +33 -0
  24. package/integration-tests/mock-app/src/tools/Hello.tsx +19 -0
  25. package/integration-tests/mock-app/src/tools/Private.tsx +34 -0
  26. package/integration-tests/mock-app/src/tools/SemiPrivate.tsx +34 -0
  27. package/integration-tests/mock-app/tsconfig.json +25 -0
  28. package/integration-tests/mock-app/vite.config.ts +22 -0
  29. package/integration-tests/package-lock.json +5749 -0
  30. package/integration-tests/package.json +20 -0
  31. package/integration-tests/playwright.config.ts +23 -0
  32. package/integration-tests/schema.graphql +10 -0
  33. package/integration-tests/tests/hello.spec.ts +11 -0
  34. package/integration-tests/tests/privateDirective.spec.ts +73 -0
  35. package/integration-tests/tests/variables.spec.ts +24 -0
  36. package/package.json +3 -2
  37. package/src/react/__tests__/createHydrationUtils.test.tsx +62 -0
  38. package/src/utilities/getToolNamesFromDocument.ts +19 -7
  39. package/src/vite/__tests__/apolloClientAiApps.test.ts +51 -2
  40. package/src/vite/apolloClientAiApps.ts +8 -2
  41. package/vitest.config.ts +2 -1
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "integration-tests",
3
+ "private": true,
4
+ "type": "module",
5
+ "workspaces": [
6
+ "./graphql-server",
7
+ "./mock-app"
8
+ ],
9
+ "scripts": {
10
+ "build:lib": "npm run build --prefix ..",
11
+ "build:mock-app": "npm run build --workspace mock-app",
12
+ "pretest": "npm-run-all build:*",
13
+ "test": "playwright test"
14
+ },
15
+ "devDependencies": {
16
+ "@apollo/mcp-impostor-host": "^0.3.0",
17
+ "@playwright/test": "^1.59.1",
18
+ "npm-run-all": "^4.1.5"
19
+ }
20
+ }
@@ -0,0 +1,23 @@
1
+ import { defineConfig } from "@playwright/test";
2
+
3
+ export default defineConfig({
4
+ testDir: "./tests",
5
+ globalTeardown: "./global-teardown.js",
6
+ webServer: [
7
+ {
8
+ command: "npx serve-impostor-host --playwright",
9
+ url: "http://localhost:8080",
10
+ reuseExistingServer: !process.env.CI,
11
+ },
12
+ {
13
+ command: "docker compose up --build",
14
+ url: "http://localhost:8000/health",
15
+ reuseExistingServer: !process.env.CI,
16
+ },
17
+ ],
18
+ workers: 1,
19
+ use: {
20
+ browserName: "chromium",
21
+ trace: "retain-on-failure",
22
+ },
23
+ });
@@ -0,0 +1,10 @@
1
+ type Query {
2
+ hello: String!
3
+ echo(message: String!): String!
4
+ user: User
5
+ }
6
+
7
+ type User {
8
+ address: String
9
+ fullName: String
10
+ }
@@ -0,0 +1,11 @@
1
+ import { test } from "@apollo/mcp-impostor-host/playwright";
2
+ import { expect } from "@playwright/test";
3
+
4
+ const URL = "http://localhost:8000/mcp?app=mock-app&appTarget=mcp";
5
+
6
+ test("renders data from a tool with no arguments", async ({ mcpHost }) => {
7
+ const connection = await mcpHost.connect({ url: URL });
8
+ const { appFrame } = await connection.callTool("Hello");
9
+
10
+ await expect(appFrame.getByTestId("greeting")).toHaveText("Hello, world!");
11
+ });
@@ -0,0 +1,73 @@
1
+ import { test } from "@apollo/mcp-impostor-host/playwright";
2
+ import { expect } from "@playwright/test";
3
+
4
+ const URL = "http://localhost:8000/mcp?app=mock-app&appTarget=mcp";
5
+
6
+ test("omits private field in structuredContent, but available to the view", async ({
7
+ mcpHost,
8
+ }) => {
9
+ const connection = await mcpHost.connect({ url: URL });
10
+ const { result, appFrame } = await connection.callTool("SemiPrivate");
11
+
12
+ expect(result.structuredContent).toEqual({
13
+ result: {
14
+ data: {
15
+ user: {
16
+ __typename: "User",
17
+ fullName: "MCP User",
18
+ },
19
+ },
20
+ },
21
+ toolName: "SemiPrivate",
22
+ });
23
+
24
+ expect(result._meta).toEqual({
25
+ toolName: "SemiPrivate",
26
+ structuredContent: {
27
+ result: {
28
+ data: {
29
+ user: {
30
+ __typename: "User",
31
+ address: "1234 Main St",
32
+ fullName: "MCP User",
33
+ },
34
+ },
35
+ },
36
+ toolName: "SemiPrivate",
37
+ },
38
+ });
39
+
40
+ await expect(appFrame.getByTestId("fullName")).toHaveText("MCP User");
41
+ await expect(appFrame.getByTestId("address")).toHaveText("1234 Main St");
42
+ });
43
+
44
+ test("omits full selection sets, but available to the view", async ({
45
+ mcpHost,
46
+ }) => {
47
+ const connection = await mcpHost.connect({ url: URL });
48
+ const { result, appFrame } = await connection.callTool("Private");
49
+
50
+ expect(result.structuredContent).toEqual({
51
+ result: { data: {} },
52
+ toolName: "Private",
53
+ });
54
+
55
+ expect(result._meta).toEqual({
56
+ toolName: "Private",
57
+ structuredContent: {
58
+ result: {
59
+ data: {
60
+ user: {
61
+ __typename: "User",
62
+ address: "1234 Main St",
63
+ fullName: "MCP User",
64
+ },
65
+ },
66
+ },
67
+ toolName: "Private",
68
+ },
69
+ });
70
+
71
+ await expect(appFrame.getByTestId("fullName")).toHaveText("MCP User");
72
+ await expect(appFrame.getByTestId("address")).toHaveText("1234 Main St");
73
+ });
@@ -0,0 +1,24 @@
1
+ import { test } from "@apollo/mcp-impostor-host/playwright";
2
+ import { expect } from "@playwright/test";
3
+
4
+ const URL = "http://localhost:8000/mcp?app=mock-app&appTarget=mcp";
5
+
6
+ test("renders data from a tool with arguments", async ({ mcpHost }) => {
7
+ const connection = await mcpHost.connect({ url: URL });
8
+ const { result, appFrame } = await connection.callTool("Echo", {
9
+ message: "Hello!",
10
+ });
11
+
12
+ expect(result.structuredContent).toStrictEqual({
13
+ toolName: "Echo",
14
+ result: {
15
+ data: {
16
+ echo: "Hello! (Hello!)",
17
+ },
18
+ },
19
+ });
20
+ expect(result._meta).toStrictEqual({
21
+ toolName: "Echo",
22
+ });
23
+ await expect(appFrame.getByTestId("echo")).toHaveText("Hello! (Hello!)");
24
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "@apollo/client-ai-apps",
4
- "version": "0.7.1",
4
+ "version": "0.7.3",
5
5
  "keywords": [
6
6
  "apollo",
7
7
  "graphql",
@@ -104,7 +104,7 @@
104
104
  "attw": "npm run build && attw --pack . --ignore-rules cjs-resolves-to-esm --profile node16"
105
105
  },
106
106
  "devDependencies": {
107
- "@apollo/client": "^4.0.12",
107
+ "@apollo/client": "^4.1.7",
108
108
  "@arethetypeswrong/cli": "^0.18.2",
109
109
  "@modelcontextprotocol/ext-apps": "^1.0.1",
110
110
  "@testing-library/jest-dom": "^6.9.1",
@@ -119,6 +119,7 @@
119
119
  "esbuild": "^0.25.12",
120
120
  "graphql": "^16.12.0",
121
121
  "happy-dom": "^20.0.10",
122
+ "jiti": "^2.6.1",
122
123
  "memfs": "^4.56.10",
123
124
  "mock-require": "^3.0.3",
124
125
  "prettier": "^3.7.4",
@@ -77,6 +77,68 @@ eachHostEnv((setupHost, ApolloClient) => {
77
77
  await expect(takeSnapshot).not.toRerender();
78
78
  });
79
79
 
80
+ test("returns tool input value when @tool name is defined by operation name", async () => {
81
+ using _ = spyOnConsole("debug");
82
+
83
+ const query: TypedDocumentNode<
84
+ { products: Array<{ __typename: "Product"; id: string }> },
85
+ { category: string; page: number; sortBy: string }
86
+ > = gql`
87
+ "Get products by category"
88
+ query Products($category: String!, $page: Int!, $sortBy: String!) @tool {
89
+ products(category: $category, page: $page, sortBy: $sortBy) {
90
+ id
91
+ }
92
+ }
93
+ `;
94
+
95
+ const client = new ApolloClient({
96
+ cache: new InMemoryCache(),
97
+ manifest: mockApplicationManifest({
98
+ operations: [parseManifestOperation(query)],
99
+ }),
100
+ });
101
+
102
+ using env = await setupHost({
103
+ client,
104
+ toolCall: {
105
+ name: "Products",
106
+ input: { category: "electronics", page: 1, sortBy: "title" },
107
+ result: { structuredContent: { result: { data: { products: [] } } } },
108
+ },
109
+ });
110
+ const { host, params } = env;
111
+
112
+ host.sendToolInput(params.toolInput);
113
+ host.sendToolResult(params.toolResult);
114
+
115
+ const { useHydratedVariables } = createHydrationUtils(query);
116
+
117
+ using _disabledAct = disableActEnvironment();
118
+ const { takeSnapshot } = await renderHookToSnapshotStream(
119
+ () =>
120
+ useHydratedVariables({
121
+ category: "music",
122
+ page: 1,
123
+ sortBy: "name",
124
+ }),
125
+ {
126
+ wrapper: ({ children }) => (
127
+ <ApolloProvider client={client}>{children}</ApolloProvider>
128
+ ),
129
+ }
130
+ );
131
+
132
+ const [variables] = await takeSnapshot();
133
+ expect(variables).toStrictEqual({
134
+ category: "electronics",
135
+ page: 1,
136
+ sortBy: "title",
137
+ });
138
+
139
+ await expect(takeSnapshot).not.toRerender();
140
+ });
141
+
80
142
  test("returns user-provided variables when tool name does not match", async () => {
81
143
  using _ = spyOnConsole("debug");
82
144
  const query = gql`
@@ -1,15 +1,27 @@
1
1
  import { getOperationDefinition } from "@apollo/client/utilities/internal";
2
- import { Kind, type DocumentNode } from "graphql";
2
+ import { Kind, type DirectiveNode, type DocumentNode } from "graphql";
3
3
 
4
4
  export function getToolNamesFromDocument(document: DocumentNode) {
5
5
  const operationDef = getOperationDefinition(document);
6
+ const operationName = operationDef?.name?.value;
7
+ const toolDirectives =
8
+ operationDef?.directives?.filter((d) => d.name.value === "tool") ?? [];
9
+
10
+ if (toolDirectives.length === 1) {
11
+ return new Set([
12
+ getToolNameFromDirective(toolDirectives[0]) ?? operationName,
13
+ ]);
14
+ }
6
15
 
7
16
  return new Set(
8
- operationDef?.directives
9
- ?.filter((d) => d.name.value === "tool")
10
- .flatMap((d) => {
11
- const nameArg = d.arguments?.find((arg) => arg.name.value === "name");
12
- return nameArg?.value.kind === Kind.STRING ? [nameArg.value.value] : [];
13
- })
17
+ toolDirectives.flatMap((d) => {
18
+ const name = getToolNameFromDirective(d);
19
+ return name ? [name] : [];
20
+ })
14
21
  );
15
22
  }
23
+
24
+ function getToolNameFromDirective(directive: DirectiveNode) {
25
+ const nameArg = directive.arguments?.find((arg) => arg.name.value === "name");
26
+ return nameArg?.value.kind === Kind.STRING ? nameArg.value.value : undefined;
27
+ }
@@ -410,7 +410,8 @@ describe("operations", () => {
410
410
  vol.fromJSON({
411
411
  "package.json": mockPackageJson(),
412
412
  "src/my-component.tsx": declareOperation(gql`
413
- query HelloWorldQuery {
413
+ "Returns a greeting"
414
+ query HelloWorldQuery @tool {
414
415
  helloWorld
415
416
  }
416
417
  `),
@@ -445,7 +446,12 @@ describe("operations", () => {
445
446
  "id": "f8604bba13e2f589608c0eb36c3039c5ef3a4c5747bc1596f9dbcbe924dc90f9",
446
447
  "name": "HelloWorldQuery",
447
448
  "prefetch": false,
448
- "tools": [],
449
+ "tools": [
450
+ {
451
+ "description": "Returns a greeting",
452
+ "name": "HelloWorldQuery",
453
+ },
454
+ ],
449
455
  "type": "query",
450
456
  "variables": {},
451
457
  },
@@ -512,6 +518,49 @@ describe("operations", () => {
512
518
  `);
513
519
  });
514
520
 
521
+ test("does not write operations if not annotated with `@tool` or `@prefetch`", async () => {
522
+ vol.fromJSON({
523
+ "package.json": mockPackageJson(),
524
+ "src/my-component.tsx": declareOperation(gql`
525
+ query HelloWorldQuery {
526
+ helloWorld
527
+ }
528
+ `),
529
+ "src/my-mutation.tsx": declareOperation(gql`
530
+ mutation SendGreeting {
531
+ sendGreeting(message: "Hello")
532
+ }
533
+ `),
534
+ });
535
+
536
+ await using server = await setupServer({
537
+ plugins: [
538
+ apolloClientAiApps({ targets: ["mcp"], appsOutDir: "dist/apps" }),
539
+ ],
540
+ });
541
+ await server.listen();
542
+
543
+ const manifest = readManifestFile();
544
+ expect(manifest).toMatchInlineSnapshot(`
545
+ {
546
+ "appVersion": "1.0.0",
547
+ "csp": {
548
+ "baseUriDomains": [],
549
+ "connectDomains": [],
550
+ "frameDomains": [],
551
+ "redirectDomains": [],
552
+ "resourceDomains": [],
553
+ },
554
+ "format": "apollo-ai-app-manifest",
555
+ "hash": "abc",
556
+ "name": "my-app",
557
+ "operations": [],
558
+ "resource": "http://localhost:3333",
559
+ "version": "1",
560
+ }
561
+ `);
562
+ });
563
+
515
564
  test("errors when a subscription operation type is discovered", async () => {
516
565
  vol.fromJSON({
517
566
  "package.json": mockPackageJson(),
@@ -453,8 +453,11 @@ export function apolloClientAiApps(
453
453
  query: source,
454
454
  fetchPolicy: "no-cache",
455
455
  });
456
+ const data = result.data!;
456
457
 
457
- manifestOperations.push(result.data!);
458
+ if (data.tools.length > 0 || data.prefetch) {
459
+ manifestOperations.push(data);
460
+ }
458
461
  break;
459
462
  }
460
463
  case OperationTypeNode.MUTATION: {
@@ -462,8 +465,11 @@ export function apolloClientAiApps(
462
465
  mutation: source,
463
466
  fetchPolicy: "no-cache",
464
467
  });
468
+ const data = result.data!;
465
469
 
466
- manifestOperations.push(result.data!);
470
+ if (data.tools.length > 0 || data.prefetch) {
471
+ manifestOperations.push(data);
472
+ }
467
473
  break;
468
474
  }
469
475
  default:
package/vitest.config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { defineConfig } from "vitest/config";
1
+ import { defineConfig, defaultExclude } from "vitest/config";
2
2
  import react from "@vitejs/plugin-react";
3
3
 
4
4
  export default defineConfig({
@@ -8,6 +8,7 @@ export default defineConfig({
8
8
  setupFiles: ["./vitest-setup.ts"],
9
9
  mockReset: true,
10
10
  unstubGlobals: true,
11
+ exclude: [...defaultExclude, "integration-tests/**"],
11
12
  tags: [
12
13
  {
13
14
  name: "flaky",