@stigmer/mcp-server 3.12.0 → 3.12.2
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.
- package/cli/mcp-server-stigmer.js +6 -4
- package/domains/agentexecutions/fetch.d.ts.map +1 -1
- package/domains/agentexecutions/fetch.js +3 -4
- package/domains/agentexecutions/fetch.js.map +1 -1
- package/domains/agents/apply.d.ts.map +1 -1
- package/domains/agents/apply.js +4 -1
- package/domains/agents/apply.js.map +1 -1
- package/domains/apply-visibility.d.ts +19 -0
- package/domains/apply-visibility.d.ts.map +1 -0
- package/domains/apply-visibility.js +34 -0
- package/domains/apply-visibility.js.map +1 -0
- package/domains/environments/apply.d.ts.map +1 -1
- package/domains/environments/apply.js +4 -1
- package/domains/environments/apply.js.map +1 -1
- package/domains/mcpservers/apply.d.ts.map +1 -1
- package/domains/mcpservers/apply.js +4 -1
- package/domains/mcpservers/apply.js.map +1 -1
- package/domains/workflows/apply.d.ts.map +1 -1
- package/domains/workflows/apply.js +4 -1
- package/domains/workflows/apply.js.map +1 -1
- package/gen/agent.js +1 -1
- package/gen/agent.js.map +1 -1
- package/gen/environment.js +1 -1
- package/gen/environment.js.map +1 -1
- package/gen/mcpserver.js +2 -2
- package/gen/mcpserver.js.map +1 -1
- package/gen/workflow.js +2 -2
- package/gen/workflow.js.map +1 -1
- package/package.json +3 -3
- package/readiness.d.ts +27 -0
- package/readiness.d.ts.map +1 -0
- package/readiness.js +81 -0
- package/readiness.js.map +1 -0
- package/server.d.ts.map +1 -1
- package/server.js +51 -17
- package/server.js.map +1 -1
- package/src/domains/agentexecutions/fetch.ts +3 -4
- package/src/domains/agents/apply.ts +10 -1
- package/src/domains/apply-visibility.ts +47 -0
- package/src/domains/apply.integration.test.ts +55 -1
- package/src/domains/environments/apply.ts +10 -1
- package/src/domains/mcpservers/apply.ts +10 -1
- package/src/domains/workflows/apply.ts +10 -1
- package/src/gen/agent.ts +1 -1
- package/src/gen/environment.ts +1 -1
- package/src/gen/mcpserver.ts +2 -2
- package/src/gen/workflow.ts +2 -2
- package/src/http.integration.test.ts +46 -6
- package/src/readiness.test.ts +90 -0
- package/src/readiness.ts +94 -0
- package/src/server.ts +58 -15
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Declared-visibility follow-up for the apply tools (oss#573).
|
|
2
|
+
//
|
|
3
|
+
// Plain updates preserve the stored visibility on both editions — the
|
|
4
|
+
// updateVisibility RPC is the only door for visibility changes, where the
|
|
5
|
+
// server-side guards live (per-kind level support, default-instance
|
|
6
|
+
// rejection). So when an apply tool's input declares a visibility and the
|
|
7
|
+
// applied resource comes back with a different level (i.e. the resource
|
|
8
|
+
// already existed), we follow up with one UpdateVisibility RPC on the same
|
|
9
|
+
// controller. The CLI's skill-push and manifest-apply flows do the same.
|
|
10
|
+
//
|
|
11
|
+
// No-ops are skipped (input omitted visibility, create honored it, or the
|
|
12
|
+
// stored level already matches), so an unchanged apply costs nothing extra.
|
|
13
|
+
// Guard rejections propagate to the caller's rpcError wrapper — the model
|
|
14
|
+
// sees the real FAILED_PRECONDITION / INVALID_ARGUMENT, not a silent lie.
|
|
15
|
+
|
|
16
|
+
import { create } from "@bufbuild/protobuf";
|
|
17
|
+
import type { CallOptions } from "@connectrpc/connect";
|
|
18
|
+
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
19
|
+
import { UpdateVisibilityInputSchema, type UpdateVisibilityInput } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
20
|
+
import type { ApiResourceMetadata } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
|
|
21
|
+
|
|
22
|
+
interface HasResourceMetadata {
|
|
23
|
+
metadata?: ApiResourceMetadata;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface UpdateVisibilityClient<T> {
|
|
27
|
+
updateVisibility(input: UpdateVisibilityInput, options?: CallOptions): Promise<T>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Land the input-declared visibility through the guarded door when the
|
|
32
|
+
* applied resource's stored level differs. Returns the resource carrying the
|
|
33
|
+
* final visibility (the follow-up's response, or `applied` unchanged when no
|
|
34
|
+
* follow-up was needed).
|
|
35
|
+
*/
|
|
36
|
+
export async function applyDeclaredVisibility<T extends HasResourceMetadata>(
|
|
37
|
+
client: UpdateVisibilityClient<T>,
|
|
38
|
+
callOptions: CallOptions,
|
|
39
|
+
applied: T,
|
|
40
|
+
declared: ApiResourceVisibility,
|
|
41
|
+
): Promise<T> {
|
|
42
|
+
if (declared === ApiResourceVisibility.api_resource_visibility_unspecified) return applied;
|
|
43
|
+
const resourceId = applied.metadata?.id ?? "";
|
|
44
|
+
if (resourceId === "") return applied;
|
|
45
|
+
if (applied.metadata?.visibility === declared) return applied;
|
|
46
|
+
return client.updateVisibility(create(UpdateVisibilityInputSchema, { resourceId, visibility: declared }), callOptions);
|
|
47
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// data map with secret flags, and the recursive workflow task_config expansion
|
|
8
8
|
// (http_call leaf, fork/for_each nesting).
|
|
9
9
|
|
|
10
|
+
import { clone } from "@bufbuild/protobuf";
|
|
10
11
|
import type { ConnectRouter } from "@connectrpc/connect";
|
|
11
12
|
import { connectNodeAdapter } from "@connectrpc/connect-node";
|
|
12
13
|
import {
|
|
@@ -18,7 +19,7 @@ import type { AddressInfo } from "node:net";
|
|
|
18
19
|
|
|
19
20
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
20
21
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
21
|
-
import type
|
|
22
|
+
import { type Agent, AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
22
23
|
import { AgentCommandController } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/command_pb";
|
|
23
24
|
import type { Environment } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
|
|
24
25
|
import { EnvironmentCommandController } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/command_pb";
|
|
@@ -29,6 +30,7 @@ import { WorkflowCommandController } from "@stigmer/protos/ai/stigmer/agentic/wo
|
|
|
29
30
|
import { WorkflowTaskKind } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/enum_pb";
|
|
30
31
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
31
32
|
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
33
|
+
import type { UpdateVisibilityInput } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
32
34
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
33
35
|
|
|
34
36
|
import { configureLogger } from "../logger";
|
|
@@ -42,6 +44,13 @@ let appliedAgent: Agent | undefined;
|
|
|
42
44
|
let appliedMcpServer: McpServerProto | undefined;
|
|
43
45
|
let appliedWorkflow: Workflow | undefined;
|
|
44
46
|
let appliedEnvironment: Environment | undefined;
|
|
47
|
+
const visibilityUpdates: UpdateVisibilityInput[] = [];
|
|
48
|
+
|
|
49
|
+
// The single-door contract (oss#573): a plain update preserves the STORED
|
|
50
|
+
// visibility, so the apply response can disagree with the request. This slug
|
|
51
|
+
// makes the mock backend simulate that — apply echoes back a pre-existing
|
|
52
|
+
// resource whose stored level is org, regardless of the requested one.
|
|
53
|
+
const STORED_ORG_SLUG = "stored-org-agent";
|
|
45
54
|
const openSessions = new Set<ServerHttp2Session>();
|
|
46
55
|
|
|
47
56
|
interface ToolResult {
|
|
@@ -58,8 +67,21 @@ beforeAll(async () => {
|
|
|
58
67
|
router.service(AgentCommandController, {
|
|
59
68
|
apply: (req) => {
|
|
60
69
|
appliedAgent = req;
|
|
70
|
+
if (req.metadata?.slug === STORED_ORG_SLUG) {
|
|
71
|
+
const stored = clone(AgentSchema, req);
|
|
72
|
+
stored.metadata!.id = "agent-stored-1";
|
|
73
|
+
stored.metadata!.visibility = ApiResourceVisibility.visibility_org;
|
|
74
|
+
return stored;
|
|
75
|
+
}
|
|
61
76
|
return req;
|
|
62
77
|
},
|
|
78
|
+
updateVisibility: (input) => {
|
|
79
|
+
visibilityUpdates.push(input);
|
|
80
|
+
const updated = clone(AgentSchema, appliedAgent!);
|
|
81
|
+
updated.metadata!.id = input.resourceId;
|
|
82
|
+
updated.metadata!.visibility = input.visibility;
|
|
83
|
+
return updated;
|
|
84
|
+
},
|
|
63
85
|
});
|
|
64
86
|
router.service(McpServerCommandController, {
|
|
65
87
|
apply: (req) => {
|
|
@@ -234,6 +256,38 @@ describe("apply tools integration", () => {
|
|
|
234
256
|
expect(forCfg.do).toHaveLength(1);
|
|
235
257
|
});
|
|
236
258
|
|
|
259
|
+
it("apply_agent lands a declared visibility the update preserved away via UpdateVisibility (oss#573)", async () => {
|
|
260
|
+
const result = await callTool("apply_agent", {
|
|
261
|
+
name: "Stored Org Agent",
|
|
262
|
+
org: "acme",
|
|
263
|
+
visibility: "PUBLIC",
|
|
264
|
+
instructions: "i",
|
|
265
|
+
});
|
|
266
|
+
expect(result.isError).toBeFalsy();
|
|
267
|
+
|
|
268
|
+
// Server "preserved" stored org; the tool must follow up once through
|
|
269
|
+
// the guarded door and return the landed level, not the stale one.
|
|
270
|
+
expect(visibilityUpdates).toHaveLength(1);
|
|
271
|
+
expect(visibilityUpdates[0].resourceId).toBe("agent-stored-1");
|
|
272
|
+
expect(visibilityUpdates[0].visibility).toBe(ApiResourceVisibility.visibility_public);
|
|
273
|
+
const text = result.content.find((c) => c.type === "text")?.text ?? "";
|
|
274
|
+
expect(JSON.parse(text).metadata.visibility).toBe("visibility_public");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("apply_agent skips the follow-up when the server already matches", async () => {
|
|
278
|
+
visibilityUpdates.length = 0;
|
|
279
|
+
const result = await callTool("apply_agent", {
|
|
280
|
+
name: "Plain Agent",
|
|
281
|
+
org: "acme",
|
|
282
|
+
visibility: "PUBLIC",
|
|
283
|
+
instructions: "i",
|
|
284
|
+
});
|
|
285
|
+
expect(result.isError).toBeFalsy();
|
|
286
|
+
// The echo backend returns the requested level (and no id) — no diff,
|
|
287
|
+
// no follow-up.
|
|
288
|
+
expect(visibilityUpdates).toHaveLength(0);
|
|
289
|
+
});
|
|
290
|
+
|
|
237
291
|
it("apply_environment rebuilds the data map with secret flags", async () => {
|
|
238
292
|
const result = await callTool("apply_environment", {
|
|
239
293
|
name: "GitHub Creds",
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
import { EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
|
|
12
12
|
import { EnvironmentCommandController } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/command_pb";
|
|
13
13
|
|
|
14
|
+
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
15
|
+
|
|
14
16
|
import { environmentInputToProto, type EnvironmentInput } from "../../gen/environment.js";
|
|
17
|
+
import { applyDeclaredVisibility } from "../apply-visibility.js";
|
|
15
18
|
import { withClient } from "../client.js";
|
|
16
19
|
import { toProtoJson } from "../marshal.js";
|
|
17
20
|
import { rpcError } from "../rpcerr.js";
|
|
@@ -30,7 +33,13 @@ export async function applyEnvironment(
|
|
|
30
33
|
token,
|
|
31
34
|
async (client, callOptions) => {
|
|
32
35
|
try {
|
|
33
|
-
const
|
|
36
|
+
const applied = await client.apply(environment, callOptions);
|
|
37
|
+
const result = await applyDeclaredVisibility(
|
|
38
|
+
client,
|
|
39
|
+
callOptions,
|
|
40
|
+
applied,
|
|
41
|
+
environment.metadata?.visibility ?? ApiResourceVisibility.api_resource_visibility_unspecified,
|
|
42
|
+
);
|
|
34
43
|
return toProtoJson(EnvironmentSchema, result);
|
|
35
44
|
} catch (err) {
|
|
36
45
|
throw rpcError(err, desc);
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
8
8
|
import { McpServerCommandController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/command_pb";
|
|
9
9
|
|
|
10
|
+
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
11
|
+
|
|
10
12
|
import { mcpServerInputToProto, type McpServerInput } from "../../gen/mcpserver.js";
|
|
13
|
+
import { applyDeclaredVisibility } from "../apply-visibility.js";
|
|
11
14
|
import { withClient } from "../client.js";
|
|
12
15
|
import { toProtoJson } from "../marshal.js";
|
|
13
16
|
import { rpcError } from "../rpcerr.js";
|
|
@@ -26,7 +29,13 @@ export async function applyMcpServer(
|
|
|
26
29
|
token,
|
|
27
30
|
async (client, callOptions) => {
|
|
28
31
|
try {
|
|
29
|
-
const
|
|
32
|
+
const applied = await client.apply(server, callOptions);
|
|
33
|
+
const result = await applyDeclaredVisibility(
|
|
34
|
+
client,
|
|
35
|
+
callOptions,
|
|
36
|
+
applied,
|
|
37
|
+
server.metadata?.visibility ?? ApiResourceVisibility.api_resource_visibility_unspecified,
|
|
38
|
+
);
|
|
30
39
|
return toProtoJson(McpServerSchema, result);
|
|
31
40
|
} catch (err) {
|
|
32
41
|
throw rpcError(err, desc);
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
|
|
12
12
|
import { WorkflowCommandController } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/command_pb";
|
|
13
13
|
|
|
14
|
+
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
15
|
+
|
|
14
16
|
import { workflowInputToProto, type WorkflowInput } from "../../gen/workflow.js";
|
|
17
|
+
import { applyDeclaredVisibility } from "../apply-visibility.js";
|
|
15
18
|
import { withClient } from "../client.js";
|
|
16
19
|
import { toProtoJson } from "../marshal.js";
|
|
17
20
|
import { rpcError } from "../rpcerr.js";
|
|
@@ -30,7 +33,13 @@ export async function applyWorkflow(
|
|
|
30
33
|
token,
|
|
31
34
|
async (client, callOptions) => {
|
|
32
35
|
try {
|
|
33
|
-
const
|
|
36
|
+
const applied = await client.apply(workflow, callOptions);
|
|
37
|
+
const result = await applyDeclaredVisibility(
|
|
38
|
+
client,
|
|
39
|
+
callOptions,
|
|
40
|
+
applied,
|
|
41
|
+
workflow.metadata?.visibility ?? ApiResourceVisibility.api_resource_visibility_unspecified,
|
|
42
|
+
);
|
|
34
43
|
return toProtoJson(WorkflowSchema, result);
|
|
35
44
|
} catch (err) {
|
|
36
45
|
throw rpcError(err, desc);
|
package/src/gen/agent.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const AgentInputShape = {
|
|
|
18
18
|
name: z.string().describe("Human-readable name of the resource."),
|
|
19
19
|
slug: z.string().optional().describe("URL-friendly identifier (lowercase alphanumeric with hyphens). Auto-generated from name if omitted."),
|
|
20
20
|
org: z.string().describe("Organization that owns this resource (e.g. acme)."),
|
|
21
|
-
visibility: z.string().optional().describe("Resource visibility: PRIVATE or PUBLIC. Omit to leave unchanged
|
|
21
|
+
visibility: z.string().optional().describe("Resource visibility: PRIVATE or PUBLIC. Applied at create; on updates a changed value is landed through the guarded UpdateVisibility RPC. Omit to leave unchanged."),
|
|
22
22
|
labels: z.record(z.string()).optional().describe("Key-value labels for organization and filtering."),
|
|
23
23
|
tags: z.array(z.string()).optional().describe("Tags for categorization and discovery."),
|
|
24
24
|
description: z.string().optional().describe("Human-readable description for UI and marketplace display."),
|
package/src/gen/environment.ts
CHANGED
|
@@ -15,7 +15,7 @@ export const EnvironmentInputShape = {
|
|
|
15
15
|
name: z.string().describe("Human-readable name of the resource."),
|
|
16
16
|
slug: z.string().optional().describe("URL-friendly identifier (lowercase alphanumeric with hyphens). Auto-generated from name if omitted."),
|
|
17
17
|
org: z.string().describe("Organization that owns this resource (e.g. acme)."),
|
|
18
|
-
visibility: z.string().optional().describe("Resource visibility: PRIVATE or PUBLIC. Omit to leave unchanged
|
|
18
|
+
visibility: z.string().optional().describe("Resource visibility: PRIVATE or PUBLIC. Applied at create; on updates a changed value is landed through the guarded UpdateVisibility RPC. Omit to leave unchanged."),
|
|
19
19
|
labels: z.record(z.string()).optional().describe("Key-value labels for organization and filtering."),
|
|
20
20
|
tags: z.array(z.string()).optional().describe("Tags for categorization and discovery."),
|
|
21
21
|
description: z.string().optional().describe("Human-readable description for UI and listing display."),
|
package/src/gen/mcpserver.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const McpServerInputShape = {
|
|
|
18
18
|
name: z.string().describe("Human-readable name of the resource."),
|
|
19
19
|
slug: z.string().optional().describe("URL-friendly identifier (lowercase alphanumeric with hyphens). Auto-generated from name if omitted."),
|
|
20
20
|
org: z.string().describe("Organization that owns this resource (e.g. acme)."),
|
|
21
|
-
visibility: z.string().optional().describe("Resource visibility: PRIVATE or PUBLIC. Omit to leave unchanged
|
|
21
|
+
visibility: z.string().optional().describe("Resource visibility: PRIVATE or PUBLIC. Applied at create; on updates a changed value is landed through the guarded UpdateVisibility RPC. Omit to leave unchanged."),
|
|
22
22
|
labels: z.record(z.string()).optional().describe("Key-value labels for organization and filtering."),
|
|
23
23
|
tags: z.array(z.string()).optional().describe("Tags for categorization and discovery."),
|
|
24
24
|
description: z.string().optional().describe("Human-readable description for marketplace display and documentation. Should explain what this MCP server does and its primary use cases. Example: 'GitHub MCP server for repository operations, code search, and PR management'"),
|
|
@@ -27,7 +27,7 @@ export const McpServerInputShape = {
|
|
|
27
27
|
http: z.lazy(() => HttpServerConfigInputSchema).optional().describe("HTTP-based server (HTTP + Server-Sent Events communication). Used for remote/managed MCP services accessible over the network."),
|
|
28
28
|
default_enabled_tools: z.array(z.string()).optional().describe("Default tools to enable from this MCP server. Empty list means all tools are enabled by default. Applies whenever an agent's McpServerUsage.enabled_tools is empty."),
|
|
29
29
|
env: z.record(z.lazy(() => EnvVarDeclarationInputSchema)).optional().describe("Environment variable declarations for this MCP server. Keys are variable names; values describe their metadata and optionality."),
|
|
30
|
-
pinned_tool_approvals: z.array(z.lazy(() => ToolApprovalPolicyInputSchema)).optional().describe("
|
|
30
|
+
pinned_tool_approvals: z.array(z.lazy(() => ToolApprovalPolicyInputSchema)).optional().describe("Tools pinned by the MCP server owner to always require approval."),
|
|
31
31
|
repository_url: z.string().optional().describe("URL of the upstream source repository for this MCP server. Shown in the marketplace so users can inspect the implementation for trust and transparency. Example: 'https://github.com/modelcontextprotocol/servers'"),
|
|
32
32
|
github_stars: z.number().optional().describe("GitHub star count at the time of curation. Used as a popularity signal in marketplace display. 0 if unknown or non-GitHub repository."),
|
|
33
33
|
auth: z.lazy(() => McpServerAuthInputSchema).optional().describe("OAuth authentication configuration for automated credential acquisition. When set, the MCP server's Connect page offers an OAuth flow instead of (or in addition to) manual credential entry. The acquired access token is stored in a system-managed environment (identified by grant.environment_id) as the env var named by auth.target_env_var. That env var must also be declared in env so the execution pipeline knows about it."),
|
package/src/gen/workflow.ts
CHANGED
|
@@ -45,7 +45,7 @@ export const WorkflowInputShape = {
|
|
|
45
45
|
name: z.string().describe("Human-readable name of the resource."),
|
|
46
46
|
slug: z.string().optional().describe("URL-friendly identifier (lowercase alphanumeric with hyphens). Auto-generated from name if omitted."),
|
|
47
47
|
org: z.string().describe("Organization that owns this resource (e.g. acme)."),
|
|
48
|
-
visibility: z.string().optional().describe("Resource visibility: PRIVATE or PUBLIC. Omit to leave unchanged
|
|
48
|
+
visibility: z.string().optional().describe("Resource visibility: PRIVATE or PUBLIC. Applied at create; on updates a changed value is landed through the guarded UpdateVisibility RPC. Omit to leave unchanged."),
|
|
49
49
|
labels: z.record(z.string()).optional().describe("Key-value labels for organization and filtering."),
|
|
50
50
|
tags: z.array(z.string()).optional().describe("Tags for categorization and discovery."),
|
|
51
51
|
description: z.string().optional().describe("Human-readable description for UI and marketplace display."),
|
|
@@ -121,7 +121,7 @@ const AgentCallTaskConfigInputSchema = z.object({
|
|
|
121
121
|
env: z.record(z.string()).optional().describe("Runtime environment variables to pass to the agent. Values can be literal strings or JQ expressions that reference workflow context or secrets. Example: {'GITHUB_TOKEN': '${ .secrets.GH_TOKEN }'} Optional."),
|
|
122
122
|
run_config: z.lazy(() => RunConfigInputSchema).optional().describe("Per-call model choice and run bounds. Unset fields inherit the platform defaults."),
|
|
123
123
|
output: z.lazy(() => AgentCallOutputContractInputSchema).optional().describe("Structured output contract for this agent call. When set, the workflow runner extracts structured JSON from the agent's final response and validates it against the declared schema. The validated JSON is placed in the task output under the 'structured' key, enabling reliable downstream routing via switch_case expressions. When not set, the task output contains the agent's raw text response. @since T02 (Structured Agent Output Model)"),
|
|
124
|
-
harness: z.string().optional().describe("Execution harness for the agent invocation. Determines which execution engine processes the agent call: - HARNESS_UNSPECIFIED / HARNESS_NATIVE: Stigmer native engine (Python/LangGraph) - HARNESS_CURSOR: Cursor SDK engine (TypeScript/Cursor) The
|
|
124
|
+
harness: z.string().optional().describe("Execution harness for the agent invocation. Determines which execution engine processes the agent call: - HARNESS_UNSPECIFIED / HARNESS_NATIVE: Stigmer native engine (Python/LangGraph) - HARNESS_CURSOR: Cursor SDK engine (TypeScript/Cursor) The runner creates a Session with this harness before creating the AgentExecution. The harness is a session-level concern — it determines tool availability, state management, model access, and billing tier. When unspecified, defaults to HARNESS_NATIVE (the workflow surface's platform default). YAML Example: - code_review: call: agent with: agent: 'code-reviewer' harness: cursor message: 'Review this PR' Allowed values: HARNESS_NATIVE, HARNESS_CURSOR."),
|
|
125
125
|
workspace_entries: z.array(z.lazy(() => WorkspaceEntryInputSchema)).optional().describe("Workspace the child run's session operates on. Empty means no workspace."),
|
|
126
126
|
environment_refs: z.array(z.lazy(() => EnvironmentRefInputSchema)).optional().describe("References to Environment resources whose values are provided to the child runs this task creates. This is how a tool-using agent becomes runnable from a workflow: bind an org-shared environment holding the needed credentials, and the child runs receive its values at runtime. The agent and its default instance stay untouched."),
|
|
127
127
|
});
|
|
@@ -68,6 +68,21 @@ afterAll(async () => {
|
|
|
68
68
|
|
|
69
69
|
const base = () => `http://127.0.0.1:${port}`;
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Every refusal must be a JSON-RPC error object with Content-Type
|
|
73
|
+
* application/json (oss#316 — strict MCP clients parse the body; text/plain
|
|
74
|
+
* surfaced as opaque content-type errors). Returns the error for
|
|
75
|
+
* code-specific assertions.
|
|
76
|
+
*/
|
|
77
|
+
async function expectJsonRpcError(res: Response): Promise<{ code: number; message: string }> {
|
|
78
|
+
expect(res.headers.get("content-type")).toBe("application/json");
|
|
79
|
+
const body = (await res.json()) as { jsonrpc: string; error: { code: number; message: string }; id: null };
|
|
80
|
+
expect(body.jsonrpc).toBe("2.0");
|
|
81
|
+
expect(body.id).toBeNull();
|
|
82
|
+
expect(typeof body.error.code).toBe("number");
|
|
83
|
+
return body.error;
|
|
84
|
+
}
|
|
85
|
+
|
|
71
86
|
describe("HTTP transport hardening + OAuth discovery", () => {
|
|
72
87
|
it("answers the /health probe without auth", async () => {
|
|
73
88
|
const res = await fetch(`${base()}/health`);
|
|
@@ -75,6 +90,16 @@ describe("HTTP transport hardening + OAuth discovery", () => {
|
|
|
75
90
|
expect(await res.json()).toEqual({ status: "ok" });
|
|
76
91
|
});
|
|
77
92
|
|
|
93
|
+
it("reports unready on /ready when the backend hop is down (no auth required)", async () => {
|
|
94
|
+
// The suite's backend address points at nothing — exactly the failure
|
|
95
|
+
// class /ready exists to expose while /health stays green (oss#316).
|
|
96
|
+
const res = await fetch(`${base()}/ready`);
|
|
97
|
+
expect(res.status).toBe(503);
|
|
98
|
+
const body = (await res.json()) as { status: string; reason?: string };
|
|
99
|
+
expect(body.status).toBe("unready");
|
|
100
|
+
expect(body.reason).toContain("backend health check failed");
|
|
101
|
+
});
|
|
102
|
+
|
|
78
103
|
it("serves RFC 9728 protected resource metadata", async () => {
|
|
79
104
|
const res = await fetch(`${base()}/.well-known/oauth-protected-resource`);
|
|
80
105
|
expect(res.status).toBe(200);
|
|
@@ -95,7 +120,7 @@ describe("HTTP transport hardening + OAuth discovery", () => {
|
|
|
95
120
|
expect(res.headers.get("access-control-allow-methods")).toContain("GET");
|
|
96
121
|
});
|
|
97
122
|
|
|
98
|
-
it("challenges token-less requests with WWW-Authenticate", async () => {
|
|
123
|
+
it("challenges token-less requests with WWW-Authenticate and a JSON-RPC body", async () => {
|
|
99
124
|
const res = await fetch(`${base()}/`, { method: "POST", body: "{}" });
|
|
100
125
|
expect(res.status).toBe(401);
|
|
101
126
|
const challenge = res.headers.get("www-authenticate") ?? "";
|
|
@@ -104,6 +129,9 @@ describe("HTTP transport hardening + OAuth discovery", () => {
|
|
|
104
129
|
'resource_metadata="https://mcp.stigmer.ai/.well-known/oauth-protected-resource"',
|
|
105
130
|
);
|
|
106
131
|
expect(challenge).toContain('scope="read write"');
|
|
132
|
+
const error = await expectJsonRpcError(res);
|
|
133
|
+
expect(error.code).toBe(-32000);
|
|
134
|
+
expect(error.message).toContain("missing or malformed Authorization");
|
|
107
135
|
});
|
|
108
136
|
|
|
109
137
|
it("rejects a malformed Authorization header as token-less", async () => {
|
|
@@ -115,16 +143,22 @@ describe("HTTP transport hardening + OAuth discovery", () => {
|
|
|
115
143
|
});
|
|
116
144
|
expect(res.status).toBe(401);
|
|
117
145
|
expect(res.headers.get("www-authenticate") ?? "").toContain('realm="stigmer"');
|
|
146
|
+
await expectJsonRpcError(res);
|
|
118
147
|
});
|
|
119
148
|
|
|
120
|
-
it("returns
|
|
149
|
+
it("returns the SDK's session-not-found shape (404, code -32001) for an unknown session", async () => {
|
|
150
|
+
// -32001 on a 404 is the streamable-HTTP recovery signal: on it a
|
|
151
|
+
// conformant client MUST open a new session with a fresh initialize —
|
|
152
|
+
// how clients survive the bridge's in-memory sessions dying on restart.
|
|
121
153
|
const res = await fetch(`${base()}/`, {
|
|
122
154
|
method: "POST",
|
|
123
155
|
headers: { authorization: "Bearer test-token", "mcp-session-id": "does-not-exist" },
|
|
124
156
|
body: "{}",
|
|
125
157
|
});
|
|
126
158
|
expect(res.status).toBe(404);
|
|
127
|
-
|
|
159
|
+
const error = await expectJsonRpcError(res);
|
|
160
|
+
expect(error.code).toBe(-32001);
|
|
161
|
+
expect(error.message).toContain("unknown or expired MCP session");
|
|
128
162
|
});
|
|
129
163
|
|
|
130
164
|
it("rejects a sessionless non-initialize POST", async () => {
|
|
@@ -134,7 +168,9 @@ describe("HTTP transport hardening + OAuth discovery", () => {
|
|
|
134
168
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
|
135
169
|
});
|
|
136
170
|
expect(res.status).toBe(400);
|
|
137
|
-
|
|
171
|
+
const error = await expectJsonRpcError(res);
|
|
172
|
+
expect(error.code).toBe(-32000);
|
|
173
|
+
expect(error.message).toContain("an initialize request is required");
|
|
138
174
|
});
|
|
139
175
|
|
|
140
176
|
it("rejects a sessionless GET (no Mcp-Session-Id)", async () => {
|
|
@@ -143,7 +179,9 @@ describe("HTTP transport hardening + OAuth discovery", () => {
|
|
|
143
179
|
headers: { authorization: "Bearer test-token" },
|
|
144
180
|
});
|
|
145
181
|
expect(res.status).toBe(400);
|
|
146
|
-
|
|
182
|
+
const error = await expectJsonRpcError(res);
|
|
183
|
+
expect(error.code).toBe(-32000);
|
|
184
|
+
expect(error.message).toContain("Mcp-Session-Id header is required");
|
|
147
185
|
});
|
|
148
186
|
});
|
|
149
187
|
|
|
@@ -189,6 +227,8 @@ describe("HTTP route dispatch (the closed route table)", () => {
|
|
|
189
227
|
// except the send tool its attachment existed for.
|
|
190
228
|
const res = await initialize("/no-such-roster");
|
|
191
229
|
expect(res.status).toBe(404);
|
|
192
|
-
|
|
230
|
+
const error = await expectJsonRpcError(res);
|
|
231
|
+
expect(error.code).toBe(-32000);
|
|
232
|
+
expect(error.message).toContain("unknown MCP route: /no-such-roster");
|
|
193
233
|
});
|
|
194
234
|
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Unit tests for the cached readiness check (oss#316). The backend RPC is
|
|
2
|
+
// injected, so caching, in-flight dedupe, and verdict propagation are pinned
|
|
3
|
+
// without a live gRPC server; the /ready route's wiring (including the real
|
|
4
|
+
// checkBackendHealth against a dead backend) is covered by
|
|
5
|
+
// http.integration.test.ts.
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createReadinessCheck,
|
|
11
|
+
READINESS_CACHE_TTL_MS,
|
|
12
|
+
type ReadinessResult,
|
|
13
|
+
} from "./readiness";
|
|
14
|
+
|
|
15
|
+
const READY: ReadinessResult = { ready: true };
|
|
16
|
+
const UNREADY: ReadinessResult = { ready: false, reason: "backend health status: NOT_SERVING" };
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.useRealTimers();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("createReadinessCheck", () => {
|
|
27
|
+
it("propagates the ready verdict", async () => {
|
|
28
|
+
const check = createReadinessCheck("addr", async () => READY);
|
|
29
|
+
await expect(check()).resolves.toEqual({ ready: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("propagates the unready verdict with its reason", async () => {
|
|
33
|
+
const check = createReadinessCheck("addr", async () => UNREADY);
|
|
34
|
+
await expect(check()).resolves.toEqual(UNREADY);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("serves cached verdicts within the TTL without re-dialing", async () => {
|
|
38
|
+
const probe = vi.fn(async () => READY);
|
|
39
|
+
const check = createReadinessCheck("addr", probe);
|
|
40
|
+
await check();
|
|
41
|
+
vi.advanceTimersByTime(READINESS_CACHE_TTL_MS - 1);
|
|
42
|
+
await check();
|
|
43
|
+
expect(probe).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("re-checks after the TTL expires", async () => {
|
|
47
|
+
const probe = vi.fn(async () => READY);
|
|
48
|
+
const check = createReadinessCheck("addr", probe);
|
|
49
|
+
await check();
|
|
50
|
+
vi.advanceTimersByTime(READINESS_CACHE_TTL_MS + 1);
|
|
51
|
+
await check();
|
|
52
|
+
expect(probe).toHaveBeenCalledTimes(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("recovers: an unready verdict is replaced by a ready one after the TTL", async () => {
|
|
56
|
+
const probe = vi
|
|
57
|
+
.fn<() => Promise<ReadinessResult>>()
|
|
58
|
+
.mockResolvedValueOnce(UNREADY)
|
|
59
|
+
.mockResolvedValueOnce(READY);
|
|
60
|
+
const check = createReadinessCheck("addr", probe);
|
|
61
|
+
await expect(check()).resolves.toEqual(UNREADY);
|
|
62
|
+
vi.advanceTimersByTime(READINESS_CACHE_TTL_MS + 1);
|
|
63
|
+
await expect(check()).resolves.toEqual({ ready: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("shares one in-flight RPC across concurrent callers", async () => {
|
|
67
|
+
let resolveProbe!: (r: ReadinessResult) => void;
|
|
68
|
+
const probe = vi.fn(
|
|
69
|
+
() => new Promise<ReadinessResult>((resolve) => (resolveProbe = resolve)),
|
|
70
|
+
);
|
|
71
|
+
const check = createReadinessCheck("addr", probe);
|
|
72
|
+
|
|
73
|
+
const first = check();
|
|
74
|
+
const second = check();
|
|
75
|
+
resolveProbe(READY);
|
|
76
|
+
|
|
77
|
+
await expect(first).resolves.toEqual({ ready: true });
|
|
78
|
+
await expect(second).resolves.toEqual({ ready: true });
|
|
79
|
+
expect(probe).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("passes the configured backend address to the probe", async () => {
|
|
83
|
+
const probe = vi.fn(async (addr: string) => {
|
|
84
|
+
expect(addr).toBe("backend:50051");
|
|
85
|
+
return READY;
|
|
86
|
+
});
|
|
87
|
+
await createReadinessCheck("backend:50051", probe)();
|
|
88
|
+
expect(probe).toHaveBeenCalledWith("backend:50051");
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/readiness.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Backend-hop readiness probe (stigmer/stigmer#316).
|
|
2
|
+
//
|
|
3
|
+
// /health reports process liveness only, which left a total backend-hop
|
|
4
|
+
// failure invisible: the bridge once spoke gRPC-web to a backend that only
|
|
5
|
+
// accepts native gRPC, every tool call failed for weeks, and all three
|
|
6
|
+
// Kubernetes probes stayed green. Readiness must therefore exercise the
|
|
7
|
+
// actual hop — transport, content-type, listener — with one cheap RPC.
|
|
8
|
+
//
|
|
9
|
+
// The probe RPC is grpc.health.v1.Health/Check: the one backend call both
|
|
10
|
+
// editions serve credential-free by design (the Go server sets it SERVING
|
|
11
|
+
// before its network listener opens; the cloud Java interceptors exempt
|
|
12
|
+
// "grpc.health.v1.Health" by name — "probes and tooling carry no bearer").
|
|
13
|
+
// The hosted bridge holds no startup credential (per-request Bearer
|
|
14
|
+
// passthrough only), so an authenticated probe is not an option.
|
|
15
|
+
//
|
|
16
|
+
// Only the READINESS probe should point here. Liveness and startup must stay
|
|
17
|
+
// on /health: readiness failure drains traffic (correct for a backend
|
|
18
|
+
// outage), while liveness/startup failures restart the pod — a restart loop
|
|
19
|
+
// that cannot fix a broken backend and destroys every live MCP session.
|
|
20
|
+
|
|
21
|
+
import { createClient } from "@connectrpc/connect";
|
|
22
|
+
import { Health, HealthCheckResponse_ServingStatus } from "@stigmer/protos/grpc/health/v1/health_pb";
|
|
23
|
+
import { transportForToken } from "./domains/client.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Per-check RPC bound. Deliberately below the probe's HTTP timeout in the
|
|
27
|
+
* deployment overlay (5 s) so a hung backend yields a clean 503 with a
|
|
28
|
+
* reason, never a probe-level timeout that hides it.
|
|
29
|
+
*/
|
|
30
|
+
export const READINESS_RPC_TIMEOUT_MS = 4_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How long a verdict is served without re-dialing the backend. Bounds probe
|
|
34
|
+
* (and operator curl) traffic to at most one backend RPC per window without
|
|
35
|
+
* meaningfully delaying either transition: at the overlay's 10 s readiness
|
|
36
|
+
* period, every scheduled probe still lands on a fresh check.
|
|
37
|
+
*/
|
|
38
|
+
export const READINESS_CACHE_TTL_MS = 5_000;
|
|
39
|
+
|
|
40
|
+
export interface ReadinessResult {
|
|
41
|
+
ready: boolean;
|
|
42
|
+
/** Human-readable cause when not ready; surfaced in the 503 body. */
|
|
43
|
+
reason?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Checks the backend hop once; see {@link createReadinessCheck} for caching. */
|
|
47
|
+
export async function checkBackendHealth(serverAddress: string): Promise<ReadinessResult> {
|
|
48
|
+
const client = createClient(Health, transportForToken(serverAddress, ""));
|
|
49
|
+
try {
|
|
50
|
+
const res = await client.check({}, { timeoutMs: READINESS_RPC_TIMEOUT_MS });
|
|
51
|
+
if (res.status === HealthCheckResponse_ServingStatus.SERVING) {
|
|
52
|
+
return { ready: true };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
ready: false,
|
|
56
|
+
reason: `backend health status: ${HealthCheckResponse_ServingStatus[res.status] ?? res.status}`,
|
|
57
|
+
};
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return {
|
|
60
|
+
ready: false,
|
|
61
|
+
reason: `backend health check failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the cached readiness check used by the /ready route: verdicts are
|
|
68
|
+
* reused for {@link READINESS_CACHE_TTL_MS} and concurrent callers share one
|
|
69
|
+
* in-flight RPC, so probe pressure can never amplify into backend pressure.
|
|
70
|
+
*/
|
|
71
|
+
export function createReadinessCheck(
|
|
72
|
+
serverAddress: string,
|
|
73
|
+
check: (addr: string) => Promise<ReadinessResult> = checkBackendHealth,
|
|
74
|
+
): () => Promise<ReadinessResult> {
|
|
75
|
+
let cached: { result: ReadinessResult; at: number } | undefined;
|
|
76
|
+
let inFlight: Promise<ReadinessResult> | undefined;
|
|
77
|
+
|
|
78
|
+
return async () => {
|
|
79
|
+
if (cached !== undefined && Date.now() - cached.at < READINESS_CACHE_TTL_MS) {
|
|
80
|
+
return cached.result;
|
|
81
|
+
}
|
|
82
|
+
if (inFlight === undefined) {
|
|
83
|
+
inFlight = check(serverAddress)
|
|
84
|
+
.then((result) => {
|
|
85
|
+
cached = { result, at: Date.now() };
|
|
86
|
+
return result;
|
|
87
|
+
})
|
|
88
|
+
.finally(() => {
|
|
89
|
+
inFlight = undefined;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return inFlight;
|
|
93
|
+
};
|
|
94
|
+
}
|