Skip to content

Commit b990aac

Browse files
committed
fix(core-tools): allow no-auth connections via connections.create
The connections.create arg schema required exactly one credential origin (`from` or `inputs`), so creating a no-auth connection (template "none", no credential) failed validation with "Expected exactly one provider credential origin". This blocked agents from programmatically wiring up public, no-auth integrations (public MCP servers, public REST APIs), forcing a bounce through the web UI via createHandoff, even though the engine already supports zero-credential connections. Relax the filter to mirror the engine: the no-auth template accepts zero origins (and rejects any origin); every other template still requires exactly one. The "inputs, if present, must be non-empty" rule is kept. Also document the no-auth path in the connections.create description.
1 parent 0f53b5b commit b990aac

2 files changed

Lines changed: 218 additions & 2 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// The agentic no-auth wire-up: an agent registers a public REST API over MCP
2+
// and then creates its connection PROGRAMMATICALLY through the gateway core
3+
// tool — `coreTools.connections.create` with `template: "none"` and no
4+
// credential origin. This is the path that used to be impossible: the core
5+
// tool's arg schema demanded "exactly one provider credential origin", so an
6+
// agent wiring up a public, no-auth integration (public MCP server, public
7+
// REST API) was forced to bounce the user into the web UI via createHandoff,
8+
// even though the engine fully supports a zero-credential connection.
9+
//
10+
// This scenario walks the WHOLE path against a real public no-auth API (the
11+
// npm registry downloads endpoint, https://api.npmjs.org) so the proof is an
12+
// actual 200 over the wire, not a stub:
13+
//
14+
// 1. MCP `execute` → `openapi.addSpec` registers a tiny no-auth spec
15+
// (no securitySchemes ⇒ the integration is no-auth)
16+
// 2. MCP `execute` → `coreTools.connections.create` with template "none"
17+
// and NEITHER `from` NOR `inputs` — the call that used to fail validation
18+
// 3. The operation is now a callable tool: invoke it and read back a 200
19+
// with the real download count
20+
// 4. Guard the relaxed-but-still-strict contract: a no-auth create that
21+
// DOES carry an origin (here an empty `inputs: {}`) is still rejected
22+
import { randomBytes } from "node:crypto";
23+
24+
import { expect } from "@effect/vitest";
25+
import { Effect } from "effect";
26+
import { composePluginApi } from "@executor-js/api/server";
27+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
28+
29+
import { scenario } from "../src/scenario";
30+
import { Api, Mcp, Target } from "../src/services";
31+
import type { McpSession } from "../src/surfaces/mcp";
32+
33+
const api = composePluginApi([openApiHttpPlugin()] as const);
34+
35+
const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
36+
37+
// A real public, no-auth REST API. No `components.securitySchemes` and no
38+
// top-level `security`, so addSpec derives no auth method and the integration
39+
// is no-auth — exactly the shape a connection on `template: "none"` targets.
40+
const NPM_DOWNLOADS_SPEC = JSON.stringify({
41+
openapi: "3.0.3",
42+
info: { title: "npm Registry Downloads", version: "1.0.0" },
43+
servers: [{ url: "https://api.npmjs.org" }],
44+
paths: {
45+
"/downloads/point/{period}/{package}": {
46+
get: {
47+
operationId: "getPackageDownloads",
48+
summary: "Total downloads for a package over a fixed period",
49+
parameters: [
50+
{ name: "period", in: "path", required: true, schema: { type: "string" } },
51+
{ name: "package", in: "path", required: true, schema: { type: "string" } },
52+
],
53+
responses: {
54+
"200": {
55+
description: "Download counts for the package",
56+
content: {
57+
"application/json": {
58+
schema: {
59+
type: "object",
60+
properties: {
61+
downloads: { type: "number" },
62+
start: { type: "string" },
63+
end: { type: "string" },
64+
package: { type: "string" },
65+
},
66+
},
67+
},
68+
},
69+
},
70+
},
71+
},
72+
},
73+
},
74+
});
75+
76+
const addSpecCode = (slug: string) => `
77+
const added = await tools.executor.openapi.addSpec({
78+
spec: { kind: "blob", value: ${JSON.stringify(NPM_DOWNLOADS_SPEC)} },
79+
slug: ${JSON.stringify(slug)},
80+
baseUrl: "https://api.npmjs.org",
81+
});
82+
return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error };
83+
`;
84+
85+
// THE call under test: a no-auth connection with no credential origin at all.
86+
const createNoAuthConnectionCode = (slug: string) => `
87+
const created = await tools.executor.coreTools.connections.create({
88+
owner: "org",
89+
name: "public",
90+
integration: ${JSON.stringify(slug)},
91+
template: "none",
92+
});
93+
return created.ok ? { ok: true, connection: created.data } : { ok: false, error: created.error };
94+
`;
95+
96+
// The relaxed filter must still reject an origin on a no-auth create — an
97+
// empty `inputs: {}` is a (degenerate) origin and a credential the connection
98+
// can't hold, so it stays a validation failure.
99+
const createNoAuthWithEmptyInputsCode = (slug: string) => `
100+
const created = await tools.executor.coreTools.connections.create({
101+
owner: "org",
102+
name: "public-bad",
103+
integration: ${JSON.stringify(slug)},
104+
template: "none",
105+
inputs: {},
106+
});
107+
return created.ok ? { ok: true, connection: created.data } : { ok: false, error: created.error };
108+
`;
109+
110+
const invokeDownloadsCode = (slug: string) => `
111+
const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "downloads", limit: 5 });
112+
const path = found.items[0]?.path;
113+
if (!path) return { ok: false, error: "no downloads tool found", items: found.items };
114+
let t = tools;
115+
for (const seg of path.split(".")) t = t[seg];
116+
const result = await t({ period: "last-week", package: "react" });
117+
return { ok: result.ok, path, data: result.ok ? result.data : result.error };
118+
`;
119+
120+
const removeConnectionsCode = (slug: string) => `
121+
const list = await tools.executor.coreTools.connections.list({});
122+
const mine = (list.ok ? list.data.connections : []).filter((c) => c.integration === ${JSON.stringify(slug)});
123+
for (const c of mine) {
124+
await tools.executor.coreTools.connections.remove({ owner: c.owner, integration: c.integration, name: c.name });
125+
}
126+
return { removed: mine.length };
127+
`;
128+
129+
/** Run `execute`, auto-approving any policy-paused execution, and parse the
130+
* sandbox's JSON return value. */
131+
const executeJson = (session: McpSession, code: string) =>
132+
Effect.gen(function* () {
133+
let result = yield* session.call("execute", { code });
134+
let guard = 0;
135+
while (result.text.includes("executionId:") && guard < 10) {
136+
result = yield* session.approvePaused(result.text);
137+
guard += 1;
138+
}
139+
expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
140+
return JSON.parse(result.text) as Record<string, unknown>;
141+
});
142+
143+
scenario(
144+
"Connections · an agent creates a no-auth connection over the core tool and the public API answers 200",
145+
{ timeout: 180_000 },
146+
Effect.gen(function* () {
147+
const target = yield* Target;
148+
const mcp = yield* Mcp;
149+
const { client: makeApiClient } = yield* Api;
150+
151+
const integration = unique("npmdl");
152+
const identity = yield* target.newIdentity();
153+
const session = mcp.session(identity);
154+
const client = yield* makeApiClient(api, identity);
155+
156+
yield* Effect.gen(function* () {
157+
// 1. Register the public no-auth API over MCP.
158+
const added = yield* executeJson(session, addSpecCode(integration));
159+
expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true);
160+
expect(added.toolCount, "the spec's operation was extracted as a tool").toBe(1);
161+
162+
// 2. THE FIX: create the connection with template "none" and NO origin.
163+
// Pre-fix this failed arg validation with
164+
// "Expected exactly one provider credential origin".
165+
const created = yield* executeJson(session, createNoAuthConnectionCode(integration));
166+
expect(
167+
created.ok,
168+
`no-auth connection created via the core tool: ${JSON.stringify(created)}`,
169+
).toBe(true);
170+
expect(
171+
(created.connection as { template?: string } | undefined)?.template,
172+
"the connection is saved on the no-auth template",
173+
).toBe("none");
174+
175+
// 3. The operation is a live tool: invoke it and read back a real 200.
176+
const invoked = yield* executeJson(session, invokeDownloadsCode(integration));
177+
expect(
178+
invoked.ok,
179+
`the no-auth operation answered over the wire: ${JSON.stringify(invoked)}`,
180+
).toBe(true);
181+
const downloads = (invoked.data as { downloads?: number } | undefined)?.downloads;
182+
expect(typeof downloads, "the public API returned a download count").toBe("number");
183+
expect(downloads as number, "react has a non-zero weekly download count").toBeGreaterThan(0);
184+
185+
// 4. The relaxation is narrow: a no-auth create that carries an origin
186+
// (empty `inputs: {}`) is still rejected.
187+
const rejected = yield* executeJson(session, createNoAuthWithEmptyInputsCode(integration));
188+
expect(
189+
rejected.ok,
190+
`a no-auth create with an empty inputs origin is rejected: ${JSON.stringify(rejected)}`,
191+
).toBe(false);
192+
}).pipe(
193+
// Selfhost shares one workspace identity — leaked connections fail other
194+
// scenarios' zero-state assertions, so drop everything this run made.
195+
Effect.ensuring(
196+
Effect.gen(function* () {
197+
yield* session.call("execute", { code: removeConnectionsCode(integration) });
198+
yield* client.openapi.removeSpec({ params: { slug: integration } });
199+
}).pipe(Effect.ignore),
200+
),
201+
);
202+
}),
203+
);

