Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@executor-js/api": "workspace:*",
"@executor-js/emulate": "^0.7.5",
"@executor-js/mcporter": "^0.11.4",
"@executor-js/plugin-google": "workspace:*",
"@executor-js/plugin-graphql": "workspace:*",
"@executor-js/plugin-mcp": "workspace:*",
"@executor-js/plugin-microsoft": "workspace:*",
Expand Down
129 changes: 129 additions & 0 deletions e2e/scenarios/health-checks-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Health checks for MCP connections, liveness-only: a connection's credential is
// probed by dialing the server and listing tools (the same path tool discovery
// uses). A live token reads healthy; a revoked/wrong token reads expired. MCP
// has no usable identity source, so there is no identity field and no operation
// picker - only the alive/expired signal. The connect-modal "Validate key" path
// (connections.validate) runs the same probe on an unsaved credential.
//
// The upstream is a real in-process MCP server (the plugin's own test helper)
// gated on a bearer token, so revoking the token mid-scenario reproduces the
// "dev token expired" transition on a saved connection.
import { randomBytes } from "node:crypto";

import { Effect } from "effect";
import { expect } from "@effect/vitest";
import type { HttpApiClient } from "effect/unstable/httpapi";
import { composePluginApi } from "@executor-js/api/server";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
import { variable } from "@executor-js/sdk/http-auth";
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";

import { scenario } from "../src/scenario";
import { Api, Target } from "../src/services";

const api = composePluginApi([mcpHttpPlugin()] as const);
type Client = HttpApiClient.ForApi<typeof api>;

const newSlug = (prefix: string) =>
IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`);

scenario(
"Health checks · MCP liveness reports healthy, then expired when the token is revoked",
{},
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const { client: makeClient } = yield* Api;
const identity = yield* target.newIdentity();
const client: Client = yield* makeClient(api, identity);
const goodToken = `mcp_${randomBytes(8).toString("hex")}`;
const slug = newSlug("hc-mcp");
const name = ConnectionName.make("main");

// A real MCP server gated on the bearer token. `live` flips off to
// reproduce a revoked credential against an already-saved connection.
let live = true;
const server = yield* serveMcpServer(() => makeEchoMcpServer({ name: "liveness-mcp" }), {
auth: {
validateAuthorization: (authorization) =>
Effect.succeed(live && authorization === `Bearer ${goodToken}`),
},
});

yield* Effect.ensuring(
Effect.gen(function* () {
yield* client.mcp.addServer({
payload: {
transport: "remote",
name: "Liveness MCP",
endpoint: server.url,
slug: String(slug),
// Pin streamable-http so the probe's failure is the server's 401
// (no auto SSE fallback to muddy the classification).
remoteTransport: "streamable-http",
authenticationTemplate: [
{
slug: "bearer",
type: "apiKey",
headers: { Authorization: ["Bearer ", variable("token")] },
},
],
},
});

yield* client.connections.create({
payload: {
owner: "org",
name,
integration: slug,
template: AuthTemplateSlug.make("bearer"),
value: goodToken,
},
});

// Saved connection with the live token: alive.
const healthy = yield* client.connections.checkHealth({
params: { owner: "org", integration: slug, name },
});
expect(healthy.status, "a live MCP credential is healthy").toBe("healthy");

// Key-first validate (unsaved credential) runs the same probe.
const validated = yield* client.connections.validate({
payload: {
owner: "org",
integration: slug,
template: AuthTemplateSlug.make("bearer"),
value: goodToken,
},
});
expect(validated.status, "validating a live key is healthy").toBe("healthy");
const rejected = yield* client.connections.validate({
payload: {
owner: "org",
integration: slug,
template: AuthTemplateSlug.make("bearer"),
value: "wrong-token",
},
});
expect(rejected.status, "validating a rejected key is expired").toBe("expired");

// The upstream revokes the saved token: the same connection now expired.
live = false;
const expired = yield* client.connections.checkHealth({
params: { owner: "org", integration: slug, name },
});
expect(expired.status, "a revoked MCP credential reads expired").toBe("expired");
// No identity is ever derived for MCP (manual label only).
expect(expired.identity, "MCP surfaces no derived identity").toBeUndefined();
}),
Effect.gen(function* () {
yield* client.connections
.remove({ params: { owner: "org", integration: slug, name } })
.pipe(Effect.ignore);
yield* client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore);
}),
);
}),
),
);
158 changes: 158 additions & 0 deletions e2e/scenarios/health-checks-providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Health checks for the Google provider plugin. Provider integrations wire the
// OpenAPI health-check backing and auto-configure a default identity check
// (People API `people.get`) at add time, so a connection reports alive/expired +
// identity out of the box. Here we pin the auto-default + the typed candidate the
// editor would show; the probe itself is the shared OpenAPI path exercised by
// health-checks.ts.
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";

import { Effect } from "effect";
import { expect } from "@effect/vitest";
import type { HttpApiClient } from "effect/unstable/httpapi";
import { composePluginApi } from "@executor-js/api/server";
import { googleHttpPlugin } from "@executor-js/plugin-google/api";
import { IntegrationSlug } from "@executor-js/sdk/shared";

import { scenario } from "../src/scenario";
import { Api, Target } from "../src/services";

const api = composePluginApi([googleHttpPlugin()] as const);
type Client = HttpApiClient.ForApi<typeof api>;

const newSlug = (prefix: string) =>
IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`);

