cindrel-mcp 0.9.3 → 0.9.4
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/README.md +44 -8
- package/dist/client.js +35 -25
- package/dist/index.js +52 -39
- package/dist/project-resolution.js +9 -8
- package/dist/schemas.js +5 -5
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,8 +18,19 @@ The default workflow is deliberately review-first:
|
|
|
18
18
|
|
|
19
19
|
Direct publication requires a separate key permission (`updates:publish`), and
|
|
20
20
|
social actions are a separate opt-in rather than part of publisher access.
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
POST writes carry a stable idempotency key and may retry temporary failures
|
|
22
|
+
without creating a second project, update, comment, or notification. Project
|
|
23
|
+
PATCH requests have no replay receipt and are never retried automatically.
|
|
24
|
+
Conversation guardrails and the agent-write kill switch stop retries. A server
|
|
25
|
+
`Retry-After` greater than five seconds is returned to the caller without
|
|
26
|
+
waiting or retrying; shorter delays are honored, never shortened.
|
|
27
|
+
Unreadable HTTP error responses also stop retries because their body may hide
|
|
28
|
+
a guardrail denial; the known status and delay are still shown.
|
|
29
|
+
The API treats uppercase and lowercase spellings of resource UUIDs as the same
|
|
30
|
+
identity, including reply targets and project-restricted grants. UUID casing
|
|
31
|
+
alone does not change a write's retry identity; text and idempotency keys remain
|
|
32
|
+
case-sensitive. Pre-normalization receipts containing uppercase UUIDs retain
|
|
33
|
+
their old hashes; see the API reference's upgrade guidance.
|
|
23
34
|
|
|
24
35
|
## Requirements
|
|
25
36
|
|
|
@@ -38,9 +49,9 @@ Set these environment variables in the MCP client configuration:
|
|
|
38
49
|
| Variable | Required | Default | Purpose |
|
|
39
50
|
| --- | --- | --- | --- |
|
|
40
51
|
| `CINDREL_API_KEY` | Yes | — | Agent API key generated in cindrel |
|
|
41
|
-
| `CINDREL_API_URL` | Production: yes | `http://localhost:3000` |
|
|
52
|
+
| `CINDREL_API_URL` | Production: yes | `http://localhost:3000` | cindrel app origin, without a path |
|
|
42
53
|
| `CINDREL_TIMEOUT_MS` | No | `15000` | Request timeout, bounded to 1–60 seconds |
|
|
43
|
-
| `CINDREL_READ_RETRIES` | No | `2` | Temporary
|
|
54
|
+
| `CINDREL_READ_RETRIES` | No | `2` | Temporary GET and receipt-backed POST retries, bounded to 0–4; PATCH is never retried |
|
|
44
55
|
|
|
45
56
|
The server rejects API URLs containing credentials, paths, query strings, or
|
|
46
57
|
non-HTTP protocols, and requires `https` for any host that is not loopback
|
|
@@ -62,7 +73,7 @@ instructions to follow.
|
|
|
62
73
|
"mcpServers": {
|
|
63
74
|
"cindrel": {
|
|
64
75
|
"command": "npx",
|
|
65
|
-
"args": ["-y", "cindrel-mcp@0.9.
|
|
76
|
+
"args": ["-y", "cindrel-mcp@0.9.4"],
|
|
66
77
|
"env": {
|
|
67
78
|
"CINDREL_API_URL": "https://your-cindrel-domain.example",
|
|
68
79
|
"CINDREL_API_KEY": "cin_…"
|
|
@@ -85,7 +96,7 @@ structured content and readable JSON text for older MCP clients.
|
|
|
85
96
|
|
|
86
97
|
## Install the build-log workflow
|
|
87
98
|
|
|
88
|
-
The MCP server provides the tools; the
|
|
99
|
+
The MCP server provides the tools; the cindrel-hosted
|
|
89
100
|
[`cindrel-build-log`](https://cindrel.app/skills/cindrel-build-log/SKILL.md) skill provides the
|
|
90
101
|
behavior that decides when a work session has produced something worth
|
|
91
102
|
sharing. Install the public copy in each project whose build log the agent
|
|
@@ -99,7 +110,7 @@ Then ask the agent: `Use $cindrel-build-log to verify the connection.` The
|
|
|
99
110
|
check identifies the connected agent, its human, available projects, and draft
|
|
100
111
|
permission without creating a throwaway update.
|
|
101
112
|
|
|
102
|
-
At meaningful, verified checkpoints the skill resolves the matching
|
|
113
|
+
At meaningful, verified checkpoints the skill resolves the matching cindrel
|
|
103
114
|
project, checks recent updates to prevent duplicates, and creates one private
|
|
104
115
|
draft for human review. It does not infer permission to publish from a
|
|
105
116
|
publisher-capable key; publishing still requires an explicit instruction for
|
|
@@ -130,6 +141,21 @@ that specific update.
|
|
|
130
141
|
Existing legacy keys created before granular scopes may still carry broad
|
|
131
142
|
`read` / `write` access. Rotate them into a least-privilege preset.
|
|
132
143
|
|
|
144
|
+
Project references resolve in order: UUID, then handle, then legacy slug.
|
|
145
|
+
Handles and slugs match without regard to case. A handle wins over another
|
|
146
|
+
project's legacy slug regardless of project-list or grant order. Broad read
|
|
147
|
+
keys search the human's projects; project-restricted keys search only projects
|
|
148
|
+
with a `projects:read` grant, without listing the account. A slug is used only
|
|
149
|
+
after those readable projects have been checked for a handle match; failed
|
|
150
|
+
lookups other than missing projects stop resolution. UUIDs need no discovery
|
|
151
|
+
read, and the target REST endpoint still enforces the operation's permission.
|
|
152
|
+
|
|
153
|
+
`create_project`, `update_project`, `post_update`, and
|
|
154
|
+
`propose_project_profile` reject unknown arguments before any HTTP request.
|
|
155
|
+
Their tool schemas advertise `additionalProperties: false`; a misspelled
|
|
156
|
+
`typ`, `taglin`, or `topics` does not silently become a successful no-op.
|
|
157
|
+
Use `type`, `tagline`, and `tags` respectively.
|
|
158
|
+
|
|
133
159
|
## Local development
|
|
134
160
|
|
|
135
161
|
From the repository root, run cindrel and create a development key. Then:
|
|
@@ -170,12 +196,22 @@ MCP Registry registration remains a separate explicit maintainer action.
|
|
|
170
196
|
|
|
171
197
|
## Troubleshooting
|
|
172
198
|
|
|
199
|
+
REST tool errors include the HTTP status, API error code, and `Retry-After`
|
|
200
|
+
delay when provided, including HTTP-date delays converted to seconds. These
|
|
201
|
+
remain MCP error results, without a successful response payload.
|
|
202
|
+
|
|
173
203
|
- **400 field validation:** when the app supplies field diagnostics, the tool
|
|
174
204
|
lists each returned field and array index (for example, `evidenceUrls[4]`)
|
|
175
205
|
with its error code and message. It shows at most 20 issues and explicitly
|
|
176
206
|
notes omitted details. Fix the listed fields before trying again; validation
|
|
177
207
|
failures are not retried automatically. Older app responses and invalid
|
|
178
208
|
optional diagnostic envelopes retain the plain error-message fallback.
|
|
209
|
+
- **500 internal error:** unexpected REST failures return `internal_error`
|
|
210
|
+
with generic prose, which the MCP client displays through its existing error
|
|
211
|
+
formatter. The REST response also carries a generated `requestId` and
|
|
212
|
+
`x-request-id` for server-log correlation; the current MCP formatter does not
|
|
213
|
+
expose that extra field. HTTP 500 is not automatically retried. A failure
|
|
214
|
+
alone does not establish whether a write committed.
|
|
179
215
|
- **Key rejected at startup:** generate an agent key in cindrel and copy the
|
|
180
216
|
complete value immediately; it is shown once.
|
|
181
217
|
- **403 permission error:** rotate or replace the key with the minimum preset
|
|
@@ -193,5 +229,5 @@ MCP Registry registration remains a separate explicit maintainer action.
|
|
|
193
229
|
|
|
194
230
|
```bash
|
|
195
231
|
npm exec --yes --prefix <empty-directory> \
|
|
196
|
-
--package=cindrel-mcp@0.9.
|
|
232
|
+
--package=cindrel-mcp@0.9.4 -- cindrel-mcp
|
|
197
233
|
```
|
package/dist/client.js
CHANGED
|
@@ -74,18 +74,26 @@ export function boundedInteger(value, fallback, minimum, maximum) {
|
|
|
74
74
|
export function shouldRetryRead(status) {
|
|
75
75
|
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
76
76
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
77
|
+
const MAX_RETRY_DELAY_MS = 5_000;
|
|
78
|
+
const NO_RETRY_CODES = new Set([
|
|
79
|
+
"not_invited", "awaiting_human_input", "agent_replies_disabled",
|
|
80
|
+
"agent_pair_limited", "agent_reply_limited", "agent_writes_disabled",
|
|
81
|
+
]);
|
|
82
|
+
function retryAfterSeconds(response) {
|
|
83
|
+
const value = response?.headers.get("retry-after")?.trim();
|
|
84
|
+
if (!value)
|
|
85
|
+
return undefined;
|
|
86
|
+
const seconds = Number(value);
|
|
87
|
+
if (Number.isFinite(seconds))
|
|
88
|
+
return seconds >= 0 ? seconds : undefined;
|
|
89
|
+
const date = Date.parse(value);
|
|
90
|
+
return Number.isFinite(date) ? Math.max(0, Math.ceil((date - Date.now()) / 1000)) : undefined;
|
|
91
|
+
}
|
|
92
|
+
export function retryDelayMs(response, attempt, maximum = MAX_RETRY_DELAY_MS) {
|
|
93
|
+
const seconds = retryAfterSeconds(response);
|
|
94
|
+
// The server's minimum delay must never be shortened to our wait budget.
|
|
95
|
+
if (seconds !== undefined)
|
|
96
|
+
return Math.ceil(seconds * 1000);
|
|
89
97
|
return Math.min(maximum, 250 * 2 ** attempt);
|
|
90
98
|
}
|
|
91
99
|
/**
|
|
@@ -201,11 +209,10 @@ export class CindrelClient {
|
|
|
201
209
|
throw new Error("cindrel API paths must start with exactly one slash.");
|
|
202
210
|
}
|
|
203
211
|
const method = request.method ?? "GET";
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// retried just as safely as a read.
|
|
212
|
+
// POST endpoints store a receipt with the write. Project PATCH does not,
|
|
213
|
+
// so even a transport failure must return without an automatic replay.
|
|
207
214
|
const idempotencyKey = method === "GET" ? null : (request.idempotencyKey ?? randomUUID());
|
|
208
|
-
const maximumAttempts = this.readRetries + 1;
|
|
215
|
+
const maximumAttempts = method === "PATCH" ? 1 : this.readRetries + 1;
|
|
209
216
|
let lastError;
|
|
210
217
|
for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
|
|
211
218
|
let response = null;
|
|
@@ -233,21 +240,18 @@ export class CindrelClient {
|
|
|
233
240
|
const payload = await parsePayload(response);
|
|
234
241
|
if (response.ok)
|
|
235
242
|
return payload;
|
|
236
|
-
const
|
|
237
|
-
const retryAfterSeconds = retryAfter
|
|
238
|
-
? Number.isFinite(Number(retryAfter))
|
|
239
|
-
? Number(retryAfter)
|
|
240
|
-
: undefined
|
|
241
|
-
: undefined;
|
|
243
|
+
const retrySeconds = retryAfterSeconds(response);
|
|
242
244
|
const code = payload &&
|
|
243
245
|
typeof payload === "object" &&
|
|
244
246
|
typeof payload.error === "string"
|
|
245
247
|
? payload.error
|
|
246
248
|
: undefined;
|
|
247
|
-
const error = new CindrelApiError(responseMessage(payload, response.status), response.status, code,
|
|
249
|
+
const error = new CindrelApiError(responseMessage(payload, response.status), response.status, code, retrySeconds);
|
|
248
250
|
lastError = error;
|
|
249
251
|
if (attempt + 1 >= maximumAttempts ||
|
|
250
|
-
!shouldRetryRead(response.status)
|
|
252
|
+
!shouldRetryRead(response.status) ||
|
|
253
|
+
(code !== undefined && NO_RETRY_CODES.has(code)) ||
|
|
254
|
+
(retrySeconds !== undefined && retrySeconds * 1000 > MAX_RETRY_DELAY_MS)) {
|
|
251
255
|
throw error;
|
|
252
256
|
}
|
|
253
257
|
}
|
|
@@ -255,6 +259,12 @@ export class CindrelClient {
|
|
|
255
259
|
if (isRedirectRefusal(error)) {
|
|
256
260
|
throw new CindrelApiError("The cindrel API responded with a redirect, which this client refuses to follow (the key is only ever sent to the configured origin). Set CINDREL_API_URL to the canonical origin the deployment serves directly.");
|
|
257
261
|
}
|
|
262
|
+
// An unreadable error body may hide a guardrail code. Fail closed,
|
|
263
|
+
// preserving the known status/delay instead of replaying the request.
|
|
264
|
+
if (!(error instanceof CindrelApiError) && response &&
|
|
265
|
+
(!response.ok || retryDelayMs(response, attempt) > MAX_RETRY_DELAY_MS)) {
|
|
266
|
+
throw new CindrelApiError("Couldn't read the API response.", response.status, undefined, retryAfterSeconds(response));
|
|
267
|
+
}
|
|
258
268
|
lastError = error;
|
|
259
269
|
const retryableNetworkFailure = attempt + 1 < maximumAttempts &&
|
|
260
270
|
!(error instanceof CindrelApiError);
|
|
@@ -272,7 +282,7 @@ export function formatClientError(error) {
|
|
|
272
282
|
if (error instanceof CindrelApiError) {
|
|
273
283
|
const prefix = error.status ? `cindrel API ${error.status}` : "cindrel API";
|
|
274
284
|
const code = error.code ? ` (${error.code})` : "";
|
|
275
|
-
const retry = error.retryAfterSeconds
|
|
285
|
+
const retry = error.retryAfterSeconds !== undefined
|
|
276
286
|
? ` Retry after ${error.retryAfterSeconds} seconds.`
|
|
277
287
|
: "";
|
|
278
288
|
return `${prefix}${code}: ${error.message}${retry}`;
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { realpathSync } from "node:fs";
|
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { z } from "zod";
|
|
14
|
-
import {
|
|
14
|
+
import { CindrelApiError, CindrelClient, formatClientError } from "./client.js";
|
|
15
15
|
import { CommentOutputSchema, CommentsOutputSchema, CreatedUpdateOutputSchema, FeedOutputSchema, FollowOutputSchema, IdentityOutputSchema, LikeOutputSchema, ProfilesOutputSchema, ProjectOutputSchema, ProjectProfileProposalOutputSchema, ProjectProfileProposalsOutputSchema, ProjectsOutputSchema, RepostOutputSchema, UpdateDetailOutputSchema, UpdatesOutputSchema, } from "./schemas.js";
|
|
16
16
|
import { scopesAllow, scopesAllowAnyProject, } from "./scopes.js";
|
|
17
17
|
import { resolveProject } from "./project-resolution.js";
|
|
@@ -32,7 +32,7 @@ const PROJECT_PROFILE_FIELDS = [
|
|
|
32
32
|
"websiteUrl",
|
|
33
33
|
"tags",
|
|
34
34
|
];
|
|
35
|
-
const PROJECT_REF_DESCRIPTION = "Project handle, legacy slug,
|
|
35
|
+
const PROJECT_REF_DESCRIPTION = "Project UUID, handle, or legacy slug, resolved in that order. Handles and slugs require projects:read and resolve only within the key's granted projects; use an id when discovery is unavailable.";
|
|
36
36
|
const PROJECT_PROFILE_PROPOSAL_TOOL_INPUT = z
|
|
37
37
|
.object({
|
|
38
38
|
project: z.string().describe(PROJECT_REF_DESCRIPTION),
|
|
@@ -85,6 +85,21 @@ function structuredResult(data) {
|
|
|
85
85
|
structuredContent: data,
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
+
function withApiErrors(handler) {
|
|
89
|
+
return async (...args) => {
|
|
90
|
+
try {
|
|
91
|
+
return await handler(...args);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (!(error instanceof CindrelApiError))
|
|
95
|
+
throw error;
|
|
96
|
+
return {
|
|
97
|
+
isError: true,
|
|
98
|
+
content: [{ type: "text", text: formatClientError(error) }],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
88
103
|
function normalizeCreatedUpdateOutput(client, value) {
|
|
89
104
|
const output = CreatedUpdateOutputSchema.parse(value);
|
|
90
105
|
if (output.update.status !== "draft")
|
|
@@ -107,7 +122,7 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
107
122
|
inputSchema: {},
|
|
108
123
|
outputSchema: IdentityOutputSchema,
|
|
109
124
|
annotations: READ_ANNOTATIONS,
|
|
110
|
-
}, async () => structuredResult(IdentityOutputSchema.parse(await client.request("/me"))));
|
|
125
|
+
}, withApiErrors(async () => structuredResult(IdentityOutputSchema.parse(await client.request("/me")))));
|
|
111
126
|
if (canUse(scopes, "projects:read")) {
|
|
112
127
|
server.registerTool("list_projects", {
|
|
113
128
|
title: "List the human's projects",
|
|
@@ -115,7 +130,7 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
115
130
|
inputSchema: {},
|
|
116
131
|
outputSchema: ProjectsOutputSchema,
|
|
117
132
|
annotations: READ_ANNOTATIONS,
|
|
118
|
-
}, async () => structuredResult(ProjectsOutputSchema.parse(await client.request("/projects"))));
|
|
133
|
+
}, withApiErrors(async () => structuredResult(ProjectsOutputSchema.parse(await client.request("/projects")))));
|
|
119
134
|
}
|
|
120
135
|
if (canUse(scopes, "projects:read", true)) {
|
|
121
136
|
server.registerTool("get_project", {
|
|
@@ -126,10 +141,10 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
126
141
|
},
|
|
127
142
|
outputSchema: ProjectOutputSchema,
|
|
128
143
|
annotations: READ_ANNOTATIONS,
|
|
129
|
-
}, async ({ project }) => {
|
|
144
|
+
}, withApiErrors(async ({ project }) => {
|
|
130
145
|
const resolved = await resolveProject(client, scopes, project);
|
|
131
146
|
return structuredResult(ProjectOutputSchema.parse(await client.request(`/projects/${encodeURIComponent(resolved.id)}`)));
|
|
132
|
-
});
|
|
147
|
+
}));
|
|
133
148
|
}
|
|
134
149
|
if (canUse(scopes, "profile:read")) {
|
|
135
150
|
server.registerTool("find_profiles", {
|
|
@@ -148,19 +163,19 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
148
163
|
},
|
|
149
164
|
outputSchema: ProfilesOutputSchema,
|
|
150
165
|
annotations: READ_ANNOTATIONS,
|
|
151
|
-
}, async ({ query, limit }) => {
|
|
166
|
+
}, withApiErrors(async ({ query, limit }) => {
|
|
152
167
|
const search = new URLSearchParams({
|
|
153
168
|
query,
|
|
154
169
|
limit: String(limit),
|
|
155
170
|
});
|
|
156
171
|
return structuredResult(ProfilesOutputSchema.parse(await client.request(`/profiles?${search.toString()}`)));
|
|
157
|
-
});
|
|
172
|
+
}));
|
|
158
173
|
}
|
|
159
174
|
if (canUse(scopes, "projects:write")) {
|
|
160
175
|
server.registerTool("create_project", {
|
|
161
176
|
title: "Create a Cindrel project",
|
|
162
177
|
description: "Create a project for the human this agent works with. Requires a key with projects:write permission.",
|
|
163
|
-
inputSchema: {
|
|
178
|
+
inputSchema: z.object({
|
|
164
179
|
handle: PROJECT_HANDLE.optional().describe("Globally unique public handle; defaults from the name when omitted"),
|
|
165
180
|
name: z.string().min(1).max(80).describe("Project name"),
|
|
166
181
|
tagline: z
|
|
@@ -185,16 +200,16 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
185
200
|
.max(10)
|
|
186
201
|
.optional()
|
|
187
202
|
.describe("Declared topics — up to 5 after normalization and dedupe (lowercase letters, digits, hyphens; '#' prefixes and case are normalized away — e.g. [\"agents\", \"rag\"]). They put the project on the matching tag pages."),
|
|
188
|
-
},
|
|
203
|
+
}).strict(),
|
|
189
204
|
outputSchema: ProjectOutputSchema,
|
|
190
205
|
annotations: CREATE_ANNOTATIONS,
|
|
191
|
-
}, async (args) => structuredResult(ProjectOutputSchema.parse(await client.request("/projects", { method: "POST", body: args }))));
|
|
206
|
+
}, withApiErrors(async (args) => structuredResult(ProjectOutputSchema.parse(await client.request("/projects", { method: "POST", body: args })))));
|
|
192
207
|
}
|
|
193
208
|
if (canUse(scopes, "projects:write", true)) {
|
|
194
209
|
server.registerTool("update_project", {
|
|
195
210
|
title: "Update a Cindrel project",
|
|
196
211
|
description: "Update an existing project. Requires projects:write permission for the selected project. A project-restricted handle also requires projects:read; otherwise use the project id.",
|
|
197
|
-
inputSchema: {
|
|
212
|
+
inputSchema: z.object({
|
|
198
213
|
project: z.string().describe(PROJECT_REF_DESCRIPTION),
|
|
199
214
|
handle: PROJECT_HANDLE.optional().describe("New globally unique public handle; the old handle remains a redirect"),
|
|
200
215
|
name: z.string().min(1).max(80).optional(),
|
|
@@ -211,16 +226,16 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
211
226
|
.max(10)
|
|
212
227
|
.optional()
|
|
213
228
|
.describe("Replace the declared topics (up to 5 after normalization and dedupe); [] clears them. Omit to leave topics untouched."),
|
|
214
|
-
},
|
|
229
|
+
}).strict(),
|
|
215
230
|
outputSchema: ProjectOutputSchema,
|
|
216
231
|
annotations: MODIFY_ANNOTATIONS,
|
|
217
|
-
}, async ({ project, ...changes }) => {
|
|
232
|
+
}, withApiErrors(async ({ project, ...changes }) => {
|
|
218
233
|
const resolved = await resolveProject(client, scopes, project);
|
|
219
234
|
return structuredResult(ProjectOutputSchema.parse(await client.request(`/projects/${encodeURIComponent(resolved.id)}`, {
|
|
220
235
|
method: "PATCH",
|
|
221
236
|
body: changes,
|
|
222
237
|
})));
|
|
223
|
-
});
|
|
238
|
+
}));
|
|
224
239
|
}
|
|
225
240
|
if (features.projectProfileProposals &&
|
|
226
241
|
canUse(scopes, "projects:read", true)) {
|
|
@@ -244,13 +259,13 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
244
259
|
},
|
|
245
260
|
outputSchema: ProjectProfileProposalsOutputSchema,
|
|
246
261
|
annotations: READ_ANNOTATIONS,
|
|
247
|
-
}, async ({ project, before, limit }) => {
|
|
262
|
+
}, withApiErrors(async ({ project, before, limit }) => {
|
|
248
263
|
const resolved = await resolveProject(client, scopes, project);
|
|
249
264
|
const query = new URLSearchParams({ limit: String(limit), cursorFormat: "stable" });
|
|
250
265
|
if (before)
|
|
251
266
|
query.set("before", before);
|
|
252
267
|
return structuredResult(ProjectProfileProposalsOutputSchema.parse(await client.request(`/projects/${encodeURIComponent(resolved.id)}/profile-proposals?${query.toString()}`)));
|
|
253
|
-
});
|
|
268
|
+
}));
|
|
254
269
|
}
|
|
255
270
|
if (features.projectProfileProposals &&
|
|
256
271
|
canUse(scopes, "projects:propose", true)) {
|
|
@@ -261,10 +276,10 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
261
276
|
inputSchema: PROJECT_PROFILE_PROPOSAL_TOOL_INPUT,
|
|
262
277
|
outputSchema: ProjectProfileProposalOutputSchema,
|
|
263
278
|
annotations: CREATE_ANNOTATIONS,
|
|
264
|
-
}, async ({ project, ...changes }) => {
|
|
279
|
+
}, withApiErrors(async ({ project, ...changes }) => {
|
|
265
280
|
const resolved = await resolveProject(client, scopes, project);
|
|
266
281
|
return structuredResult(ProjectProfileProposalOutputSchema.parse(await client.request(`/projects/${encodeURIComponent(resolved.id)}/profile-proposals`, { method: "POST", body: changes })));
|
|
267
|
-
});
|
|
282
|
+
}));
|
|
268
283
|
}
|
|
269
284
|
if (canUse(scopes, "updates:draft", true)) {
|
|
270
285
|
const mayPublish = canUse(scopes, "updates:publish", true);
|
|
@@ -273,7 +288,7 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
273
288
|
description: mayPublish
|
|
274
289
|
? "Create a build-log update. The safe default is a private draft for human review; publish only with deliberate human intent."
|
|
275
290
|
: "Create a private build-log draft for human review. This key cannot publish directly.",
|
|
276
|
-
inputSchema: {
|
|
291
|
+
inputSchema: z.object({
|
|
277
292
|
project: z.string().describe(PROJECT_REF_DESCRIPTION),
|
|
278
293
|
title: z.string().max(140).optional().describe("Optional headline"),
|
|
279
294
|
body: z
|
|
@@ -299,13 +314,13 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
299
314
|
.default("draft")
|
|
300
315
|
.describe("Use draft unless the human deliberately requested publication")
|
|
301
316
|
: z.literal("draft").default("draft"),
|
|
302
|
-
},
|
|
317
|
+
}).strict(),
|
|
303
318
|
outputSchema: CreatedUpdateOutputSchema,
|
|
304
319
|
annotations: CREATE_ANNOTATIONS,
|
|
305
|
-
}, async ({ project, ...update }) => {
|
|
320
|
+
}, withApiErrors(async ({ project, ...update }) => {
|
|
306
321
|
const resolved = await resolveProject(client, scopes, project);
|
|
307
322
|
return structuredResult(normalizeCreatedUpdateOutput(client, await client.request(`/projects/${encodeURIComponent(resolved.id)}/updates`, { method: "POST", body: update })));
|
|
308
|
-
});
|
|
323
|
+
}));
|
|
309
324
|
}
|
|
310
325
|
if (canUse(scopes, "updates:read", true)) {
|
|
311
326
|
server.registerTool("list_updates", {
|
|
@@ -328,13 +343,13 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
328
343
|
},
|
|
329
344
|
outputSchema: UpdatesOutputSchema,
|
|
330
345
|
annotations: READ_ANNOTATIONS,
|
|
331
|
-
}, async ({ project, before, limit }) => {
|
|
346
|
+
}, withApiErrors(async ({ project, before, limit }) => {
|
|
332
347
|
const resolved = await resolveProject(client, scopes, project);
|
|
333
348
|
const search = new URLSearchParams({ limit: String(limit) });
|
|
334
349
|
if (before)
|
|
335
350
|
search.set("before", before);
|
|
336
351
|
return structuredResult(UpdatesOutputSchema.parse(await client.request(`/projects/${encodeURIComponent(resolved.id)}/updates?${search.toString()}`)));
|
|
337
|
-
});
|
|
352
|
+
}));
|
|
338
353
|
}
|
|
339
354
|
if (canUse(scopes, "feed:read")) {
|
|
340
355
|
server.registerTool("get_feed", {
|
|
@@ -361,12 +376,12 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
361
376
|
},
|
|
362
377
|
outputSchema: FeedOutputSchema,
|
|
363
378
|
annotations: READ_ANNOTATIONS,
|
|
364
|
-
}, async ({ scope, before, limit }) => {
|
|
379
|
+
}, withApiErrors(async ({ scope, before, limit }) => {
|
|
365
380
|
const search = new URLSearchParams({ scope, limit: String(limit) });
|
|
366
381
|
if (before)
|
|
367
382
|
search.set("before", before);
|
|
368
383
|
return structuredResult(FeedOutputSchema.parse(await client.request(`/feed?${search.toString()}`)));
|
|
369
|
-
});
|
|
384
|
+
}));
|
|
370
385
|
}
|
|
371
386
|
if (canUse(scopes, "updates:read", true)) {
|
|
372
387
|
server.registerTool("get_update", {
|
|
@@ -376,7 +391,7 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
376
391
|
inputSchema: { updateId: z.string().uuid().describe("Update id") },
|
|
377
392
|
outputSchema: UpdateDetailOutputSchema,
|
|
378
393
|
annotations: READ_ANNOTATIONS,
|
|
379
|
-
}, async ({ updateId }) => structuredResult(UpdateDetailOutputSchema.parse(await client.request(`/updates/${encodeURIComponent(updateId)}`))));
|
|
394
|
+
}, withApiErrors(async ({ updateId }) => structuredResult(UpdateDetailOutputSchema.parse(await client.request(`/updates/${encodeURIComponent(updateId)}`)))));
|
|
380
395
|
}
|
|
381
396
|
if (canUse(scopes, "follows:write")) {
|
|
382
397
|
server.registerTool("follow_profile", {
|
|
@@ -391,7 +406,7 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
391
406
|
},
|
|
392
407
|
outputSchema: FollowOutputSchema,
|
|
393
408
|
annotations: DESIRED_STATE_ANNOTATIONS,
|
|
394
|
-
}, async ({ profileId, following }) => structuredResult(FollowOutputSchema.parse(await client.request(`/profiles/${encodeURIComponent(profileId)}/follow`, { method: "POST", body: { following } }))));
|
|
409
|
+
}, withApiErrors(async ({ profileId, following }) => structuredResult(FollowOutputSchema.parse(await client.request(`/profiles/${encodeURIComponent(profileId)}/follow`, { method: "POST", body: { following } })))));
|
|
395
410
|
}
|
|
396
411
|
if (canUse(scopes, "likes:write", true)) {
|
|
397
412
|
server.registerTool("like_update", {
|
|
@@ -406,7 +421,7 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
406
421
|
},
|
|
407
422
|
outputSchema: LikeOutputSchema,
|
|
408
423
|
annotations: DESIRED_STATE_ANNOTATIONS,
|
|
409
|
-
}, async ({ updateId, liked }) => structuredResult(LikeOutputSchema.parse(await client.request(`/updates/${encodeURIComponent(updateId)}/like`, { method: "POST", body: { liked } }))));
|
|
424
|
+
}, withApiErrors(async ({ updateId, liked }) => structuredResult(LikeOutputSchema.parse(await client.request(`/updates/${encodeURIComponent(updateId)}/like`, { method: "POST", body: { liked } })))));
|
|
410
425
|
}
|
|
411
426
|
if (canUse(scopes, "reposts:write", true)) {
|
|
412
427
|
server.registerTool("repost_update", {
|
|
@@ -421,7 +436,7 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
421
436
|
},
|
|
422
437
|
outputSchema: RepostOutputSchema,
|
|
423
438
|
annotations: DESIRED_STATE_ANNOTATIONS,
|
|
424
|
-
}, async ({ updateId, reposted }) => structuredResult(RepostOutputSchema.parse(await client.request(`/updates/${encodeURIComponent(updateId)}/repost`, { method: "POST", body: { reposted } }))));
|
|
439
|
+
}, withApiErrors(async ({ updateId, reposted }) => structuredResult(RepostOutputSchema.parse(await client.request(`/updates/${encodeURIComponent(updateId)}/repost`, { method: "POST", body: { reposted } })))));
|
|
425
440
|
}
|
|
426
441
|
if (canUse(scopes, "comments:read", true)) {
|
|
427
442
|
server.registerTool("list_comments", {
|
|
@@ -445,7 +460,7 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
445
460
|
},
|
|
446
461
|
outputSchema: CommentsOutputSchema,
|
|
447
462
|
annotations: READ_ANNOTATIONS,
|
|
448
|
-
}, async ({ updateId, before, limit }) => {
|
|
463
|
+
}, withApiErrors(async ({ updateId, before, limit }) => {
|
|
449
464
|
const search = new URLSearchParams();
|
|
450
465
|
if (before)
|
|
451
466
|
search.set("before", before);
|
|
@@ -453,7 +468,7 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
453
468
|
search.set("limit", String(limit));
|
|
454
469
|
const query = search.size ? `?${search.toString()}` : "";
|
|
455
470
|
return structuredResult(CommentsOutputSchema.parse(await client.request(`/updates/${encodeURIComponent(updateId)}/comments${query}`)));
|
|
456
|
-
});
|
|
471
|
+
}));
|
|
457
472
|
}
|
|
458
473
|
if (canUse(scopes, "comments:write", true)) {
|
|
459
474
|
server.registerTool("post_comment", {
|
|
@@ -470,23 +485,21 @@ export function buildServer(client, scopes, features = {}) {
|
|
|
470
485
|
},
|
|
471
486
|
outputSchema: CommentOutputSchema,
|
|
472
487
|
annotations: CREATE_ANNOTATIONS,
|
|
473
|
-
}, async ({ updateId, body, replyToCommentId }) => structuredResult(CommentOutputSchema.parse(await client.request(`/updates/${encodeURIComponent(updateId)}/comments`, {
|
|
488
|
+
}, withApiErrors(async ({ updateId, body, replyToCommentId }) => structuredResult(CommentOutputSchema.parse(await client.request(`/updates/${encodeURIComponent(updateId)}/comments`, {
|
|
474
489
|
method: "POST",
|
|
475
490
|
body: replyToCommentId ? { body, replyToCommentId } : { body },
|
|
476
|
-
}))));
|
|
491
|
+
})))));
|
|
477
492
|
}
|
|
478
493
|
return server;
|
|
479
494
|
}
|
|
480
495
|
export async function main() {
|
|
481
496
|
const apiKey = process.env.CINDREL_API_KEY ?? "";
|
|
482
497
|
const apiUrl = process.env.CINDREL_API_URL ?? DEFAULT_API_URL;
|
|
483
|
-
const timeoutMs = boundedInteger(process.env.CINDREL_TIMEOUT_MS, 15_000, 1_000, 60_000);
|
|
484
|
-
const readRetries = boundedInteger(process.env.CINDREL_READ_RETRIES, 2, 0, 4);
|
|
485
498
|
const client = new CindrelClient({
|
|
486
499
|
apiKey,
|
|
487
500
|
apiUrl,
|
|
488
|
-
timeoutMs,
|
|
489
|
-
readRetries,
|
|
501
|
+
timeoutMs: process.env.CINDREL_TIMEOUT_MS,
|
|
502
|
+
readRetries: process.env.CINDREL_READ_RETRIES,
|
|
490
503
|
});
|
|
491
504
|
const identity = IdentityOutputSchema.parse(await client.request("/me"));
|
|
492
505
|
const server = buildServer(client, identity.scopes, {
|
|
@@ -13,12 +13,6 @@ function scopedReadableProjectIds(scopes) {
|
|
|
13
13
|
}
|
|
14
14
|
return [...ids];
|
|
15
15
|
}
|
|
16
|
-
function projectMatches(project, ref) {
|
|
17
|
-
const normalized = ref.toLowerCase();
|
|
18
|
-
return (project.id === ref ||
|
|
19
|
-
project.handle?.toLowerCase() === normalized ||
|
|
20
|
-
project.slug.toLowerCase() === normalized);
|
|
21
|
-
}
|
|
22
16
|
function missingProject(ref, projects) {
|
|
23
17
|
const known = projects.map((project) => project.handle ?? project.slug).join(", ") ||
|
|
24
18
|
"(none)";
|
|
@@ -35,9 +29,11 @@ export async function resolveProject(client, scopes, ref) {
|
|
|
35
29
|
if (z.uuid().safeParse(ref).success) {
|
|
36
30
|
return { id: ref, slug: ref, name: ref };
|
|
37
31
|
}
|
|
32
|
+
const normalized = ref.toLowerCase();
|
|
38
33
|
if (scopesAllow(scopes, "projects:read")) {
|
|
39
34
|
const data = await client.request("/projects");
|
|
40
|
-
const project = data.projects.find((row) =>
|
|
35
|
+
const project = data.projects.find((row) => row.handle?.toLowerCase() === normalized) ??
|
|
36
|
+
data.projects.find((row) => row.slug.toLowerCase() === normalized);
|
|
41
37
|
if (!project)
|
|
42
38
|
throw missingProject(ref, data.projects);
|
|
43
39
|
return project;
|
|
@@ -47,7 +43,7 @@ export async function resolveProject(client, scopes, ref) {
|
|
|
47
43
|
try {
|
|
48
44
|
const data = await client.request(`/projects/${encodeURIComponent(id)}`);
|
|
49
45
|
readable.push(data.project);
|
|
50
|
-
if (
|
|
46
|
+
if (data.project.handle?.toLowerCase() === normalized)
|
|
51
47
|
return data.project;
|
|
52
48
|
}
|
|
53
49
|
catch (error) {
|
|
@@ -59,5 +55,10 @@ export async function resolveProject(client, scopes, ref) {
|
|
|
59
55
|
throw error;
|
|
60
56
|
}
|
|
61
57
|
}
|
|
58
|
+
// A legacy slug is a fallback only after every readable grant has been
|
|
59
|
+
// checked for a canonical handle, regardless of grant order.
|
|
60
|
+
const legacyProject = readable.find((row) => row.slug.toLowerCase() === normalized);
|
|
61
|
+
if (legacyProject)
|
|
62
|
+
return legacyProject;
|
|
62
63
|
throw missingProject(ref, readable);
|
|
63
64
|
}
|
package/dist/schemas.js
CHANGED
|
@@ -46,7 +46,7 @@ export const UpdateVideoSchema = z.looseObject({
|
|
|
46
46
|
url: z.string(),
|
|
47
47
|
startSeconds: z.number().int().nonnegative().nullable(),
|
|
48
48
|
});
|
|
49
|
-
|
|
49
|
+
const UpdateSchema = z.looseObject({
|
|
50
50
|
id: z.uuid(),
|
|
51
51
|
projectId: z.uuid(),
|
|
52
52
|
authorId: z.uuid(),
|
|
@@ -68,7 +68,7 @@ export const UpdateSchema = z.looseObject({
|
|
|
68
68
|
createdAt: z.string(),
|
|
69
69
|
updatedAt: z.string(),
|
|
70
70
|
});
|
|
71
|
-
|
|
71
|
+
const CommentSchema = z.looseObject({
|
|
72
72
|
id: z.uuid(),
|
|
73
73
|
body: z.string(),
|
|
74
74
|
parentId: z.uuid().nullable(),
|
|
@@ -127,15 +127,15 @@ export const ProjectsOutputSchema = z.looseObject({
|
|
|
127
127
|
export const ProjectOutputSchema = z.looseObject({
|
|
128
128
|
project: ProjectSchema,
|
|
129
129
|
});
|
|
130
|
-
|
|
130
|
+
const ProjectProfileSnapshotSchema = z.strictObject({
|
|
131
131
|
tagline: z.string().nullable(),
|
|
132
132
|
description: z.string().nullable(),
|
|
133
133
|
audience: z.string().nullable(),
|
|
134
134
|
websiteUrl: z.string().nullable(),
|
|
135
135
|
tags: z.array(z.string()).max(5),
|
|
136
136
|
});
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
const ProjectProfileChangesSchema = ProjectProfileSnapshotSchema.partial();
|
|
138
|
+
const ProjectProfileProposalSchema = z.looseObject({
|
|
139
139
|
id: z.uuid(),
|
|
140
140
|
projectId: z.uuid(),
|
|
141
141
|
authorId: z.uuid().nullable(),
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const MCP_VERSION = "0.9.
|
|
1
|
+
export const MCP_VERSION = "0.9.4";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cindrel-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "MCP server for source-linked, human-reviewed build logs on cindrel",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"prepublishOnly": "npm run check"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.23.0",
|
|
46
46
|
"zod": "^4.4.3"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|