@zackbart/connecta 0.10.1 → 0.10.3
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/AGENTS.md +113 -0
- package/CHANGELOG.md +70 -0
- package/README.md +53 -9
- package/bin/connecta.mjs +272 -0
- package/dist/catalog-service.d.ts +40 -1
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog-service.js +137 -12
- package/dist/catalog-service.js.map +1 -1
- package/dist/catalog.d.ts +17 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +113 -13
- package/dist/catalog.js.map +1 -1
- package/dist/errors.d.ts +28 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +40 -0
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +45 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +265 -68
- package/dist/execute.js.map +1 -1
- package/dist/invocation.d.ts.map +1 -1
- package/dist/invocation.js +34 -6
- package/dist/invocation.js.map +1 -1
- package/dist/meta-tools.d.ts +1 -0
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +412 -12
- package/dist/meta-tools.js.map +1 -1
- package/dist/skills.d.ts +1 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +1 -1
- package/dist/tool-safety.d.ts +10 -0
- package/dist/tool-safety.d.ts.map +1 -0
- package/dist/tool-safety.js +12 -0
- package/dist/tool-safety.js.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +100 -1
- package/dist/validate.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +7 -0
- package/documentation/auth.md +58 -0
- package/documentation/call-admission.md +7 -0
- package/documentation/code-first-exploration.md +292 -0
- package/documentation/code-mode.md +696 -0
- package/documentation/connector-guides.md +7 -0
- package/documentation/connectors.md +69 -0
- package/documentation/mcp-2026-07-28.md +46 -0
- package/documentation/meta-tools.md +185 -0
- package/documentation/operations.md +7 -0
- package/documentation/operator-ui.md +7 -0
- package/documentation/request-admission.md +7 -0
- package/documentation/storage-and-credentials.md +54 -0
- package/ethos.md +132 -0
- package/examples/node/README.md +53 -0
- package/examples/node/src/index.ts +73 -0
- package/examples/worker/README.md +160 -0
- package/examples/worker/src/cloudflare-kv.ts +43 -0
- package/examples/worker/src/d1-activity-row.ts +100 -0
- package/examples/worker/src/d1-activity.ts +144 -0
- package/examples/worker/src/index.ts +136 -0
- package/examples/worker/wrangler.jsonc +26 -0
- package/package.json +11 -1
- package/src/catalog-service.ts +181 -16
- package/src/catalog.ts +143 -12
- package/src/errors.ts +88 -1
- package/src/execute.ts +372 -96
- package/src/invocation.ts +45 -8
- package/src/meta-tools.ts +506 -11
- package/src/skills.ts +1 -1
- package/src/tool-safety.ts +15 -0
- package/src/validate.ts +128 -0
- package/src/version.ts +1 -1
- package/templates/node/.env.example +5 -0
- package/templates/node/AGENTS.md +19 -0
- package/templates/node/README.md +33 -0
- package/templates/node/package.json +23 -0
- package/templates/node/src/index.ts +43 -0
- package/templates/node/tsconfig.json +12 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connecta on Node.
|
|
3
|
+
*
|
|
4
|
+
* One MCP endpoint aggregating two in-code HTTP API connectors behind the
|
|
5
|
+
* seven-tool code-first surface (execute_code in a QuickJS/WASM sandbox plus the
|
|
6
|
+
* six explicit tools), guarded by a static bearer token, with OAuth/cache state
|
|
7
|
+
* on disk.
|
|
8
|
+
*
|
|
9
|
+
* Run:
|
|
10
|
+
* CONNECTA_TOKEN=dev-token npx tsx examples/node/src/index.ts
|
|
11
|
+
* # then point an MCP client at http://localhost:8787/mcp with
|
|
12
|
+
* # Authorization: Bearer dev-token
|
|
13
|
+
*/
|
|
14
|
+
import { api, bearerToken, createConnecta } from "@zackbart/connecta";
|
|
15
|
+
import { fileStorage, listen } from "@zackbart/connecta/node";
|
|
16
|
+
import { quickJsExecutor } from "@zackbart/connecta/quickjs";
|
|
17
|
+
|
|
18
|
+
const token = process.env.CONNECTA_TOKEN;
|
|
19
|
+
if (!token) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
"CONNECTA_TOKEN is required. Refusing to start without inbound auth.",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const port = Number(process.env.PORT ?? 8787);
|
|
25
|
+
|
|
26
|
+
const connecta = createConnecta({
|
|
27
|
+
// fileStorage persists downstream-OAuth/cache state across restarts.
|
|
28
|
+
// Swap for memoryStorage() if you don't need persistence.
|
|
29
|
+
storage: fileStorage("./.connecta-state.json"),
|
|
30
|
+
auth: bearerToken(token, { subjectId: "operator" }),
|
|
31
|
+
// Downstream OAuth callbacks use this deployment origin.
|
|
32
|
+
publicUrl: `http://localhost:${port}`,
|
|
33
|
+
// Code mode: QuickJS runs model-written JS in a bounded disposable child.
|
|
34
|
+
// This line is also what selects the seven-tool code-first surface; remove it
|
|
35
|
+
// to serve the nine classic meta-tools instead.
|
|
36
|
+
executor: quickJsExecutor(),
|
|
37
|
+
connectors: [
|
|
38
|
+
api("time", {
|
|
39
|
+
description: "Time — current timestamp",
|
|
40
|
+
tools: [
|
|
41
|
+
{
|
|
42
|
+
name: "get_now",
|
|
43
|
+
description: "Return the current time as an ISO 8601 timestamp.",
|
|
44
|
+
inputSchema: { type: "object", properties: {} },
|
|
45
|
+
annotations: { readOnlyHint: true },
|
|
46
|
+
handler: async () => ({ now: new Date().toISOString() }),
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
}),
|
|
50
|
+
api("text", {
|
|
51
|
+
description: "Text — string utilities",
|
|
52
|
+
tools: [
|
|
53
|
+
{
|
|
54
|
+
name: "upper",
|
|
55
|
+
description: "Uppercase the given text.",
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: { text: { type: "string" } },
|
|
59
|
+
required: ["text"],
|
|
60
|
+
},
|
|
61
|
+
annotations: { readOnlyHint: true },
|
|
62
|
+
handler: async ({ text }: { text: string }) => ({
|
|
63
|
+
text: text.toUpperCase(),
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
}),
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
listen(connecta, port);
|
|
72
|
+
|
|
73
|
+
console.log(`connecta listening on http://localhost:${port}/mcp`);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# connecta — Cloudflare Worker example
|
|
2
|
+
|
|
3
|
+
A deployable Worker that aggregates a downstream remote MCP and an in-code HTTP
|
|
4
|
+
API connector, guarded by Clerk OAuth *and* a static bearer token, with state in
|
|
5
|
+
a KV namespace. One Wrangler binding turns on paid code mode, which is also what
|
|
6
|
+
selects the seven-tool code-first surface; the checked-in configuration deploys
|
|
7
|
+
without it, serving the nine classic meta-tools on the Workers Free plan.
|
|
8
|
+
|
|
9
|
+
This is also the **starting template for a deployment**: a real deployment
|
|
10
|
+
should be its own repository that pins an exact `@zackbart/connecta` version and
|
|
11
|
+
owns only its connector configuration, auth policy, domain, bindings,
|
|
12
|
+
migrations, and secrets. See [deployment architecture](../../documentation/operations.md).
|
|
13
|
+
|
|
14
|
+
## Files
|
|
15
|
+
|
|
16
|
+
| File | What it is |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `src/index.ts` | the Worker entrypoint — connector and auth configuration |
|
|
19
|
+
| `src/cloudflare-kv.ts` | `KVStorage` over Workers KV (deployment-owned, not a package export) |
|
|
20
|
+
| `src/d1-activity.ts` | `ActivityStore` over D1 (deployment-owned; see below) |
|
|
21
|
+
| `wrangler.jsonc` | Worker name, vars, bindings, `compatibility_flags` |
|
|
22
|
+
|
|
23
|
+
`cloudflare-kv.ts` and `d1-activity.ts` deliberately live here rather than in
|
|
24
|
+
the package: storage backends are deployment-owned, so the package ships only
|
|
25
|
+
the generic `KVStorage` and `ActivityStore` contracts. Workers KV is eventually
|
|
26
|
+
consistent across locations; use a strongly consistent `KVStorage` adapter when
|
|
27
|
+
OAuth disconnect, credential rotation, or access-token issuance/revocation must
|
|
28
|
+
become globally visible immediately.
|
|
29
|
+
|
|
30
|
+
## Deploy
|
|
31
|
+
|
|
32
|
+
This example has no `package.json` of its own — it resolves the installed
|
|
33
|
+
package from the repository root.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npm install # from the package root
|
|
37
|
+
|
|
38
|
+
wrangler kv namespace create CONNECTA_KV # paste the id into wrangler.jsonc
|
|
39
|
+
|
|
40
|
+
cd examples/worker
|
|
41
|
+
wrangler secret put SUPPORT_TOKEN # one headless client
|
|
42
|
+
wrangler secret put EXEC_TOKEN # another headless client
|
|
43
|
+
wrangler secret put CLERK_SECRET_KEY
|
|
44
|
+
wrangler secret put DOWNSTREAM_TOKEN
|
|
45
|
+
wrangler deploy
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`PUBLIC_URL` and `CLERK_PUBLISHABLE_KEY` are plain vars in `wrangler.jsonc`.
|
|
49
|
+
Enable Dynamic Client Registration on the Clerk instance (OAuth Applications →
|
|
50
|
+
DCR) so Claude/Cursor can self-register — full walkthrough in
|
|
51
|
+
[setting up Clerk](../../documentation/auth.md).
|
|
52
|
+
|
|
53
|
+
Then point an MCP client at `<PUBLIC_URL>/mcp`, and open `<PUBLIC_URL>/` for
|
|
54
|
+
Connections. Credentials is at `/credentials`, named MCP access tokens are at
|
|
55
|
+
`/tokens`, Activity is at `/activity`, and legacy `/ui` redirects to `/`.
|
|
56
|
+
|
|
57
|
+
## Code mode
|
|
58
|
+
|
|
59
|
+
Code mode is a deploy-time opt-in because its Dynamic Worker sandbox requires
|
|
60
|
+
the [Workers Paid plan](https://developers.cloudflare.com/dynamic-workers/pricing/).
|
|
61
|
+
The Worker Loader binding is the switch; no TypeScript change or separate
|
|
62
|
+
environment variable is needed. Add this block to `wrangler.jsonc` (and a comma
|
|
63
|
+
after the preceding property):
|
|
64
|
+
|
|
65
|
+
```jsonc
|
|
66
|
+
"worker_loaders": [{ "binding": "LOADER" }]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`src/index.ts` detects `env.LOADER`, constructs `DynamicWorkerExecutor`, and
|
|
70
|
+
serves the seven-tool code-first surface. Leave the binding absent — as it is in
|
|
71
|
+
the checked-in config — to deploy the same source on the Workers Free plan with
|
|
72
|
+
the nine classic meta-tools. A deployment copied into its own repository must
|
|
73
|
+
also install the executor package before enabling the binding:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
npm install @cloudflare/codemode
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Activity history (optional)
|
|
80
|
+
|
|
81
|
+
`src/d1-activity.ts` is a complete `ActivityStore` over D1 — keyset paging on
|
|
82
|
+
`(occurred_at_ms, id)` plus a batched retention pass — but it is **not wired
|
|
83
|
+
into `src/index.ts`**, so the example deploys without a database. To enable it:
|
|
84
|
+
|
|
85
|
+
1. Create the database and bind it in `wrangler.jsonc`:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
wrangler d1 create connecta-activity
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```jsonc
|
|
92
|
+
"d1_databases": [
|
|
93
|
+
{ "binding": "ACTIVITY_DB", "database_name": "connecta-activity", "database_id": "…" }
|
|
94
|
+
]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
2. Apply the schema (keep it in a deployment-owned `migrations/` directory):
|
|
98
|
+
|
|
99
|
+
```sql
|
|
100
|
+
CREATE TABLE IF NOT EXISTS tool_call_activity (
|
|
101
|
+
id TEXT PRIMARY KEY,
|
|
102
|
+
occurred_at_ms INTEGER NOT NULL,
|
|
103
|
+
request_id TEXT NOT NULL,
|
|
104
|
+
actor_kind TEXT NOT NULL,
|
|
105
|
+
actor_id TEXT,
|
|
106
|
+
actor_namespace TEXT,
|
|
107
|
+
connector_id TEXT NOT NULL,
|
|
108
|
+
tool_name TEXT NOT NULL,
|
|
109
|
+
source TEXT NOT NULL,
|
|
110
|
+
outcome TEXT NOT NULL,
|
|
111
|
+
duration_ms INTEGER NOT NULL,
|
|
112
|
+
attempts INTEGER NOT NULL,
|
|
113
|
+
error_code TEXT,
|
|
114
|
+
server_name TEXT NOT NULL,
|
|
115
|
+
server_version TEXT NOT NULL,
|
|
116
|
+
deployment_id TEXT
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
CREATE INDEX IF NOT EXISTS tool_call_activity_recent
|
|
120
|
+
ON tool_call_activity (occurred_at_ms DESC, id DESC);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Already have this table?** `actor_namespace` was added after the original
|
|
124
|
+
example, and `CREATE TABLE IF NOT EXISTS` will not add it to a table that
|
|
125
|
+
already exists. Add it as a migration:
|
|
126
|
+
|
|
127
|
+
```sql
|
|
128
|
+
ALTER TABLE tool_call_activity ADD COLUMN actor_namespace TEXT;
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Do this **before** deploying the updated `d1-activity.ts`: its `INSERT`
|
|
132
|
+
names the column, so against an un-migrated table every write fails with
|
|
133
|
+
`no such column`. Activity writes are best-effort by design — connecta logs
|
|
134
|
+
the failure and returns the tool result unharmed — so the symptom is not an
|
|
135
|
+
error your agent sees, it is an activity log that quietly stops recording.
|
|
136
|
+
|
|
137
|
+
3. Pass the store to `createConnecta`:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { d1ActivityStore } from "./d1-activity.js";
|
|
141
|
+
|
|
142
|
+
createConnecta({
|
|
143
|
+
// …
|
|
144
|
+
activity: {
|
|
145
|
+
store: d1ActivityStore(env.ACTIVITY_DB),
|
|
146
|
+
deploymentId: "production",
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Events carry no arguments, results, generated code, or raw error messages — see
|
|
152
|
+
[activity history](../../documentation/operator-ui.md).
|
|
153
|
+
The Worker entrypoint already forwards `ctx` to `connecta.fetch`, which lets
|
|
154
|
+
async activity writes settle on `waitUntil`.
|
|
155
|
+
|
|
156
|
+
For retention, add a
|
|
157
|
+
[Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/)
|
|
158
|
+
(`triggers.crons` in `wrangler.jsonc` plus a `scheduled` handler — this example
|
|
159
|
+
no longer ships one) and call `pruneActivity(env.ACTIVITY_DB, retentionDays)`
|
|
160
|
+
from it.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { KVStorage } from "@zackbart/connecta";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* KVStorage backed by a Cloudflare Workers KV namespace binding.
|
|
5
|
+
* Note: Workers KV enforces a 60s minimum TTL; shorter TTLs are dropped
|
|
6
|
+
* (stored without expiry) rather than rejected.
|
|
7
|
+
*
|
|
8
|
+
* Workers KV is eventually consistent across locations. It is suitable for
|
|
9
|
+
* this example's durable state, but cannot promise immediate global OAuth
|
|
10
|
+
* disconnect, credential rotation, or access-token issuance/revocation; use a
|
|
11
|
+
* strongly consistent adapter when that is required.
|
|
12
|
+
*/
|
|
13
|
+
export function cloudflareKvStorage(namespace: KVNamespace): KVStorage {
|
|
14
|
+
return {
|
|
15
|
+
async get(key) {
|
|
16
|
+
return namespace.get(key);
|
|
17
|
+
},
|
|
18
|
+
async set(key, value, opts) {
|
|
19
|
+
const ttl = opts?.ttlSeconds;
|
|
20
|
+
await namespace.put(
|
|
21
|
+
key,
|
|
22
|
+
value,
|
|
23
|
+
ttl && ttl >= 60 ? { expirationTtl: ttl } : undefined,
|
|
24
|
+
);
|
|
25
|
+
},
|
|
26
|
+
async delete(key) {
|
|
27
|
+
await namespace.delete(key);
|
|
28
|
+
},
|
|
29
|
+
async list(prefix) {
|
|
30
|
+
const keys: string[] = [];
|
|
31
|
+
let cursor: string | undefined;
|
|
32
|
+
do {
|
|
33
|
+
const page = await namespace.list({
|
|
34
|
+
prefix,
|
|
35
|
+
...(cursor ? { cursor } : {}),
|
|
36
|
+
});
|
|
37
|
+
keys.push(...page.keys.map((key) => key.name));
|
|
38
|
+
cursor = page.list_complete ? undefined : page.cursor;
|
|
39
|
+
} while (cursor);
|
|
40
|
+
return keys.sort();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Keep this pure mapping module dependency-free so the repository's tests can
|
|
2
|
+
// exercise the example before the package's dist/ entrypoint has been built.
|
|
3
|
+
// The public adapter in d1-activity.ts remains checked against ActivityStore.
|
|
4
|
+
export interface ActivityEvent {
|
|
5
|
+
schemaVersion: 1;
|
|
6
|
+
id: string;
|
|
7
|
+
occurredAt: string;
|
|
8
|
+
requestId: string;
|
|
9
|
+
actor: {
|
|
10
|
+
kind: string;
|
|
11
|
+
id?: string;
|
|
12
|
+
namespace?: string;
|
|
13
|
+
};
|
|
14
|
+
connectorId: string;
|
|
15
|
+
toolName: string;
|
|
16
|
+
address: string;
|
|
17
|
+
source:
|
|
18
|
+
| "call_tool"
|
|
19
|
+
| "call_destructive_tool"
|
|
20
|
+
| "batch_call"
|
|
21
|
+
| "execute_code";
|
|
22
|
+
outcome: "success" | "error" | "timeout" | "cancelled";
|
|
23
|
+
durationMs: number;
|
|
24
|
+
attempts: number;
|
|
25
|
+
errorCode?: string;
|
|
26
|
+
serverName: string;
|
|
27
|
+
serverVersion: string;
|
|
28
|
+
deploymentId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ActivityRow {
|
|
32
|
+
id: string;
|
|
33
|
+
occurred_at_ms: number;
|
|
34
|
+
request_id: string;
|
|
35
|
+
actor_kind: string;
|
|
36
|
+
actor_id: string | null;
|
|
37
|
+
actor_namespace: string | null;
|
|
38
|
+
connector_id: string;
|
|
39
|
+
tool_name: string;
|
|
40
|
+
source: ActivityEvent["source"];
|
|
41
|
+
outcome: ActivityEvent["outcome"];
|
|
42
|
+
duration_ms: number;
|
|
43
|
+
attempts: number;
|
|
44
|
+
error_code: string | null;
|
|
45
|
+
server_name: string;
|
|
46
|
+
server_version: string;
|
|
47
|
+
deployment_id: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function activityEventToRow(
|
|
51
|
+
event: ActivityEvent,
|
|
52
|
+
): ActivityRow {
|
|
53
|
+
return {
|
|
54
|
+
id: event.id,
|
|
55
|
+
occurred_at_ms: Date.parse(event.occurredAt),
|
|
56
|
+
request_id: event.requestId,
|
|
57
|
+
actor_kind: event.actor.kind,
|
|
58
|
+
actor_id: event.actor.id ?? null,
|
|
59
|
+
actor_namespace: event.actor.namespace ?? null,
|
|
60
|
+
connector_id: event.connectorId,
|
|
61
|
+
tool_name: event.toolName,
|
|
62
|
+
source: event.source,
|
|
63
|
+
outcome: event.outcome,
|
|
64
|
+
duration_ms: event.durationMs,
|
|
65
|
+
attempts: event.attempts,
|
|
66
|
+
error_code: event.errorCode ?? null,
|
|
67
|
+
server_name: event.serverName,
|
|
68
|
+
server_version: event.serverVersion,
|
|
69
|
+
deployment_id: event.deploymentId ?? null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function activityRowToEvent(
|
|
74
|
+
row: ActivityRow,
|
|
75
|
+
): ActivityEvent {
|
|
76
|
+
return {
|
|
77
|
+
schemaVersion: 1,
|
|
78
|
+
id: row.id,
|
|
79
|
+
occurredAt: new Date(row.occurred_at_ms).toISOString(),
|
|
80
|
+
requestId: row.request_id,
|
|
81
|
+
actor: {
|
|
82
|
+
kind: row.actor_kind,
|
|
83
|
+
...(row.actor_id ? { id: row.actor_id } : {}),
|
|
84
|
+
...(row.actor_namespace ? { namespace: row.actor_namespace } : {}),
|
|
85
|
+
},
|
|
86
|
+
connectorId: row.connector_id,
|
|
87
|
+
toolName: row.tool_name,
|
|
88
|
+
address: `${row.connector_id}.${row.tool_name}`,
|
|
89
|
+
source: row.source,
|
|
90
|
+
outcome: row.outcome,
|
|
91
|
+
durationMs: row.duration_ms,
|
|
92
|
+
attempts: row.attempts,
|
|
93
|
+
...(row.error_code ? { errorCode: row.error_code } : {}),
|
|
94
|
+
serverName: row.server_name,
|
|
95
|
+
serverVersion: row.server_version,
|
|
96
|
+
...(row.deployment_id
|
|
97
|
+
? { deploymentId: row.deployment_id }
|
|
98
|
+
: {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActivityPage,
|
|
3
|
+
ActivityStore,
|
|
4
|
+
} from "@zackbart/connecta";
|
|
5
|
+
import { InvalidActivityCursorError } from "@zackbart/connecta";
|
|
6
|
+
import {
|
|
7
|
+
activityEventToRow,
|
|
8
|
+
activityRowToEvent,
|
|
9
|
+
type ActivityRow,
|
|
10
|
+
} from "./d1-activity-row.js";
|
|
11
|
+
|
|
12
|
+
interface Cursor {
|
|
13
|
+
occurredAtMs: number;
|
|
14
|
+
id: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function encodeCursor(row: ActivityRow): string {
|
|
18
|
+
return btoa(`${row.occurred_at_ms}:${row.id}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function decodeCursor(value: string): Cursor {
|
|
22
|
+
let decoded: string;
|
|
23
|
+
try {
|
|
24
|
+
decoded = atob(value);
|
|
25
|
+
} catch {
|
|
26
|
+
throw new InvalidActivityCursorError();
|
|
27
|
+
}
|
|
28
|
+
const separator = decoded.indexOf(":");
|
|
29
|
+
const occurredAtMs = Number(decoded.slice(0, separator));
|
|
30
|
+
const id = decoded.slice(separator + 1);
|
|
31
|
+
if (
|
|
32
|
+
separator < 1 ||
|
|
33
|
+
!Number.isSafeInteger(occurredAtMs) ||
|
|
34
|
+
occurredAtMs < 0 ||
|
|
35
|
+
!/^[0-9a-f-]{36}$/i.test(id)
|
|
36
|
+
) {
|
|
37
|
+
throw new InvalidActivityCursorError();
|
|
38
|
+
}
|
|
39
|
+
return { occurredAtMs, id };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One append-only row per completed downstream call; no arguments or results. */
|
|
43
|
+
export function d1ActivityStore(db: D1Database): ActivityStore {
|
|
44
|
+
return {
|
|
45
|
+
async record(event) {
|
|
46
|
+
const row = activityEventToRow(event);
|
|
47
|
+
await db
|
|
48
|
+
.prepare(
|
|
49
|
+
`INSERT INTO tool_call_activity (
|
|
50
|
+
id, occurred_at_ms, request_id, actor_kind, actor_id,
|
|
51
|
+
actor_namespace,
|
|
52
|
+
connector_id, tool_name, source, outcome, duration_ms, attempts,
|
|
53
|
+
error_code, server_name, server_version, deployment_id
|
|
54
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
55
|
+
)
|
|
56
|
+
.bind(
|
|
57
|
+
row.id,
|
|
58
|
+
row.occurred_at_ms,
|
|
59
|
+
row.request_id,
|
|
60
|
+
row.actor_kind,
|
|
61
|
+
row.actor_id,
|
|
62
|
+
row.actor_namespace,
|
|
63
|
+
row.connector_id,
|
|
64
|
+
row.tool_name,
|
|
65
|
+
row.source,
|
|
66
|
+
row.outcome,
|
|
67
|
+
row.duration_ms,
|
|
68
|
+
row.attempts,
|
|
69
|
+
row.error_code,
|
|
70
|
+
row.server_name,
|
|
71
|
+
row.server_version,
|
|
72
|
+
row.deployment_id,
|
|
73
|
+
)
|
|
74
|
+
.run();
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async list({ cursor, limit }): Promise<ActivityPage> {
|
|
78
|
+
const boundedLimit = Math.min(100, Math.max(1, Math.trunc(limit)));
|
|
79
|
+
const pageSize = boundedLimit + 1;
|
|
80
|
+
const position = cursor ? decodeCursor(cursor) : undefined;
|
|
81
|
+
const statement = position
|
|
82
|
+
? db
|
|
83
|
+
.prepare(
|
|
84
|
+
`SELECT * FROM tool_call_activity
|
|
85
|
+
WHERE occurred_at_ms < ?
|
|
86
|
+
OR (occurred_at_ms = ? AND id < ?)
|
|
87
|
+
ORDER BY occurred_at_ms DESC, id DESC
|
|
88
|
+
LIMIT ?`,
|
|
89
|
+
)
|
|
90
|
+
.bind(
|
|
91
|
+
position.occurredAtMs,
|
|
92
|
+
position.occurredAtMs,
|
|
93
|
+
position.id,
|
|
94
|
+
pageSize,
|
|
95
|
+
)
|
|
96
|
+
: db
|
|
97
|
+
.prepare(
|
|
98
|
+
`SELECT * FROM tool_call_activity
|
|
99
|
+
ORDER BY occurred_at_ms DESC, id DESC
|
|
100
|
+
LIMIT ?`,
|
|
101
|
+
)
|
|
102
|
+
.bind(pageSize);
|
|
103
|
+
const result = await statement.all<ActivityRow>();
|
|
104
|
+
const rows = result.results ?? [];
|
|
105
|
+
const hasMore = rows.length > boundedLimit;
|
|
106
|
+
const visible = hasMore ? rows.slice(0, boundedLimit) : rows;
|
|
107
|
+
const last = visible.at(-1);
|
|
108
|
+
return {
|
|
109
|
+
events: visible.map(activityRowToEvent),
|
|
110
|
+
...(hasMore && last ? { nextCursor: encodeCursor(last) } : {}),
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Daily retention pass. Each statement is capped at 5,000 rows, then repeats
|
|
118
|
+
* up to a per-run ceiling so normal traffic catches up without allowing one
|
|
119
|
+
* scheduled invocation to consume unbounded queries.
|
|
120
|
+
*/
|
|
121
|
+
export async function pruneActivity(
|
|
122
|
+
db: D1Database,
|
|
123
|
+
retentionDays: number,
|
|
124
|
+
maxBatches = 20,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const cutoff = Date.now() -
|
|
127
|
+
Math.max(1, Math.trunc(retentionDays)) * 24 * 60 * 60 * 1_000;
|
|
128
|
+
const batchLimit = Math.max(1, Math.trunc(maxBatches));
|
|
129
|
+
for (let batch = 0; batch < batchLimit; batch++) {
|
|
130
|
+
const result = await db
|
|
131
|
+
.prepare(
|
|
132
|
+
`DELETE FROM tool_call_activity
|
|
133
|
+
WHERE id IN (
|
|
134
|
+
SELECT id FROM tool_call_activity
|
|
135
|
+
WHERE occurred_at_ms < ?
|
|
136
|
+
ORDER BY occurred_at_ms ASC
|
|
137
|
+
LIMIT 5000
|
|
138
|
+
)`,
|
|
139
|
+
)
|
|
140
|
+
.bind(cutoff)
|
|
141
|
+
.run();
|
|
142
|
+
if ((result.meta.changes ?? 0) < 5_000) return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connecta on Cloudflare Workers.
|
|
3
|
+
*
|
|
4
|
+
* One MCP endpoint aggregating a downstream remote MCP and an HTTP API, guarded
|
|
5
|
+
* by Clerk OAuth *and* a static bearer token, with OAuth/cache state in a KV
|
|
6
|
+
* namespace. Add the optional Worker Loader binding in wrangler.jsonc for the
|
|
7
|
+
* seven-tool code-first surface; without it this serves the nine classic tools.
|
|
8
|
+
*
|
|
9
|
+
* Setup (this example has no package.json of its own — it self-references the
|
|
10
|
+
* installed `@zackbart/connecta` package):
|
|
11
|
+
* 1. `npm install` in the connecta package root (../../ from here) so the
|
|
12
|
+
* package import and wrangler resolve.
|
|
13
|
+
* 2. Create a KV namespace and put its id in wrangler.jsonc under `kv_namespaces`.
|
|
14
|
+
* 3. Set secrets:
|
|
15
|
+
* wrangler secret put SUPPORT_TOKEN
|
|
16
|
+
* wrangler secret put EXEC_TOKEN
|
|
17
|
+
* wrangler secret put CLERK_SECRET_KEY
|
|
18
|
+
* wrangler secret put DOWNSTREAM_TOKEN
|
|
19
|
+
* and CLERK_PUBLISHABLE_KEY + PUBLIC_URL as plain vars in wrangler.jsonc.
|
|
20
|
+
* 4. Enable Dynamic Client Registration in the Clerk dashboard
|
|
21
|
+
* (OAuth Applications -> DCR toggle) so Claude/Cursor can self-register.
|
|
22
|
+
* 5. Optional paid code mode: add the documented `worker_loaders` binding to
|
|
23
|
+
* wrangler.jsonc. Binding presence enables execute_code automatically.
|
|
24
|
+
* 6. `wrangler deploy` from this folder (examples/worker), where wrangler.jsonc
|
|
25
|
+
* lives. Point your MCP client at `<PUBLIC_URL>/mcp`.
|
|
26
|
+
*/
|
|
27
|
+
import { DynamicWorkerExecutor } from "@cloudflare/codemode";
|
|
28
|
+
import {
|
|
29
|
+
api,
|
|
30
|
+
bearerToken,
|
|
31
|
+
createConnecta,
|
|
32
|
+
remoteMcp,
|
|
33
|
+
} from "@zackbart/connecta";
|
|
34
|
+
import { clerkAuth } from "@zackbart/connecta/auth/clerk";
|
|
35
|
+
import { cloudflareKvStorage } from "./cloudflare-kv.js";
|
|
36
|
+
|
|
37
|
+
interface Env {
|
|
38
|
+
CONNECTA_KV: KVNamespace;
|
|
39
|
+
/** Bearer token for one headless client in this deployment's audience. */
|
|
40
|
+
SUPPORT_TOKEN: string;
|
|
41
|
+
/** Bearer token for another headless client in the same audience. */
|
|
42
|
+
EXEC_TOKEN: string;
|
|
43
|
+
CLERK_PUBLISHABLE_KEY: string;
|
|
44
|
+
CLERK_SECRET_KEY: string;
|
|
45
|
+
DOWNSTREAM_TOKEN: string;
|
|
46
|
+
PUBLIC_URL: string;
|
|
47
|
+
/**
|
|
48
|
+
* Worker Loader binding (wrangler.jsonc `worker_loaders`) powering
|
|
49
|
+
* execute_code and, with it, the code-first surface. Dynamic Workers require
|
|
50
|
+
* the Workers Paid plan; leave the binding absent for the nine classic
|
|
51
|
+
* meta-tools on either plan.
|
|
52
|
+
*/
|
|
53
|
+
LOADER?: WorkerLoader;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function build(env: Env) {
|
|
57
|
+
return createConnecta({
|
|
58
|
+
publicUrl: env.PUBLIC_URL,
|
|
59
|
+
storage: cloudflareKvStorage(env.CONNECTA_KV),
|
|
60
|
+
// Binding-as-switch: adding worker_loaders in wrangler.jsonc enables code
|
|
61
|
+
// mode and the seven-tool code-first surface with it; leaving it absent
|
|
62
|
+
// keeps this deployment free-tier compatible on the classic surface.
|
|
63
|
+
...(env.LOADER
|
|
64
|
+
? { executor: new DynamicWorkerExecutor({ loader: env.LOADER }) }
|
|
65
|
+
: {}),
|
|
66
|
+
auth: [
|
|
67
|
+
// Multiple credentials may identify callers in one deployment. Every
|
|
68
|
+
// admitted caller reaches this deployment's deliberate connector set.
|
|
69
|
+
bearerToken(env.SUPPORT_TOKEN, {
|
|
70
|
+
subjectId: "support-team",
|
|
71
|
+
}),
|
|
72
|
+
bearerToken(env.EXEC_TOKEN, {
|
|
73
|
+
subjectId: "exec-team",
|
|
74
|
+
}),
|
|
75
|
+
// The operator signs in with Clerk. Restrict who may sign in with
|
|
76
|
+
// `allowedDomains` (or a `gate`, for anything a domain cannot express).
|
|
77
|
+
clerkAuth({
|
|
78
|
+
publishableKey: env.CLERK_PUBLISHABLE_KEY,
|
|
79
|
+
secretKey: env.CLERK_SECRET_KEY,
|
|
80
|
+
publicUrl: env.PUBLIC_URL,
|
|
81
|
+
// allowedDomains: ["acme.com"],
|
|
82
|
+
}),
|
|
83
|
+
],
|
|
84
|
+
// Eligible Clerk operators can create named, revocable MCP Bearer tokens
|
|
85
|
+
// at /tokens. Secrets are shown once; only their hashes enter KV.
|
|
86
|
+
accessTokens: {},
|
|
87
|
+
connectors: [
|
|
88
|
+
remoteMcp("notion", {
|
|
89
|
+
url: "https://mcp.notion.com/mcp",
|
|
90
|
+
description: "Notion — pages, databases, comments (static token)",
|
|
91
|
+
auth: {
|
|
92
|
+
type: "headers",
|
|
93
|
+
headers: { Authorization: `Bearer ${env.DOWNSTREAM_TOKEN}` },
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
api("echo", {
|
|
97
|
+
description: "Echo — text transforms",
|
|
98
|
+
tools: [
|
|
99
|
+
{
|
|
100
|
+
name: "shout",
|
|
101
|
+
description: "Uppercase the given text.",
|
|
102
|
+
inputSchema: {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: {
|
|
105
|
+
text: { type: "string", description: "Text to uppercase." },
|
|
106
|
+
},
|
|
107
|
+
required: ["text"],
|
|
108
|
+
},
|
|
109
|
+
annotations: { readOnlyHint: true },
|
|
110
|
+
handler: async (args: { text: string }) => ({
|
|
111
|
+
shouted: args.text.toUpperCase(),
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
}),
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Lazy per-isolate singleton: reuses the plain-data tool cache. Downstream MCP
|
|
121
|
+
// clients are request-scoped internally so Worker I/O never crosses requests.
|
|
122
|
+
let connecta: ReturnType<typeof build> | undefined;
|
|
123
|
+
|
|
124
|
+
export default {
|
|
125
|
+
// Pass `ctx` through: connecta hands deferred work (activity sinks) to
|
|
126
|
+
// ctx.waitUntil so it settles after the response is returned instead of
|
|
127
|
+
// being cancelled with the request.
|
|
128
|
+
async fetch(
|
|
129
|
+
request: Request,
|
|
130
|
+
env: Env,
|
|
131
|
+
ctx: ExecutionContext,
|
|
132
|
+
): Promise<Response> {
|
|
133
|
+
connecta ??= build(env);
|
|
134
|
+
return connecta.fetch(request, env, ctx);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
|
+
"name": "connecta-example",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"compatibility_date": "2025-01-01",
|
|
6
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
7
|
+
"observability": { "enabled": true },
|
|
8
|
+
|
|
9
|
+
// Plain vars. Secrets (SUPPORT_TOKEN, EXEC_TOKEN, CLERK_SECRET_KEY,
|
|
10
|
+
// DOWNSTREAM_TOKEN) are set with `wrangler secret put <NAME>`, not here.
|
|
11
|
+
"vars": {
|
|
12
|
+
"PUBLIC_URL": "https://connecta.example.workers.dev",
|
|
13
|
+
"CLERK_PUBLISHABLE_KEY": "pk_test_replace-me"
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
// Create with `wrangler kv namespace create CONNECTA_KV` and paste the id.
|
|
17
|
+
"kv_namespaces": [
|
|
18
|
+
{ "binding": "CONNECTA_KV", "id": "replace-with-kv-namespace-id" }
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
// Optional paid code mode: add a comma above, then uncomment this binding.
|
|
22
|
+
// Its presence is the entire switch — src/index.ts detects env.LOADER and
|
|
23
|
+
// serves the seven-tool code-first surface. Leave it absent for a free-tier
|
|
24
|
+
// deployment with the nine classic meta-tools.
|
|
25
|
+
// "worker_loaders": [{ "binding": "LOADER" }]
|
|
26
|
+
}
|