/** A minimal Google Discovery document for the People API, served locally so
* `addBundle` (which fetches the discovery URL) is hermetic. `people.get` is the
* canonical identity call; its response carries `emailAddresses[].value`. */
const peopleDiscoveryDoc = (): string =>
JSON.stringify({
kind: "discovery#restDescription",
name: "people",
version: "v1",
title: "People API",
rootUrl: "https://people.example.com/",
servicePath: "",
auth: {
oauth2: {
scopes: {
"https://www.googleapis.com/auth/userinfo.email": { description: "See your email" },
},
},
},
resources: {
people: {
methods: {
get: {
id: "people.people.get",
httpMethod: "GET",
path: "v1/{resourceName}",
scopes: ["https://www.googleapis.com/auth/userinfo.email"],
parameters: {
resourceName: { location: "path", required: true, type: "string" },
personFields: { location: "query", type: "string" },
},
response: { $ref: "Person" },
},
},
},
},
schemas: {
Person: {
id: "Person",
type: "object",
properties: {
resourceName: { type: "string" },
emailAddresses: { type: "array", items: { $ref: "EmailAddress" } },
names: { type: "array", items: { $ref: "Name" } },
},
},
EmailAddress: {
id: "EmailAddress",
type: "object",
properties: { value: { type: "string" } },
},
Name: { id: "Name", type: "object", properties: { displayName: { type: "string" } } },
},
});

/** Serve the People discovery doc at a `/people/`-containing path (so the plugin
* recognizes the bundle as containing the People API). */
const servePeopleDiscovery = () =>
Effect.acquireRelease(
Effect.callback<{ readonly discoveryUrl: string; readonly close: () => void }>((resume) => {
const doc = peopleDiscoveryDoc();
const server = createServer((request, response) => {
if ((request.url ?? "").includes("/apis/people/")) {
response.writeHead(200, { "content-type": "application/json" });
response.end(doc);
return;
}
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "not_found" }));
});
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
resume(
Effect.succeed({
discoveryUrl: `http://127.0.0.1:${port}/discovery/v1/apis/people/v1/rest`,
close: () => {
server.close();
server.closeAllConnections();
},
}),
);
});
}),
(server) => Effect.sync(server.close),
);

scenario(
"Health checks · adding a Google People bundle auto-configures the identity check",
{},
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const { client: makeClient } = yield* Api;
const identity = yield* target.newIdentity();
const client: Client = yield* makeClient(api, identity);
const discovery = yield* servePeopleDiscovery();
const slug = newSlug("hc-google");

yield* Effect.ensuring(
Effect.gen(function* () {
yield* client.google.addBundle({
payload: { urls: [discovery.discoveryUrl], slug: String(slug) },
});

// The People identity call is offered as a candidate, with its typed
// response fields (so the editor's identity picker lists the email).
const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } });
const peopleGet = candidates.find((candidate) => candidate.method === "get");
if (!peopleGet) return yield* Effect.die("People bundle exposed no GET candidate");
expect(
(peopleGet.responseFields ?? []).map((field) => field.path),
"the email identity field is projected from the response schema",
).toContain("emailAddresses.0.value");

// Adding the bundle auto-wrote the default identity health check: the
// People identity call, with the required pinned args and email field.
const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
expect(stored?.operation, "the default check targets the People identity call").toBe(
peopleGet.operation,
);
expect(stored?.identityField, "the default reads the email field").toBe(
"emailAddresses.0.value",
);
expect(stored?.args, "the People call's required args are pinned").toEqual({
resourceName: "people/me",
personFields: "names,emailAddresses",
});
}),
client.google.removeBundle({ params: { slug: String(slug) } }).pipe(Effect.ignore),
);
}),
),
);
Loading
Loading