@zackbart/connecta 0.11.0 → 0.12.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 +84 -0
- package/dist/apps-shell.d.ts +38 -0
- package/dist/apps-shell.d.ts.map +1 -0
- package/dist/apps-shell.js +175 -0
- package/dist/apps-shell.js.map +1 -0
- package/dist/execute.d.ts +36 -5
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +139 -23
- package/dist/execute.js.map +1 -1
- package/dist/executor-admission.d.ts +8 -0
- package/dist/executor-admission.d.ts.map +1 -1
- package/dist/executor-admission.js +11 -0
- package/dist/executor-admission.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +2 -2
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +48 -0
- package/dist/routes/mcp.js.map +1 -1
- package/dist/skills.d.ts +1 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +17 -13
- package/dist/skills.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/code-mode.md +121 -7
- package/documentation/mcp-2026-07-28.md +1 -1
- package/documentation/mcp-ui-design.md +382 -0
- package/ethos.md +8 -2
- package/package.json +1 -1
- package/src/apps-shell.ts +179 -0
- package/src/execute.ts +152 -22
- package/src/executor-admission.ts +12 -0
- package/src/executors/quickjs.ts +2 -1
- package/src/routes/mcp.ts +60 -0
- package/src/skills.ts +17 -13
- package/src/version.ts +1 -1
- package/templates/node/package.json +1 -1
package/src/execute.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { ActivityRequestContext } from "./activity.js";
|
|
4
|
+
import {
|
|
5
|
+
PROGRAM_UI_META_KEY,
|
|
6
|
+
PROGRAM_UI_RESOURCE_URI,
|
|
7
|
+
} from "./apps-shell.js";
|
|
4
8
|
import {
|
|
5
9
|
boundedDiscoveryText,
|
|
6
10
|
CatalogService,
|
|
@@ -15,6 +19,7 @@ import {
|
|
|
15
19
|
} from "./executor-result.js";
|
|
16
20
|
import {
|
|
17
21
|
ExecutorAdmissionError,
|
|
22
|
+
ExecutorExecutionError,
|
|
18
23
|
isAdmittingExecutor,
|
|
19
24
|
} from "./executor-admission.js";
|
|
20
25
|
import { classifyCallError } from "./errors.js";
|
|
@@ -69,6 +74,7 @@ class ExecuteDiagnostics {
|
|
|
69
74
|
setupMs = 0;
|
|
70
75
|
executorWallMs = 0;
|
|
71
76
|
private emitted?: { count: number; bytes: number };
|
|
77
|
+
private ui?: number;
|
|
72
78
|
|
|
73
79
|
/** Numbers only, per R8 — and only once something was emitted, so a
|
|
74
80
|
* non-emitting run's diagnostics stay byte-for-byte what they were. */
|
|
@@ -76,6 +82,15 @@ class ExecuteDiagnostics {
|
|
|
76
82
|
if (count > 0) this.emitted = { count, bytes };
|
|
77
83
|
}
|
|
78
84
|
|
|
85
|
+
/**
|
|
86
|
+
* U9: the UI payload gets its own aggregate — one number, the payload's
|
|
87
|
+
* serialized size. Folding it into `emitted` would desync that aggregate's
|
|
88
|
+
* pair, which reports the bytes a specific block count cost.
|
|
89
|
+
*/
|
|
90
|
+
recordUi(bytes: number): void {
|
|
91
|
+
this.ui = bytes;
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
private stats(operation: ExecuteDiagnosticOperation) {
|
|
80
95
|
let stats = this.operations.get(operation);
|
|
81
96
|
if (!stats) {
|
|
@@ -158,6 +173,7 @@ class ExecuteDiagnostics {
|
|
|
158
173
|
};
|
|
159
174
|
operations: ExecuteOperationDiagnostics[];
|
|
160
175
|
emitted?: { count: number; bytes: number };
|
|
176
|
+
ui?: number;
|
|
161
177
|
} {
|
|
162
178
|
const operations = [...this.operations.values()];
|
|
163
179
|
return {
|
|
@@ -174,6 +190,7 @@ class ExecuteDiagnostics {
|
|
|
174
190
|
},
|
|
175
191
|
operations,
|
|
176
192
|
...(this.emitted ? { emitted: this.emitted } : {}),
|
|
193
|
+
...(this.ui !== undefined ? { ui: this.ui } : {}),
|
|
177
194
|
};
|
|
178
195
|
}
|
|
179
196
|
}
|
|
@@ -231,16 +248,49 @@ function requireEmittedBlock(raw: unknown): EmittedBlock {
|
|
|
231
248
|
return raw as EmittedBlock;
|
|
232
249
|
}
|
|
233
250
|
|
|
251
|
+
const UI_SHAPE_HINT =
|
|
252
|
+
"connecta.ui accepts exactly one argument: a non-empty string of HTML";
|
|
253
|
+
|
|
254
|
+
/** What the argument was, named the way the emit validator names a bad field. */
|
|
255
|
+
function describeUiArgument(raw: unknown): string {
|
|
256
|
+
if (raw === null) return "null";
|
|
257
|
+
if (raw === undefined) return "undefined";
|
|
258
|
+
if (Array.isArray(raw)) return "an array";
|
|
259
|
+
if (typeof raw === "string") return "an empty string";
|
|
260
|
+
const kind = typeof raw;
|
|
261
|
+
return `${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
234
264
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
|
|
239
|
-
|
|
265
|
+
* Strict U1 validation. There is no options parameter and no sugar form, for
|
|
266
|
+
* M1's reason: sugar is how a one-shape contract grows hair. An options bag or
|
|
267
|
+
* an MCP block object is just a non-string, and fails as one.
|
|
268
|
+
*/
|
|
269
|
+
function requireUiHtml(raw: unknown): string {
|
|
270
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
271
|
+
throw new Error(`${UI_SHAPE_HINT}; got ${describeUiArgument(raw)}`);
|
|
272
|
+
}
|
|
273
|
+
return raw;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Request-local collection for `connecta.emit` and `connecta.ui`. Budgets fail
|
|
278
|
+
* loudly at the crossing call — nothing is partially accepted and prior blocks
|
|
279
|
+
* are unaffected — so a program learns it is over budget while it can still
|
|
280
|
+
* choose differently (M5, U4). Accepted output never rides `ExecuteResult`; the
|
|
281
|
+
* handler that owns this collector delivers it on the final tool result.
|
|
282
|
+
*
|
|
283
|
+
* The two channels share the byte aggregate and nothing else: a UI payload is
|
|
284
|
+
* not a block, so it spends no block count, and at most one is ever accepted.
|
|
240
285
|
*/
|
|
241
286
|
export class EmitCollector {
|
|
242
287
|
readonly blocks: EmittedBlock[] = [];
|
|
288
|
+
/** The shared transport aggregate: emitted blocks plus the UI payload. */
|
|
243
289
|
bytes = 0;
|
|
290
|
+
/** The one accepted UI payload (U2), delivered in result `_meta` on success. */
|
|
291
|
+
ui?: { html: string };
|
|
292
|
+
/** What the blocks alone cost, so the `emitted` aggregate stays a true pair. */
|
|
293
|
+
private blockBytes = 0;
|
|
244
294
|
constructor(
|
|
245
295
|
private readonly maxBytes: number,
|
|
246
296
|
private readonly maxBlocks: number,
|
|
@@ -262,7 +312,38 @@ export class EmitCollector {
|
|
|
262
312
|
}
|
|
263
313
|
this.blocks.push(block);
|
|
264
314
|
this.bytes += size;
|
|
265
|
-
this.
|
|
315
|
+
this.blockBytes += size;
|
|
316
|
+
this.diagnostics?.recordEmitted(this.blocks.length, this.blockBytes);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* U2 and U4: one payload per run, measured as the serialized bytes of
|
|
321
|
+
* `{ html }` against the same aggregate emit spends. A second call throws
|
|
322
|
+
* naming the constraint rather than replacing the first — one tool result
|
|
323
|
+
* renders one view, and last-wins would silently discard a payload the
|
|
324
|
+
* program deliberately supplied.
|
|
325
|
+
*
|
|
326
|
+
* Multiplicity is checked before shape, so the second call is told what it
|
|
327
|
+
* actually broke. A program whose second payload is also malformed has one
|
|
328
|
+
* problem worth naming — that there is a second payload at all — and a
|
|
329
|
+
* complaint about its type would send the author to fix the wrong thing.
|
|
330
|
+
*/
|
|
331
|
+
acceptUi(raw: unknown): void {
|
|
332
|
+
if (this.ui) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
"connecta.ui accepts at most one payload per run: a view was already accepted and stands",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
const payload = { html: requireUiHtml(raw) };
|
|
338
|
+
const size = diagnosticsEncoder.encode(JSON.stringify(payload)).byteLength;
|
|
339
|
+
if (this.bytes + size > this.maxBytes) {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`connecta.ui byte budget exceeded: payload is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
this.ui = payload;
|
|
345
|
+
this.bytes += size;
|
|
346
|
+
this.diagnostics?.recordUi(size);
|
|
266
347
|
}
|
|
267
348
|
}
|
|
268
349
|
|
|
@@ -558,6 +639,18 @@ export async function buildSandboxProviders(
|
|
|
558
639
|
}
|
|
559
640
|
limits.emitCollector.accept(block);
|
|
560
641
|
},
|
|
642
|
+
// The rendered-output channel rides the same bridge for the same
|
|
643
|
+
// reason (U7): one more provider fn, no change to ExecuteResult or
|
|
644
|
+
// the Executor contract. Delivery is the handler's job, not the
|
|
645
|
+
// guest's — nothing here becomes addressable.
|
|
646
|
+
ui: async (html: unknown) => {
|
|
647
|
+
if (!limits.emitCollector) {
|
|
648
|
+
throw new Error(
|
|
649
|
+
"connecta.ui is unavailable: no emission collector was configured for this execution",
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
limits.emitCollector.acceptUi(html);
|
|
653
|
+
},
|
|
561
654
|
batch: async (calls: unknown) => {
|
|
562
655
|
const started = Date.now();
|
|
563
656
|
const callCount = Array.isArray(calls) ? calls.length : 0;
|
|
@@ -812,6 +905,9 @@ export function createExecuteTool(
|
|
|
812
905
|
? { retryAfterMs: err.retryAfterMs }
|
|
813
906
|
: {}),
|
|
814
907
|
},
|
|
908
|
+
...(err instanceof ExecutorExecutionError
|
|
909
|
+
? discardedEmits(emitted)
|
|
910
|
+
: {}),
|
|
815
911
|
...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
|
|
816
912
|
});
|
|
817
913
|
result.isError = true;
|
|
@@ -936,9 +1032,19 @@ export function createExecuteTool(
|
|
|
936
1032
|
const response = jsonResult({
|
|
937
1033
|
result,
|
|
938
1034
|
...(emitted.blocks.length > 0 ? { emitted: emitted.blocks.length } : {}),
|
|
1035
|
+
// U3: the model learns a view rendered without seeing its bytes. Spread
|
|
1036
|
+
// conditionally so a program that never called connecta.ui produces
|
|
1037
|
+
// today's byte-for-byte response.
|
|
1038
|
+
...(emitted.ui ? { ui: true } : {}),
|
|
939
1039
|
...(logs ? { logs } : {}),
|
|
940
1040
|
...(diagnostics ? { diagnostics: diagnostics.finish() } : {}),
|
|
941
1041
|
});
|
|
1042
|
+
if (emitted.ui) {
|
|
1043
|
+
// _meta is where the Apps spec's best practices put data "not intended
|
|
1044
|
+
// for model context", and how shipped hosts behave. The shell reads
|
|
1045
|
+
// exactly this key out of the tool result the host delivers to it.
|
|
1046
|
+
response._meta = { [PROGRAM_UI_META_KEY]: { html: emitted.ui.html } };
|
|
1047
|
+
}
|
|
942
1048
|
if (emitted.blocks.length > 0) {
|
|
943
1049
|
// Emitted image/audio blocks are valid MCP content that ToolResult's
|
|
944
1050
|
// text-only typing does not model — the same acknowledged gap
|
|
@@ -951,37 +1057,49 @@ export function createExecuteTool(
|
|
|
951
1057
|
};
|
|
952
1058
|
}
|
|
953
1059
|
|
|
954
|
-
/**
|
|
1060
|
+
/**
|
|
1061
|
+
* M4 and U3: a failed program delivers no blocks and no view, but each
|
|
1062
|
+
* discard is visible — and one failure can discard both.
|
|
1063
|
+
*/
|
|
955
1064
|
function discardedEmits(emitted: EmitCollector): {
|
|
956
1065
|
emittedDiscarded?: number;
|
|
1066
|
+
uiDiscarded?: true;
|
|
957
1067
|
} {
|
|
958
|
-
return
|
|
959
|
-
|
|
960
|
-
|
|
1068
|
+
return {
|
|
1069
|
+
...(emitted.blocks.length > 0
|
|
1070
|
+
? { emittedDiscarded: emitted.blocks.length }
|
|
1071
|
+
: {}),
|
|
1072
|
+
...(emitted.ui ? { uiDiscarded: true as const } : {}),
|
|
1073
|
+
};
|
|
961
1074
|
}
|
|
962
1075
|
|
|
963
1076
|
/** The same visibility for the plain-text error paths. */
|
|
964
1077
|
function discardedEmitsText(emitted: EmitCollector): string {
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1078
|
+
const lines = [
|
|
1079
|
+
...(emitted.blocks.length > 0
|
|
1080
|
+
? [`emittedDiscarded: ${emitted.blocks.length}`]
|
|
1081
|
+
: []),
|
|
1082
|
+
...(emitted.ui ? ["uiDiscarded: true"] : []),
|
|
1083
|
+
];
|
|
1084
|
+
return lines.length > 0 ? `\n\n${lines.join("\n")}` : "";
|
|
968
1085
|
}
|
|
969
1086
|
|
|
970
1087
|
const executeDescription = (
|
|
971
1088
|
emitBudgets: { maxBytes: number; maxBlocks: number },
|
|
972
|
-
) => `The primary surface. Use for discovery beyond one lookup, two or more calls, dependent steps, loops, joins, branching, or reducing large results before they reach the model — connecta.search and connecta.describe browse and expand catalogs in the run, and connecta.batch handles independent calls. The exception is
|
|
1089
|
+
) => `The primary surface. Use for discovery beyond one lookup, two or more calls, dependent steps, loops, joins, branching, or reducing large results before they reach the model — connecta.search and connecta.describe browse and expand catalogs in the run, and connecta.batch handles independent calls. The exception is one call at an address already in hand: search_tools then one call_tool is cheaper than a program. Only tools explicitly annotated readOnlyHint: true are available. Each run is limited to ${EXECUTE_MAX_HOST_CALLS} host calls, connecta.batch to at most ${EXECUTE_MAX_BATCH_CALLS}; each host call has a ${EXECUTE_HOST_CALL_TIMEOUT_MS / 1_000}-second deadline.
|
|
973
1090
|
|
|
974
1091
|
Write an async arrow function. It runs with NO network, filesystem, timers, or imports — the only capabilities are:
|
|
975
|
-
- One global per connector: every address <connectorId>.<toolName> from search_tools
|
|
976
|
-
- connecta.call(address, args) and connecta.batch(calls) — call raw addresses.
|
|
977
|
-
- connecta.search(args)
|
|
978
|
-
- connecta.emit(block) — deliver
|
|
979
|
-
-
|
|
1092
|
+
- One global per connector: call every address <connectorId>.<toolName> from search_tools as <connectorId>.<toolName>(args), with a single args object matching the schema from connecta.describe. Names are sanitized to JS identifiers: characters outside [A-Za-z0-9_$] become "_" (my-service.get.thing → my_service.get_thing), leading digits get "_" prefixed, reserved words "_" appended.
|
|
1093
|
+
- connecta.call(address, args) and connecta.batch(calls) — call raw addresses. Every batch entry is { address, ok: true, data } or { address, ok: false, error, errorDetails: { code, retryable } }; destructure that, not a bare result.
|
|
1094
|
+
- connecta.search(args) and connecta.describe, taking { address: "<connectorId>.<toolName>" } or { addresses: [...] } — load and inspect request-local catalogs on demand. Use safety: "readOnly" to avoid advertising calls this sandbox cannot execute; it changes results, not authority. Matches carrying schemas also list inputKeys, requiredInputKeys, and outputKeys — the schema's own names, checkable before building args. A missing list means the schema is not a plain object shape, not that the tool has no fields — read the schema.
|
|
1095
|
+
- connecta.emit(block) — deliver MCP content beside the JSON return: exactly { type: "text", text } or { type: "image" | "audio", data (base64), mimeType }, nothing else. Blocks are appended on success only, spend no host calls, and are budgeted per run (${emitBudgets.maxBlocks} blocks, ${emitBudgets.maxBytes} serialized bytes); an over-budget or invalid emit throws catchably and accepts nothing.
|
|
1096
|
+
- connecta.ui(html) — hand the client one rendered view: exactly one argument, a non-empty HTML string, no options, no block object. Delivered on success only, spends no host calls, and draws on the same ${emitBudgets.maxBytes}-byte budget connecta.emit does — one budget, not two; a second, over-budget, or invalid call throws catchably and accepts nothing. The view is display-only (no network, no tool calls, no links) and out of model context — the envelope reports only ui: true, so the model reads the return value, not the view: return the summary it should reason over, built from the same variables the view renders.
|
|
1097
|
+
- console.log(...) — captured and returned with the result.
|
|
980
1098
|
|
|
981
|
-
Tool calls return plain values (MCP text
|
|
1099
|
+
Tool calls return plain values (MCP text is JSON-parsed when possible) and throw on downstream errors — use try/catch. A thrown error carries only a message, so use connecta.batch when a program must tell a policy refusal from a transient failure. Never retry a failure whose retryable is false, and never retry a rate_limited one immediately — the sandbox has no timers. Return a JSON-serializable value; large results are truncated, so reduce data in code rather than return raw payloads.
|
|
982
1100
|
|
|
983
|
-
Plain JavaScript only — no TypeScript syntax. For unknown-address dependent work, use one execute_code call: search inside it, read the compact schemas,
|
|
984
|
-
Dependent example (only when the second call requires a value returned by the first): async () => { const { tools } = await connecta.search({ query: "pipeline run job logs", safety: "readOnly", includeSchemas: "compact" }); const pick = (suffix) => { const match = tools.find((
|
|
1101
|
+
Plain JavaScript only — no TypeScript syntax. For unknown-address dependent work, use one execute_code call: search inside it, read the compact schemas, continue to the dependent calls; do not return search results for a second execute_code call. Compact schemas are TypeScript-like strings, not JSON Schema objects: write the property names they display, never a positional guess or an invented alias.
|
|
1102
|
+
Dependent example (only when the second call requires a value returned by the first): async () => { const { tools } = await connecta.search({ query: "pipeline run job logs", safety: "readOnly", includeSchemas: "compact" }); const pick = (suffix) => { const match = tools.find((t) => t.address.endsWith(suffix)); if (!match) throw new Error("no tool for " + suffix); return match.address; }; const run = await connecta.call(pick(".get_run"), { runId: 42 }); const logs = await connecta.call(pick(".get_job_logs"), { jobId: run.failedJobId }); return [run, logs]; }`;
|
|
985
1103
|
|
|
986
1104
|
/** Register the execute_code meta-tool. Only called when an executor is configured. */
|
|
987
1105
|
export function registerExecuteTool(
|
|
@@ -1055,6 +1173,18 @@ export function registerExecuteTool(
|
|
|
1055
1173
|
destructiveHint: false,
|
|
1056
1174
|
openWorldHint: true,
|
|
1057
1175
|
},
|
|
1176
|
+
// U5 and U10: declared unconditionally. A host without the Apps
|
|
1177
|
+
// extension ignores unknown _meta and sees the ordinary envelope, which
|
|
1178
|
+
// is the text fallback the spec mandates — and a stateless aggregator
|
|
1179
|
+
// has nowhere dependable to hold a negotiation check anyway. The
|
|
1180
|
+
// explicit visibility keeps hosts from being told the view may call
|
|
1181
|
+
// execute_code; the default ["model","app"] would say exactly that.
|
|
1182
|
+
_meta: {
|
|
1183
|
+
ui: {
|
|
1184
|
+
resourceUri: PROGRAM_UI_RESOURCE_URI,
|
|
1185
|
+
visibility: ["model"],
|
|
1186
|
+
},
|
|
1187
|
+
},
|
|
1058
1188
|
},
|
|
1059
1189
|
async (args, extra) => {
|
|
1060
1190
|
const controller = new AbortController();
|
|
@@ -36,6 +36,18 @@ export class ExecutorAdmissionError extends Error {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A lifecycle failure after the sandbox started running. It keeps the stable
|
|
41
|
+
* admission-error envelope while letting response assembly distinguish work
|
|
42
|
+
* torn down in flight from work that never entered the executor.
|
|
43
|
+
*/
|
|
44
|
+
export class ExecutorExecutionError extends ExecutorAdmissionError {
|
|
45
|
+
constructor(code: ExecutorAdmissionErrorCode, message: string) {
|
|
46
|
+
super(code, message);
|
|
47
|
+
this.name = "ExecutorExecutionError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
export interface AdmissionLease {
|
|
40
52
|
/** Time spent waiting behind active work. Zero for immediate admission. */
|
|
41
53
|
readonly waitMs: number;
|
package/src/executors/quickjs.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|
|
9
9
|
import {
|
|
10
10
|
AdmissionController,
|
|
11
11
|
ExecutorAdmissionError,
|
|
12
|
+
ExecutorExecutionError,
|
|
12
13
|
} from "../executor-admission.js";
|
|
13
14
|
import type {
|
|
14
15
|
AdmittingExecutor,
|
|
@@ -737,7 +738,7 @@ class QuickJsChildPool implements AdmittingExecutor {
|
|
|
737
738
|
if (!child) return Promise.resolve();
|
|
738
739
|
this.rejectActive(
|
|
739
740
|
slot,
|
|
740
|
-
new
|
|
741
|
+
new ExecutorExecutionError(
|
|
741
742
|
"executor_closed",
|
|
742
743
|
"Executor is shutting down.",
|
|
743
744
|
),
|
package/src/routes/mcp.ts
CHANGED
|
@@ -5,6 +5,12 @@ import {
|
|
|
5
5
|
WebStandardStreamableHTTPServerTransport,
|
|
6
6
|
} from "@modelcontextprotocol/server";
|
|
7
7
|
import type { ActivityActor, ActivityRequestContext } from "../activity.js";
|
|
8
|
+
import {
|
|
9
|
+
MCP_APPS_EXTENSION,
|
|
10
|
+
PROGRAM_UI_MIME_TYPE,
|
|
11
|
+
PROGRAM_UI_RESOURCE_URI,
|
|
12
|
+
PROGRAM_UI_SHELL_HTML,
|
|
13
|
+
} from "../apps-shell.js";
|
|
8
14
|
import { registerExecuteTool } from "../execute.js";
|
|
9
15
|
import {
|
|
10
16
|
ExecutorAdmissionError,
|
|
@@ -184,6 +190,42 @@ function toolkitRetired(logger: Logger): Response {
|
|
|
184
190
|
);
|
|
185
191
|
}
|
|
186
192
|
|
|
193
|
+
/**
|
|
194
|
+
* U5: one static template, served by a handler that answers exactly one URI
|
|
195
|
+
* and fails on every other. Registering it is also what declares the
|
|
196
|
+
* `resources` capability — which is why `resources/list` has to answer, and
|
|
197
|
+
* why it answers with nothing. That is the Apps spec's permitted omission of
|
|
198
|
+
* UI-only resources from listing, taken exactly: the capability stays honest
|
|
199
|
+
* because the method answers, and nothing downstream is ever listed or
|
|
200
|
+
* aggregated. Widening this handler to proxy downstream templates is a
|
|
201
|
+
* decision (see the design record), not a diff.
|
|
202
|
+
*/
|
|
203
|
+
function registerProgramUiResource(server: McpServer): void {
|
|
204
|
+
server.registerResource(
|
|
205
|
+
"connecta-program-ui",
|
|
206
|
+
PROGRAM_UI_RESOURCE_URI,
|
|
207
|
+
{
|
|
208
|
+
title: "connecta program view",
|
|
209
|
+
description:
|
|
210
|
+
"The MCP Apps shell that renders HTML an execute_code program handed connecta.ui.",
|
|
211
|
+
mimeType: PROGRAM_UI_MIME_TYPE,
|
|
212
|
+
},
|
|
213
|
+
(uri) => ({
|
|
214
|
+
contents: [
|
|
215
|
+
{
|
|
216
|
+
uri: uri.href,
|
|
217
|
+
mimeType: PROGRAM_UI_MIME_TYPE,
|
|
218
|
+
text: PROGRAM_UI_SHELL_HTML,
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
// The SDK's generated listing would advertise the template it just
|
|
224
|
+
// registered. Replace it rather than accept that: the URI reaches the host
|
|
225
|
+
// through tool metadata, so the listing has nothing to carry.
|
|
226
|
+
server.server.setRequestHandler("resources/list", () => ({ resources: [] }));
|
|
227
|
+
}
|
|
228
|
+
|
|
187
229
|
async function serveMcp(
|
|
188
230
|
request: Request,
|
|
189
231
|
opts: ServerOptions,
|
|
@@ -195,6 +237,23 @@ async function serveMcp(
|
|
|
195
237
|
const createServer = (): McpServer => {
|
|
196
238
|
const server = new McpServer(opts.serverInfo, {
|
|
197
239
|
instructions: instructionsFor(),
|
|
240
|
+
// U11: the Apps extension must be explicitly negotiated, and a
|
|
241
|
+
// conforming client acts on an extension only when both sides declare
|
|
242
|
+
// it — without this line no host reads execute_code's _meta.ui, no host
|
|
243
|
+
// fetches the shell, and the whole design is inert. This is the one
|
|
244
|
+
// extension connecta advertises; the versioned extensions framework
|
|
245
|
+
// stays declined as a general surface (documentation/mcp-2026-07-28.md).
|
|
246
|
+
capabilities: {
|
|
247
|
+
extensions: {
|
|
248
|
+
[MCP_APPS_EXTENSION]: { mimeTypes: [PROGRAM_UI_MIME_TYPE] },
|
|
249
|
+
},
|
|
250
|
+
// Registering the shell below declares `resources` on its own, but it
|
|
251
|
+
// would default `listChanged` to true. Connecta serves one build-time
|
|
252
|
+
// template and never sends a list_changed notification, so say so:
|
|
253
|
+
// a client that subscribes on the strength of that flag would wait
|
|
254
|
+
// forever for an event this server has no way to produce.
|
|
255
|
+
resources: { listChanged: false },
|
|
256
|
+
},
|
|
198
257
|
cacheHints: {
|
|
199
258
|
"tools/list": {
|
|
200
259
|
ttlMs: 3_600_000,
|
|
@@ -202,6 +261,7 @@ async function serveMcp(
|
|
|
202
261
|
},
|
|
203
262
|
},
|
|
204
263
|
});
|
|
264
|
+
registerProgramUiResource(server);
|
|
205
265
|
const activity: ActivityRequestContext | undefined = opts.activity
|
|
206
266
|
? {
|
|
207
267
|
sink: opts.activity,
|
package/src/skills.ts
CHANGED
|
@@ -7,27 +7,31 @@ export const USAGE_SKILL = `# Connecta usage
|
|
|
7
7
|
|
|
8
8
|
## The surface
|
|
9
9
|
|
|
10
|
-
Seven tools: \`execute_code\`, \`search_tools\`, \`call_tool\`, \`call_destructive_tool\`, \`authorize_connector\`, \`get_result\`, \`skills\`. Broad discovery and multi-call work live
|
|
10
|
+
Seven tools: \`execute_code\`, \`search_tools\`, \`call_tool\`, \`call_destructive_tool\`, \`authorize_connector\`, \`get_result\`, \`skills\`. Broad discovery and multi-call work live in a program, not in top-level tools.
|
|
11
11
|
|
|
12
12
|
## Choose the smallest execution tool
|
|
13
13
|
|
|
14
|
-
Use exact addresses
|
|
14
|
+
Use exact addresses from discovery; never invent one. Search 2–4 distinctive action/object terms, not the whole request.
|
|
15
15
|
|
|
16
|
-
- One read at an unknown address: \`search_tools({ query, includeSchemas: "compact" })\`, then \`call_tool\` once
|
|
17
|
-
- Anything wider — two or more calls, dependent steps, loops, joins, branching,
|
|
18
|
-
- Any unannotated, write-capable, or destructive call: \`call_destructive_tool\`,
|
|
19
|
-
- Truncated result: retry with \`fields
|
|
20
|
-
- \`auth_required\`:
|
|
16
|
+
- One read at an unknown address: \`search_tools({ query, includeSchemas: "compact" })\`, then \`call_tool\` once — one cold call is cheaper direct than a program.
|
|
17
|
+
- Anything wider — two or more calls, dependent steps, loops, joins, branching, a whole-catalog browse, or a result to reduce: one \`execute_code\` run.
|
|
18
|
+
- Any unannotated, write-capable, or destructive call: \`call_destructive_tool\`, one at a time, after reviewing its schema and consequences.
|
|
19
|
+
- Truncated result: retry with \`fields\`, else page it with \`get_result\`.
|
|
20
|
+
- \`auth_required\`: \`authorize_connector\`, hand its recovery text to the operator, retry the call.
|
|
21
21
|
|
|
22
22
|
## Inside a program
|
|
23
23
|
|
|
24
|
-
One async arrow function. The only capabilities are one global per connector (\`<connectorId>.<toolName>(args)\`), the
|
|
24
|
+
One async arrow function. The only capabilities are one global per connector (\`<connectorId>.<toolName>(args)\`), the \`connecta\` functions, and \`console.log\`.
|
|
25
25
|
|
|
26
|
-
-
|
|
27
|
-
- Exact schemas
|
|
28
|
-
- Two to ten independent calls: \`connecta.batch([...])\`. Each outcome is \`{ address, ok: true, data }\` or \`{ address, ok: false, error, errorDetails: { code, retryable } }
|
|
29
|
-
- Search inside the run
|
|
30
|
-
- Only tools annotated \`readOnlyHint: true\` are reachable; the
|
|
26
|
+
- \`connecta.search({})\` browses every catalog; \`safety: "readOnly"\` narrows to calls a program can execute, \`connector: "<id>"\` to one. This filters results, not authority; matches carry \`address\` and annotations.
|
|
27
|
+
- Exact schemas: \`connecta.describe({ address: "connector.tool" })\` for one, \`{ addresses: [...] }\` for many; \`format: "json"\` only for exact constraints.
|
|
28
|
+
- Two to ten independent calls: \`connecta.batch([...])\`. Each outcome is \`{ address, ok: true, data }\` or \`{ address, ok: false, error, errorDetails: { code, retryable } }\` — how a program tells a policy refusal from a transient failure.
|
|
29
|
+
- Search inside the run, not before it; return only the reduction the answer needs, never raw payloads.
|
|
30
|
+
- Only tools annotated \`readOnlyHint: true\` are reachable; the gate, credentials, and admission are enforced below the sandbox — nothing a program does widens its reach.
|
|
31
|
+
|
|
32
|
+
## Rendering a view
|
|
33
|
+
|
|
34
|
+
\`connecta.ui(html)\` renders one view per successful run for the client, never for the model. Fetch first, check the shape in code. On a surprise — empty array, missing key — return a trimmed first record instead of rendering: the wrong view becomes the sample you needed. Otherwise render from the variables you return; the model reads the return value, not the view.
|
|
31
35
|
`;
|
|
32
36
|
|
|
33
37
|
/**
|
package/src/version.ts
CHANGED