cindrel-mcp 0.9.0 → 0.9.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/README.md CHANGED
@@ -58,7 +58,7 @@ instructions to follow.
58
58
  "mcpServers": {
59
59
  "cindrel": {
60
60
  "command": "npx",
61
- "args": ["-y", "cindrel-mcp@0.9.0"],
61
+ "args": ["-y", "cindrel-mcp@0.9.1"],
62
62
  "env": {
63
63
  "CINDREL_API_URL": "https://your-cindrel-domain.example",
64
64
  "CINDREL_API_KEY": "cin_…"
@@ -108,7 +108,7 @@ that specific update.
108
108
  | `whoami` | `profile:read` | Verify the connection, agent, human, scopes, and operation-level capabilities |
109
109
  | `find_profiles` | `profile:read` | Resolve a handle or display name to profile ids |
110
110
  | `list_projects` | `projects:read` | List the human's projects with global handles and ids |
111
- | `get_project` | `projects:read` | Fetch one project by handle, slug, or id the read path for project-restricted keys |
111
+ | `get_project` | `projects:read` | Fetch one project by handle, slug, or id; project-restricted keys resolve names only among their granted projects without listing the account |
112
112
  | `create_project` | `projects:write` | Create a globally addressable project with an optional audience statement and declared topic tags |
113
113
  | `update_project` | `projects:write` | Edit its handle, audience, other metadata, declared topic tags, or status |
114
114
  | `list_project_profile_proposals` | `projects:read` | List cursor-paged private pending profile proposals for one owned project, including the total pending count |
@@ -183,5 +183,5 @@ MCP Registry registration remains a separate explicit maintainer action.
183
183
 
184
184
  ```bash
185
185
  npm exec --yes --prefix <empty-directory> \
186
- --package=cindrel-mcp@0.9.0 -- cindrel-mcp
186
+ --package=cindrel-mcp@0.9.1 -- cindrel-mcp
187
187
  ```
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { z } from "zod";
14
14
  import { CindrelClient, boundedInteger, 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
+ import { resolveProject } from "./project-resolution.js";
17
18
  import { MCP_VERSION } from "./version.js";
18
19
  const DEFAULT_API_URL = "http://localhost:3000";
19
20
  const HTTP_URL = z
@@ -31,11 +32,10 @@ const PROJECT_PROFILE_FIELDS = [
31
32
  "websiteUrl",
32
33
  "tags",
33
34
  ];
35
+ const PROJECT_REF_DESCRIPTION = "Project handle, legacy slug, or id. Handles require projects:read and resolve only within the key's granted projects; use an id when discovery is unavailable.";
34
36
  const PROJECT_PROFILE_PROPOSAL_TOOL_INPUT = z
35
37
  .object({
36
- project: z
37
- .string()
38
- .describe("Project handle, legacy slug, or id (see list_projects)"),
38
+ project: z.string().describe(PROJECT_REF_DESCRIPTION),
39
39
  tagline: z.string().max(140).nullable().optional(),
40
40
  description: z.string().max(10000).nullable().optional(),
41
41
  audience: z.string().max(240).nullable().optional(),
@@ -94,25 +94,6 @@ function normalizeCreatedUpdateOutput(client, value) {
94
94
  reviewUrl: new URL(output.reviewUrl ?? `/drafts/${output.update.id}/edit`, `${client.apiUrl}/`).toString(),
95
95
  };
96
96
  }
97
- async function resolveProject(client, ref) {
98
- // A UUID already carries the exact project boundary. Do not require a
99
- // global /projects listing first: project-restricted keys deliberately
100
- // may not have projects:read across their human's whole account.
101
- if (z.uuid().safeParse(ref).success) {
102
- return { id: ref, slug: ref, name: ref };
103
- }
104
- // Accept a project id, global handle, or legacy owner-scoped slug; try all
105
- // so a hex-and-dash slug that merely looks uuid-shaped still resolves.
106
- const data = await client.request("/projects");
107
- const project = data.projects.find((row) => row.id === ref) ??
108
- data.projects.find((row) => row.handle === ref.toLowerCase()) ??
109
- data.projects.find((row) => row.slug === ref.toLowerCase());
110
- if (!project) {
111
- const known = data.projects.map((row) => row.handle ?? row.slug).join(", ") || "(none)";
112
- throw new Error(`No project "${ref}". Known projects: ${known}`);
113
- }
114
- return project;
115
- }
116
97
  function canUse(scopes, permission, projectAware = false) {
117
98
  return projectAware
118
99
  ? scopesAllowAnyProject(scopes, permission)
@@ -139,16 +120,14 @@ export function buildServer(client, scopes, features = {}) {
139
120
  if (canUse(scopes, "projects:read", true)) {
140
121
  server.registerTool("get_project", {
141
122
  title: "Get one Cindrel project",
142
- description: "Fetch one project owned by the human this agent works with, including its declared tags and canonical URL. Requires projects:read permission for the selected project. Use a UUID when the key is project-restricted.",
123
+ description: "Fetch one project owned by the human this agent works with, including its declared tags and canonical URL. Requires projects:read permission for the selected project; project-restricted handles resolve only among granted projects.",
143
124
  inputSchema: {
144
- project: z
145
- .string()
146
- .describe("Project handle, legacy slug, or id (see list_projects)"),
125
+ project: z.string().describe(PROJECT_REF_DESCRIPTION),
147
126
  },
148
127
  outputSchema: ProjectOutputSchema,
149
128
  annotations: READ_ANNOTATIONS,
150
129
  }, async ({ project }) => {
151
- const resolved = await resolveProject(client, project);
130
+ const resolved = await resolveProject(client, scopes, project);
152
131
  return structuredResult(ProjectOutputSchema.parse(await client.request(`/projects/${encodeURIComponent(resolved.id)}`)));
153
132
  });
154
133
  }
@@ -214,11 +193,9 @@ export function buildServer(client, scopes, features = {}) {
214
193
  if (canUse(scopes, "projects:write", true)) {
215
194
  server.registerTool("update_project", {
216
195
  title: "Update a Cindrel project",
217
- description: "Update an existing project. Requires projects:write permission for the selected project. Use a UUID when the key is project-restricted.",
196
+ 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.",
218
197
  inputSchema: {
219
- project: z
220
- .string()
221
- .describe("Project handle, legacy slug, or id (see list_projects)"),
198
+ project: z.string().describe(PROJECT_REF_DESCRIPTION),
222
199
  handle: PROJECT_HANDLE.optional().describe("New globally unique public handle; the old handle remains a redirect"),
223
200
  name: z.string().min(1).max(80).optional(),
224
201
  tagline: z.string().max(140).nullable().optional(),
@@ -238,7 +215,7 @@ export function buildServer(client, scopes, features = {}) {
238
215
  outputSchema: ProjectOutputSchema,
239
216
  annotations: MODIFY_ANNOTATIONS,
240
217
  }, async ({ project, ...changes }) => {
241
- const resolved = await resolveProject(client, project);
218
+ const resolved = await resolveProject(client, scopes, project);
242
219
  return structuredResult(ProjectOutputSchema.parse(await client.request(`/projects/${encodeURIComponent(resolved.id)}`, {
243
220
  method: "PATCH",
244
221
  body: changes,
@@ -249,12 +226,10 @@ export function buildServer(client, scopes, features = {}) {
249
226
  canUse(scopes, "projects:read", true)) {
250
227
  server.registerTool("list_project_profile_proposals", {
251
228
  title: "List pending project profile proposals",
252
- description: "List private profile changes awaiting the human owner's review for one project. Use a UUID when the key is project-restricted." +
229
+ description: "List private profile changes awaiting the human owner's review for one project. Project-restricted handles resolve only among granted projects." +
253
230
  UNTRUSTED_CONTENT_NOTE,
254
231
  inputSchema: {
255
- project: z
256
- .string()
257
- .describe("Project handle, legacy slug, or id (see list_projects)"),
232
+ project: z.string().describe(PROJECT_REF_DESCRIPTION),
258
233
  before: z
259
234
  .uuid()
260
235
  .optional()
@@ -270,7 +245,7 @@ export function buildServer(client, scopes, features = {}) {
270
245
  outputSchema: ProjectProfileProposalsOutputSchema,
271
246
  annotations: READ_ANNOTATIONS,
272
247
  }, async ({ project, before, limit }) => {
273
- const resolved = await resolveProject(client, project);
248
+ const resolved = await resolveProject(client, scopes, project);
274
249
  const query = new URLSearchParams({ limit: String(limit) });
275
250
  if (before)
276
251
  query.set("before", before);
@@ -287,7 +262,7 @@ export function buildServer(client, scopes, features = {}) {
287
262
  outputSchema: ProjectProfileProposalOutputSchema,
288
263
  annotations: CREATE_ANNOTATIONS,
289
264
  }, async ({ project, ...changes }) => {
290
- const resolved = await resolveProject(client, project);
265
+ const resolved = await resolveProject(client, scopes, project);
291
266
  return structuredResult(ProjectProfileProposalOutputSchema.parse(await client.request(`/projects/${encodeURIComponent(resolved.id)}/profile-proposals`, { method: "POST", body: changes })));
292
267
  });
293
268
  }
@@ -299,9 +274,7 @@ export function buildServer(client, scopes, features = {}) {
299
274
  ? "Create a build-log update. The safe default is a private draft for human review; publish only with deliberate human intent."
300
275
  : "Create a private build-log draft for human review. This key cannot publish directly.",
301
276
  inputSchema: {
302
- project: z
303
- .string()
304
- .describe("Project handle, legacy slug, or id (see list_projects)"),
277
+ project: z.string().describe(PROJECT_REF_DESCRIPTION),
305
278
  title: z.string().max(140).optional().describe("Optional headline"),
306
279
  body: z
307
280
  .string()
@@ -330,7 +303,7 @@ export function buildServer(client, scopes, features = {}) {
330
303
  outputSchema: CreatedUpdateOutputSchema,
331
304
  annotations: CREATE_ANNOTATIONS,
332
305
  }, async ({ project, ...update }) => {
333
- const resolved = await resolveProject(client, project);
306
+ const resolved = await resolveProject(client, scopes, project);
334
307
  return structuredResult(normalizeCreatedUpdateOutput(client, await client.request(`/projects/${encodeURIComponent(resolved.id)}/updates`, { method: "POST", body: update })));
335
308
  });
336
309
  }
@@ -340,9 +313,7 @@ export function buildServer(client, scopes, features = {}) {
340
313
  description: "List updates, including private drafts, on one of the human's projects. Pass nextCursor back as before to continue. Draft bodies can embed text from external sources (e.g. GitHub commit messages)." +
341
314
  UNTRUSTED_CONTENT_NOTE,
342
315
  inputSchema: {
343
- project: z
344
- .string()
345
- .describe("Project handle, legacy slug, or id (see list_projects)"),
316
+ project: z.string().describe(PROJECT_REF_DESCRIPTION),
346
317
  before: z
347
318
  .uuid()
348
319
  .optional()
@@ -358,7 +329,7 @@ export function buildServer(client, scopes, features = {}) {
358
329
  outputSchema: UpdatesOutputSchema,
359
330
  annotations: READ_ANNOTATIONS,
360
331
  }, async ({ project, before, limit }) => {
361
- const resolved = await resolveProject(client, project);
332
+ const resolved = await resolveProject(client, scopes, project);
362
333
  const search = new URLSearchParams({ limit: String(limit) });
363
334
  if (before)
364
335
  search.set("before", before);
@@ -0,0 +1,63 @@
1
+ import { z } from "zod";
2
+ import { CindrelApiError } from "./client.js";
3
+ import { scopesAllow } from "./scopes.js";
4
+ function scopedReadableProjectIds(scopes) {
5
+ const suffix = ":projects:read";
6
+ const ids = new Set();
7
+ for (const scope of scopes) {
8
+ if (!scope.startsWith("project:") || !scope.endsWith(suffix))
9
+ continue;
10
+ const id = scope.slice("project:".length, -suffix.length);
11
+ if (z.uuid().safeParse(id).success)
12
+ ids.add(id);
13
+ }
14
+ return [...ids];
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
+ function missingProject(ref, projects) {
23
+ const known = projects.map((project) => project.handle ?? project.slug).join(", ") ||
24
+ "(none)";
25
+ return new Error(`No project "${ref}". Known projects: ${known}`);
26
+ }
27
+ /**
28
+ * Resolve only within projects the key may read. A project-restricted key can
29
+ * therefore use the same handle-oriented tools as a broad key without gaining
30
+ * access to the human's account-wide project list.
31
+ */
32
+ export async function resolveProject(client, scopes, ref) {
33
+ // A UUID already carries the exact project boundary. The target endpoint
34
+ // authorizes it, so no discovery read is needed before the actual tool call.
35
+ if (z.uuid().safeParse(ref).success) {
36
+ return { id: ref, slug: ref, name: ref };
37
+ }
38
+ if (scopesAllow(scopes, "projects:read")) {
39
+ const data = await client.request("/projects");
40
+ const project = data.projects.find((row) => projectMatches(row, ref));
41
+ if (!project)
42
+ throw missingProject(ref, data.projects);
43
+ return project;
44
+ }
45
+ const readable = [];
46
+ for (const id of scopedReadableProjectIds(scopes)) {
47
+ try {
48
+ const data = await client.request(`/projects/${encodeURIComponent(id)}`);
49
+ readable.push(data.project);
50
+ if (projectMatches(data.project, ref))
51
+ return data.project;
52
+ }
53
+ catch (error) {
54
+ // A deleted or newly unavailable project is simply no longer a
55
+ // candidate. Authentication, throttling, and server/network failures
56
+ // are actionable and must not be disguised as a missing handle.
57
+ if (error instanceof CindrelApiError && error.status === 404)
58
+ continue;
59
+ throw error;
60
+ }
61
+ }
62
+ throw missingProject(ref, readable);
63
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const MCP_VERSION = "0.9.0";
1
+ export const MCP_VERSION = "0.9.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cindrel-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "MCP server for source-linked, human-reviewed build logs on cindrel",
5
5
  "type": "module",
6
6
  "bin": {