Skip to content

Commit 1287945

Browse files
committed
feat: health checks for Microsoft, Google, and MCP
- Microsoft Graph and Google wire the four OpenAPI health-check hooks to the shared backing (same store + config superset). Google auto-configures the People API identity check when the bundle includes it. - MCP gets a liveness-only probe (connect + list tools, classify the result); it has no usable identity source, so no identity is derived. - Shared MCP HTTP-status extraction moved to a small module so the connect path can surface a 401/403 to the liveness check.
1 parent 522f93b commit 1287945

11 files changed

Lines changed: 478 additions & 31 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@executor-js/api": "workspace:*",
2424
"@executor-js/emulate": "^0.7.5",
2525
"@executor-js/mcporter": "^0.11.4",
26+
"@executor-js/plugin-google": "workspace:*",
2627
"@executor-js/plugin-graphql": "workspace:*",
2728
"@executor-js/plugin-mcp": "workspace:*",
2829
"@executor-js/plugin-microsoft": "workspace:*",
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
// Health checks for the provider plugins beyond generic OpenAPI:
2+
//
3+
// 1. MCP, liveness-only: a connection's credential is probed by dialing the
4+
// server and listing tools (the same path tool discovery uses). A live token
5+
// reads healthy; a revoked/wrong token reads expired. MCP has no usable
6+
// identity source, so there is no identity field and no operation picker -
7+
// only the alive/expired signal. The connect-modal "Validate key" path
8+
// (connections.validate) runs the same probe on an unsaved credential.
9+
// 2. Google: provider integrations wire the OpenAPI health-check backing and
10+
// auto-configure a default identity check (People API `people.get`) at
11+
// add time, so a connection reports alive/expired + identity out of the box.
12+
// Here we pin the auto-default + the typed candidate the editor would show;
13+
// the probe itself is the shared OpenAPI path exercised by health-checks.ts
14+
// and the MCP scenario's dispatch.
15+
//
16+
// The MCP upstream is a real in-process MCP server (the plugin's own test
17+
// helper) gated on a bearer token, so revoking the token mid-scenario reproduces
18+
// the "dev token expired" transition on a saved connection.
19+
import { randomBytes } from "node:crypto";
20+
import { createServer } from "node:http";
21+
22+
import { Effect } from "effect";
23+
import { expect } from "@effect/vitest";
24+
import type { HttpApiClient } from "effect/unstable/httpapi";
25+
import { composePluginApi } from "@executor-js/api/server";
26+
import { googleHttpPlugin } from "@executor-js/plugin-google/api";
27+
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
28+
import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
29+
import { variable } from "@executor-js/sdk/http-auth";
30+
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
31+
32+
import { scenario } from "../src/scenario";
33+
import { Api, Target } from "../src/services";
34+
35+
const api = composePluginApi([mcpHttpPlugin(), googleHttpPlugin()] as const);
36+
type Client = HttpApiClient.ForApi<typeof api>;
37+
38+
const newSlug = (prefix: string) =>
39+
IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`);
40+
41+
// ===========================================================================
42+
// 1. MCP liveness: healthy -> expired on a saved connection, plus validate.
43+
// ===========================================================================
44+
45+
scenario(
46+
"Health checks · MCP liveness reports healthy, then expired when the token is revoked",
47+
{},
48+
Effect.scoped(
49+
Effect.gen(function* () {
50+
const target = yield* Target;
51+
const { client: makeClient } = yield* Api;
52+
const identity = yield* target.newIdentity();
53+
const client: Client = yield* makeClient(api, identity);
54+
const goodToken = `mcp_${randomBytes(8).toString("hex")}`;
55+
const slug = newSlug("hc-mcp");
56+
const name = ConnectionName.make("main");
57+
58+
// A real MCP server gated on the bearer token. `live` flips off to
59+
// reproduce a revoked credential against an already-saved connection.
60+
let live = true;
61+
const server = yield* serveMcpServer(() => makeEchoMcpServer({ name: "liveness-mcp" }), {
62+
auth: {
63+
validateAuthorization: (authorization) =>
64+
Effect.succeed(live && authorization === `Bearer ${goodToken}`),
65+
},
66+
});
67+
68+
yield* Effect.ensuring(
69+
Effect.gen(function* () {
70+
yield* client.mcp.addServer({
71+
payload: {
72+
transport: "remote",
73+
name: "Liveness MCP",
74+
endpoint: server.url,
75+
slug: String(slug),
76+
// Pin streamable-http so the probe's failure is the server's 401
77+
// (no auto SSE fallback to muddy the classification).
78+
remoteTransport: "streamable-http",
79+
authenticationTemplate: [
80+
{
81+
slug: "bearer",
82+
type: "apiKey",
83+
headers: { Authorization: ["Bearer ", variable("token")] },
84+
},
85+
],
86+
},
87+
});
88+
89+
yield* client.connections.create({
90+
payload: {
91+
owner: "org",
92+
name,
93+
integration: slug,
94+
template: AuthTemplateSlug.make("bearer"),
95+
value: goodToken,
96+
},
97+
});
98+
99+
// Saved connection with the live token: alive.
100+
const healthy = yield* client.connections.checkHealth({
101+
params: { owner: "org", integration: slug, name },
102+
});
103+
expect(healthy.status, "a live MCP credential is healthy").toBe("healthy");
104+
105+
// Key-first validate (unsaved credential) runs the same probe.
106+
const validated = yield* client.connections.validate({
107+
payload: {
108+
owner: "org",
109+
integration: slug,
110+
template: AuthTemplateSlug.make("bearer"),
111+
value: goodToken,
112+
},
113+
});
114+
expect(validated.status, "validating a live key is healthy").toBe("healthy");
115+
const rejected = yield* client.connections.validate({
116+
payload: {
117+
owner: "org",
118+
integration: slug,
119+
template: AuthTemplateSlug.make("bearer"),
120+
value: "wrong-token",
121+
},
122+
});
123+
expect(rejected.status, "validating a rejected key is expired").toBe("expired");
124+
125+
// The upstream revokes the saved token: the same connection now expired.
126+
live = false;
127+
const expired = yield* client.connections.checkHealth({
128+
params: { owner: "org", integration: slug, name },
129+
});
130+
expect(expired.status, "a revoked MCP credential reads expired").toBe("expired");
131+
// No identity is ever derived for MCP (manual label only).
132+
expect(expired.identity, "MCP surfaces no derived identity").toBeUndefined();
133+
}),
134+
Effect.gen(function* () {
135+
yield* client.connections
136+
.remove({ params: { owner: "org", integration: slug, name } })
137+
.pipe(Effect.ignore);
138+
yield* client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore);
139+
}),
140+
);
141+
}),
142+
),
143+
);
144+
145+
// ===========================================================================
146+
// 2. Google: adding a bundle with the People API auto-configures the identity
147+
// health check, and the editor's candidate exposes the typed identity field.
148+
// ===========================================================================
149+
150+
/** A minimal Google Discovery document for the People API, served locally so
151+
* `addBundle` (which fetches the discovery URL) is hermetic. `people.get` is the
152+
* canonical identity call; its response carries `emailAddresses[].value`. */
153+
const peopleDiscoveryDoc = (): string =>
154+
JSON.stringify({
155+
kind: "discovery#restDescription",
156+
name: "people",
157+
version: "v1",
158+
title: "People API",
159+
rootUrl: "https://people.example.com/",
160+
servicePath: "",
161+
auth: {
162+
oauth2: {
163+
scopes: {
164+
"https://www.googleapis.com/auth/userinfo.email": { description: "See your email" },
165+
},
166+
},
167+
},
168+
resources: {
169+
people: {
170+
methods: {
171+
get: {
172+
id: "people.people.get",
173+
httpMethod: "GET",
174+
path: "v1/{resourceName}",
175+
scopes: ["https://www.googleapis.com/auth/userinfo.email"],
176+
parameters: {
177+
resourceName: { location: "path", required: true, type: "string" },
178+
personFields: { location: "query", type: "string" },
179+
},
180+
response: { $ref: "Person" },
181+
},
182+
},
183+
},
184+
},
185+
schemas: {
186+
Person: {
187+
id: "Person",
188+
type: "object",
189+
properties: {
190+
resourceName: { type: "string" },
191+
emailAddresses: { type: "array", items: { $ref: "EmailAddress" } },
192+
names: { type: "array", items: { $ref: "Name" } },
193+
},
194+
},
195+
EmailAddress: {
196+
id: "EmailAddress",
197+
type: "object",
198+
properties: { value: { type: "string" } },
199+
},
200+
Name: { id: "Name", type: "object", properties: { displayName: { type: "string" } } },
201+
},
202+
});
203+
204+
/** Serve the People discovery doc at a `/people/`-containing path (so the plugin
205+
* recognizes the bundle as containing the People API). */
206+
const servePeopleDiscovery = () =>
207+
Effect.acquireRelease(
208+
Effect.callback<{ readonly discoveryUrl: string; readonly close: () => void }>((resume) => {
209+
const doc = peopleDiscoveryDoc();
210+
const server = createServer((request, response) => {
211+
if ((request.url ?? "").includes("/apis/people/")) {
212+
response.writeHead(200, { "content-type": "application/json" });
213+
response.end(doc);
214+
return;
215+
}
216+
response.writeHead(404, { "content-type": "application/json" });
217+
response.end(JSON.stringify({ error: "not_found" }));
218+
});
219+
server.listen(0, "127.0.0.1", () => {
220+
const address = server.address();
221+
const port = typeof address === "object" && address ? address.port : 0;
222+
resume(
223+
Effect.succeed({
224+
discoveryUrl: `http://127.0.0.1:${port}/discovery/v1/apis/people/v1/rest`,
225+
close: () => {
226+
server.close();
227+
server.closeAllConnections();
228+
},
229+
}),
230+
);
231+
});
232+
}),
233+
(server) => Effect.sync(server.close),
234+
);
235+
236+
scenario(
237+
"Health checks · adding a Google People bundle auto-configures the identity check",
238+
{},
239+
Effect.scoped(
240+
Effect.gen(function* () {
241+
const target = yield* Target;
242+
const { client: makeClient } = yield* Api;
243+
const identity = yield* target.newIdentity();
244+
const client: Client = yield* makeClient(api, identity);
245+
const discovery = yield* servePeopleDiscovery();
246+
const slug = newSlug("hc-google");
247+
248+
yield* Effect.ensuring(
249+
Effect.gen(function* () {
250+
yield* client.google.addBundle({
251+
payload: { urls: [discovery.discoveryUrl], slug: String(slug) },
252+
});
253+
254+
// The People identity call is offered as a candidate, with its typed
255+
// response fields (so the editor's identity picker lists the email).
256+
const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } });
257+
const peopleGet = candidates.find((candidate) => candidate.method === "get");
258+
if (!peopleGet) return yield* Effect.die("People bundle exposed no GET candidate");
259+
expect(
260+
(peopleGet.responseFields ?? []).map((field) => field.path),
261+
"the email identity field is projected from the response schema",
262+
).toContain("emailAddresses.0.value");
263+
264+
// Adding the bundle auto-wrote the default identity health check: the
265+
// People identity call, with the required pinned args and email field.
266+
const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
267+
expect(stored?.operation, "the default check targets the People identity call").toBe(
268+
peopleGet.operation,
269+
);
270+
expect(stored?.identityField, "the default reads the email field").toBe(
271+
"emailAddresses.0.value",
272+
);
273+
expect(stored?.args, "the People call's required args are pinned").toEqual({
274+
resourceName: "people/me",
275+
personFields: "names,emailAddresses",
276+
});
277+
}),
278+
client.google.removeBundle({ params: { slug: String(slug) } }).pipe(Effect.ignore),
279+
);
280+
}),
281+
),
282+
);

packages/plugins/google/src/sdk/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Option, Schema } from "effect";
2+
import { HealthCheckSpec } from "@executor-js/sdk/core";
23
import { AuthenticationSchema, type Authentication } from "@executor-js/plugin-openapi";
34

45
export const GoogleIntegrationConfigSchema = Schema.Struct({
@@ -9,6 +10,9 @@ export const GoogleIntegrationConfigSchema = Schema.Struct({
910
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
1011
queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
1112
authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
13+
// The declared health check survives the plugin's own decode (updateBundle
14+
// read-modify-write); the OpenAPI backing reads/writes it via the base config.
15+
healthCheck: Schema.optional(HealthCheckSpec),
1216
});
1317

1418
export type GoogleIntegrationConfig = Omit<

0 commit comments

Comments
 (0)