Skip to content

Commit 0fdd8fe

Browse files
committed
feat: MCP connection liveness health checks
Give MCP connections a liveness probe. `checkHealth` dials the server and lists its tools (the same connect path tool discovery uses): a credential that authenticates and gets a tool list reads healthy; a 401/403 (or an OAuth re-authorization signal) reads expired; any other connection/discovery failure reads degraded. The connect-modal "Validate key" path runs the same probe on an unsaved credential. MCP gets liveness ONLY: there is no usable identity source (no id_token, no userinfo, no whoami convention across servers), so no identity is derived and no operation/identity editor is shown - the connection's name stays the user's label. The plugin implements only `checkHealth`, so the editor self-hides while the generic status dot + "Check now" still render. Factors the HTTP-status extraction out of invoke into a shared http-status helper and surfaces the upstream status in connect errors so the probe can classify a 401/403 as expired rather than a generic degraded. Covered by e2e: a saved MCP connection reads healthy, then expired once the upstream revokes the token; validate reports healthy for a live key and expired for a rejected one; no identity is ever derived.
1 parent b90461f commit 0fdd8fe

5 files changed

Lines changed: 240 additions & 28 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Health checks for MCP connections, liveness-only: a connection's credential is
2+
// probed by dialing the server and listing tools (the same path tool discovery
3+
// uses). A live token reads healthy; a revoked/wrong token reads expired. MCP
4+
// has no usable identity source, so there is no identity field and no operation
5+
// picker - only the alive/expired signal. The connect-modal "Validate key" path
6+
// (connections.validate) runs the same probe on an unsaved credential.
7+
//
8+
// The upstream is a real in-process MCP server (the plugin's own test helper)
9+
// gated on a bearer token, so revoking the token mid-scenario reproduces the
10+
// "dev token expired" transition on a saved connection.
11+
import { randomBytes } from "node:crypto";
12+
13+
import { Effect } from "effect";
14+
import { expect } from "@effect/vitest";
15+
import type { HttpApiClient } from "effect/unstable/httpapi";
16+
import { composePluginApi } from "@executor-js/api/server";
17+
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
18+
import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
19+
import { variable } from "@executor-js/sdk/http-auth";
20+
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
21+
22+
import { scenario } from "../src/scenario";
23+
import { Api, Target } from "../src/services";
24+
25+
const api = composePluginApi([mcpHttpPlugin()] as const);
26+
type Client = HttpApiClient.ForApi<typeof api>;
27+
28+
const newSlug = (prefix: string) =>
29+
IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`);
30+
31+
scenario(
32+
"Health checks · MCP liveness reports healthy, then expired when the token is revoked",
33+
{},
34+
Effect.scoped(
35+
Effect.gen(function* () {
36+
const target = yield* Target;
37+
const { client: makeClient } = yield* Api;
38+
const identity = yield* target.newIdentity();
39+
const client: Client = yield* makeClient(api, identity);
40+
const goodToken = `mcp_${randomBytes(8).toString("hex")}`;
41+
const slug = newSlug("hc-mcp");
42+
const name = ConnectionName.make("main");
43+
44+
// A real MCP server gated on the bearer token. `live` flips off to
45+
// reproduce a revoked credential against an already-saved connection.
46+
let live = true;
47+
const server = yield* serveMcpServer(() => makeEchoMcpServer({ name: "liveness-mcp" }), {
48+
auth: {
49+
validateAuthorization: (authorization) =>
50+
Effect.succeed(live && authorization === `Bearer ${goodToken}`),
51+
},
52+
});
53+
54+
yield* Effect.ensuring(
55+
Effect.gen(function* () {
56+
yield* client.mcp.addServer({
57+
payload: {
58+
transport: "remote",
59+
name: "Liveness MCP",
60+
endpoint: server.url,
61+
slug: String(slug),
62+
// Pin streamable-http so the probe's failure is the server's 401
63+
// (no auto SSE fallback to muddy the classification).
64+
remoteTransport: "streamable-http",
65+
authenticationTemplate: [
66+
{
67+
slug: "bearer",
68+
type: "apiKey",
69+
headers: { Authorization: ["Bearer ", variable("token")] },
70+
},
71+
],
72+
},
73+
});
74+
75+
yield* client.connections.create({
76+
payload: {
77+
owner: "org",
78+
name,
79+
integration: slug,
80+
template: AuthTemplateSlug.make("bearer"),
81+
value: goodToken,
82+
},
83+
});
84+
85+
// Saved connection with the live token: alive.
86+
const healthy = yield* client.connections.checkHealth({
87+
params: { owner: "org", integration: slug, name },
88+
});
89+
expect(healthy.status, "a live MCP credential is healthy").toBe("healthy");
90+
91+
// Key-first validate (unsaved credential) runs the same probe.
92+
const validated = yield* client.connections.validate({
93+
payload: {
94+
owner: "org",
95+
integration: slug,
96+
template: AuthTemplateSlug.make("bearer"),
97+
value: goodToken,
98+
},
99+
});
100+
expect(validated.status, "validating a live key is healthy").toBe("healthy");
101+
const rejected = yield* client.connections.validate({
102+
payload: {
103+
owner: "org",
104+
integration: slug,
105+
template: AuthTemplateSlug.make("bearer"),
106+
value: "wrong-token",
107+
},
108+
});
109+
expect(rejected.status, "validating a rejected key is expired").toBe("expired");
110+
111+
// The upstream revokes the saved token: the same connection now expired.
112+
live = false;
113+
const expired = yield* client.connections.checkHealth({
114+
params: { owner: "org", integration: slug, name },
115+
});
116+
expect(expired.status, "a revoked MCP credential reads expired").toBe("expired");
117+
// No identity is ever derived for MCP (manual label only).
118+
expect(expired.identity, "MCP surfaces no derived identity").toBeUndefined();
119+
}),
120+
Effect.gen(function* () {
121+
yield* client.connections
122+
.remove({ params: { owner: "org", integration: slug, name } })
123+
.pipe(Effect.ignore);
124+
yield* client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore);
125+
}),
126+
);
127+
}),
128+
),
129+
);

packages/plugins/mcp/src/sdk/connection.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http";
1717

1818
import type { McpRemoteIntegrationConfig, McpStdioIntegrationConfig } from "./types";
1919
import { McpConnectionError, McpOAuthReauthorizationRequired } from "./errors";
20+
import { httpStatusFromCause } from "./http-status";
2021

2122
// ---------------------------------------------------------------------------
2223
// Connection type
@@ -198,8 +199,18 @@ const connectClient = (input: {
198199

199200
yield* Effect.tryPromise({
200201
try: () => client.connect(transportInstance),
201-
catch: (cause) =>
202-
connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause),
202+
catch: (cause) => {
203+
// Surface the handshake HTTP status (e.g. 401/403) in the message so the
204+
// liveness health check can classify a rejected credential as expired
205+
// rather than a generic connection failure.
206+
const status = httpStatusFromCause(cause);
207+
const suffix = status === undefined ? "" : ` (HTTP ${status})`;
208+
return connectionFailure(
209+
input.transport,
210+
`Failed connecting via ${input.transport}${suffix}`,
211+
cause,
212+
);
213+
},
203214
}).pipe(
204215
Effect.withSpan("plugin.mcp.connection.handshake", {
205216
attributes: { "plugin.mcp.transport": input.transport },
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// ---------------------------------------------------------------------------
2+
// Extract the HTTP status from an MCP SDK transport error. The SDK surfaces
3+
// transport failures two ways: a `StreamableHTTPError` subclass carrying a
4+
// numeric `code`, and an SSE POST failure whose message embeds `(HTTP nnn)`.
5+
// Shared by the invoke path (classifies tool-call failures) and the connect
6+
// path (so a 401/403 during the handshake reaches the liveness health check).
7+
// ---------------------------------------------------------------------------
8+
9+
import { Option, Schema } from "effect";
10+
11+
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
12+
13+
const SsePostErrorCause = Schema.Struct({ message: Schema.String });
14+
const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);
15+
16+
// Matches the SDK's SSEClientTransport POST-failure message (sse.js); re-verify
17+
// on SDK bumps. A format drift just yields undefined (generic error, no crash).
18+
const statusFromSsePostError = (cause: unknown): number | undefined =>
19+
Option.match(decodeSsePostErrorCause(cause), {
20+
onNone: () => undefined,
21+
onSome: ({ message }) => {
22+
const match = /^Error POSTing to endpoint \(HTTP ([1-5][0-9]{2})\):/.exec(message);
23+
if (!match) return undefined;
24+
return Number(match[1]);
25+
},
26+
});
27+
28+
const statusFromStreamableHttpError = (cause: unknown): number | undefined => {
29+
// oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK exposes transport HTTP failures as this Error subclass; protocol errors can carry the same numeric code
30+
if (!(cause instanceof StreamableHTTPError)) return undefined;
31+
const code = cause.code;
32+
return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
33+
};
34+
35+
export const httpStatusFromCause = (cause: unknown): number | undefined =>
36+
statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);

packages/plugins/mcp/src/sdk/invoke.ts

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";
1515

16-
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1716
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
1817

1918
import {
@@ -26,6 +25,7 @@ import {
2625

2726
import { McpConnectionError, McpInvocationError, McpOAuthReauthorizationRequired } from "./errors";
2827
import type { McpConnection, McpConnector } from "./connection";
28+
import { httpStatusFromCause } from "./http-status";
2929

3030
// ---------------------------------------------------------------------------
3131
// Helpers
@@ -37,31 +37,6 @@ const decodeArgsRecord = Schema.decodeUnknownOption(ArgsRecord);
3737
const argsRecord = (value: unknown): Record<string, unknown> =>
3838
Option.getOrElse(decodeArgsRecord(value), () => ({}));
3939

40-
const SsePostErrorCause = Schema.Struct({ message: Schema.String });
41-
const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);
42-
43-
// Matches the SDK's SSEClientTransport POST-failure message (sse.js); re-verify
44-
// on SDK bumps. A format drift just yields undefined (generic error, no crash).
45-
const statusFromSsePostError = (cause: unknown): number | undefined =>
46-
Option.match(decodeSsePostErrorCause(cause), {
47-
onNone: () => undefined,
48-
onSome: ({ message }) => {
49-
const match = /^Error POSTing to endpoint \(HTTP ([1-5][0-9]{2})\):/.exec(message);
50-
if (!match) return undefined;
51-
return Number(match[1]);
52-
},
53-
});
54-
55-
const statusFromStreamableHttpError = (cause: unknown): number | undefined => {
56-
// oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK exposes transport HTTP failures as this Error subclass; protocol errors can carry the same numeric code
57-
if (!(cause instanceof StreamableHTTPError)) return undefined;
58-
const code = cause.code;
59-
return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
60-
};
61-
62-
const httpStatusFromCause = (cause: unknown): number | undefined =>
63-
statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);
64-
6540
// ---------------------------------------------------------------------------
6641
// Elicitation bridge — decode incoming MCP ElicitRequest, route through
6742
// the host's elicit function, marshal the response back to MCP shape.

packages/plugins/mcp/src/sdk/plugin.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
tool,
1616
ToolResult,
1717
type AuthMethodDescriptor,
18+
type HealthCheckResult,
1819
type Integration,
1920
type IntegrationConfig,
2021
type IntegrationRecord,
@@ -64,6 +65,21 @@ import {
6465

6566
const MCP_PLUGIN_ID = "mcp" as const;
6667

68+
/** Classify a failed liveness probe. An auth wall (OAuth re-authorization, or a
69+
* 401/403 surfaced into the connect message) means the credential is expired;
70+
* anything else (server down, wrong transport) is degraded, not a credential
71+
* problem. */
72+
const mcpLivenessFailureStatus = (message: string): "expired" | "degraded" => {
73+
const lower = message.toLowerCase();
74+
const authWalled =
75+
lower.includes("oauth re-authorization") ||
76+
lower.includes("(http 401)") ||
77+
lower.includes("(http 403)") ||
78+
lower.includes("unauthorized") ||
79+
lower.includes("forbidden");
80+
return authWalled ? "expired" : "degraded";
81+
};
82+
6783
const legacyOAuthClientSlugCandidate = (value: string): string | null => {
6884
const slug = value
6985
.trim()
@@ -1165,6 +1181,51 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => {
11651181
return out;
11661182
}),
11671183

1184+
// Liveness-only health check. MCP has no usable identity source (no
1185+
// id_token/userinfo, no standard whoami), so this answers "is this
1186+
// credential still alive?" by dialing the server and listing tools (the same
1187+
// path resolveTools uses); identity stays the user-supplied connection label.
1188+
// Only checkHealth is implemented (no candidates/describe/set), so the
1189+
// operation/identity editor stays hidden while the status dot + "Check now"
1190+
// light up.
1191+
checkHealth: ({ credential }) =>
1192+
Effect.gen(function* () {
1193+
const parsed = parseMcpIntegrationConfig(credential.config);
1194+
if (!parsed) {
1195+
return { status: "unknown" as const, checkedAt: Date.now() } satisfies HealthCheckResult;
1196+
}
1197+
const connector = yield* buildConnectorInput(
1198+
parsed,
1199+
credential.values,
1200+
credential.template === null ? null : String(credential.template),
1201+
allowStdio,
1202+
).pipe(Effect.map((ci) => createMcpConnector(ci)));
1203+
1204+
return yield* discoverTools(connector).pipe(
1205+
Effect.map(
1206+
() =>
1207+
({ status: "healthy" as const, checkedAt: Date.now() }) satisfies HealthCheckResult,
1208+
),
1209+
Effect.catchTag("McpToolDiscoveryError", (error) =>
1210+
Effect.succeed({
1211+
status: mcpLivenessFailureStatus(error.message),
1212+
checkedAt: Date.now(),
1213+
detail: error.message,
1214+
} satisfies HealthCheckResult),
1215+
),
1216+
);
1217+
}).pipe(
1218+
// buildConnectorInput rejects (e.g. stdio disabled / missing config).
1219+
Effect.catchTag("McpConnectionError", (error) =>
1220+
Effect.succeed({
1221+
status: mcpLivenessFailureStatus(error.message),
1222+
checkedAt: Date.now(),
1223+
detail: error.message,
1224+
} satisfies HealthCheckResult),
1225+
),
1226+
Effect.withSpan("mcp.plugin.check_health"),
1227+
),
1228+
11681229
describeAuthMethods: describeMcpAuthMethods,
11691230
describeIntegrationDisplay: describeMcpIntegrationDisplay,
11701231

0 commit comments

Comments
 (0)