loopctl-mcp-server 2.34.0 → 2.36.0

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.
Files changed (3) hide show
  1. package/README.md +15 -5
  2. package/index.js +292 -0
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  MCP (Model Context Protocol) server for [loopctl](https://loopctl.com) -- structural trust for AI development loops.
4
4
 
5
- Wraps the loopctl REST API into 72 typed MCP tools so AI coding agents (Claude Code, etc.) can interact with loopctl without writing curl commands.
5
+ Wraps the loopctl REST API into 73 typed MCP tools so AI coding agents (Claude Code, etc.) can interact with loopctl without writing curl commands.
6
6
 
7
7
  ## Installation
8
8
 
@@ -84,9 +84,18 @@ per-operation model overrides (`extraction_model`, `classification_model`,
84
84
 
85
85
  ### The smooth path (once, at onboarding)
86
86
 
87
- 1. **Sign up** and obtain your keys. Tenant signup is anchored to a hardware
88
- authenticator (WebAuthn) the one human touch. Signup mints your **user-role**
89
- API key. Also grab your **agent** and **orchestrator** keys for day-to-day work.
87
+ 1. **Sign up** and obtain your keys. loopctl has two trust tiers (US-26.7.1):
88
+ - **Human-anchored (unlocks work-breakdown / chain-of-custody too):** visit
89
+ `https://loopctl.com/signup` and enroll a hardware authenticator (WebAuthn)
90
+ the one human touch. This mints your **user-role** API key; also grab your
91
+ **agent** and **orchestrator** keys for day-to-day work.
92
+ - **Agent-rooted (KB-tier only, fully automated):** call the `signup` MCP tool
93
+ (or `POST /api/v1/signup` directly) with `name`/`slug`/`email` — no human, no
94
+ hardware key. This mints a one-time `role: user` API key with the FULL
95
+ knowledge-wiki surface (ingest/search/curate, BYO LLM config, agent
96
+ registration), but it **cannot** perform work-breakdown / chain-of-custody
97
+ operations. The `raw_key` in the response is shown ONCE — save it
98
+ immediately (it cannot be retrieved again).
90
99
  2. **Set the env** in your `.mcp.json` (see [Configuration](#configuration)):
91
100
  `LOOPCTL_SERVER`, `LOOPCTL_USER_KEY` (needed for this step), plus `LOOPCTL_AGENT_KEY`
92
101
  / `LOOPCTL_ORCH_KEY`.
@@ -112,7 +121,7 @@ REST endpoint (`PATCH /api/v1/tenants/me/llm-config`), and the docs — so you (
112
121
  autonomous agent) can self-remediate without a human. Full agent-tenant lifecycle:
113
122
  [`docs/onboarding-agent-tenant.md`](../docs/onboarding-agent-tenant.md).
114
123
 
115
- ## Tools (72)
124
+ ## Tools (73)
116
125
 
117
126
  ### Project Tools
118
127
 
@@ -258,6 +267,7 @@ Key distribution for the dispatch pattern (Epic 26): per-dispatch ephemeral keys
258
267
 
259
268
  | Tool | Description |
260
269
  |---|---|
270
+ | `signup` | **US-26.7.1.** Create a NEW **agent-rooted (KB-tier)** tenant and mint its one-time root API key — entirely through this call, no human operator, no hardware authenticator, no existing API key required. The tenant gets the FULL knowledge-wiki surface but **cannot** perform work-breakdown / chain-of-custody operations (those require a separate human-anchored tenant via the WebAuthn ceremony at `https://loopctl.com/signup`). Rate-limited per client IP (<= 5/hour). The `raw_key` is shown ONCE — save it immediately (e.g. as `LOOPCTL_USER_KEY`). Required: `name`, `slug`, `email`. |
261
271
  | `dispatch` | Mint an ephemeral, scoped api_key for a sub-agent dispatch, carrying its lineage path. The `raw_key` is returned ONCE — pass it to the sub-agent's launch args, never store it in env vars; it expires after `expires_in_seconds` (default 3600, max 14400). Required: `role` (`agent`/`orchestrator`), `agent_id`. Optional: `parent_dispatch_id`, `story_id`. |
262
272
  | `recover_cap` | Re-mint a capability token for a story you're assigned to, after a session crash lost your cap. Required: `story_id`. Optional: `cap_type` (`start_cap`/`report_cap`, default `start_cap`), `lineage`. |
263
273
  | `get_sth` | Get the latest Signed Tree Head for a tenant's tamper-evident audit chain. Public — no auth required. Required: `tenant_id`. |
package/index.js CHANGED
@@ -189,6 +189,75 @@ async function apiCall(method, path, body, keyOverride, { exactKey = false } = {
189
189
  return responseBody;
190
190
  }
191
191
 
192
+ /**
193
+ * Unauthenticated request helper for genuinely public endpoints (currently
194
+ * only POST /api/v1/signup). No Authorization header is sent — the public
195
+ * signup POST carries no witness header either (US-26.7.1), so this
196
+ * bypasses `witnessClientFor` entirely and calls `fetch` directly. Response
197
+ * parsing mirrors `apiCall`'s so callers can pass the result straight to
198
+ * `toContent`/`toContentCompact`.
199
+ */
200
+ async function publicApiCall(method, path, body) {
201
+ const url = `${getBaseUrl()}${path}`;
202
+ const headers = { "Content-Type": "application/json", Accept: "application/json" };
203
+ const serializedBody =
204
+ body !== undefined && body !== null ? JSON.stringify(body) : undefined;
205
+
206
+ let response;
207
+ try {
208
+ response = await fetch(url, {
209
+ method,
210
+ headers,
211
+ body: serializedBody,
212
+ signal: AbortSignal.timeout(30_000),
213
+ });
214
+ } catch (err) {
215
+ if (err.name === "TimeoutError") {
216
+ return { error: true, status: 0, body: "Request timed out after 30s" };
217
+ }
218
+ const cause = err.cause?.message ? ` (${err.cause.message})` : "";
219
+ return { error: true, status: 0, body: `Network error: ${err.message}${cause}` };
220
+ }
221
+
222
+ if (response.status === 204) {
223
+ return { ok: true };
224
+ }
225
+
226
+ let responseBody;
227
+ const contentType = response.headers.get("content-type") || "";
228
+ if (contentType.includes("application/json")) {
229
+ const raw = await response.text();
230
+ const outcome = parseJsonResponseBody(raw, response.status);
231
+ if (outcome.error) {
232
+ return outcome;
233
+ }
234
+ responseBody = outcome.parsed;
235
+ } else {
236
+ const text = await response.text();
237
+ try {
238
+ responseBody = JSON.parse(text);
239
+ } catch {
240
+ responseBody = text;
241
+ }
242
+ }
243
+
244
+ if (!response.ok) {
245
+ let errorBody = responseBody;
246
+ if (typeof errorBody === "string" && errorBody.length > 500) {
247
+ errorBody = errorBody
248
+ .replace(/<[^>]+>/g, " ")
249
+ .replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
250
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ")
251
+ .replace(/\s+/g, " ")
252
+ .trim()
253
+ .slice(0, 500) + "... (truncated)";
254
+ }
255
+ return { error: true, status: response.status, body: errorBody };
256
+ }
257
+
258
+ return responseBody;
259
+ }
260
+
192
261
  function toContent(result) {
193
262
  const isErr = result && result.error === true;
194
263
  return {
@@ -1611,6 +1680,81 @@ async function createDispatch({
1611
1680
  return toContent(result);
1612
1681
  }
1613
1682
 
1683
+ // US-26.7.1: public, agent-rooted (KB-tier) self-signup. No API key required —
1684
+ // this creates the tenant AND the key. The resulting tenant is KB-tier only
1685
+ // (knowledge ingest/search/curate on the caller's own BYO LLM keys); the
1686
+ // work-breakdown / chain-of-custody surface requires a separate,
1687
+ // human-anchored WebAuthn signup ceremony at https://loopctl.com/signup.
1688
+ async function signup({ name, slug, email }) {
1689
+ const result = await publicApiCall("POST", "/api/v1/signup", { name, slug, email });
1690
+ return toContent(result);
1691
+ }
1692
+
1693
+ // US-26.7.2: opt-in WebAuthn trust-tier upgrade ceremony (agent_rooted ->
1694
+ // human_anchored) + authenticator revocation. All four require the EXACT
1695
+ // user-role key (LOOPCTL_USER_KEY) + ownership of `tenant_id` — mirroring
1696
+ // set_llm_config's exactKey:true pattern, since these mutate the tenant's
1697
+ // root of trust. NOT headless: completing enrollment/revocation requires an
1698
+ // INTERACTIVE WebAuthn client (a browser or a native FIDO2 library) with a
1699
+ // human present to touch the hardware authenticator — an agent alone cannot
1700
+ // produce a valid attestation or assertion, by design (see
1701
+ // docs/chain-of-custody-v2.md §9).
1702
+ async function requestAuthenticatorChallenge({ tenant_id }) {
1703
+ const result = await apiCall(
1704
+ "POST",
1705
+ `/api/v1/tenants/${tenant_id}/authenticators/challenge`,
1706
+ {},
1707
+ process.env.LOOPCTL_USER_KEY,
1708
+ { exactKey: true },
1709
+ );
1710
+ return toContent(result);
1711
+ }
1712
+
1713
+ async function enrollAuthenticator({
1714
+ tenant_id,
1715
+ challenge_id,
1716
+ attestation_object,
1717
+ client_data_json,
1718
+ credential_id,
1719
+ friendly_name,
1720
+ reauth_assertion,
1721
+ }) {
1722
+ const body = { challenge_id, attestation_object, client_data_json, credential_id };
1723
+ if (friendly_name != null) body.friendly_name = friendly_name;
1724
+ if (reauth_assertion != null) body.reauth_assertion = reauth_assertion;
1725
+
1726
+ const result = await apiCall(
1727
+ "POST",
1728
+ `/api/v1/tenants/${tenant_id}/authenticators`,
1729
+ body,
1730
+ process.env.LOOPCTL_USER_KEY,
1731
+ { exactKey: true },
1732
+ );
1733
+ return toContent(result);
1734
+ }
1735
+
1736
+ async function requestAuthenticatorRevokeChallenge({ tenant_id }) {
1737
+ const result = await apiCall(
1738
+ "POST",
1739
+ `/api/v1/tenants/${tenant_id}/authenticators/revoke-challenge`,
1740
+ {},
1741
+ process.env.LOOPCTL_USER_KEY,
1742
+ { exactKey: true },
1743
+ );
1744
+ return toContent(result);
1745
+ }
1746
+
1747
+ async function revokeAuthenticator({ tenant_id, authenticator_id, webauthn_assertion }) {
1748
+ const result = await apiCall(
1749
+ "DELETE",
1750
+ `/api/v1/tenants/${tenant_id}/authenticators/${authenticator_id}`,
1751
+ { webauthn_assertion },
1752
+ process.env.LOOPCTL_USER_KEY,
1753
+ { exactKey: true },
1754
+ );
1755
+ return toContent(result);
1756
+ }
1757
+
1614
1758
  // US-26: Signed Tree Head retrieval
1615
1759
  async function getSth({ tenant_id }) {
1616
1760
  const result = await apiCall("GET", `/api/v1/audit/sth/${tenant_id}`);
@@ -3788,6 +3932,139 @@ const TOOLS = [
3788
3932
  },
3789
3933
 
3790
3934
  // Chain of Custody v2 tools
3935
+ {
3936
+ name: "signup",
3937
+ description:
3938
+ "Create a NEW agent-rooted (KB-tier) tenant and mint its one-time root API key — " +
3939
+ "entirely through this call, no human operator and no hardware authenticator required. " +
3940
+ "Public — no existing API key needed to call this tool. The returned tenant can " +
3941
+ "immediately use the FULL knowledge-wiki surface (ingest/search/context/curate, BYO " +
3942
+ "LLM key config, agent registration) but CANNOT perform work-breakdown / " +
3943
+ "chain-of-custody operations (create projects/epics/stories, claim/verify/report, " +
3944
+ "dispatch, etc.) — those require a separate, human-anchored tenant created via the " +
3945
+ "WebAuthn ceremony at https://loopctl.com/signup. Rate-limited per client IP " +
3946
+ "(<= 5 signups/hour). The raw_key in the response is shown ONCE — save it immediately " +
3947
+ "(e.g. as LOOPCTL_USER_KEY) since it cannot be retrieved again. Next steps after " +
3948
+ "signup: configure your BYO LLM keys with set_llm_config, then register an agent " +
3949
+ "identity, then ingest/search the wiki.",
3950
+ inputSchema: {
3951
+ type: "object",
3952
+ properties: {
3953
+ name: { type: "string", description: "Tenant display name.", maxLength: 120 },
3954
+ slug: { type: "string", description: "URL-safe unique tenant slug.", maxLength: 64 },
3955
+ email: { type: "string", description: "Contact email for the tenant." },
3956
+ },
3957
+ required: ["name", "slug", "email"],
3958
+ },
3959
+ },
3960
+ {
3961
+ name: "request_authenticator_challenge",
3962
+ description:
3963
+ "Step 1 of the opt-in WebAuthn trust-tier upgrade ceremony (US-26.7.2): issues a " +
3964
+ "registration challenge for enrolling a hardware authenticator against an EXISTING " +
3965
+ "agent_rooted (KB-tier) tenant, promoting it to human_anchored on success. " +
3966
+ "IMPORTANT: completing this ceremony requires an INTERACTIVE WebAuthn client " +
3967
+ "(a browser calling navigator.credentials.create(), or a native FIDO2 library) " +
3968
+ "with a HUMAN present to physically touch the authenticator — an agent alone " +
3969
+ "cannot produce a valid attestation, by design. Use this tool to fetch the " +
3970
+ "challenge/rp/user/pubKeyCredParams payload, drive the WebAuthn ceremony in your " +
3971
+ "interactive client, then call enroll_authenticator with the result. If the " +
3972
+ "tenant is already human_anchored, the response includes reauth_required: true " +
3973
+ "plus a reauth_challenge — enroll_authenticator will need a fresh assertion from " +
3974
+ "an EXISTING authenticator too (see enroll_authenticator's description). Requires " +
3975
+ "LOOPCTL_USER_KEY (user role) bound to tenant_id.",
3976
+ inputSchema: {
3977
+ type: "object",
3978
+ properties: {
3979
+ tenant_id: { type: "string", description: "Tenant UUID to enroll an authenticator for." },
3980
+ },
3981
+ required: ["tenant_id"],
3982
+ },
3983
+ },
3984
+ {
3985
+ name: "enroll_authenticator",
3986
+ description:
3987
+ "Step 2 of the opt-in WebAuthn trust-tier upgrade ceremony: completes enrollment " +
3988
+ "with the attestation produced by navigator.credentials.create() against the " +
3989
+ "challenge from request_authenticator_challenge. On a tenant's FIRST enrollment " +
3990
+ "(zero prior authenticators) this call FLIPS the tenant from agent_rooted to " +
3991
+ "human_anchored, unlocking the work-breakdown / chain-of-custody surface " +
3992
+ "(projects, stories, dispatch, ...). On a SUBSEQUENT (backup) enrollment for an " +
3993
+ "already human_anchored tenant, `reauth_assertion` (a fresh WebAuthn assertion " +
3994
+ "from an EXISTING enrolled authenticator, from the challenge's reauth_challenge) " +
3995
+ "is REQUIRED — omitting it when required returns 401 reauth_required. All " +
3996
+ "binary fields are base64url encoded. Requires LOOPCTL_USER_KEY (user role) " +
3997
+ "bound to tenant_id. Cannot be completed by an agent alone — see " +
3998
+ "request_authenticator_challenge.",
3999
+ inputSchema: {
4000
+ type: "object",
4001
+ properties: {
4002
+ tenant_id: { type: "string", description: "Tenant UUID." },
4003
+ challenge_id: {
4004
+ type: "string",
4005
+ description: "challenge_id from request_authenticator_challenge.",
4006
+ },
4007
+ attestation_object: { type: "string", description: "Base64url attestation object." },
4008
+ client_data_json: { type: "string", description: "Base64url raw client data JSON." },
4009
+ credential_id: { type: "string", description: "Base64url credential id." },
4010
+ friendly_name: {
4011
+ type: "string",
4012
+ description: "Operator-supplied label (1..120 chars, default \"Authenticator\").",
4013
+ },
4014
+ reauth_assertion: {
4015
+ type: "object",
4016
+ description:
4017
+ "Required ONLY when enrolling a backup authenticator on an already " +
4018
+ "human_anchored tenant: a fresh assertion (challenge_id, credential_id, " +
4019
+ "authenticator_data, signature, client_data_json — all base64url) from an " +
4020
+ "EXISTING enrolled authenticator, bound to the reauth_challenge returned by " +
4021
+ "request_authenticator_challenge.",
4022
+ },
4023
+ },
4024
+ required: ["tenant_id", "challenge_id", "attestation_object", "client_data_json", "credential_id"],
4025
+ },
4026
+ },
4027
+ {
4028
+ name: "request_authenticator_revoke_challenge",
4029
+ description:
4030
+ "Issues a fresh-assertion challenge to authorize revoking one of a tenant's " +
4031
+ "enrolled WebAuthn authenticators. Requires an INTERACTIVE WebAuthn client + " +
4032
+ "human touch to produce the assertion revoke_authenticator needs — an agent " +
4033
+ "alone cannot authorize a revocation. Requires LOOPCTL_USER_KEY (user role) " +
4034
+ "bound to tenant_id.",
4035
+ inputSchema: {
4036
+ type: "object",
4037
+ properties: {
4038
+ tenant_id: { type: "string", description: "Tenant UUID." },
4039
+ },
4040
+ required: ["tenant_id"],
4041
+ },
4042
+ },
4043
+ {
4044
+ name: "revoke_authenticator",
4045
+ description:
4046
+ "Revokes an enrolled authenticator using the assertion from " +
4047
+ "request_authenticator_revoke_challenge (navigator.credentials.get() against an " +
4048
+ "EXISTING authenticator — human touch required). Refuses (409 last_authenticator) " +
4049
+ "to remove a human_anchored tenant's LAST authenticator — there is no " +
4050
+ "auto-downgrade; losing every authenticator is a fatal, human-recovery-only event " +
4051
+ "(docs/chain-of-custody-v2.md §9.3). Requires LOOPCTL_USER_KEY (user role) bound " +
4052
+ "to tenant_id.",
4053
+ inputSchema: {
4054
+ type: "object",
4055
+ properties: {
4056
+ tenant_id: { type: "string", description: "Tenant UUID." },
4057
+ authenticator_id: { type: "string", description: "UUID of the authenticator to revoke." },
4058
+ webauthn_assertion: {
4059
+ type: "object",
4060
+ description:
4061
+ "The assertion (challenge_id, credential_id, authenticator_data, signature, " +
4062
+ "client_data_json — all base64url) bound to the revoke-challenge.",
4063
+ },
4064
+ },
4065
+ required: ["tenant_id", "authenticator_id", "webauthn_assertion"],
4066
+ },
4067
+ },
3791
4068
  {
3792
4069
  name: "get_sth",
3793
4070
  description: "Get the latest Signed Tree Head for a tenant's audit chain. Public — no auth required.",
@@ -4075,6 +4352,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4075
4352
  case "dispatch":
4076
4353
  return await createDispatch(args);
4077
4354
 
4355
+ case "signup":
4356
+ return await signup(args);
4357
+
4358
+ case "request_authenticator_challenge":
4359
+ return await requestAuthenticatorChallenge(args);
4360
+
4361
+ case "enroll_authenticator":
4362
+ return await enrollAuthenticator(args);
4363
+
4364
+ case "request_authenticator_revoke_challenge":
4365
+ return await requestAuthenticatorRevokeChallenge(args);
4366
+
4367
+ case "revoke_authenticator":
4368
+ return await revokeAuthenticator(args);
4369
+
4078
4370
  case "get_sth":
4079
4371
  return await getSth(args);
4080
4372
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.34.0",
3
+ "version": "2.36.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",