@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.
@@ -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: trimmedString(branding?.themeColor) ?? "#ffffff",
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("&", "&amp;")
166
+ .replaceAll('"', "&quot;")
167
+ .replaceAll("<", "&lt;")
168
+ .replaceAll(">", "&gt;");
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 provider.resetAuthorization(operatorDisconnected);
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 (startOpts?.force || (await p.operatorDisconnected())) {
1199
- await disconnectAuthorization(ctx, state);
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
- const pending = await p.pendingAuthorizationUrl();
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 JavaScript as async () => { ... }; connecta is global, not a parameter:
1127
- - connecta.search({ connector, query, ${resumable ? "" : 'safety: "readOnly", '}includeSchemas: "json" }) returns { tools }. Search each operation separately; choose by connectorTitle and schemas. Use schema.required and .properties to build args, never guessed fields. Compact schemas are text.
1128
- - connecta.describe({ address }) returns { tools } for unclear schemas.
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 before reading value; rejected calls and missing fields 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.
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. If a provider result has an unfamiliar shape, return a small sample and continue in another call; 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.`;
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: { status: 200, message: "Connected. You can close this window." },
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
- message: "Authorization was declined at the provider. Nothing was stored; start authorization again when you are ready.",
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
- message: "The provider returned an error instead of an authorization code. Nothing was stored.",
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
- message: "Authorization could not be completed. The link may be expired, already used, or replaced by a newer attempt. Re-run authorization from connecta and try again.",
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
- message: "Authorization could not be completed. Nothing was exchanged; try again shortly.",
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
- message: "Authorization failed: the provider did not accept the authorization code exchange. Nothing was stored.",
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
- /** Page copy and fix prompt for one reason. Nothing here reads the request. */
77
- export function oauthCallbackOutcome(reason, connectorId) {
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
- // Success is reached only after the state check proved the id configured,
80
- // so it is the one page that may name the connector in its copy.
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
- message,
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
+ }