packages/core/sdk/src/core-tools.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
AuthTemplateSlug,
1515
ConnectionName,
1616
IntegrationSlug,
17+
NO_AUTH_TEMPLATE,
1718
OAuthClientSlug,
1819
OAuthState,
1920
ProviderItemId,
@@ -134,7 +135,19 @@ const ConnectionCreateInput = Schema.Struct({
134135
Schema.makeFilter((payload) => {
135136
const originCount =
136137
(payload.from === undefined ? 0 : 1) + (payload.inputs === undefined ? 0 : 1);
137-
if (originCount !== 1) return "Expected exactly one provider credential origin";
138+
// The no-auth template ("none") binds zero credentials — both `from` and
139+
// `inputs` are legitimately absent (public MCP servers, public REST APIs).
140+
// Mirror the engine, which accepts an empty input set only for this
141+
// template; a stray origin would wire a credential the connection can't
142+
// hold, so reject any. Every other template needs exactly one origin.
143+
const isNoAuth = String(payload.template) === String(NO_AUTH_TEMPLATE);
144+
if (isNoAuth) {
145+
if (originCount > 0) {
146+
return 'A no-auth connection (template "none") takes no provider credential origin';
147+
}
148+
} else if (originCount !== 1) {
149+
return "Expected exactly one provider credential origin";
150+
}
138151
if (payload.inputs !== undefined && Object.keys(payload.inputs).length === 0) {
139152
return "Expected at least one provider credential input";
140153
}
@@ -500,7 +513,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = {
500513
tool({
501514
name: "connections.create",
502515
description:
503-
"Low-level create or replace for a saved connection from provider item references. For normal API keys/tokens, use `connections.createHandoff` so the user enters the credential in the web UI. OAuth credentials should use `oauth.start`.",
516+
'Low-level create or replace for a saved connection from provider item references. For a no-auth integration (public MCP server, public REST API), pass `template: "none"` with no `from`/`inputs` to wire it up directly. For normal API keys/tokens, use `connections.createHandoff` so the user enters the credential in the web UI. OAuth credentials should use `oauth.start`.',
504517
inputSchema: ConnectionCreateInputStd,
505518
outputSchema: ConnectionOutputStd,
506519
execute: (input: typeof ConnectionCreateInput.Type, { ctx }) =>

0 commit comments

Comments
 (0)