@zackbart/connecta 0.5.0 → 0.6.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 +505 -0
- package/README.md +159 -267
- package/dist/auth/bearer.d.ts +10 -3
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +21 -0
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +28 -3
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +161 -4
- package/dist/auth/clerk.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +8 -0
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +220 -0
- package/dist/credential-health.d.ts.map +1 -0
- package/dist/credential-health.js +551 -0
- package/dist/credential-health.js.map +1 -0
- package/dist/credentials.d.ts +35 -1
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +42 -0
- package/dist/credentials.js.map +1 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +16 -4
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +46 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +118 -15
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +56 -5
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +249 -92
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +62 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +85 -1
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +305 -40
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +1 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +3 -3
- package/dist/skills.js.map +1 -1
- package/dist/timeout.d.ts +16 -0
- package/dist/timeout.d.ts.map +1 -0
- package/dist/timeout.js +38 -0
- package/dist/timeout.js.map +1 -0
- package/dist/toolkits.d.ts +95 -1
- package/dist/toolkits.d.ts.map +1 -1
- package/dist/toolkits.js +190 -5
- package/dist/toolkits.js.map +1 -1
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +52 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +144 -13
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/auth/bearer.ts +35 -1
- package/src/auth/clerk.ts +204 -7
- package/src/connectors/remote-mcp.ts +9 -0
- package/src/credential-health.ts +753 -0
- package/src/credentials.ts +71 -1
- package/src/execute.ts +28 -4
- package/src/index.ts +204 -22
- package/src/meta-tools.ts +286 -109
- package/src/registry.ts +125 -1
- package/src/server.ts +366 -38
- package/src/skills.ts +3 -3
- package/src/timeout.ts +49 -0
- package/src/toolkits.ts +241 -6
- package/src/types.ts +87 -1
- package/src/ui.ts +156 -14
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
-
One MCP
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
One MCP endpoint in front of all your connectors. Agents see nine tools instead
|
|
6
|
+
of hundreds, and each client is configured once instead of once per integration.
|
|
7
|
+
|
|
8
|
+
## The problem
|
|
9
|
+
|
|
10
|
+
An agent connected to N MCP servers pays for all N before it does anything. Every
|
|
11
|
+
server's tool list is injected into the context window at connect time — hundreds
|
|
12
|
+
of definitions and their schemas, nearly all irrelevant to the task actually at
|
|
13
|
+
hand. That budget is spent whether the model calls one of them or none, and it
|
|
14
|
+
comes out of the same window the work needs.
|
|
15
|
+
|
|
16
|
+
The second cost is configuration. Every client — Claude, Cursor, whatever comes
|
|
17
|
+
next — has to be pointed at each server separately, with its own auth for each.
|
|
18
|
+
Adding an integration means touching every client; rotating one token means
|
|
19
|
+
finding every place it was pasted.
|
|
20
|
+
|
|
21
|
+
## What connecta is
|
|
22
|
+
|
|
23
|
+
One MCP endpoint you deploy — Cloudflare Worker, Node, or Docker — that
|
|
24
|
+
aggregates your downstream connectors behind a fixed set of **nine meta-tools**.
|
|
25
|
+
A connector is either a remote MCP server that connecta proxies, or a plain HTTP
|
|
26
|
+
API with hand-written tool definitions and a fetch handler.
|
|
8
27
|
|
|
9
28
|
```
|
|
10
29
|
┌── remoteMcp("notion") → mcp.notion.com
|
|
@@ -13,14 +32,96 @@ Claude / Cursor ── MCP ──▶ connecta ───┼── remoteMcp("lin
|
|
|
13
32
|
└── api("internal") → fetch(...)
|
|
14
33
|
```
|
|
15
34
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
Rather than receiving every tool up front, the agent discovers what it needs.
|
|
36
|
+
`search_tools` returns ranked matches for a query and `describe_tools` returns
|
|
37
|
+
schemas — compact by default, raw JSON Schema on request — for only the
|
|
38
|
+
addresses it is about to call. `call_tool`,
|
|
39
|
+
`batch_call`, and `call_destructive_tool` invoke them by address
|
|
40
|
+
(`<connectorId>.<toolName>`). `list_connectors` reports what exists and whether
|
|
41
|
+
it is reachable, `authorize_connector` starts a downstream OAuth flow,
|
|
42
|
+
`get_result` pages through a result too large to return inline, and `skills`
|
|
43
|
+
hands the model a short guide to choosing among the rest.
|
|
44
|
+
|
|
45
|
+
That is the entire surface. The agent's context holds nine tool definitions
|
|
46
|
+
whether ten tools sit behind them or a thousand — and the client holds one URL
|
|
47
|
+
and one credential, no matter how many services that URL fans out to.
|
|
48
|
+
|
|
49
|
+
An optional tenth meta-tool, `execute_code`, runs model-written async JavaScript
|
|
50
|
+
in a sandbox with no network, filesystem, or environment access — only the
|
|
51
|
+
explicitly read-only tools as callable globals — turning a loop, a join across
|
|
52
|
+
connectors, or a filter over a large response into one round trip instead of a
|
|
53
|
+
dozen. Configure no `executor` and connecta is exactly the nine-tool server.
|
|
54
|
+
|
|
55
|
+
## Why it's shaped this way
|
|
56
|
+
|
|
57
|
+
**Config as code, one deployment per tenant.** Connectors are declared in
|
|
58
|
+
TypeScript. Adding one is a code change and a deploy — no database of
|
|
59
|
+
integrations, no registration API, no runtime admin. A deployment is a small
|
|
60
|
+
config file you can read in one sitting and review in a pull request, not a
|
|
61
|
+
platform to administer.
|
|
62
|
+
|
|
63
|
+
**Credentials stay server-side.** Downstream tokens live in an AES-GCM encrypted
|
|
64
|
+
vault over the deployment's own storage, with the key held outside it. A
|
|
65
|
+
connector reaches its own credential through `ctx.credential`; `/ui`, the
|
|
66
|
+
meta-tools, and the code sandbox only ever see masked metadata. Rotating a token
|
|
67
|
+
is an operator action rather than a redeploy — though writing to the vault is
|
|
68
|
+
deliberately narrower than everything else, requiring a Clerk-authenticated
|
|
69
|
+
operator on a same-origin request, so a bearer-only deployment cannot administer
|
|
70
|
+
credentials from the browser. Which tools exist is still code either way.
|
|
71
|
+
|
|
72
|
+
**Read-only is fail-closed.** Only tools explicitly annotated `readOnlyHint:
|
|
73
|
+
true` are reachable through `call_tool`, `batch_call`, and the sandbox. Missing,
|
|
74
|
+
false, or contradictory annotations do not get the benefit of the doubt: they
|
|
75
|
+
require `call_destructive_tool`, which is itself annotated so the MCP host can
|
|
76
|
+
put the question to a human. Connecta makes the boundary visible; approval is
|
|
77
|
+
the host's job.
|
|
78
|
+
|
|
79
|
+
**Toolkits scope what a team sees.** A deployment belongs to an org; a toolkit
|
|
80
|
+
is a named view over its registry for one group inside that org — support sees
|
|
81
|
+
Zendesk and Notion, exec also sees Gmail. A client selects one with
|
|
82
|
+
`?toolkit=support` on the MCP URL, and a credential can be bound so it opens
|
|
83
|
+
that view and nothing else. Inside a scoped session an out-of-scope address
|
|
84
|
+
fails exactly as a nonexistent one does. Two teams, one deployment. Who gets in
|
|
85
|
+
at all is the prior question: a static bearer token, or Clerk — where
|
|
86
|
+
`allowedDomains: ["acme.com"]` admits anyone whose verified primary email is on
|
|
87
|
+
your domain without enumerating users, and a `gate` hook handles what a domain
|
|
88
|
+
rule cannot express. Both fail closed, and each one configured must pass.
|
|
89
|
+
|
|
90
|
+
**Activity records the fact, not the payload.** The optional activity store logs
|
|
91
|
+
which resolved tool ran, for whom, and how it went — never arguments, results,
|
|
92
|
+
generated code, search text, or raw error messages. The exclusion is structural
|
|
93
|
+
rather than a redaction pass: the event type has nowhere to put a payload, which
|
|
94
|
+
is what keeps an operations log from becoming something worth stealing.
|
|
95
|
+
|
|
96
|
+
**An operator dashboard that can only look.** `GET /ui` is a read-only status
|
|
97
|
+
page with no build step: connector health, tool counts, downstream authorization
|
|
98
|
+
links, credential controls, and an activity tab when a store is configured. It
|
|
99
|
+
displays state and administers credentials; it cannot change what an agent is
|
|
100
|
+
allowed to call. Credentials connecta stores are also probed for liveness
|
|
101
|
+
proactively — using each connector's own test or status hook, never a downstream
|
|
102
|
+
tool call — so a dead token surfaces as `auth_required` with the URL to open on
|
|
103
|
+
`/ui` and in `list_connectors` before an agent's real call trips over it.
|
|
104
|
+
|
|
105
|
+
## When not to use it
|
|
106
|
+
|
|
107
|
+
Connecta is deliberately small, and declines several tempting shapes.
|
|
108
|
+
It is **not multi-tenant** — one deployment is one tenant, with one registry and
|
|
109
|
+
one credential store, and toolkits are scoped views rather than tenants. There
|
|
110
|
+
is **no policy engine**, no approvals, and no pauses — access decisions are
|
|
111
|
+
fixed ones connecta already knows how to answer (is this tool read-only, may
|
|
112
|
+
this credential open this toolkit), not rules you author. There is **no runtime
|
|
113
|
+
administration** — you cannot add a connector from a browser or an API. It
|
|
114
|
+
aggregates **tools only**, not MCP resources or prompts, and it will not ingest
|
|
115
|
+
a **GraphQL** schema, because generating hundreds of low-quality tool
|
|
116
|
+
definitions is the problem the nine meta-tools exist to solve. OpenAPI is a
|
|
117
|
+
softer no: not built in today, not refused either, and tracked as
|
|
118
|
+
[issue #26](https://github.com/zackbart/connecta/issues/26). If you want a
|
|
119
|
+
hosted multi-tenant integration platform with an approval workflow, this is the
|
|
120
|
+
wrong shape; the
|
|
121
|
+
[non-goals](https://github.com/zackbart/connecta/blob/main/docs/decisions.md#non-goals)
|
|
122
|
+
say so at more length.
|
|
123
|
+
|
|
124
|
+
## Getting started
|
|
24
125
|
|
|
25
126
|
Node deployments require Node.js 20.9 or newer.
|
|
26
127
|
|
|
@@ -28,277 +129,68 @@ Node deployments require Node.js 20.9 or newer.
|
|
|
28
129
|
npm install @zackbart/connecta
|
|
29
130
|
```
|
|
30
131
|
|
|
31
|
-
|
|
32
|
-
optional packages a deployment uses—for example:
|
|
33
|
-
|
|
34
|
-
```sh
|
|
35
|
-
npm install @clerk/backend # optional Clerk auth adapter
|
|
36
|
-
npm install quickjs-emscripten # optional Node code-mode executor
|
|
37
|
-
npm install @cloudflare/codemode # optional Worker code-mode executor
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
## The nine meta-tools
|
|
41
|
-
|
|
42
|
-
A tool **address** is `<connectorId>.<toolName>` (e.g. `notion.search`).
|
|
43
|
-
|
|
44
|
-
| Tool | Input | Returns |
|
|
45
|
-
| --- | --- | --- |
|
|
46
|
-
| `list_connectors` | `{ probe? }` | live (`probe: true`, default) or cached health, tool count, and recent real-call observations |
|
|
47
|
-
| `skills` | `{ name? }` | lists or fetches the concise `usage` guide for choosing among the meta-tools, plus any operator-authored per-connector guide (`connector:<connectorId>`) |
|
|
48
|
-
| `search_tools` | `{ query?, connector?, limit?, offset?, fullDescriptions?, includeSchemas? }` | ranked, paginated matches; optionally includes compact/raw schemas to remove a round trip |
|
|
49
|
-
| `describe_tools` | `{ addresses[], format?, fullDescriptions? }` | names, descriptions, input/output schemas, and behavior annotations |
|
|
50
|
-
| `call_tool` | `{ address, args?, fields?, resultMode?, timeoutMs?, maxRetries?, diagnostics? }` | invokes only tools explicitly annotated `readOnlyHint: true` |
|
|
51
|
-
| `call_destructive_tool` | same as `call_tool` | invokes unannotated, write-capable, or destructive tools through a host-visible approval boundary |
|
|
52
|
-
| `authorize_connector` | `{ connector, force? }` | starts (or with `force`, restarts) the downstream OAuth flow; returns the `authorizationUrl` to open |
|
|
53
|
-
| `get_result` | `{ id, offset?, maxBytes? }` | a byte-slice page of a truncated result — `{ text, offset, nextOffset?, totalBytes }`; `maxBytes` is a whole number of bytes >= 1 |
|
|
54
|
-
| `batch_call` | `{ calls, resultMode?, timeoutMs?, maxRetries? }` | 1–10 parallel calls sharing request-scoped clients, with attempts/timing/errors |
|
|
55
|
-
| `execute_code` *(optional)* | `{ code }` | result + logs of bounded async JS orchestration over explicitly read-only tools; registered only when an `executor` is configured |
|
|
56
|
-
|
|
57
|
-
## Package vs. deployments
|
|
58
|
-
|
|
59
|
-
This repository owns the reusable package. Each deployment should be a small,
|
|
60
|
-
separate Worker project that pins an exact package version and owns only its
|
|
61
|
-
connector configuration, auth policy, domain, bindings, migrations, and secrets.
|
|
62
|
-
[`examples/worker/`](./examples/worker/) is the starting template.
|
|
63
|
-
|
|
64
|
-
## Quickstart — Cloudflare Worker
|
|
65
|
-
|
|
66
|
-
```ts
|
|
67
|
-
import { DynamicWorkerExecutor } from "@cloudflare/codemode";
|
|
68
|
-
import { bearerToken, createConnecta, remoteMcp } from "@zackbart/connecta";
|
|
69
|
-
import { clerkAuth } from "@zackbart/connecta/auth/clerk";
|
|
70
|
-
import { cloudflareKvStorage } from "./cloudflare-kv.js";
|
|
71
|
-
|
|
72
|
-
const build = (env: Env) =>
|
|
73
|
-
createConnecta({
|
|
74
|
-
publicUrl: env.PUBLIC_URL,
|
|
75
|
-
storage: cloudflareKvStorage(env.CONNECTA_KV),
|
|
76
|
-
// Code mode (optional): needs a `worker_loaders` binding in wrangler.jsonc.
|
|
77
|
-
executor: new DynamicWorkerExecutor({ loader: env.LOADER }),
|
|
78
|
-
auth: [
|
|
79
|
-
bearerToken(env.CONNECTA_TOKEN),
|
|
80
|
-
clerkAuth({
|
|
81
|
-
publishableKey: env.CLERK_PUBLISHABLE_KEY,
|
|
82
|
-
secretKey: env.CLERK_SECRET_KEY,
|
|
83
|
-
publicUrl: env.PUBLIC_URL,
|
|
84
|
-
}),
|
|
85
|
-
],
|
|
86
|
-
connectors: [
|
|
87
|
-
remoteMcp("notion", {
|
|
88
|
-
url: "https://mcp.notion.com/mcp",
|
|
89
|
-
auth: { type: "headers", headers: { Authorization: `Bearer ${env.NOTION_TOKEN}` } },
|
|
90
|
-
}),
|
|
91
|
-
],
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
// Lazy per-isolate singleton: keeps only serializable tool/catalog data warm.
|
|
95
|
-
let connecta: ReturnType<typeof build> | undefined;
|
|
96
|
-
|
|
97
|
-
export default {
|
|
98
|
-
fetch(request: Request, env: Env): Promise<Response> {
|
|
99
|
-
connecta ??= build(env);
|
|
100
|
-
return connecta.fetch(request);
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
Deployable example: [`examples/worker/`](./examples/worker/).
|
|
106
|
-
Its Cloudflare KV and D1 implementations are deployment-owned examples over the
|
|
107
|
-
generic `KVStorage` and `ActivityStore` contracts; they are not package exports.
|
|
108
|
-
|
|
109
|
-
`clerkAuth` is an optional adapter. Install `@clerk/backend` and import it from
|
|
110
|
-
`@zackbart/connecta/auth/clerk` only in deployments that use Clerk. Other
|
|
111
|
-
identity providers can implement the exported `InboundAuth` interface.
|
|
112
|
-
|
|
113
|
-
## Quickstart — Node
|
|
132
|
+
A minimal server with one hand-written connector:
|
|
114
133
|
|
|
115
134
|
```ts
|
|
116
135
|
import { api, bearerToken, createConnecta } from "@zackbart/connecta";
|
|
117
136
|
import { fileStorage, listen } from "@zackbart/connecta/node";
|
|
118
|
-
import { quickJsExecutor } from "@zackbart/connecta/quickjs";
|
|
119
137
|
|
|
120
138
|
const connecta = createConnecta({
|
|
121
139
|
storage: fileStorage("./.connecta-state.json"), // or memoryStorage()
|
|
122
|
-
auth: bearerToken(process.env.CONNECTA_TOKEN
|
|
123
|
-
executor: quickJsExecutor(), // code mode (optional): QuickJS/WASM sandbox
|
|
140
|
+
auth: bearerToken(process.env.CONNECTA_TOKEN ?? "dev-token"),
|
|
124
141
|
connectors: [
|
|
125
142
|
api("time", {
|
|
126
|
-
description: "Time —
|
|
127
|
-
tools: [
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
143
|
+
description: "Time — current timestamp",
|
|
144
|
+
tools: [
|
|
145
|
+
{
|
|
146
|
+
name: "get_now",
|
|
147
|
+
description: "Return the current time as an ISO 8601 timestamp.",
|
|
148
|
+
inputSchema: { type: "object", properties: {} },
|
|
149
|
+
annotations: { readOnlyHint: true },
|
|
150
|
+
handler: async () => ({ now: new Date().toISOString() }),
|
|
151
|
+
},
|
|
152
|
+
],
|
|
134
153
|
}),
|
|
135
154
|
],
|
|
136
155
|
});
|
|
137
156
|
|
|
138
|
-
listen(connecta, 8787); // http://localhost:8787/mcp
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
Example: [`examples/node/`](./examples/node/). Docker (single-service compose
|
|
142
|
-
stack): [`examples/docker/`](./examples/docker/).
|
|
143
|
-
|
|
144
|
-
## Toolkits — one deployment, many teams
|
|
145
|
-
|
|
146
|
-
A deployment belongs to an org. Optional **toolkits** give each group of team
|
|
147
|
-
members its own scoped view of the same registry, so different teams no longer
|
|
148
|
-
need separate deployments:
|
|
149
|
-
|
|
150
|
-
```ts
|
|
151
|
-
createConnecta({
|
|
152
|
-
connectors: [zendesk, notion, gmail],
|
|
153
|
-
auth: bearerToken(env.CONNECTA_TOKEN),
|
|
154
|
-
toolkits: {
|
|
155
|
-
support: { connectors: ["zendesk", "notion"] },
|
|
156
|
-
exec: {
|
|
157
|
-
connectors: ["zendesk", "notion", "gmail"],
|
|
158
|
-
excludeTools: ["gmail.send_message"], // finer grain than a connector id
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
A client picks one at connect time: `https://…/mcp?toolkit=support`. Inside a
|
|
165
|
-
scoped session **every** meta-tool — search, describe, call, batch, skills,
|
|
166
|
-
authorize, `get_result`, and `execute_code` host calls — behaves as if
|
|
167
|
-
out-of-scope connectors and tools do not exist, and an out-of-scope address
|
|
168
|
-
fails identically to a nonexistent one. No `?toolkit=` ⇒ the full registry, as
|
|
169
|
-
before; an unknown name is an error, never a silent fallback.
|
|
170
|
-
|
|
171
|
-
A toolkit scopes **visibility, not identity**: any authenticated client may
|
|
172
|
-
select any toolkit, or omit the parameter and see everything, so a credential
|
|
173
|
-
shared by two teams gives both teams every view. Binding a member to a toolkit
|
|
174
|
-
belongs in `auth`, which stays the access check; enforcing that binding is a
|
|
175
|
-
deliberate follow-up
|
|
176
|
-
([issue #37](https://github.com/zackbart/connecta/issues/37)). Details:
|
|
177
|
-
[docs §16](./docs/documentation.md#16-toolkits-scoped-views).
|
|
178
|
-
|
|
179
|
-
## Code mode
|
|
180
|
-
|
|
181
|
-
With an `executor` configured, the model can write an async function instead of
|
|
182
|
-
making one `call_tool` round trip per step — loops, joins across connectors,
|
|
183
|
-
filtering big responses down in-sandbox before they hit the context window:
|
|
184
|
-
|
|
185
|
-
```js
|
|
186
|
-
async () => {
|
|
187
|
-
const pages = await notion.search({ query: "roadmap" });
|
|
188
|
-
return pages.results.map((p) => p.title);
|
|
189
|
-
}
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
The sandbox has **no** network/filesystem/env — only explicitly read-only
|
|
193
|
-
connector globals and
|
|
194
|
-
`connecta.call`, `connecta.batch`, `connecta.search`, and `connecta.describe`;
|
|
195
|
-
credentials stay host-side. A run may make at most 20 host calls, a
|
|
196
|
-
`connecta.batch` may contain at most 10 calls, and every host call has a
|
|
197
|
-
15-second deadline. Executors:
|
|
198
|
-
`DynamicWorkerExecutor` (`@cloudflare/codemode`,
|
|
199
|
-
Workers, Worker Loader binding — open beta) or `quickJsExecutor()`
|
|
200
|
-
(`@zackbart/connecta/quickjs`, QuickJS-in-WASM, runs anywhere). Omit the `executor` and
|
|
201
|
-
connecta is exactly the nine-tool server. Details:
|
|
202
|
-
[docs §13](./docs/documentation.md#13-code-mode-execute_code).
|
|
203
|
-
|
|
204
|
-
## Credentials in `/ui`
|
|
205
|
-
|
|
206
|
-
API connectors can declare one operator-managed credential or a named set of
|
|
207
|
-
credential fields. Connecta renders
|
|
208
|
-
Add / Replace / Test / Remove controls inside that connector's `/ui` card,
|
|
209
|
-
encrypts the values with AES-GCM, and stores only ciphertext in the deployment's
|
|
210
|
-
existing `KVStorage`. Credentials are available only to the connector through
|
|
211
|
-
`ctx.credential.get()`, `get(name)`, or `getAll()`; they are never returned by
|
|
212
|
-
`/ui`, MCP tools, or code mode.
|
|
213
|
-
|
|
214
|
-
```ts
|
|
215
|
-
api("example", {
|
|
216
|
-
description: "Example — authenticated API",
|
|
217
|
-
credential: {
|
|
218
|
-
label: "API token",
|
|
219
|
-
description: "Token used for outbound Example API requests.",
|
|
220
|
-
},
|
|
221
|
-
tools: [{
|
|
222
|
-
name: "get_profile",
|
|
223
|
-
description: "Get the authenticated Example profile.",
|
|
224
|
-
inputSchema: { type: "object", properties: {} },
|
|
225
|
-
annotations: { readOnlyHint: true },
|
|
226
|
-
handler: async (_args, ctx) => {
|
|
227
|
-
const token = await ctx.credential?.get();
|
|
228
|
-
if (!token) throw new Error("Example API token is not configured.");
|
|
229
|
-
return fetch("https://api.example.com/profile", {
|
|
230
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
231
|
-
}).then((response) => response.json());
|
|
232
|
-
},
|
|
233
|
-
}],
|
|
234
|
-
})
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
Set `credentialEncryptionKey` on `createConnecta` to a base64-encoded 32-byte
|
|
238
|
-
key held in the runtime's secret store (`openssl rand -base64 32`) — a connector
|
|
239
|
-
that declares a credential without one fails at construction rather than booting
|
|
240
|
-
with an unusable vault. Credential mutation routes require the configured Clerk
|
|
241
|
-
provider and a same-origin browser request; the static inbound bearer cannot
|
|
242
|
-
administer the vault.
|
|
243
|
-
|
|
244
|
-
Connecta does not bundle service-specific HTTP API connectors. Package consumers
|
|
245
|
-
define them with `api()` (or implement `Connector` directly), keeping endpoint,
|
|
246
|
-
credential, and tool choices in the consuming project.
|
|
247
|
-
|
|
248
|
-
## Payload-free tool activity
|
|
249
|
-
|
|
250
|
-
Connecta can record which resolved downstream tools were actually invoked
|
|
251
|
-
without storing their arguments, results, generated code, search text, or raw
|
|
252
|
-
errors. Supply a vendor-neutral `activity` store:
|
|
253
|
-
|
|
254
|
-
```ts
|
|
255
|
-
const events: ToolCallActivityEvent[] = [];
|
|
256
|
-
|
|
257
|
-
const connecta = createConnecta({
|
|
258
|
-
connectors,
|
|
259
|
-
activity: {
|
|
260
|
-
record(event) {
|
|
261
|
-
events.push(event);
|
|
262
|
-
},
|
|
263
|
-
async list({ limit }) {
|
|
264
|
-
return { events: events.slice(-limit).reverse() };
|
|
265
|
-
},
|
|
266
|
-
},
|
|
267
|
-
});
|
|
157
|
+
listen(connecta, 8787); // MCP at http://localhost:8787/mcp, status at /ui
|
|
268
158
|
```
|
|
269
159
|
|
|
270
|
-
|
|
271
|
-
`
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
[`
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
configured it signs operators in through Clerk's hosted portal; a bearer-only
|
|
290
|
-
deployment falls back to a pasted token.
|
|
291
|
-
|
|
292
|
-
Every deployment-facing label and mark on `/ui` and the OAuth result pages comes
|
|
293
|
-
from `ConnectaConfig.branding` — `productName`, `ownerName`, their URLs,
|
|
294
|
-
`description`, `pageTitle`, `themeColor`, and `favicon` — each falling back to a
|
|
295
|
-
neutral Connecta default. Nothing about the operator is baked into the package.
|
|
160
|
+
Point an MCP client at `http://localhost:8787/mcp` with an
|
|
161
|
+
`Authorization: Bearer` header and it will see the nine meta-tools, with
|
|
162
|
+
`time.get_now` discoverable through `search_tools`.
|
|
163
|
+
|
|
164
|
+
Runnable deployments live in
|
|
165
|
+
[`examples/`](https://github.com/zackbart/connecta/tree/main/examples):
|
|
166
|
+
[`worker/`](https://github.com/zackbart/connecta/tree/main/examples/worker) is a
|
|
167
|
+
deployable Cloudflare Worker with KV and D1 adapters, and the template to copy
|
|
168
|
+
for a real deployment;
|
|
169
|
+
[`node/`](https://github.com/zackbart/connecta/tree/main/examples/node) adds
|
|
170
|
+
toolkits and code mode to the server above;
|
|
171
|
+
[`docker/`](https://github.com/zackbart/connecta/tree/main/examples/docker) is a
|
|
172
|
+
single-service compose stack. Anything beyond the core is installed only by the
|
|
173
|
+
deployments that use it: `@clerk/backend` and `quickjs-emscripten` are optional
|
|
174
|
+
peer dependencies, reached through the `/auth/clerk` and `/quickjs` subpaths,
|
|
175
|
+
and a Worker using code mode brings its own `@cloudflare/codemode`. Connecta
|
|
176
|
+
ships no service-specific connectors:
|
|
177
|
+
endpoint, credential, and tool choices stay in your project, declared with
|
|
178
|
+
`remoteMcp()` and `api()`.
|
|
296
179
|
|
|
297
180
|
## Learn more
|
|
298
181
|
|
|
299
|
-
- **[
|
|
300
|
-
the
|
|
301
|
-
OAuth, storage,
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
182
|
+
- **[Documentation](https://github.com/zackbart/connecta/blob/main/docs/documentation.md)**
|
|
183
|
+
— the reference manual: architecture, connectors, inbound auth and downstream
|
|
184
|
+
OAuth, storage, Clerk setup, testing, and troubleshooting. Start with the
|
|
185
|
+
[meta-tools reference](https://github.com/zackbart/connecta/blob/main/docs/documentation.md#3-meta-tools-reference),
|
|
186
|
+
the [config options](https://github.com/zackbart/connecta/blob/main/docs/documentation.md#8-running-it),
|
|
187
|
+
[code mode](https://github.com/zackbart/connecta/blob/main/docs/documentation.md#13-code-mode-execute_code),
|
|
188
|
+
[toolkits](https://github.com/zackbart/connecta/blob/main/docs/documentation.md#16-toolkits-scoped-views),
|
|
189
|
+
or [credential health](https://github.com/zackbart/connecta/blob/main/docs/documentation.md#17-credential-health-proactive-liveness-checks).
|
|
190
|
+
- **[Decisions](https://github.com/zackbart/connecta/blob/main/docs/decisions.md)**
|
|
191
|
+
— what connecta refuses to be, which alternatives lost and why, and the
|
|
192
|
+
invariants a change must preserve.
|
|
193
|
+
- **[CHANGELOG](https://github.com/zackbart/connecta/blob/main/CHANGELOG.md)** —
|
|
194
|
+
what changed in each release.
|
|
195
|
+
- **[SECURITY](https://github.com/zackbart/connecta/blob/main/SECURITY.md)** —
|
|
196
|
+
supported versions and how to report a vulnerability.
|
package/dist/auth/bearer.d.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
import { type ToolkitBindingOptions } from "../toolkits.js";
|
|
1
2
|
import type { InboundAuth } from "../types.js";
|
|
3
|
+
export interface BearerTokenOptions extends ToolkitBindingOptions {
|
|
4
|
+
/** Stable identity for this credential, used on activity events. */
|
|
5
|
+
subjectId?: string;
|
|
6
|
+
}
|
|
2
7
|
/**
|
|
3
8
|
* Static bearer-token inbound auth. Constant-time compares the Bearer token
|
|
4
9
|
* against `secret`. Checked BEFORE the Clerk gate in the server; a mismatch
|
|
5
10
|
* falls through so a co-configured Clerk provider can still admit the request.
|
|
11
|
+
*
|
|
12
|
+
* `toolkits` binds the token to named toolkits (§16): one `bearerToken(...)` per
|
|
13
|
+
* team credential, each naming the view that team may open. Omit it and the
|
|
14
|
+
* token stays unbound — every declared toolkit plus the full registry.
|
|
6
15
|
*/
|
|
7
|
-
export declare function bearerToken(secret: string, options?:
|
|
8
|
-
subjectId?: string;
|
|
9
|
-
}): InboundAuth;
|
|
16
|
+
export declare function bearerToken(secret: string, options?: BearerTokenOptions): InboundAuth;
|
|
10
17
|
//# sourceMappingURL=bearer.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bearer.d.ts","sourceRoot":"","sources":["../../src/auth/bearer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,aAAa,CAAC;AAiB3D
|
|
1
|
+
{"version":3,"file":"bearer.d.ts","sourceRoot":"","sources":["../../src/auth/bearer.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,aAAa,CAAC;AAiB3D,MAAM,WAAW,kBAAmB,SAAQ,qBAAqB;IAC/D,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,kBAAuB,GAC/B,WAAW,CAiDb"}
|
package/dist/auth/bearer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveToolkitBinding, } from "../toolkits.js";
|
|
1
2
|
const encoder = new TextEncoder();
|
|
2
3
|
/** Constant-time byte comparison. Differing lengths still iterate to reduce leak. */
|
|
3
4
|
function timingSafeEqual(a, b) {
|
|
@@ -17,11 +18,31 @@ function timingSafeEqual(a, b) {
|
|
|
17
18
|
* Static bearer-token inbound auth. Constant-time compares the Bearer token
|
|
18
19
|
* against `secret`. Checked BEFORE the Clerk gate in the server; a mismatch
|
|
19
20
|
* falls through so a co-configured Clerk provider can still admit the request.
|
|
21
|
+
*
|
|
22
|
+
* `toolkits` binds the token to named toolkits (§16): one `bearerToken(...)` per
|
|
23
|
+
* team credential, each naming the view that team may open. Omit it and the
|
|
24
|
+
* token stays unbound — every declared toolkit plus the full registry.
|
|
20
25
|
*/
|
|
21
26
|
export function bearerToken(secret, options = {}) {
|
|
22
27
|
const secretBytes = encoder.encode(secret);
|
|
28
|
+
const toolkitBinding = resolveToolkitBinding(options.subjectId
|
|
29
|
+
? `bearerToken (subjectId "${options.subjectId}")`
|
|
30
|
+
: "bearerToken", options);
|
|
31
|
+
if (toolkitBinding && !options.subjectId) {
|
|
32
|
+
// A bound token stands for one team, and both surfaces that report on it —
|
|
33
|
+
// the 403 refusal log (§16) and activity events (§15) — can only say
|
|
34
|
+
// "bearer" without a subjectId. With several bound tokens that makes an
|
|
35
|
+
// operator unable to tell which credential was refused, or whose call
|
|
36
|
+
// succeeded. console.warn (as the Clerk adapter does) because an adapter is
|
|
37
|
+
// constructed before `createConnecta` has a logger to hand it.
|
|
38
|
+
console.warn("[connecta] bearerToken is bound to toolkits " +
|
|
39
|
+
`(${toolkitBinding.toolkits.join(", ") || "unscoped only"}) but has no ` +
|
|
40
|
+
"subjectId: refusal logs and activity events cannot say which " +
|
|
41
|
+
"credential they came from. Pass { subjectId: \"<team>\" }.");
|
|
42
|
+
}
|
|
23
43
|
return {
|
|
24
44
|
kind: "bearer",
|
|
45
|
+
...(toolkitBinding ? { toolkitBinding } : {}),
|
|
25
46
|
authorize(request) {
|
|
26
47
|
const header = request.headers.get("authorization") ?? "";
|
|
27
48
|
const match = /^Bearer\s+(.+)$/i.exec(header);
|
package/dist/auth/bearer.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bearer.js","sourceRoot":"","sources":["../../src/auth/bearer.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"bearer.js","sourceRoot":"","sources":["../../src/auth/bearer.ts"],"names":[],"mappings":"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"}
|
package/dist/auth/clerk.d.ts
CHANGED
|
@@ -1,20 +1,45 @@
|
|
|
1
1
|
import { createClerkClient } from "@clerk/backend";
|
|
2
|
+
import { type ToolkitBindingOptions } from "../toolkits.js";
|
|
2
3
|
import type { InboundAuth } from "../types.js";
|
|
3
4
|
type ClerkClient = ReturnType<typeof createClerkClient>;
|
|
4
|
-
export interface ClerkAuthOptions {
|
|
5
|
+
export interface ClerkAuthOptions extends ToolkitBindingOptions {
|
|
5
6
|
publishableKey: string;
|
|
6
7
|
secretKey: string;
|
|
7
8
|
/** Public base URL of this deployment. Defaults to the request origin. */
|
|
8
9
|
publicUrl?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Email domains this deployment admits, e.g. `["acme.com"]`. An
|
|
12
|
+
* authenticated user whose verified primary email is not on one of them is
|
|
13
|
+
* rejected exactly like a `gate` rejection. Matching is exact on the whole
|
|
14
|
+
* domain and case-insensitive: `acme.com` admits neither `evil-acme.com` nor
|
|
15
|
+
* `mail.acme.com` — spell a subdomain out to allow it. Entries must be ASCII
|
|
16
|
+
* (punycode for an internationalized domain) and are validated at
|
|
17
|
+
* construction. Absent ⇒ every authenticated user passes this check, as
|
|
18
|
+
* before the option existed. Governs Clerk sign-in only: a co-configured
|
|
19
|
+
* `bearerToken` has no email to read and is admitted without a domain check.
|
|
20
|
+
*/
|
|
21
|
+
allowedDomains?: readonly string[];
|
|
9
22
|
/** Optional allow-list hook. Return false to reject an authenticated user. */
|
|
10
23
|
gate?: (userId: string, clerk: ClerkClient) => boolean | Promise<boolean>;
|
|
11
24
|
/** Advertised scopes in protected-resource metadata. */
|
|
12
25
|
scopes?: string[];
|
|
13
|
-
/** Optional hosted Account Portal sign-in URL for `/ui`. */
|
|
26
|
+
/** Optional hosted Account Portal sign-in URL for `/ui`. Absolute https only. */
|
|
14
27
|
signInUrl?: string;
|
|
15
|
-
/** Optional hosted Account Portal sign-up URL for `/ui`. */
|
|
28
|
+
/** Optional hosted Account Portal sign-up URL for `/ui`. Absolute https only. */
|
|
16
29
|
signUpUrl?: string;
|
|
17
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Clerk inbound auth.
|
|
33
|
+
*
|
|
34
|
+
* `allowedDomains` and `gate` decide WHO is admitted (both must pass);
|
|
35
|
+
* `toolkits` decides WHICH view the admitted user gets.
|
|
36
|
+
*
|
|
37
|
+
* `toolkits` binds every user this provider admits to those toolkits (§16). For
|
|
38
|
+
* a per-team split, configure one `clerkAuth(...)` per team — the same keys, a
|
|
39
|
+
* `gate` naming that team's users, and that team's `toolkits`. The server tries
|
|
40
|
+
* providers in order and the first that admits the user supplies the binding, so
|
|
41
|
+
* a user one gate rejects falls through to the next.
|
|
42
|
+
*/
|
|
18
43
|
export declare function clerkAuth(opts: ClerkAuthOptions): InboundAuth;
|
|
19
44
|
export {};
|
|
20
45
|
//# sourceMappingURL=clerk.d.ts.map
|
package/dist/auth/clerk.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"clerk.d.ts","sourceRoot":"","sources":["../../src/auth/clerk.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,aAAa,CAAC;AAE3D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAExD,MAAM,WAAW,
|
|
1
|
+
{"version":3,"file":"clerk.d.ts","sourceRoot":"","sources":["../../src/auth/clerk.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,aAAa,CAAC;AAE3D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAExD,MAAM,WAAW,gBAAiB,SAAQ,qBAAqB;IAC7D,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;OAUG;IACH,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,8EAA8E;IAC9E,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1E,wDAAwD;IACxD,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA0ID;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,WAAW,CA0O7D"}
|