@zackbart/connecta 0.26.0 → 0.26.1
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/CHANGELOG.md +116 -0
- package/dist/artifacts/document.js +6 -2
- package/dist/artifacts/markdown.js +43 -14
- package/dist/artifacts/routes.js +27 -4
- package/dist/artifacts/validate.js +1 -0
- package/dist/auth/downstream-oauth.js +397 -48
- package/dist/auth/oauth-start-reset.js +20 -0
- package/dist/branding.d.ts +87 -0
- package/dist/branding.js +170 -1
- package/dist/connectors/remote-mcp.js +32 -7
- package/dist/execute.js +7 -8
- package/dist/oauth-callback-outcome.js +43 -18
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/view.js +288 -33
- package/dist/page-styles.js +7 -0
- package/dist/registry.js +16 -13
- package/dist/routes/mcp.js +5 -1
- package/dist/routes/oauth-management.js +66 -12
- package/dist/routes/oauth.js +54 -87
- package/dist/routes/ui.js +7 -1
- package/dist/server.js +7 -5
- package/dist/skills.js +6 -6
- package/dist/types.d.ts +5 -0
- package/dist/ui.d.ts +5 -0
- package/dist/ui.js +20 -63
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +18 -8
- package/documentation/auth.md +103 -0
- package/documentation/code-mode.md +15 -15
- package/documentation/meta-tools.md +12 -5
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
package/dist/branding.d.ts
CHANGED
|
@@ -18,6 +18,11 @@ interface ResolvedBranding {
|
|
|
18
18
|
pageTitle: string;
|
|
19
19
|
/** href for the page's icon link. */
|
|
20
20
|
faviconHref: string;
|
|
21
|
+
/**
|
|
22
|
+
* The configured `theme-color`, or the documented `#ffffff` default.
|
|
23
|
+
* Informational: pages emit a configured value as-is, and without one they
|
|
24
|
+
* follow the scheme (`themeColorMeta`) rather than painting this default.
|
|
25
|
+
*/
|
|
21
26
|
themeColor: string;
|
|
22
27
|
/** Only the tokens that survived their gate; the stylesheet owns the rest. */
|
|
23
28
|
theme: ResolvedTheme;
|
|
@@ -43,6 +48,88 @@ export declare function droppedThemeTokens(theme?: ConnectaTheme): string[];
|
|
|
43
48
|
* anything that would need escaping is dropped rather than rewritten.
|
|
44
49
|
*/
|
|
45
50
|
export declare function themeCss(theme: ResolvedTheme): string;
|
|
51
|
+
/**
|
|
52
|
+
* The resolved scheme as the root element's attribute. The dark palette keys
|
|
53
|
+
* off `html[data-scheme]`, so "system" leaves it off and the media query
|
|
54
|
+
* decides.
|
|
55
|
+
*/
|
|
56
|
+
export declare function schemeAttribute(theme: ResolvedTheme): string;
|
|
57
|
+
/** Text or attribute value, escaped for any position in an HTML document. */
|
|
58
|
+
export declare function escapeHtml(value: string): string;
|
|
59
|
+
export interface PageLayout {
|
|
60
|
+
/** The complete `<title>`. */
|
|
61
|
+
title: string;
|
|
62
|
+
/** Whether the operator UI is mounted, and so serves `/favicon.*` and `/`. */
|
|
63
|
+
uiMounted: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* The origin that serves `/favicon.*`, for a page that may be rendered on
|
|
66
|
+
* another host: root-relative icon hrefs resolve against it. A browser 404
|
|
67
|
+
* on the artifact host passes `publicUrl`, since that host answers every
|
|
68
|
+
* non-artifact path, the favicons included, with 404. Omitted, they stay
|
|
69
|
+
* root-relative.
|
|
70
|
+
*/
|
|
71
|
+
iconOrigin?: string | undefined;
|
|
72
|
+
/** Markup after the masthead: the page's `<main>`. */
|
|
73
|
+
body: string;
|
|
74
|
+
/**
|
|
75
|
+
* The page's stylesheet before the theme block. Defaults to the shared
|
|
76
|
+
* tokens and primitives; the operator shell passes its own bundle, which
|
|
77
|
+
* already imports both.
|
|
78
|
+
*/
|
|
79
|
+
styles?: string;
|
|
80
|
+
/** Page-specific rules appended to `styles`, still before the theme. */
|
|
81
|
+
extraStyles?: string;
|
|
82
|
+
/** Additional head markup, emitted verbatim (the Clerk loader). */
|
|
83
|
+
head?: string;
|
|
84
|
+
/** Emitted verbatim at the masthead's end (the operator nav mount). */
|
|
85
|
+
mastheadEnd?: string;
|
|
86
|
+
/** A skip link to this element id, for pages with navigation to skip. */
|
|
87
|
+
skipTo?: {
|
|
88
|
+
id: string;
|
|
89
|
+
label: string;
|
|
90
|
+
};
|
|
91
|
+
/** Markup after the body content, emitted verbatim (the operator script). */
|
|
92
|
+
tail?: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The one page layout every server-rendered page shares: the operator shell,
|
|
96
|
+
* the OAuth callback, and browser-facing 404s. Doctype and meta, the favicon a
|
|
97
|
+
* deployment actually serves, the resolved scheme, the token stylesheet with
|
|
98
|
+
* the theme block after it, and the masthead.
|
|
99
|
+
*
|
|
100
|
+
* `/favicon.svg` and `/favicon.ico` are operator UI routes. Without the UI they
|
|
101
|
+
* are 404s, so the default links are left out rather than pointing at nothing
|
|
102
|
+
* (a browser then asks for `/favicon.ico` on its own and gets the same 404). A
|
|
103
|
+
* configured `favicon.href` is the operator's own icon and is linked either way.
|
|
104
|
+
*
|
|
105
|
+
* Every interpolated branding value is escaped here; `body`, `head`,
|
|
106
|
+
* `mastheadEnd`, and `tail` are the caller's markup and are not.
|
|
107
|
+
*/
|
|
108
|
+
export declare function renderPage(branding: ConnectaBranding | undefined, layout: PageLayout): string;
|
|
109
|
+
/**
|
|
110
|
+
* Status marks for one-message pages. Decorative: the label and heading beside
|
|
111
|
+
* each carry the meaning, so the shape repeats it and color is never alone.
|
|
112
|
+
*/
|
|
113
|
+
export declare const STATUS_ICONS: {
|
|
114
|
+
readonly ok: "<svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M5 10.5l3.2 3.2L15 6.8\"/></svg>";
|
|
115
|
+
readonly problem: "<svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\"><path d=\"M10 5.5v5.5\"/><path d=\"M10 14.6v.1\"/></svg>";
|
|
116
|
+
readonly declined: "<svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\"><path d=\"M6 10h8\"/></svg>";
|
|
117
|
+
readonly missing: "<svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"><circle cx=\"9\" cy=\"9\" r=\"4.5\"/><path d=\"M12.4 12.4L16 16\"/></svg>";
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* The 404 a browser gets for a path nothing serves, and everyone else's plain
|
|
121
|
+
* `Not Found`. The body is built from deployment configuration alone and names
|
|
122
|
+
* no path, so every unserved path — on the main host or an artifact host —
|
|
123
|
+
* answers with the same bytes for the same `Accept`: a 404 must not tell one
|
|
124
|
+
* route from another. The home link appears only when the operator UI is
|
|
125
|
+
* mounted to land on, and points at the public origin so an artifact host's
|
|
126
|
+
* 404 does not link to another 404.
|
|
127
|
+
*/
|
|
128
|
+
export declare function notFoundResponse(request: Request, opts: {
|
|
129
|
+
branding?: ConnectaBranding | undefined;
|
|
130
|
+
ui?: object | undefined;
|
|
131
|
+
publicUrl?: string | undefined;
|
|
132
|
+
}): Response;
|
|
46
133
|
/**
|
|
47
134
|
* Names of the branding URLs the operator set that failed their gate and were
|
|
48
135
|
* replaced by a default. Lives beside the gates so the startup warning cannot
|
package/dist/branding.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PAGE_CSS, TOKENS_CSS } from "./page-styles.js";
|
|
1
2
|
/** Connecta's default monochrome "C" mark. */
|
|
2
3
|
export const CONNECTA_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
3
4
|
<style>
|
|
@@ -37,7 +38,7 @@ export function resolveBranding(branding) {
|
|
|
37
38
|
faviconHref: faviconHref && isSafeIconHref(faviconHref)
|
|
38
39
|
? faviconHref
|
|
39
40
|
: DEFAULT_FAVICON_HREF,
|
|
40
|
-
themeColor:
|
|
41
|
+
themeColor: configuredThemeColor(branding) ?? "#ffffff",
|
|
41
42
|
theme: resolveTheme(branding?.theme),
|
|
42
43
|
};
|
|
43
44
|
}
|
|
@@ -148,6 +149,174 @@ export function themeCss(theme) {
|
|
|
148
149
|
].filter(Boolean);
|
|
149
150
|
return declarations.length ? `:root{${declarations.join(";")}}` : "";
|
|
150
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* The resolved scheme as the root element's attribute. The dark palette keys
|
|
154
|
+
* off `html[data-scheme]`, so "system" leaves it off and the media query
|
|
155
|
+
* decides.
|
|
156
|
+
*/
|
|
157
|
+
export function schemeAttribute(theme) {
|
|
158
|
+
return theme.colorScheme === "system"
|
|
159
|
+
? ""
|
|
160
|
+
: ` data-scheme="${theme.colorScheme}"`;
|
|
161
|
+
}
|
|
162
|
+
/** Text or attribute value, escaped for any position in an HTML document. */
|
|
163
|
+
export function escapeHtml(value) {
|
|
164
|
+
return value
|
|
165
|
+
.replaceAll("&", "&")
|
|
166
|
+
.replaceAll('"', """)
|
|
167
|
+
.replaceAll("<", "<")
|
|
168
|
+
.replaceAll(">", ">");
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The masthead's `--surface` in each palette, for the browser chrome a
|
|
172
|
+
* `theme-color` paints. A configured `themeColor` wins; without one a dark
|
|
173
|
+
* page should not sit under a white status bar.
|
|
174
|
+
*/
|
|
175
|
+
const SURFACE = { light: "#ffffff", dark: "#151a21" };
|
|
176
|
+
/** The operator's own `themeColor`, read the one way both callers read it. */
|
|
177
|
+
function configuredThemeColor(branding) {
|
|
178
|
+
return trimmedString(branding?.themeColor);
|
|
179
|
+
}
|
|
180
|
+
function themeColorMeta(branding, theme) {
|
|
181
|
+
const configured = configuredThemeColor(branding);
|
|
182
|
+
if (configured)
|
|
183
|
+
return `<meta name="theme-color" content="${escapeHtml(configured)}">`;
|
|
184
|
+
if (theme.colorScheme !== "system") {
|
|
185
|
+
return `<meta name="theme-color" content="${SURFACE[theme.colorScheme]}">`;
|
|
186
|
+
}
|
|
187
|
+
return `<meta name="theme-color" content="${SURFACE.light}" media="(prefers-color-scheme: light)">\n` +
|
|
188
|
+
`<meta name="theme-color" content="${SURFACE.dark}" media="(prefers-color-scheme: dark)">`;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The one page layout every server-rendered page shares: the operator shell,
|
|
192
|
+
* the OAuth callback, and browser-facing 404s. Doctype and meta, the favicon a
|
|
193
|
+
* deployment actually serves, the resolved scheme, the token stylesheet with
|
|
194
|
+
* the theme block after it, and the masthead.
|
|
195
|
+
*
|
|
196
|
+
* `/favicon.svg` and `/favicon.ico` are operator UI routes. Without the UI they
|
|
197
|
+
* are 404s, so the default links are left out rather than pointing at nothing
|
|
198
|
+
* (a browser then asks for `/favicon.ico` on its own and gets the same 404). A
|
|
199
|
+
* configured `favicon.href` is the operator's own icon and is linked either way.
|
|
200
|
+
*
|
|
201
|
+
* Every interpolated branding value is escaped here; `body`, `head`,
|
|
202
|
+
* `mastheadEnd`, and `tail` are the caller's markup and are not.
|
|
203
|
+
*/
|
|
204
|
+
export function renderPage(branding, layout) {
|
|
205
|
+
const brand = resolveBranding(branding);
|
|
206
|
+
// Top-left corner. With an owner set it reads "<owner> <product>"; without
|
|
207
|
+
// one the product label stands alone. Either half links out when the
|
|
208
|
+
// matching URL is configured.
|
|
209
|
+
const label = (className, text, href) => href
|
|
210
|
+
? `<a class="${className} navlink" href="${escapeHtml(href)}">${escapeHtml(text)}</a>`
|
|
211
|
+
: `<span class="${className}">${escapeHtml(text)}</span>`;
|
|
212
|
+
const owner = brand.ownerName
|
|
213
|
+
? label("brand", brand.ownerName, brand.ownerUrl)
|
|
214
|
+
: label("brand", brand.productName, brand.productUrl);
|
|
215
|
+
const product = brand.ownerName
|
|
216
|
+
? label("product", brand.productName, brand.productUrl)
|
|
217
|
+
: "";
|
|
218
|
+
const customIcon = brand.faviconHref !== DEFAULT_FAVICON_HREF;
|
|
219
|
+
// `isSafeIconHref` already limited a relative href to one leading slash, so
|
|
220
|
+
// resolving it can only land on `iconOrigin` itself.
|
|
221
|
+
const iconHref = (href) => layout.iconOrigin && href.startsWith("/")
|
|
222
|
+
? new URL(href, layout.iconOrigin).toString()
|
|
223
|
+
: href;
|
|
224
|
+
const icons = [
|
|
225
|
+
...(customIcon || layout.uiMounted
|
|
226
|
+
? [`<link rel="icon" href="${escapeHtml(iconHref(brand.faviconHref))}" type="image/svg+xml">`]
|
|
227
|
+
: []),
|
|
228
|
+
...(layout.uiMounted
|
|
229
|
+
? [`<link rel="shortcut icon" href="${escapeHtml(iconHref("/favicon.ico"))}">`]
|
|
230
|
+
: []),
|
|
231
|
+
];
|
|
232
|
+
const skip = layout.skipTo
|
|
233
|
+
? `<a class="skip-link" href="#${escapeHtml(layout.skipTo.id)}">${escapeHtml(layout.skipTo.label)}</a>\n`
|
|
234
|
+
: "";
|
|
235
|
+
const styles = (layout.styles ?? TOKENS_CSS + PAGE_CSS) + (layout.extraStyles ?? "");
|
|
236
|
+
return `<!doctype html>
|
|
237
|
+
<html lang="en"${schemeAttribute(brand.theme)}>
|
|
238
|
+
<head>
|
|
239
|
+
<meta charset="utf-8">
|
|
240
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
241
|
+
${themeColorMeta(branding, brand.theme)}
|
|
242
|
+
<meta name="description" content="${escapeHtml(brand.description)}">
|
|
243
|
+
${icons.join("\n")}
|
|
244
|
+
<title>${escapeHtml(layout.title)}</title>
|
|
245
|
+
${layout.head ?? ""}
|
|
246
|
+
<style>${styles}${themeCss(brand.theme)}</style>
|
|
247
|
+
</head>
|
|
248
|
+
<body>
|
|
249
|
+
${skip}<header class="masthead shell">
|
|
250
|
+
<div class="masthead-inner">
|
|
251
|
+
<div class="mast-nav">
|
|
252
|
+
${owner}${product ? `\n ${product}` : ""}
|
|
253
|
+
</div>${layout.mastheadEnd ? `\n ${layout.mastheadEnd}` : ""}
|
|
254
|
+
</div>
|
|
255
|
+
</header>
|
|
256
|
+
${layout.body}${layout.tail ? `\n${layout.tail}` : ""}
|
|
257
|
+
</body>
|
|
258
|
+
</html>`;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Status marks for one-message pages. Decorative: the label and heading beside
|
|
262
|
+
* each carry the meaning, so the shape repeats it and color is never alone.
|
|
263
|
+
*/
|
|
264
|
+
export const STATUS_ICONS = {
|
|
265
|
+
ok: '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 10.5l3.2 3.2L15 6.8"/></svg>',
|
|
266
|
+
problem: '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M10 5.5v5.5"/><path d="M10 14.6v.1"/></svg>',
|
|
267
|
+
declined: '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 10h8"/></svg>',
|
|
268
|
+
missing: '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="9" cy="9" r="4.5"/><path d="M12.4 12.4L16 16"/></svg>',
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* A request a person's browser made for a page, as opposed to a client asking
|
|
272
|
+
* for data: navigations send `text/html` in `Accept`, `fetch()` and MCP
|
|
273
|
+
* clients do not.
|
|
274
|
+
*/
|
|
275
|
+
function wantsHtml(request) {
|
|
276
|
+
return /\btext\/html\b/i.test(request.headers.get("Accept") ?? "");
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The 404 a browser gets for a path nothing serves, and everyone else's plain
|
|
280
|
+
* `Not Found`. The body is built from deployment configuration alone and names
|
|
281
|
+
* no path, so every unserved path — on the main host or an artifact host —
|
|
282
|
+
* answers with the same bytes for the same `Accept`: a 404 must not tell one
|
|
283
|
+
* route from another. The home link appears only when the operator UI is
|
|
284
|
+
* mounted to land on, and points at the public origin so an artifact host's
|
|
285
|
+
* 404 does not link to another 404.
|
|
286
|
+
*/
|
|
287
|
+
export function notFoundResponse(request, opts) {
|
|
288
|
+
if (!wantsHtml(request)) {
|
|
289
|
+
return new Response("Not Found", { status: 404, headers: { Vary: "Accept" } });
|
|
290
|
+
}
|
|
291
|
+
const brand = resolveBranding(opts.branding);
|
|
292
|
+
const uiMounted = Boolean(opts.ui);
|
|
293
|
+
const homeHref = opts.publicUrl ? new URL("/", opts.publicUrl).toString() : "/";
|
|
294
|
+
const home = uiMounted
|
|
295
|
+
? `<div class="status-actions"><a class="btn" href="${escapeHtml(homeHref)}">Go to ${escapeHtml(brand.productName)}</a></div>`
|
|
296
|
+
: "";
|
|
297
|
+
const body = `<main class="page shell">
|
|
298
|
+
<section class="status-page" data-not-found>
|
|
299
|
+
<div class="status-head">
|
|
300
|
+
<span class="status-mark" aria-hidden="true">${STATUS_ICONS.missing}</span>
|
|
301
|
+
<p class="status-label">404</p>
|
|
302
|
+
</div>
|
|
303
|
+
<h1>Page not found</h1>
|
|
304
|
+
<p class="status-copy">Nothing is served at this address. Check the link, or ask whoever runs ${escapeHtml(brand.productName)} for the right one.</p>
|
|
305
|
+
${home}
|
|
306
|
+
</section>
|
|
307
|
+
</main>`;
|
|
308
|
+
return new Response(renderPage(opts.branding, {
|
|
309
|
+
title: `Page not found — ${brand.pageTitle}`,
|
|
310
|
+
uiMounted,
|
|
311
|
+
// Same bytes on either host: the icons, like the home link, come from
|
|
312
|
+
// the public origin.
|
|
313
|
+
iconOrigin: opts.publicUrl,
|
|
314
|
+
body,
|
|
315
|
+
}), {
|
|
316
|
+
status: 404,
|
|
317
|
+
headers: { "Content-Type": "text/html; charset=utf-8", Vary: "Accept" },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
151
320
|
/**
|
|
152
321
|
* Whether the operator meant to supply a value here — the question every
|
|
153
322
|
* dropped-value warning asks before naming a field, and one definition so the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Client, ProtocolError, SdkHttpError, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
2
2
|
import { Deferred, Duration, Effect, Exit, Scope } from "effect";
|
|
3
3
|
import { KvOAuthProvider, OAuthRefreshCoordinator, } from "../auth/downstream-oauth.js";
|
|
4
|
+
import { trackOAuthStartReset } from "../auth/oauth-start-reset.js";
|
|
4
5
|
import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
|
|
5
6
|
import { boundedEchoText, ConnectorCallError, msg, unavailableCallError, } from "../errors.js";
|
|
6
7
|
import { CONNECTA_VERSION } from "../version.js";
|
|
@@ -587,7 +588,20 @@ export function remoteMcp(id, opts) {
|
|
|
587
588
|
const newProvider = (ctx, state) => {
|
|
588
589
|
if (state?.provider)
|
|
589
590
|
return state.provider;
|
|
590
|
-
const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinator, ctx.allowAuthorization === true, oauthSealerFor(ctx)
|
|
591
|
+
const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinator, ctx.allowAuthorization === true, oauthSealerFor(ctx), ctx.signal, JSON.stringify({
|
|
592
|
+
url: new URL(opts.url).href,
|
|
593
|
+
redirectUri: `${ctx.baseUrl}/oauth/callback/${id}`,
|
|
594
|
+
clientMetadata: {
|
|
595
|
+
redirect_uris: [`${ctx.baseUrl}/oauth/callback/${id}`],
|
|
596
|
+
client_name: "connecta",
|
|
597
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
598
|
+
response_types: ["code"],
|
|
599
|
+
token_endpoint_auth_method: "none",
|
|
600
|
+
},
|
|
601
|
+
authScope: opts.authScope ?? "shared",
|
|
602
|
+
versionNegotiation: opts.versionNegotiation ?? "auto",
|
|
603
|
+
redirects: opts.redirects ?? "none",
|
|
604
|
+
}), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset));
|
|
591
605
|
if (state)
|
|
592
606
|
state.provider = provider;
|
|
593
607
|
return provider;
|
|
@@ -871,13 +885,14 @@ export function remoteMcp(id, opts) {
|
|
|
871
885
|
}));
|
|
872
886
|
});
|
|
873
887
|
};
|
|
874
|
-
const disconnectAuthorization = async (ctx, state, operatorDisconnected = false) => {
|
|
888
|
+
const disconnectAuthorization = async (ctx, state, operatorDisconnected = false, preserveClient = false) => {
|
|
875
889
|
const provider = newProvider(ctx, state);
|
|
876
890
|
// Publish the replacement epoch before waiting on or closing any
|
|
877
891
|
// request-local transport. A hung connect therefore cannot delay the
|
|
878
892
|
// fence, and every late OAuth write stays in the older namespace.
|
|
893
|
+
const reset = provider.resetAuthorization(operatorDisconnected, preserveClient);
|
|
879
894
|
try {
|
|
880
|
-
await
|
|
895
|
+
await reset;
|
|
881
896
|
}
|
|
882
897
|
finally {
|
|
883
898
|
// Abandon any connect in flight and close whichever half of the
|
|
@@ -1195,22 +1210,32 @@ export function remoteMcp(id, opts) {
|
|
|
1195
1210
|
const state = stateFor(ctx);
|
|
1196
1211
|
state.provider = null;
|
|
1197
1212
|
const p = newProvider(ctx, state);
|
|
1198
|
-
if (
|
|
1199
|
-
|
|
1213
|
+
if (ctx.signal?.aborted)
|
|
1214
|
+
throw ctx.signal.reason;
|
|
1215
|
+
const disconnected = startOpts?.force ? false : await p.operatorDisconnected();
|
|
1216
|
+
if (ctx.signal?.aborted)
|
|
1217
|
+
throw ctx.signal.reason;
|
|
1218
|
+
if (startOpts?.force || disconnected) {
|
|
1219
|
+
await disconnectAuthorization(ctx, state, false, startOpts?.force === true);
|
|
1200
1220
|
}
|
|
1201
1221
|
else {
|
|
1202
1222
|
// A consent URL already outstanding? Re-issue it rather than re-running
|
|
1203
1223
|
// the SDK flow, which would overwrite the PKCE verifier and invalidate
|
|
1204
|
-
// the URL the operator may be mid-consent on.
|
|
1205
|
-
|
|
1224
|
+
// the URL the operator may be mid-consent on. Only a recent one: an
|
|
1225
|
+
// old or untimed URL falls through to a fresh flow in this same
|
|
1226
|
+
// epoch, which keeps the stored client registration and discovery.
|
|
1227
|
+
const pending = await p.reusablePendingAuthorizationUrl();
|
|
1206
1228
|
if (pending) {
|
|
1207
1229
|
return {
|
|
1208
1230
|
state: "auth_required",
|
|
1209
1231
|
authorizationUrl: pending,
|
|
1232
|
+
authorizationReused: true,
|
|
1210
1233
|
message: "Authorization required — open the URL to connect.",
|
|
1211
1234
|
};
|
|
1212
1235
|
}
|
|
1213
1236
|
}
|
|
1237
|
+
if (ctx.signal?.aborted)
|
|
1238
|
+
throw ctx.signal.reason;
|
|
1214
1239
|
try {
|
|
1215
1240
|
await ensureConnected(ctx, state);
|
|
1216
1241
|
return {
|
package/dist/execute.js
CHANGED
|
@@ -1118,20 +1118,19 @@ function connectorInventory(connectors) {
|
|
|
1118
1118
|
return `${prefix}${shown.join(", ")}${shown.length > 0 ? "; " : ""}+${omitted} more.`;
|
|
1119
1119
|
}
|
|
1120
1120
|
const executeDescription = (emitBudgets, hostLimits, connectorGuides, connectors, resumable) => `Use the configured services below to answer the task. A known address uses call_tool. Unknown-address and wider ${resumable ? "work, writes included," : "read-only work"} uses one execute_code program for discovery, calls, and reduction. Do not return catalog matches alone. ${resumable
|
|
1121
|
-
? `Writes pause for resume_execution. Limits: ${hostLimits.maxHostCalls} host calls, ${resumable.maxWrites} writes`
|
|
1121
|
+
? `Writes pause for resume_execution. Keep an authorized batch in one program; approval "tool" covers later same-address calls, other approval-required writes pause separately. Limits: ${hostLimits.maxHostCalls} host calls, ${resumable.maxWrites} writes`
|
|
1122
1122
|
: `Only readOnlyHint: true tools are available. Limits: ${hostLimits.maxHostCalls} host calls`}, ${hostLimits.hostCallTimeoutMs / 1_000}s/host call.
|
|
1123
1123
|
|
|
1124
1124
|
${connectorInventory(connectors)}
|
|
1125
1125
|
|
|
1126
|
-
Read guides with top-level skills. Write
|
|
1127
|
-
- connecta.search({ connector, query, ${resumable ? "" : 'safety: "readOnly", '}includeSchemas: "json" }) returns { tools }. Search
|
|
1128
|
-
- connecta.describe({ address })
|
|
1126
|
+
Read guides with top-level skills. Write async () => { ... } using the global connecta:
|
|
1127
|
+
- connecta.search({ connector, query, ${resumable ? "" : 'safety: "readOnly", '}includeSchemas: "json" }) returns { tools }. Search operations separately; choose by connectorTitle and schema.required/.properties, never guessed fields. Compact schemas are text.
|
|
1128
|
+
- connecta.describe({ address }) clarifies schemas.
|
|
1129
1129
|
- connecta.call(address, args) returns the provider value directly.
|
|
1130
|
-
- Use Promise.all for independent calls, or Promise.allSettled to retain failures. Check status
|
|
1131
|
-
- connecta.emit(block): { type: "text", text } or { type: "image" | "audio", data (base64), mimeType }; success-only, ${emitBudgets.maxBlocks} blocks/${emitBudgets.maxBytes} bytes.
|
|
1132
|
-
- console.log(...) is captured. Return data for the client to render.
|
|
1130
|
+
- Use Promise.all for independent calls, or Promise.allSettled to retain failures. Check status; missing values are unknown, never false or zero.
|
|
1131
|
+
- connecta.emit(block): { type: "text", text } or { type: "image" | "audio", data (base64), mimeType }; success-only, ${emitBudgets.maxBlocks} blocks/${emitBudgets.maxBytes} bytes. console.log(...) is captured.
|
|
1133
1132
|
|
|
1134
|
-
No portable ambient capabilities. Return reduced JSON.
|
|
1133
|
+
No portable ambient capabilities. Return reduced JSON. Sample unfamiliar reads. Reduce a one-time write's full result here, or use a direct call and get_result paging; never repeat it to recover output. Never guess fields or use the whole text as an id. Top-level skills({ name: "usage" }): repair${connectorGuides ? ", guide handling" : ""}; skills({ name: "investigate" }): task planning.`;
|
|
1135
1134
|
// Module scope, like the other six meta-tool inputs: its JSON Schema is
|
|
1136
1135
|
// derived once per process. Budgets and connectors vary by deployment and
|
|
1137
1136
|
// view, so they live in the description, never here.
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { renderFixPrompt } from "./fix-prompt.js";
|
|
2
2
|
const OUTCOMES = {
|
|
3
|
-
connected: {
|
|
3
|
+
connected: {
|
|
4
|
+
status: 200,
|
|
5
|
+
tone: "ok",
|
|
6
|
+
heading: "{name} is connected",
|
|
7
|
+
message: "You can close this window.",
|
|
8
|
+
namesConnector: true,
|
|
9
|
+
},
|
|
4
10
|
denied: {
|
|
5
11
|
status: 400,
|
|
6
|
-
|
|
12
|
+
tone: "declined",
|
|
13
|
+
heading: "Authorization was declined",
|
|
14
|
+
message: "Consent was declined at the provider, so nothing was stored. Start authorization again whenever you are ready.",
|
|
7
15
|
fix: {
|
|
8
16
|
problem: "Consent for a downstream OAuth connector was declined at the provider, so no grant was stored.",
|
|
9
17
|
steps: [
|
|
@@ -15,7 +23,9 @@ const OUTCOMES = {
|
|
|
15
23
|
},
|
|
16
24
|
provider_error: {
|
|
17
25
|
status: 400,
|
|
18
|
-
|
|
26
|
+
tone: "problem",
|
|
27
|
+
heading: "The provider returned an error",
|
|
28
|
+
message: "It sent back an error instead of an authorization code, so nothing was stored.",
|
|
19
29
|
fix: {
|
|
20
30
|
problem: "A provider redirected back to the OAuth callback with an OAuth error instead of an authorization code.",
|
|
21
31
|
steps: [
|
|
@@ -27,7 +37,9 @@ const OUTCOMES = {
|
|
|
27
37
|
},
|
|
28
38
|
invalid_callback: {
|
|
29
39
|
status: 400,
|
|
30
|
-
|
|
40
|
+
tone: "problem",
|
|
41
|
+
heading: "Authorization could not be completed",
|
|
42
|
+
message: "The link may be expired, already used, or replaced by a newer attempt. Start authorization again from {product}.",
|
|
31
43
|
fix: {
|
|
32
44
|
problem: "An OAuth callback arrived that the deployment could not match to an authorization flow it started: stale, already used, replaced by a newer attempt, or finished in a different session.",
|
|
33
45
|
steps: [
|
|
@@ -39,7 +51,9 @@ const OUTCOMES = {
|
|
|
39
51
|
},
|
|
40
52
|
handoff_failed: {
|
|
41
53
|
status: 500,
|
|
42
|
-
|
|
54
|
+
tone: "problem",
|
|
55
|
+
heading: "{name} could not be connected",
|
|
56
|
+
message: "Authorization could not be completed, and nothing was exchanged. Try again shortly.",
|
|
43
57
|
namesConnector: true,
|
|
44
58
|
fix: {
|
|
45
59
|
problem: "The deployment could not consume the stored principal handoff for a personal-auth OAuth flow, so no authorization code was exchanged.",
|
|
@@ -51,7 +65,9 @@ const OUTCOMES = {
|
|
|
51
65
|
},
|
|
52
66
|
exchange_failed: {
|
|
53
67
|
status: 500,
|
|
54
|
-
|
|
68
|
+
tone: "problem",
|
|
69
|
+
heading: "{name} could not be connected",
|
|
70
|
+
message: "The provider did not accept the authorization code exchange. Nothing was stored.",
|
|
55
71
|
namesConnector: true,
|
|
56
72
|
fix: {
|
|
57
73
|
problem: "A provider returned an authorization code, but exchanging it for tokens failed.",
|
|
@@ -63,7 +79,6 @@ const OUTCOMES = {
|
|
|
63
79
|
},
|
|
64
80
|
},
|
|
65
81
|
};
|
|
66
|
-
const CONNECTOR_ID_RE = /^[a-z0-9_-]+$/;
|
|
67
82
|
/**
|
|
68
83
|
* RFC 6749 names `access_denied` for a declined consent (section 4.1.2.1). Every other
|
|
69
84
|
* value, standard or invented, is the provider refusing the request itself —
|
|
@@ -73,22 +88,32 @@ const CONNECTOR_ID_RE = /^[a-z0-9_-]+$/;
|
|
|
73
88
|
export function providerErrorReason(error) {
|
|
74
89
|
return error === "access_denied" ? "denied" : "provider_error";
|
|
75
90
|
}
|
|
76
|
-
/**
|
|
77
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Page copy and fix prompt for one reason. Nothing here reads the request.
|
|
93
|
+
*
|
|
94
|
+
* `connector` is read only for a reason that sets `namesConnector`. The route
|
|
95
|
+
* passes one only once the state check has proved the flow, and this gate
|
|
96
|
+
* keeps a caller that passed one earlier from naming it anyway. `productName`
|
|
97
|
+
* is deployment configuration, the same for every connector, so it cannot
|
|
98
|
+
* tell one refusal from another.
|
|
99
|
+
*/
|
|
100
|
+
export function oauthCallbackOutcome(reason, connector, productName = "Connecta") {
|
|
78
101
|
const outcome = OUTCOMES[reason];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const message = reason === "connected" && connectorId && CONNECTOR_ID_RE.test(connectorId)
|
|
82
|
-
? `Connected "${connectorId}". You can close this window.`
|
|
83
|
-
: outcome.message;
|
|
102
|
+
const named = outcome.namesConnector ? connector : undefined;
|
|
103
|
+
const name = named ? displayName(named) : "The connection";
|
|
84
104
|
return {
|
|
85
105
|
reason,
|
|
86
106
|
status: outcome.status,
|
|
87
|
-
|
|
107
|
+
tone: outcome.tone,
|
|
108
|
+
heading: outcome.heading.replace("{name}", () => name),
|
|
109
|
+
message: outcome.message.replace("{product}", () => productName),
|
|
88
110
|
...(outcome.fix
|
|
89
|
-
? {
|
|
90
|
-
fixPrompt: renderFixPrompt(outcome.fix, outcome.namesConnector ? connectorId : undefined),
|
|
91
|
-
}
|
|
111
|
+
? { fixPrompt: renderFixPrompt(outcome.fix, named?.id) }
|
|
92
112
|
: {}),
|
|
93
113
|
};
|
|
94
114
|
}
|
|
115
|
+
/** A connector's title when it has a usable one, otherwise its id. */
|
|
116
|
+
function displayName(connector) {
|
|
117
|
+
const title = typeof connector.title === "string" ? connector.title.trim() : "";
|
|
118
|
+
return title || connector.id;
|
|
119
|
+
}
|